(null);
31 | const [deleteModalOpen, setDeleteModalOpen] = useState(false);
32 |
33 | const tagsAvailable = tags.filter((t) => {
34 | const noteTagIds = note.tags.map((noteTag) => noteTag.id);
35 | return !noteTagIds.includes(t.id);
36 | });
37 |
38 | // trpc
39 | const renameMutation = api.notes.rename.useMutation();
40 | const addTagMutation = api.notes.addTag.useMutation();
41 | const removeTagMutation = api.notes.removeTag.useMutation();
42 | const utils = api.useContext();
43 |
44 | // TODO: implement this function
45 | function onClickRenameNote() {
46 | //
47 |
48 | if (!note) return;
49 |
50 | renameMutation.mutate(
51 | { id: note.id, newTitle: noteTitle },
52 | {
53 | onSuccess: (updatedNote, variables, context) => {
54 | utils.notes.getAll.setData(undefined, (oldNotes) =>
55 | oldNotes?.map((oldNote) =>
56 | oldNote.id === variables.id ? updatedNote : oldNote
57 | )
58 | );
59 | void utils.notes.invalidate();
60 | },
61 | }
62 | );
63 | }
64 |
65 | // TODO: implement this function
66 | function onClickAddTagToNote() {
67 | if (!note || !selectedTag) return;
68 |
69 | addTagMutation.mutate(
70 | { id: note.id, tagId: selectedTag.id },
71 | {
72 | onSuccess: (updatedNote, variables, context) => {
73 | utils.notes.getAll.setData(undefined, (oldNotes) =>
74 | oldNotes?.map((oldNote) =>
75 | oldNote.id === variables.id ? updatedNote : oldNote
76 | )
77 | );
78 | void utils.notes.invalidate();
79 | setSelectedTag(null);
80 | },
81 | }
82 | );
83 | }
84 |
85 | function onClickRemoveTag(tagId: string) {
86 | if (!note) return;
87 |
88 | removeTagMutation.mutate(
89 | { id: note.id, tagId },
90 | {
91 | onSuccess: (updatedNote, variables, context) => {
92 | utils.notes.getAll.setData(undefined, (oldNotes) =>
93 | oldNotes?.map((oldNote) =>
94 | oldNote.id === updatedNote.id ? updatedNote : oldNote
95 | )
96 | );
97 | void utils.notes.invalidate();
98 | },
99 | }
100 | );
101 | }
102 |
103 | if (note === null) return ;
104 |
105 | return (
106 |
107 | {/* Title & Description */}
108 |
109 | Note Options
110 |
111 |
112 | This dialog allows you to view and modify options for your note
113 |
114 |
115 | {/* Title */}
116 |
117 | Title
118 |
119 |
120 | setNoteTitle(e.target.value)}
126 | />
127 |
139 |
140 |
141 | {/* Tags */}
142 |
143 | Tags
144 |
145 |
146 | {note.tags.length !== 0 ? (
147 | note.tags.map((tag: Tag) => (
148 | onClickRemoveTag(tag.id)}
154 | loading={
155 | removeTagMutation.isLoading &&
156 | removeTagMutation.variables?.tagId === tag.id
157 | }
158 | />
159 | ))
160 | ) : (
161 | No tags - nothing to see here.
162 | )}
163 |
164 |
165 | {/* Add tags */}
166 | {tagsAvailable.length !== 0 && (
167 | <>
168 |
169 | Add tags
170 |
171 | {/* New tag color selector */}
172 |
173 |
179 | {/* Button */}
180 |
181 | {selectedTag ? (
182 | selectedTag.label
183 | ) : (
184 |
185 | Select a tag to add
186 |
187 | )}
188 |
189 |
190 |
191 | {/* Options */}
192 |
193 | {tagsAvailable.map((tag, index) => (
194 |
199 |
204 | {tag.label}
205 |
206 | ))}
207 |
208 |
209 |
210 |
222 |
223 | >
224 | )}
225 |
226 | {/* Danger Zone */}
227 |
228 | Danger zone
229 |
230 |
250 | );
251 | }
252 |
253 | export default NoteOptionsModal;
254 |
--------------------------------------------------------------------------------
/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | // React, Next, Zustand
2 | import React, { useState } from "react";
3 | import Link from "next/link";
4 | import { signIn, signOut, useSession } from "next-auth/react";
5 | import { Session } from "next-auth";
6 | import useThemeStore from "../stores/theme";
7 |
8 | // Components
9 | import { Listbox, Popover, Transition } from "@headlessui/react";
10 | import {
11 | BookBookmark,
12 | Gear,
13 | Info,
14 | Moon,
15 | Palette,
16 | SignOut,
17 | Sun,
18 | } from "phosphor-react";
19 | import CaretUpDownIcon from "./CaretUpDownIcon";
20 | import Button from "./Button";
21 | import Image from "next/image";
22 |
23 | function ThemeOption({ theme }: { theme: "light" | "dark" }) {
24 | return (
25 |
26 | {theme === "light" && }
27 | {theme === "dark" && }
28 | {theme === "light" ? "Light" : "Dark"}
29 |
30 | );
31 | }
32 |
33 | function Slash() {
34 | return /;
35 | }
36 |
37 | function DropdownLine({
38 | children,
39 | onClick,
40 | }: {
41 | children?: React.ReactNode;
42 | onClick?: (onClickProps: unknown) => void;
43 | }) {
44 | const className =
45 | "flex items-center gap-3 px-6 py-1.5 hover:bg-gray-200 hover:bg-opacity-50 dark:hover:bg-gray-700";
46 | return onClick ? (
47 |
50 | ) : (
51 | {children}
52 | );
53 | }
54 |
55 | function Logo({ session }: { session?: Session }) {
56 | return (
57 |
58 | {/* Icon */}
59 |
60 |
61 |
62 | {/* App Name */}
63 | {/* {session && ( */}
64 |
65 | LuccaNotes
66 |
67 | {/* )} */}
68 |
69 | );
70 | }
71 |
72 | function ThemeSelector() {
73 | const { theme, setTheme } = useThemeStore();
74 | return (
75 |
81 | {/* Button */}
82 |
83 | {theme === "light" ? "Light" : "Dark"}
84 |
85 |
86 |
87 | {/* Options */}
88 |
89 | {/* Light option */}
90 |
94 |
95 |
96 | {/* Dark option */}
97 |
101 |
102 |
103 |
104 |
105 | );
106 | }
107 |
108 | function PfpDropdown({ session }: { session: Session }) {
109 | return (
110 |
111 | {/* PFP button */}
112 |
113 |
119 |
120 |
121 | {/* Dropdown menu */}
122 |
130 |
131 | {/* Account settings */}
132 | {/*
133 |
138 | Account settings
139 | */}
140 | {/* Theme Selector */}
141 |
142 |
147 | Theme
148 |
149 |
150 |
151 | {/* About/more info */}
152 | {/*
153 |
158 | About
159 | */}
160 | {/* Logout */}
161 |
162 | void signOut({ callbackUrl: "/" })}>
163 |
168 | Log out
169 |
170 |
171 |
172 |
173 | );
174 | }
175 |
176 | function Navbar({
177 | bgTransparent,
178 | noteTitle,
179 | session,
180 | }: {
181 | bgTransparent?: boolean;
182 | noteTitle?: string;
183 | session?: Session;
184 | }) {
185 | return (
186 |
272 | );
273 | }
274 |
275 | export default Navbar;
276 |
--------------------------------------------------------------------------------
/src/components/NoteCard.tsx:
--------------------------------------------------------------------------------
1 | import { type Note, type Tag } from "@prisma/client";
2 | import { useRouter } from "next/router";
3 | import { useEffect, useState } from "react";
4 | import { NoteWithTags } from "..";
5 | import { formatDate } from "../utils/dates";
6 | import Button from "./Button";
7 | import NoteOptionsModal from "./Modals/NoteOptions";
8 | import TagPill from "./TagPill";
9 | import Tooltip from "./Tooltip";
10 |
11 | function HiddenTagPillContainer({
12 | hiddenTags,
13 | flipTags,
14 | }: {
15 | hiddenTags: Tag[];
16 | flipTags: boolean;
17 | }) {
18 | return (
19 | <>
20 | {/* Mobile */}
21 |
22 |
23 | {hiddenTags.length} more {hiddenTags.length === 1 ? "tag" : "tags"}
24 |
25 |
29 |
30 | {hiddenTags.map((tag, index) => (
31 |
32 | ))}
33 |
34 |
35 |
36 | {/* Desktop */}
37 |
38 |
39 | {hiddenTags.length} more {hiddenTags.length === 1 ? "tag" : "tags"}
40 |
41 |
42 |
43 | {hiddenTags.map((tag, index) => (
44 |
45 | ))}
46 |
47 |
48 |
49 | >
50 | );
51 | }
52 |
53 | function TagPillContainer({
54 | tags,
55 | flipTags = false,
56 | }: {
57 | tags: Tag[];
58 | flipTags: boolean;
59 | }) {
60 | return (
61 | <>
62 | {/* Mobile Tag container */}
63 |
64 | {tags[0] && }
65 | {tags.length - 1 > 0 && (
66 |
70 | )}
71 | {tags.length === 0 && (
72 | No tags for this note yet!
73 | )}
74 |
75 | {/* Desktop Tag container */}
76 |
77 | {tags[0] && }
78 | {tags[1] && }
79 | {tags.length - 2 > 0 && (
80 |
84 | )}
85 | {tags.length === 0 && (
86 | No tags for this note yet!
87 | )}
88 |
89 | >
90 | );
91 | }
92 |
93 | function NoteCard({
94 | note,
95 | flipTags = false,
96 | dateType = "lastUpdated",
97 | tags,
98 | }: {
99 | note: NoteWithTags;
100 | flipTags?: boolean;
101 | dateType?: "lastUpdated" | "createdAt";
102 | tags: Tag[];
103 | }) {
104 | const [modalOpen, setModalOpen] = useState(false);
105 | const [navLoading, setNavLoading] = useState(false);
106 |
107 | const router = useRouter();
108 |
109 | useEffect(() => {
110 | const handler = (url: string) => {
111 | if (url.includes(note.id)) {
112 | setNavLoading(true);
113 | }
114 | };
115 |
116 | router.events.on("routeChangeStart", handler);
117 |
118 | return () => {
119 | router.events.off("routeChangeStart", handler);
120 | };
121 | }, []);
122 |
123 | return (
124 |
125 |
126 | {/* Note title */}
127 |
128 | {note.title}
129 |
130 |
131 | {/* Date field */}
132 | {dateType === "lastUpdated" ? (
133 |
134 | Last edited {formatDate(note.lastUpdated)}
135 |
136 | ) : (
137 |
138 | Created {formatDate(note.createdAt)}
139 |
140 | )}
141 |
142 |
143 |
144 |
145 | {/* Buttons */}
146 |
147 |
158 |
169 |
170 |
n.id === selectedNoteId) ?? null}
175 | note={note}
176 | tags={tags}
177 | />
178 |
179 | );
180 | }
181 |
182 | export default NoteCard;
183 |
--------------------------------------------------------------------------------
/src/components/SearchBar.tsx:
--------------------------------------------------------------------------------
1 | import { Listbox, Popover, RadioGroup, Transition } from "@headlessui/react";
2 | import { type Tag } from "@prisma/client";
3 | import {
4 | ArrowCircleDown,
5 | ArrowCircleUp,
6 | CaretDown,
7 | Check,
8 | MagnifyingGlass,
9 | } from "phosphor-react";
10 | import { type ChangeEvent, useState } from "react";
11 | import { useDebouncedCallback } from "use-debounce";
12 | import useSearchStore from "../stores/search";
13 | import CaretUpDownIcon from "./CaretUpDownIcon";
14 | import TagPill from "./TagPill";
15 | import Tooltip from "./Tooltip";
16 |
17 | // Sort fields
18 | const sortFieldLabels = {
19 | title: "Title",
20 | createdAt: "Creation Date",
21 | lastUpdated: "Last Updated",
22 | } as const;
23 | export type SortField = keyof typeof sortFieldLabels;
24 |
25 | function SortingSection() {
26 | const { sortField, setSortField, sortOrder, setSortOrder } = useSearchStore();
27 |
28 | return (
29 |
30 |
31 | Sorting
32 |
33 |
34 | {/* Sort Field Selector */}
35 |
36 | {/* Label */}
37 | Sort by
38 |
44 | {/* Button */}
45 |
46 | {sortFieldLabels[sortField]}
47 |
48 |
49 |
50 | {/* Dropdown menu */}
51 |
52 | {Object.keys(sortFieldLabels).map((sortFieldOption, index) => (
53 |
58 |
63 | {sortFieldLabels[sortFieldOption as SortField]}
64 |
65 | ))}
66 |
67 |
68 |
69 |
70 | {/* Sort Order Selector */}
71 |
77 | {/* Label */}
78 |
79 | Order
80 |
81 | {/* Options */}
82 | {sortField === "title" ? (
83 | // sorting by title
84 |
85 |
86 |
87 | A-z
88 |
89 |
90 |
91 |
92 | Z-a
93 |
94 |
95 |
96 | ) : (
97 | // sorting by 'createdAt' or 'lastUpdate'
98 |
99 |
100 |
105 |
106 |
107 |
112 |
113 |
114 | )}
115 |
116 |
117 |
118 | );
119 | }
120 |
121 | function TagsSection({ tags }: { tags: Tag[] }) {
122 | const { selectedTagIds, toggleSelectedTag } = useSearchStore();
123 |
124 | console.log("selectedTagIds is", selectedTagIds);
125 |
126 | return (
127 |
128 |
129 | Filter by Tags{" "}
130 |
131 | Click tags to toggle filter
132 |
133 |
134 |
135 | {tags.map(({ id, label, color }) => (
136 |
toggleSelectedTag(id)}
140 | >
141 |
150 |
151 | ))}
152 |
153 |
154 | );
155 | }
156 |
157 | function SearchBar({ tags }: { tags: Tag[] }) {
158 | const { setSearchInput } = useSearchStore();
159 |
160 | const [input, setInput] = useState("");
161 |
162 | const debouncedSetInput = useDebouncedCallback((textInput: string) => {
163 | setSearchInput(textInput);
164 | }, 200);
165 |
166 | const onChange = (e: ChangeEvent) => {
167 | setInput(e.target.value);
168 | debouncedSetInput(e.target.value);
169 | };
170 |
171 | return (
172 |
173 |
174 |
181 |
182 |
183 |
184 | {({ open }) => (
185 | <>
186 | {/* Desktop */}
187 |
188 |
189 |
194 |
195 | {!open && (
196 |
197 | Sorting & Filtering
198 |
199 | )}
200 |
201 | {/* Mobile */}
202 |
203 |
204 |
209 |
210 | {!open && (
211 |
212 | Sorting & Filtering
213 |
214 | )}
215 |
216 | >
217 | )}
218 |
219 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 | );
235 | }
236 |
237 | export default SearchBar;
238 |
--------------------------------------------------------------------------------
/src/components/TagPill.tsx:
--------------------------------------------------------------------------------
1 | import { Color } from "@prisma/client";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import { Spinner, X } from "phosphor-react";
4 | import Tooltip from "./Tooltip";
5 |
6 | const tagPillStyles = cva("py-1 px-4 rounded-full select-none", {
7 | variants: {
8 | color: {
9 | sky: "bg-sky-200 text-sky-700",
10 | red: "bg-red-200 text-red-700",
11 | green: "bg-green-200 text-green-700",
12 | violet: "bg-violet-200 text-violet-700",
13 | yellow: "bg-yellow-200 text-yellow-700",
14 | lightGray: "bg-gray-300 text-gray-700",
15 | darkGray: "bg-gray-700 text-gray-300",
16 | },
17 | deletable: {
18 | true: "rounded-r-none",
19 | },
20 | dark: {
21 | true: "brightness-75 opacity-75 dark:brightness-50",
22 | },
23 | },
24 | defaultVariants: {
25 | deletable: false,
26 | dark: false,
27 | },
28 | });
29 |
30 | type TagPillProps = {
31 | label: string;
32 | onClickDelete?: (onClickDeleteProps: any) => void;
33 | loading?: boolean;
34 | destructive?: boolean;
35 | };
36 |
37 | type TagPillVariantProps = VariantProps;
38 | export type TagColor = NonNullable;
39 | interface Props extends TagPillProps, TagPillVariantProps {}
40 |
41 | // type TagPillVariantProps = VariantProps;
42 | // export type TagColor = NonNullable;
43 | // type ProcessedVariantProps =
44 | // | Omit
45 | // | (Required & {
46 | // onClickDelete: (onClickDeleteProps: any) => void;
47 | // });
48 | // // interface Props extends TagPillProps, Omit {}
49 | // interface Props extends TagPillProps, ProcessedVariantProps {}
50 |
51 | export const tagColorNames: Record = {
52 | sky: "Sky",
53 | red: "Red",
54 | green: "Green",
55 | violet: "Violet",
56 | yellow: "Yellow",
57 | lightGray: "Light Gray",
58 | darkGray: "Dark Gray",
59 | } as const;
60 |
61 | function TagPill({
62 | label,
63 | color,
64 | deletable,
65 | onClickDelete,
66 | loading = false,
67 | destructive = false,
68 | dark,
69 | }: Props) {
70 | return (
71 |
72 |
{label}
73 | {deletable && (
74 |
75 |
94 | {!loading && (
95 |
96 | {destructive ? "Delete tag" : "Remove tag"}
97 |
98 | )}
99 |
100 | )}
101 |
102 | );
103 | }
104 |
105 | export default TagPill;
106 |
--------------------------------------------------------------------------------
/src/components/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from "class-variance-authority";
2 |
3 | const tooltipStyles = cva(
4 | "absolute z-10 min-w-max rounded-lg bg-gray-100 dark:bg-gray-800 py-2 px-4 font-semibold text-gray-800 dark:text-gray-200 drop-shadow-lg transition-all scale-0 peer-hover:scale-100 peer-focus-visible:scale-100 group-focus-visible:scale-100 outline-none",
5 | {
6 | variants: {
7 | tooltipPosition: {
8 | top: "left-1/2 -translate-x-1/2 origin-bottom bottom-full mb-3",
9 | bottom: "origin-top top-full mt-3",
10 | left: "top-1/2 -translate-y-1/2 origin-right right-full mr-3",
11 | right: "origin-left left-full ml-3",
12 | },
13 | alignment: {
14 | left: "right-0",
15 | xCenter: "left-1/2 -translate-x-1/2",
16 | right: "left-0",
17 | top: "top-0",
18 | yCenter: "top-1/2 -translate-y-1/2",
19 | bottom: "bottom-0",
20 | },
21 | },
22 | }
23 | );
24 |
25 | type ToolTipProps = {
26 | // label: string;
27 | children: React.ReactNode;
28 | };
29 |
30 | type TooltipVariantProps = Required>;
31 | export type TooltipPosition = TooltipVariantProps["tooltipPosition"];
32 | export type TooltipAlignment = TooltipVariantProps["alignment"];
33 |
34 | interface Props
35 | extends ToolTipProps,
36 | Required> {}
37 |
38 | const Tooltip = ({ tooltipPosition, children, alignment }: Props) => {
39 | return (
40 |
41 | {children}
42 |
43 | );
44 | };
45 |
46 | export default Tooltip;
47 |
--------------------------------------------------------------------------------
/src/env/client.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { clientEnv, clientSchema } from "./schema.mjs";
3 |
4 | const _clientEnv = clientSchema.safeParse(clientEnv);
5 |
6 | export const formatErrors = (
7 | /** @type {import('zod').ZodFormattedError