diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index cc9b7fc7..54e40480 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -9,6 +9,7 @@ import usersRouter from "./users"; import statsRouter from "./stats"; import storageRouter from "./storage"; import settingsRouter from "./settings"; +import notesRouter from "./notes"; const router: IRouter = Router(); @@ -22,5 +23,6 @@ router.use(usersRouter); router.use(statsRouter); router.use(storageRouter); router.use(settingsRouter); +router.use(notesRouter); export default router; diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts new file mode 100644 index 00000000..33b9f86c --- /dev/null +++ b/artifacts/api-server/src/routes/notes.ts @@ -0,0 +1,289 @@ +import { Router, type IRouter } from "express"; +import { eq, and, desc, inArray } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { + notesTable, + noteLabelsTable, + noteLabelAssignmentsTable, +} from "@workspace/db"; +import { requireAuth } from "../middlewares/auth"; + +const router: IRouter = Router(); + +function parseIdParam(raw: unknown): { ok: true; id: number } | { ok: false; error: string } { + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) return { ok: false, error: "Invalid id" }; + return { ok: true, id: n }; +} + +function parseStringMax(v: unknown, max: number): string | undefined { + if (v === undefined || v === null) return undefined; + if (typeof v !== "string") return undefined; + return v.slice(0, max); +} + +function parseLabelIds(v: unknown): number[] | undefined { + if (v === undefined) return undefined; + if (!Array.isArray(v)) return undefined; + const out: number[] = []; + for (const x of v) { + const n = Number(x); + if (Number.isInteger(n) && n > 0) out.push(n); + } + return out; +} + +interface NoteInput { + title?: string; + content?: string; + color?: string; + isPinned?: boolean; + isArchived?: boolean; + labelIds?: number[]; +} + +function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInput } | { ok: false; error: string } { + if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" }; + const data: NoteInput = {}; + if (body.title !== undefined) { + const t = parseStringMax(body.title, 300); + if (t === undefined) return { ok: false, error: "Invalid title" }; + data.title = t; + } else if (isCreate) data.title = ""; + if (body.content !== undefined) { + if (typeof body.content !== "string") return { ok: false, error: "Invalid content" }; + data.content = body.content; + } else if (isCreate) data.content = ""; + if (body.color !== undefined) { + const c = parseStringMax(body.color, 32); + if (c === undefined) return { ok: false, error: "Invalid color" }; + data.color = c; + } else if (isCreate) data.color = "default"; + if (body.isPinned !== undefined) { + if (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" }; + data.isPinned = body.isPinned; + } else if (isCreate) data.isPinned = false; + if (body.isArchived !== undefined) { + if (typeof body.isArchived !== "boolean") return { ok: false, error: "Invalid isArchived" }; + data.isArchived = body.isArchived; + } + if (body.labelIds !== undefined) { + const ids = parseLabelIds(body.labelIds); + if (ids === undefined) return { ok: false, error: "Invalid labelIds" }; + data.labelIds = ids; + } else if (isCreate) data.labelIds = []; + return { ok: true, data }; +} + +function parseLabelName(body: any): { ok: true; name: string } | { ok: false; error: string } { + if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" }; + if (typeof body.name !== "string") return { ok: false, error: "Invalid name" }; + const name = body.name.trim(); + if (name.length === 0 || name.length > 80) return { ok: false, error: "Invalid name length" }; + return { ok: true, name }; +} + +async function loadNotesWithLabels(userId: number, archived: boolean) { + const notes = await db + .select() + .from(notesTable) + .where(and(eq(notesTable.userId, userId), eq(notesTable.isArchived, archived))) + .orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt)); + + if (notes.length === 0) return []; + + const noteIds = notes.map((n) => n.id); + const assignments = await db + .select() + .from(noteLabelAssignmentsTable) + .where(inArray(noteLabelAssignmentsTable.noteId, noteIds)); + + const labelsByNote = new Map(); + for (const a of assignments) { + const arr = labelsByNote.get(a.noteId) ?? []; + arr.push(a.labelId); + labelsByNote.set(a.noteId, arr); + } + + return notes.map((n) => ({ ...n, labelIds: labelsByNote.get(n.id) ?? [] })); +} + +async function setNoteLabels(noteId: number, userId: number, labelIds: number[]) { + await db.delete(noteLabelAssignmentsTable).where(eq(noteLabelAssignmentsTable.noteId, noteId)); + if (labelIds.length === 0) return; + const valid = await db + .select({ id: noteLabelsTable.id }) + .from(noteLabelsTable) + .where(and(eq(noteLabelsTable.userId, userId), inArray(noteLabelsTable.id, labelIds))); + if (valid.length === 0) return; + await db.insert(noteLabelAssignmentsTable).values( + valid.map((l) => ({ noteId, labelId: l.id })), + ); +} + +router.get("/notes", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const archived = req.query.archived === "true"; + const notes = await loadNotesWithLabels(userId, archived); + res.json(notes); +}); + +router.post("/notes", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const parsed = parseNoteInput(req.body, true); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }); + return; + } + const { labelIds = [], ...data } = parsed.data; + const [note] = await db + .insert(notesTable) + .values({ + userId, + title: data.title ?? "", + content: data.content ?? "", + color: data.color ?? "default", + isPinned: data.isPinned ?? false, + isArchived: data.isArchived ?? false, + }) + .returning(); + if (labelIds.length > 0) await setNoteLabels(note.id, userId, labelIds); + const [withLabels] = await loadOne(note.id, userId); + res.status(201).json(withLabels); +}); + +router.patch("/notes/:id", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const id = parseIdParam(req.params.id); + if (!id.ok) { + res.status(400).json({ error: id.error }); + return; + } + const parsed = parseNoteInput(req.body, false); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }); + return; + } + const { labelIds, ...data } = parsed.data; + const [existing] = await db + .select() + .from(notesTable) + .where(and(eq(notesTable.id, id.id), eq(notesTable.userId, userId))); + if (!existing) { + res.status(404).json({ error: "Note not found" }); + return; + } + if (Object.keys(data).length > 0) { + await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id)); + } + if (labelIds) await setNoteLabels(existing.id, userId, labelIds); + const [withLabels] = await loadOne(existing.id, userId); + res.json(withLabels); +}); + +async function loadOne(noteId: number, userId: number) { + const [note] = await db + .select() + .from(notesTable) + .where(and(eq(notesTable.id, noteId), eq(notesTable.userId, userId))); + if (!note) return [null]; + const assignments = await db + .select() + .from(noteLabelAssignmentsTable) + .where(eq(noteLabelAssignmentsTable.noteId, noteId)); + return [{ ...note, labelIds: assignments.map((a) => a.labelId) }]; +} + +router.delete("/notes/:id", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const id = parseIdParam(req.params.id); + if (!id.ok) { + res.status(400).json({ error: id.error }); + return; + } + const result = await db + .delete(notesTable) + .where(and(eq(notesTable.id, id.id), eq(notesTable.userId, userId))) + .returning(); + if (result.length === 0) { + res.status(404).json({ error: "Note not found" }); + return; + } + res.json({ success: true }); +}); + +router.get("/note-labels", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const labels = await db + .select() + .from(noteLabelsTable) + .where(eq(noteLabelsTable.userId, userId)) + .orderBy(noteLabelsTable.name); + res.json(labels); +}); + +router.post("/note-labels", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const parsed = parseLabelName(req.body); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }); + return; + } + try { + const [label] = await db + .insert(noteLabelsTable) + .values({ userId, name: parsed.name }) + .returning(); + res.status(201).json(label); + } catch (e) { + res.status(409).json({ error: "Label already exists" }); + } +}); + +router.patch("/note-labels/:id", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const id = parseIdParam(req.params.id); + if (!id.ok) { + res.status(400).json({ error: id.error }); + return; + } + const parsed = parseLabelName(req.body); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }); + return; + } + try { + const [label] = await db + .update(noteLabelsTable) + .set({ name: parsed.name }) + .where(and(eq(noteLabelsTable.id, id.id), eq(noteLabelsTable.userId, userId))) + .returning(); + if (!label) { + res.status(404).json({ error: "Label not found" }); + return; + } + res.json(label); + } catch (e) { + res.status(409).json({ error: "Label already exists" }); + } +}); + +router.delete("/note-labels/:id", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const id = parseIdParam(req.params.id); + if (!id.ok) { + res.status(400).json({ error: id.error }); + return; + } + const result = await db + .delete(noteLabelsTable) + .where(and(eq(noteLabelsTable.id, id.id), eq(noteLabelsTable.userId, userId))) + .returning(); + if (result.length === 0) { + res.status(404).json({ error: "Label not found" }); + return; + } + res.json({ success: true }); +}); + +export default router; diff --git a/artifacts/teaboy-os/src/App.tsx b/artifacts/teaboy-os/src/App.tsx index 0d078584..fa833bbc 100644 --- a/artifacts/teaboy-os/src/App.tsx +++ b/artifacts/teaboy-os/src/App.tsx @@ -14,6 +14,7 @@ import ServicesPage from "@/pages/services"; import ChatPage from "@/pages/chat"; import NotificationsPage from "@/pages/notifications"; import AdminPage from "@/pages/admin"; +import NotesPage from "@/pages/notes"; const queryClient = new QueryClient({ defaultOptions: { @@ -42,6 +43,7 @@ function Router() { + diff --git a/artifacts/teaboy-os/src/lib/notes-api.ts b/artifacts/teaboy-os/src/lib/notes-api.ts new file mode 100644 index 00000000..2ea3c197 --- /dev/null +++ b/artifacts/teaboy-os/src/lib/notes-api.ts @@ -0,0 +1,134 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +export interface Note { + id: number; + userId: number; + title: string; + content: string; + color: string; + isPinned: boolean; + isArchived: boolean; + createdAt: string; + updatedAt: string; + labelIds: number[]; +} + +export interface NoteLabel { + id: number; + userId: number; + name: string; + createdAt: string; +} + +const BASE = `${import.meta.env.BASE_URL}api`.replace(/\/+/g, "/"); + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + ...init, + }); + if (!res.ok) { + let msg = `${res.status}`; + try { + const j = await res.json(); + msg = j.error ?? msg; + } catch {} + throw new Error(msg); + } + return res.json() as Promise; +} + +export const notesKey = (archived: boolean) => ["notes", { archived }] as const; +export const labelsKey = ["note-labels"] as const; + +export function useNotes(archived: boolean) { + return useQuery({ + queryKey: notesKey(archived), + queryFn: () => req(`/notes?archived=${archived}`), + }); +} + +export function useNoteLabels() { + return useQuery({ queryKey: labelsKey, queryFn: () => req("/note-labels") }); +} + +export function useCreateNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: Partial) => + req("/notes", { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useUpdateNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...body }: Partial & { id: number }) => + req(`/notes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useDeleteNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => req<{ success: true }>(`/notes/${id}`, { method: "DELETE" }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["notes"] }), + }); +} + +export function useCreateLabel() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => + req("/note-labels", { method: "POST", body: JSON.stringify({ name }) }), + onSuccess: () => qc.invalidateQueries({ queryKey: labelsKey }), + }); +} + +export function useUpdateLabel() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, name }: { id: number; name: string }) => + req(`/note-labels/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: labelsKey }); + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useDeleteLabel() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + req<{ success: true }>(`/note-labels/${id}`, { method: "DELETE" }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: labelsKey }); + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export const NOTE_COLORS: { id: string; bg: string; ring: string }[] = [ + { id: "default", bg: "bg-white dark:bg-card", ring: "ring-slate-300" }, + { id: "red", bg: "bg-red-100 dark:bg-red-900/40", ring: "ring-red-400" }, + { id: "orange", bg: "bg-orange-100 dark:bg-orange-900/40", ring: "ring-orange-400" }, + { id: "yellow", bg: "bg-yellow-100 dark:bg-yellow-900/40", ring: "ring-yellow-400" }, + { id: "green", bg: "bg-green-100 dark:bg-green-900/40", ring: "ring-green-400" }, + { id: "teal", bg: "bg-teal-100 dark:bg-teal-900/40", ring: "ring-teal-400" }, + { id: "blue", bg: "bg-sky-100 dark:bg-sky-900/40", ring: "ring-sky-400" }, + { id: "purple", bg: "bg-purple-100 dark:bg-purple-900/40", ring: "ring-purple-400" }, + { id: "pink", bg: "bg-pink-100 dark:bg-pink-900/40", ring: "ring-pink-400" }, + { id: "gray", bg: "bg-slate-200 dark:bg-slate-800/60", ring: "ring-slate-400" }, +]; + +export function colorBg(id: string): string { + return NOTE_COLORS.find((c) => c.id === id)?.bg ?? NOTE_COLORS[0].bg; +} diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index ec1881fe..2d0d6b14 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -348,5 +348,30 @@ "success": "تمت العملية بنجاح", "language": "English", "appName": "نظام TeaBoy" + }, + "notes": { + "title": "الملاحظات", + "searchPlaceholder": "ابحث في الملاحظات", + "labels": "التصنيفات", + "manageLabels": "إدارة التصنيفات", + "newLabel": "تصنيف جديد", + "noLabelsYet": "لا توجد تصنيفات بعد", + "allLabels": "الكل", + "tabs": { "active": "ملاحظات", "archived": "الأرشيف" }, + "pinned": "مثبّتة", + "others": "أخرى", + "empty": "ستظهر ملاحظاتك هنا", + "emptyArchived": "لا توجد ملاحظات في الأرشيف", + "takeNote": "اكتب ملاحظة...", + "titlePlaceholder": "العنوان", + "contentPlaceholder": "اكتب ملاحظة...", + "save": "حفظ", + "pin": "تثبيت", + "archive": "أرشفة", + "unarchive": "إلغاء الأرشفة", + "delete": "حذف", + "deleteConfirm": "حذف هذه الملاحظة؟", + "deleteLabelConfirm": "حذف هذا التصنيف؟", + "editTitle": "تعديل ملاحظة" } } diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 665139c4..601a7bd7 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -345,5 +345,30 @@ "success": "Success", "language": "العربية", "appName": "TeaBoy OS" + }, + "notes": { + "title": "Notes", + "searchPlaceholder": "Search notes", + "labels": "Labels", + "manageLabels": "Manage labels", + "newLabel": "New label", + "noLabelsYet": "No labels yet", + "allLabels": "All", + "tabs": { "active": "Notes", "archived": "Archived" }, + "pinned": "Pinned", + "others": "Others", + "empty": "Your notes will appear here", + "emptyArchived": "No archived notes", + "takeNote": "Take a note...", + "titlePlaceholder": "Title", + "contentPlaceholder": "Take a note...", + "save": "Save", + "pin": "Pin", + "archive": "Archive", + "unarchive": "Unarchive", + "delete": "Delete", + "deleteConfirm": "Delete this note?", + "deleteLabelConfirm": "Delete this label?", + "editTitle": "Edit note" } } diff --git a/artifacts/teaboy-os/src/pages/notes.tsx b/artifacts/teaboy-os/src/pages/notes.tsx new file mode 100644 index 00000000..5054e963 --- /dev/null +++ b/artifacts/teaboy-os/src/pages/notes.tsx @@ -0,0 +1,745 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "wouter"; +import { + ArrowLeft, + ArrowRight, + StickyNote, + Pin, + Archive, + ArchiveRestore, + Trash2, + Palette, + Tag, + Search, + Plus, + X, + Check, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + type Note, + type NoteLabel, + NOTE_COLORS, + colorBg, + useCreateLabel, + useCreateNote, + useDeleteLabel, + useDeleteNote, + useNoteLabels, + useNotes, + useUpdateLabel, + useUpdateNote, +} from "@/lib/notes-api"; + +export default function NotesPage() { + const { t, i18n } = useTranslation(); + const [, setLocation] = useLocation(); + const isRtl = i18n.language === "ar"; + const BackIcon = isRtl ? ArrowRight : ArrowLeft; + + const [view, setView] = useState<"active" | "archived">("active"); + const [search, setSearch] = useState(""); + const [labelFilter, setLabelFilter] = useState(null); + const [editing, setEditing] = useState(null); + const [labelsDialogOpen, setLabelsDialogOpen] = useState(false); + + const { data: notes = [], isLoading } = useNotes(view === "archived"); + const { data: labels = [] } = useNoteLabels(); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return notes.filter((n) => { + if (labelFilter && !n.labelIds.includes(labelFilter)) return false; + if (!q) return true; + return ( + n.title.toLowerCase().includes(q) || + n.content.toLowerCase().includes(q) + ); + }); + }, [notes, search, labelFilter]); + + const pinned = filtered.filter((n) => n.isPinned); + const others = filtered.filter((n) => !n.isPinned); + + return ( +
+ {/* Header */} +
+
+ +
+ +

+ {t("notes.title", "Notes")} +

+
+ +
+ + setSearch(e.target.value)} + placeholder={t("notes.searchPlaceholder", "Search notes")} + className={isRtl ? "pe-9" : "ps-9"} + /> +
+ + +
+ + {/* Tabs + label chips */} +
+
+ + +
+ {labels.length > 0 && ( +
+ + {labels.map((l) => ( + + ))} +
+ )} +
+
+ +
+ {view === "active" && ( + + )} + + {isLoading ? ( +
+ {t("common.loading", "Loading...")} +
+ ) : filtered.length === 0 ? ( +
+ + + {view === "archived" + ? t("notes.emptyArchived", "No archived notes") + : t("notes.empty", "Your notes will appear here")} + +
+ ) : ( +
+ {pinned.length > 0 && ( +
+ )} +
0 ? t("notes.others", "Others") : ""} + notes={others} + labels={labels} + onEdit={setEditing} + /> +
+ )} +
+ + {editing && ( + setEditing(null)} + /> + )} + + setLabelsDialogOpen(false)} + labels={labels} + /> +
+ ); +} + +function Section({ + title, + notes, + labels, + onEdit, +}: { + title: string; + notes: Note[]; + labels: NoteLabel[]; + onEdit: (n: Note) => void; +}) { + if (notes.length === 0) return null; + return ( +
+ {title && ( +

+ {title} +

+ )} +
+ {notes.map((n) => ( + + ))} +
+
+ ); +} + +function NoteCard({ + note, + labels, + onEdit, +}: { + note: Note; + labels: NoteLabel[]; + onEdit: (n: Note) => void; +}) { + const { t } = useTranslation(); + const update = useUpdateNote(); + const del = useDeleteNote(); + const noteLabels = labels.filter((l) => note.labelIds.includes(l.id)); + + return ( +
onEdit(note)} + > +
+
+ {note.title && ( +
+ {note.title} +
+ )} + {note.content && ( +
+ {note.content} +
+ )} +
+ +
+ + {noteLabels.length > 0 && ( +
+ {noteLabels.map((l) => ( + + {l.name} + + ))} +
+ )} + +
e.stopPropagation()} + > + update.mutate({ id: note.id, color: c })} + /> + update.mutate({ id: note.id, labelIds: ids })} + /> + + +
+
+ ); +} + +function ColorPicker({ + value, + onChange, +}: { + value: string; + onChange: (c: string) => void; +}) { + return ( + + + + + +
+ {NOTE_COLORS.map((c) => ( +
+
+
+ ); +} + +function LabelMenu({ + labels, + selected, + onChange, +}: { + labels: NoteLabel[]; + selected: number[]; + onChange: (ids: number[]) => void; +}) { + const { t } = useTranslation(); + const create = useCreateLabel(); + const [name, setName] = useState(""); + + const toggle = (id: number) => { + if (selected.includes(id)) onChange(selected.filter((x) => x !== id)); + else onChange([...selected, id]); + }; + + return ( + + + + + +
+ {labels.length === 0 && ( +
+ {t("notes.noLabelsYet", "No labels yet")} +
+ )} + {labels.map((l) => ( + + ))} +
+
+ setName(e.target.value)} + placeholder={t("notes.newLabel", "New label")} + className="h-8 text-sm" + onKeyDown={(e) => { + if (e.key === "Enter" && name.trim()) { + create.mutate(name.trim(), { onSuccess: () => setName("") }); + } + }} + /> + +
+
+
+ ); +} + +function Composer({ labels }: { labels: NoteLabel[] }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [color, setColor] = useState("default"); + const [labelIds, setLabelIds] = useState([]); + const create = useCreateNote(); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onClick = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) { + save(); + } + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, title, content, color, labelIds]); + + const reset = () => { + setTitle(""); + setContent(""); + setColor("default"); + setLabelIds([]); + setOpen(false); + }; + + const save = () => { + if (!title.trim() && !content.trim()) { + reset(); + return; + } + create.mutate( + { title: title.trim(), content: content.trim(), color, labelIds }, + { onSuccess: reset }, + ); + }; + + return ( +
+ {open ? ( + <> + setTitle(e.target.value)} + placeholder={t("notes.titlePlaceholder", "Title")} + className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold" + /> +