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.
This commit is contained in:
Riyadh
2026-04-21 11:29:05 +00:00
parent 75147c8f24
commit 5a0a573609
9 changed files with 1263 additions and 0 deletions
+2
View File
@@ -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;
+289
View File
@@ -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<number, number[]>();
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
+2
View File
@@ -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() {
<Route path="/chat" component={ChatPage} />
<Route path="/notifications" component={NotificationsPage} />
<Route path="/admin" component={AdminPage} />
<Route path="/notes" component={NotesPage} />
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
+134
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
}
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<Note[]>(`/notes?archived=${archived}`),
});
}
export function useNoteLabels() {
return useQuery({ queryKey: labelsKey, queryFn: () => req<NoteLabel[]>("/note-labels") });
}
export function useCreateNote() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Partial<Note>) =>
req<Note>("/notes", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["notes"] });
},
});
}
export function useUpdateNote() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...body }: Partial<Note> & { id: number }) =>
req<Note>(`/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<NoteLabel>("/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<NoteLabel>(`/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;
}
+25
View File
@@ -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": "تعديل ملاحظة"
}
}
+25
View File
@@ -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"
}
}
+745
View File
@@ -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<number | null>(null);
const [editing, setEditing] = useState<Note | null>(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 (
<div className="min-h-screen os-bg flex flex-col" dir={isRtl ? "rtl" : "ltr"}>
{/* Header */}
<div className="glass-panel border-b border-slate-200/70 px-4 py-3 sticky top-0 z-20">
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => setLocation("/")}
className="p-2 rounded-xl hover:bg-slate-100/60 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("common.back", "Back")}
>
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2">
<StickyNote size={20} className="text-amber-500" />
<h1 className="text-lg font-semibold text-foreground">
{t("notes.title", "Notes")}
</h1>
</div>
<div className="flex-1 min-w-[180px] max-w-md ms-auto relative">
<Search
size={16}
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground"
style={isRtl ? { right: 10 } : { left: 10 }}
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("notes.searchPlaceholder", "Search notes")}
className={isRtl ? "pe-9" : "ps-9"}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setLabelsDialogOpen(true)}
>
<Tag size={16} className="me-1" />
{t("notes.labels", "Labels")}
</Button>
</div>
{/* Tabs + label chips */}
<div className="mt-3 flex items-center gap-2 flex-wrap">
<div className="inline-flex rounded-xl bg-slate-100/70 p-1">
<button
onClick={() => setView("active")}
className={`px-3 py-1 text-sm rounded-lg transition ${
view === "active"
? "bg-white shadow-sm text-foreground"
: "text-muted-foreground"
}`}
>
{t("notes.tabs.active", "Notes")}
</button>
<button
onClick={() => setView("archived")}
className={`px-3 py-1 text-sm rounded-lg transition ${
view === "archived"
? "bg-white shadow-sm text-foreground"
: "text-muted-foreground"
}`}
>
{t("notes.tabs.archived", "Archived")}
</button>
</div>
{labels.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setLabelFilter(null)}
className={`text-xs px-2.5 py-1 rounded-full border transition ${
labelFilter === null
? "bg-foreground text-background border-foreground"
: "border-slate-300/60 text-muted-foreground hover:border-foreground"
}`}
>
{t("notes.allLabels", "All")}
</button>
{labels.map((l) => (
<button
key={l.id}
onClick={() => setLabelFilter(l.id)}
className={`text-xs px-2.5 py-1 rounded-full border transition flex items-center gap-1 ${
labelFilter === l.id
? "bg-foreground text-background border-foreground"
: "border-slate-300/60 text-muted-foreground hover:border-foreground"
}`}
>
<Tag size={11} /> {l.name}
</button>
))}
</div>
)}
</div>
</div>
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto">
{view === "active" && (
<Composer labels={labels} />
)}
{isLoading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 gap-3 text-muted-foreground">
<StickyNote size={48} className="opacity-30" />
<span>
{view === "archived"
? t("notes.emptyArchived", "No archived notes")
: t("notes.empty", "Your notes will appear here")}
</span>
</div>
) : (
<div className="space-y-6 mt-6">
{pinned.length > 0 && (
<Section
title={t("notes.pinned", "Pinned")}
notes={pinned}
labels={labels}
onEdit={setEditing}
/>
)}
<Section
title={pinned.length > 0 ? t("notes.others", "Others") : ""}
notes={others}
labels={labels}
onEdit={setEditing}
/>
</div>
)}
</div>
{editing && (
<EditNoteDialog
note={editing}
labels={labels}
onClose={() => setEditing(null)}
/>
)}
<LabelsDialog
open={labelsDialogOpen}
onClose={() => setLabelsDialogOpen(false)}
labels={labels}
/>
</div>
);
}
function Section({
title,
notes,
labels,
onEdit,
}: {
title: string;
notes: Note[];
labels: NoteLabel[];
onEdit: (n: Note) => void;
}) {
if (notes.length === 0) return null;
return (
<div>
{title && (
<h2 className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
{title}
</h2>
)}
<div
className="gap-3 [column-fill:_balance]"
style={{ columnWidth: 230 }}
>
{notes.map((n) => (
<NoteCard key={n.id} note={n} labels={labels} onEdit={onEdit} />
))}
</div>
</div>
);
}
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 (
<div
className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group ${colorBg(
note.color,
)}`}
onClick={() => onEdit(note)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{note.title && (
<div className="font-semibold text-sm text-foreground break-words">
{note.title}
</div>
)}
{note.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
{note.content}
</div>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
update.mutate({ id: note.id, isPinned: !note.isPinned });
}}
className={`shrink-0 p-1 rounded-md hover:bg-black/5 ${
note.isPinned ? "text-amber-600" : "text-muted-foreground opacity-0 group-hover:opacity-100"
}`}
aria-label={t("notes.pin", "Pin")}
>
<Pin size={16} className={note.isPinned ? "fill-amber-500" : ""} />
</button>
</div>
{noteLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{noteLabels.map((l) => (
<Badge key={l.id} variant="secondary" className="text-[10px] py-0 px-1.5">
{l.name}
</Badge>
))}
</div>
)}
<div
className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 transition"
onClick={(e) => e.stopPropagation()}
>
<ColorPicker
value={note.color}
onChange={(c) => update.mutate({ id: note.id, color: c })}
/>
<LabelMenu
labels={labels}
selected={note.labelIds}
onChange={(ids) => update.mutate({ id: note.id, labelIds: ids })}
/>
<button
onClick={() =>
update.mutate({ id: note.id, isArchived: !note.isArchived })
}
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
aria-label={
note.isArchived
? t("notes.unarchive", "Unarchive")
: t("notes.archive", "Archive")
}
>
{note.isArchived ? <ArchiveRestore size={15} /> : <Archive size={15} />}
</button>
<button
onClick={() => {
if (confirm(t("notes.deleteConfirm", "Delete this note?"))) {
del.mutate(note.id);
}
}}
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-destructive"
aria-label={t("notes.delete", "Delete")}
>
<Trash2 size={15} />
</button>
</div>
</div>
);
}
function ColorPicker({
value,
onChange,
}: {
value: string;
onChange: (c: string) => void;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
aria-label="Color"
>
<Palette size={15} />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2">
<div className="grid grid-cols-5 gap-1.5">
{NOTE_COLORS.map((c) => (
<button
key={c.id}
onClick={() => onChange(c.id)}
className={`w-7 h-7 rounded-full border border-black/10 ${c.bg} ${
value === c.id ? "ring-2 ring-offset-1 " + c.ring : ""
}`}
aria-label={c.id}
/>
))}
</div>
</PopoverContent>
</Popover>
);
}
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 (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
aria-label="Labels"
>
<Tag size={15} />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2">
<div className="space-y-1 max-h-48 overflow-y-auto">
{labels.length === 0 && (
<div className="text-xs text-muted-foreground px-2 py-1">
{t("notes.noLabelsYet", "No labels yet")}
</div>
)}
{labels.map((l) => (
<button
key={l.id}
onClick={() => toggle(l.id)}
className="w-full flex items-center justify-between px-2 py-1 text-sm rounded hover:bg-slate-100"
>
<span className="flex items-center gap-2">
<Tag size={12} /> {l.name}
</span>
{selected.includes(l.id) && <Check size={14} />}
</button>
))}
</div>
<div className="mt-2 flex gap-1 border-t pt-2">
<Input
value={name}
onChange={(e) => 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("") });
}
}}
/>
<Button
size="sm"
variant="ghost"
disabled={!name.trim()}
onClick={() =>
create.mutate(name.trim(), { onSuccess: () => setName("") })
}
>
<Plus size={14} />
</Button>
</div>
</PopoverContent>
</Popover>
);
}
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<number[]>([]);
const create = useCreateNote();
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
className={`max-w-xl mx-auto rounded-xl border border-black/10 shadow-sm p-3 transition ${colorBg(
color,
)}`}
>
{open ? (
<>
<Input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("notes.titlePlaceholder", "Title")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
/>
<div className="flex items-center gap-1 mt-2">
<ColorPicker value={color} onChange={setColor} />
<LabelMenu
labels={labels}
selected={labelIds}
onChange={setLabelIds}
/>
<div className="ms-auto">
<Button size="sm" variant="ghost" onClick={save}>
{t("notes.save", "Save")}
</Button>
</div>
</div>
</>
) : (
<button
onClick={() => setOpen(true)}
className="w-full text-start text-muted-foreground py-1 px-1"
>
{t("notes.takeNote", "Take a note...")}
</button>
)}
</div>
);
}
function EditNoteDialog({
note,
labels,
onClose,
}: {
note: Note;
labels: NoteLabel[];
onClose: () => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(note.title);
const [content, setContent] = useState(note.content);
const [color, setColor] = useState(note.color);
const [labelIds, setLabelIds] = useState<number[]>(note.labelIds);
const update = useUpdateNote();
const save = () => {
update.mutate(
{ id: note.id, title: title.trim(), content: content.trim(), color, labelIds },
{ onSuccess: onClose },
);
};
return (
<Dialog open onOpenChange={(o) => !o && save()}>
<DialogContent
className={`max-w-lg ${colorBg(color)}`}
onInteractOutside={(e) => {
e.preventDefault();
save();
}}
>
<DialogHeader>
<DialogTitle className="sr-only">
{t("notes.editTitle", "Edit note")}
</DialogTitle>
</DialogHeader>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("notes.titlePlaceholder", "Title")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold text-base"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[160px]"
/>
<div className="flex items-center gap-1 mt-2">
<ColorPicker value={color} onChange={setColor} />
<LabelMenu labels={labels} selected={labelIds} onChange={setLabelIds} />
<div className="ms-auto">
<Button size="sm" onClick={save}>
{t("notes.save", "Save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function LabelsDialog({
open,
onClose,
labels,
}: {
open: boolean;
onClose: () => void;
labels: NoteLabel[];
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState("");
const create = useCreateLabel();
const update = useUpdateLabel();
const del = useDeleteLabel();
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("notes.manageLabels", "Manage labels")}</DialogTitle>
</DialogHeader>
<div className="flex gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("notes.newLabel", "New label")}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) {
create.mutate(name.trim(), { onSuccess: () => setName("") });
}
}}
/>
<Button
disabled={!name.trim()}
onClick={() =>
create.mutate(name.trim(), { onSuccess: () => setName("") })
}
>
<Plus size={16} />
</Button>
</div>
<div className="space-y-1 max-h-72 overflow-y-auto">
{labels.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center">
{t("notes.noLabelsYet", "No labels yet")}
</div>
)}
{labels.map((l) => (
<div
key={l.id}
className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-100 group"
>
<Tag size={14} className="text-muted-foreground" />
{editingId === l.id ? (
<>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
autoFocus
className="h-7"
onKeyDown={(e) => {
if (e.key === "Enter" && editingName.trim()) {
update.mutate(
{ id: l.id, name: editingName.trim() },
{ onSuccess: () => setEditingId(null) },
);
}
if (e.key === "Escape") setEditingId(null);
}}
/>
<button
onClick={() => {
if (editingName.trim())
update.mutate(
{ id: l.id, name: editingName.trim() },
{ onSuccess: () => setEditingId(null) },
);
}}
className="p-1 text-muted-foreground hover:text-foreground"
>
<Check size={14} />
</button>
<button
onClick={() => setEditingId(null)}
className="p-1 text-muted-foreground hover:text-foreground"
>
<X size={14} />
</button>
</>
) : (
<>
<span
className="flex-1 text-sm cursor-pointer"
onClick={() => {
setEditingId(l.id);
setEditingName(l.name);
}}
>
{l.name}
</span>
<button
onClick={() => {
if (confirm(t("notes.deleteLabelConfirm", "Delete this label?"))) {
del.mutate(l.id);
}
}}
className="p-1 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -8,3 +8,4 @@ export * from "./settings";
export * from "./user-app-orders";
export * from "./app-opens";
export * from "./password-reset-tokens";
export * from "./notes";
+40
View File
@@ -0,0 +1,40 @@
import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { usersTable } from "./users";
export const notesTable = pgTable("notes", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
title: varchar("title", { length: 300 }).notNull().default(""),
content: text("content").notNull().default(""),
color: varchar("color", { length: 32 }).notNull().default("default"),
isPinned: boolean("is_pinned").notNull().default(false),
isArchived: boolean("is_archived").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
});
export const noteLabelsTable = pgTable("note_labels", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
name: varchar("name", { length: 80 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
userNameUnique: uniqueIndex("note_labels_user_name_unique").on(t.userId, t.name),
}));
export const noteLabelAssignmentsTable = pgTable("note_label_assignments", {
noteId: integer("note_id").notNull().references(() => notesTable.id, { onDelete: "cascade" }),
labelId: integer("label_id").notNull().references(() => noteLabelsTable.id, { onDelete: "cascade" }),
}, (t) => ({
pk: primaryKey({ columns: [t.noteId, t.labelId] }),
}));
export const insertNoteSchema = createInsertSchema(notesTable).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertNote = z.infer<typeof insertNoteSchema>;
export type Note = typeof notesTable.$inferSelect;
export const insertNoteLabelSchema = createInsertSchema(noteLabelsTable).omit({ id: true, createdAt: true });
export type InsertNoteLabel = z.infer<typeof insertNoteLabelSchema>;
export type NoteLabel = typeof noteLabelsTable.$inferSelect;