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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": "تعديل ملاحظة"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from "./settings";
|
||||
export * from "./user-app-orders";
|
||||
export * from "./app-opens";
|
||||
export * from "./password-reset-tokens";
|
||||
export * from "./notes";
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user