From 5a0a573609b74704a0180e636f0f34e531d368e3 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Tue, 21 Apr 2026 11:29:05 +0000 Subject: [PATCH] Add notes functionality to the application with CRUD operations and labeling Integrates a new Notes feature, including backend API routes for notes and labels, database schema updates, frontend UI components for creating, viewing, editing, and deleting notes, and internationalization support for notes in both English and Arabic. --- artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/notes.ts | 289 +++++++++ artifacts/teaboy-os/src/App.tsx | 2 + artifacts/teaboy-os/src/lib/notes-api.ts | 134 ++++ artifacts/teaboy-os/src/locales/ar.json | 25 + artifacts/teaboy-os/src/locales/en.json | 25 + artifacts/teaboy-os/src/pages/notes.tsx | 745 +++++++++++++++++++++++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/notes.ts | 40 ++ 9 files changed, 1263 insertions(+) create mode 100644 artifacts/api-server/src/routes/notes.ts create mode 100644 artifacts/teaboy-os/src/lib/notes-api.ts create mode 100644 artifacts/teaboy-os/src/pages/notes.tsx create mode 100644 lib/db/src/schema/notes.ts 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" + /> +