diff --git a/src/components/NotesSidebar.tsx b/src/components/NotesSidebar.tsx index 6e79bc0..72c3036 100644 --- a/src/components/NotesSidebar.tsx +++ b/src/components/NotesSidebar.tsx @@ -14,7 +14,7 @@ export const NotesSidebar: Component<{ const currentUserId = pb.authStore.model?.id; const filteredNotes = createMemo(() => { - let n = store.notes; + let n = store.notes.filter(note => !note.deletedAt); if (searchQuery()) { const q = searchQuery().toLowerCase(); n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q))); diff --git a/src/store/index.ts b/src/store/index.ts index e24fd4e..568f7c8 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -146,6 +146,7 @@ export interface Note { tasks: string[]; // List of related task IDs created: string; updated: string; + deletedAt?: number | null; // Timestamp of soft delete or null if restored } export interface FilterTag { @@ -522,7 +523,8 @@ const mapRecordToNote = (r: any): Note => { user: r.user, tasks: r.tasks || [], created: r.created, - updated: r.updated + updated: r.updated, + deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined }; }; @@ -1648,6 +1650,57 @@ export const removeTagDefinition = async (name: string) => { } }; +export const updateNote = async (id: string, updates: Partial) => { + if (!pb.authStore.isValid) return; + + // Optimistic update + const optimisticUpdates = { + ...updates, + updated: new Date().toISOString() + }; + setStore("notes", (n) => n.id === id, optimisticUpdates); + + try { + const pbUpdates: any = { ...updates }; + + if ("deletedAt" in updates) { + if (updates.deletedAt) { + pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString(); + } else { + pbUpdates.deletedAt = null; + } + } + + await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null }); + } catch (err) { + console.error("Note update failed", err); + toast.error("Failed to update note."); + } +}; + +export const removeNote = (id: string) => { + const nowTs = Date.now(); + updateNote(id, { deletedAt: nowTs }); +}; + +export const restoreNote = (id: string) => { + updateNote(id, { deletedAt: null }); +}; + +export const deleteNotePermanently = async (id: string) => { + if (!pb.authStore.isValid) return; + + // Optimistic + setStore("notes", (n) => n.filter((n) => n.id !== id)); + + try { + await pb.collection(NOTES_COLLECTION).delete(id); + } catch (err) { + console.error("Note delete failed", err); + toast.error("Failed to delete note."); + } +}; + export const syncSystemTagsAndRules = async () => { diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx index ee9b1a4..b2caedb 100644 --- a/src/views/NotepadView.tsx +++ b/src/views/NotepadView.tsx @@ -1,14 +1,14 @@ -import { type Component, createSignal, createMemo, For, Show } from "solid-js"; -import { store, renameTagDefinition } from "@/store"; +import { type Component, createSignal, createMemo, createEffect, For, Show } from "solid-js"; +import { store, renameTagDefinition, updateNote, removeNote, restoreNote } from "@/store"; import { pb } from "@/lib/pocketbase"; import { type Note } from "@/store"; import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { TaskEditor } from "@/components/TaskEditor"; -import { NOTES_COLLECTION } from "@/lib/constants"; import { TaskCard } from "@/components/TaskCard"; import { NotesSidebar } from "@/components/NotesSidebar"; +import { toast } from "solid-sonner"; export const NotepadView: Component<{ selectedNoteId: string | null; @@ -28,26 +28,14 @@ export const NotepadView: Component<{ }); const handleUpdateNote = async (id: string, data: Partial) => { - try { - await pb.collection(NOTES_COLLECTION).update(id, data); - } catch (e: any) { - if (!e.isAbort) { - console.error("Failed to update note", e); - } - } + await updateNote(id, data); }; let contentUpdateTimeout: number | undefined; const handleUpdateContent = (id: string, html: string) => { clearTimeout(contentUpdateTimeout); contentUpdateTimeout = window.setTimeout(async () => { - try { - await pb.collection(NOTES_COLLECTION).update(id, { content: html }); - } catch (e: any) { - if (!e.isAbort) { - console.error("Failed to update note content", e); - } - } + await updateNote(id, { content: html }); }, 500); }; @@ -59,15 +47,22 @@ export const NotepadView: Component<{ // Use '#' prefix for note tags await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`); }; + const handleDeleteNote = async (id: string) => { - try { - await pb.collection(NOTES_COLLECTION).delete(id); - if (props.selectedNoteId === id) { - props.setSelectedNoteId(null); - } - } catch (e) { - console.error("Failed to delete note", e); + removeNote(id); + if (props.selectedNoteId === id) { + props.setSelectedNoteId(null); } + + toast.success("Note moved to trash", { + action: { + label: "Undo", + onClick: () => { + restoreNote(id); + props.setSelectedNoteId(id); + } + } + }); }; const handleLinkTask = async (taskId: string) => { @@ -174,21 +169,42 @@ export const NotepadView: Component<{ return ( <> {/* Editor Area */} -
-
+
+
- { const val = e.currentTarget.value.trim() || 'Untitled'; if (val !== note().title) handleRenameNote(note().id, note().title, val); }} + onInput={(e) => { + e.currentTarget.style.height = 'auto'; + e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px'; + }} + ref={(el) => { + createEffect(() => { + note().title; + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; + }); + }} />
+ + +
-
- - + ); + }} + +
+ + {/* Notes Trash */} +
+

Notes

+ n.deletedAt)} fallback={ +
+ No trashed notes. +
+ }> + {(note) => { + const daysLeft = Math.ceil(((note.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24)); + return ( +
+
+

{note.title || "Untitled"}

+

Expires in {Math.max(0, daysLeft)} days

+
+
+ + +
-
- ); - }} - + ); + }} + +