Files
TX/artifacts/tx-os/src/pages/notes.tsx
T
Riyadh aa462f9f52 Rename project and remove unused services
Update project name from teaboy-os to tx-os and remove obsolete services.
2026-04-22 10:52:09 +00:00

746 lines
23 KiB
TypeScript

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>
);
}