Notes layout improvements and soft delete
This commit is contained in:
@@ -14,7 +14,7 @@ export const NotesSidebar: Component<{
|
|||||||
const currentUserId = pb.authStore.model?.id;
|
const currentUserId = pb.authStore.model?.id;
|
||||||
|
|
||||||
const filteredNotes = createMemo(() => {
|
const filteredNotes = createMemo(() => {
|
||||||
let n = store.notes;
|
let n = store.notes.filter(note => !note.deletedAt);
|
||||||
if (searchQuery()) {
|
if (searchQuery()) {
|
||||||
const q = searchQuery().toLowerCase();
|
const q = searchQuery().toLowerCase();
|
||||||
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
|
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
|
||||||
|
|||||||
+54
-1
@@ -146,6 +146,7 @@ export interface Note {
|
|||||||
tasks: string[]; // List of related task IDs
|
tasks: string[]; // List of related task IDs
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
|
deletedAt?: number | null; // Timestamp of soft delete or null if restored
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterTag {
|
export interface FilterTag {
|
||||||
@@ -522,7 +523,8 @@ const mapRecordToNote = (r: any): Note => {
|
|||||||
user: r.user,
|
user: r.user,
|
||||||
tasks: r.tasks || [],
|
tasks: r.tasks || [],
|
||||||
created: r.created,
|
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<Note>) => {
|
||||||
|
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 () => {
|
export const syncSystemTagsAndRules = async () => {
|
||||||
|
|||||||
+46
-29
@@ -1,14 +1,14 @@
|
|||||||
import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
import { type Component, createSignal, createMemo, createEffect, For, Show } from "solid-js";
|
||||||
import { store, renameTagDefinition } from "@/store";
|
import { store, renameTagDefinition, updateNote, removeNote, restoreNote } from "@/store";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { type Note } from "@/store";
|
import { type Note } from "@/store";
|
||||||
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
|
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { TaskEditor } from "@/components/TaskEditor";
|
import { TaskEditor } from "@/components/TaskEditor";
|
||||||
import { NOTES_COLLECTION } from "@/lib/constants";
|
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
import { NotesSidebar } from "@/components/NotesSidebar";
|
import { NotesSidebar } from "@/components/NotesSidebar";
|
||||||
|
import { toast } from "solid-sonner";
|
||||||
|
|
||||||
export const NotepadView: Component<{
|
export const NotepadView: Component<{
|
||||||
selectedNoteId: string | null;
|
selectedNoteId: string | null;
|
||||||
@@ -28,26 +28,14 @@ export const NotepadView: Component<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
|
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
|
||||||
try {
|
await updateNote(id, data);
|
||||||
await pb.collection(NOTES_COLLECTION).update(id, data);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!e.isAbort) {
|
|
||||||
console.error("Failed to update note", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let contentUpdateTimeout: number | undefined;
|
let contentUpdateTimeout: number | undefined;
|
||||||
const handleUpdateContent = (id: string, html: string) => {
|
const handleUpdateContent = (id: string, html: string) => {
|
||||||
clearTimeout(contentUpdateTimeout);
|
clearTimeout(contentUpdateTimeout);
|
||||||
contentUpdateTimeout = window.setTimeout(async () => {
|
contentUpdateTimeout = window.setTimeout(async () => {
|
||||||
try {
|
await updateNote(id, { content: html });
|
||||||
await pb.collection(NOTES_COLLECTION).update(id, { content: html });
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!e.isAbort) {
|
|
||||||
console.error("Failed to update note content", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,15 +47,22 @@ export const NotepadView: Component<{
|
|||||||
// Use '#' prefix for note tags
|
// Use '#' prefix for note tags
|
||||||
await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`);
|
await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteNote = async (id: string) => {
|
const handleDeleteNote = async (id: string) => {
|
||||||
try {
|
removeNote(id);
|
||||||
await pb.collection(NOTES_COLLECTION).delete(id);
|
if (props.selectedNoteId === id) {
|
||||||
if (props.selectedNoteId === id) {
|
props.setSelectedNoteId(null);
|
||||||
props.setSelectedNoteId(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to delete note", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Note moved to trash", {
|
||||||
|
action: {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
restoreNote(id);
|
||||||
|
props.setSelectedNoteId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLinkTask = async (taskId: string) => {
|
const handleLinkTask = async (taskId: string) => {
|
||||||
@@ -174,21 +169,42 @@ export const NotepadView: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Editor Area */}
|
{/* Editor Area */}
|
||||||
<div class="flex-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative p-4 md:p-6 space-y-4 md:space-y-6 scroll-smooth">
|
<div class="flex-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative px-4 md:px-6 pb-4 md:pb-6 space-y-4 md:space-y-6 scroll-smooth pt-0">
|
||||||
<div class="space-y-4 sticky top-0 z-30 bg-card/95 backdrop-blur-sm pt-4 pb-2 -mt-4 -mx-4 px-4 md:pt-6 md:-mt-6 md:-mx-6 md:px-6 md:pb-2">
|
<div class="space-y-4 sticky top-0 z-30 bg-card/95 backdrop-blur-sm pt-4 pb-2 -mx-4 px-4 md:pt-6 md:-mx-6 md:px-6 md:pb-2">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<input
|
<textarea
|
||||||
class="text-3xl sm:text-4xl font-black tracking-tight bg-transparent border-none outline-none focus:ring-0 flex-1 placeholder:text-muted-foreground/30 px-0 min-w-0"
|
class="text-3xl sm:text-4xl font-black tracking-tight bg-transparent border-none outline-none focus:ring-0 flex-1 placeholder:text-muted-foreground/30 px-0 min-w-0 resize-none overflow-hidden h-auto"
|
||||||
value={note().title}
|
value={note().title}
|
||||||
placeholder="Note Title"
|
placeholder="Note Title"
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
|
rows={1}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const val = e.currentTarget.value.trim() || 'Untitled';
|
const val = e.currentTarget.value.trim() || 'Untitled';
|
||||||
if (val !== note().title) handleRenameNote(note().id, note().title, val);
|
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';
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||||
<Show when={isOwner}>
|
<Show when={isOwner}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="hidden md:flex h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all rounded-xl border border-border/40 shadow-sm"
|
||||||
|
onClick={() => handleDeleteNote(note().id)}
|
||||||
|
title="Move to Trash"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -206,13 +222,14 @@ export const NotepadView: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor Instance */}
|
{/* Editor Instance */}
|
||||||
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
|
<div class="grow shrink-0 flex flex-col min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
|
||||||
<TaskEditor
|
<TaskEditor
|
||||||
content={note().content}
|
content={note().content}
|
||||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="shrink-0 h-16 md:h-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Linked Tasks Sidebar */}
|
{/* Linked Tasks Sidebar */}
|
||||||
|
|||||||
+97
-44
@@ -1,6 +1,6 @@
|
|||||||
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
||||||
import { ThemeToggle } from "../components/ThemeToggle";
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
import { store, restoreTask, deleteTaskPermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
|
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
|
||||||
import { TagPicker } from "@/components/TagPicker";
|
import { TagPicker } from "@/components/TagPicker";
|
||||||
@@ -826,51 +826,104 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isTrashOpen()}>
|
<Show when={isTrashOpen()}>
|
||||||
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
<div class="space-y-6 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
{/* Tasks Trash */}
|
||||||
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
<div class="space-y-2">
|
||||||
Trash is empty.
|
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Tasks</h4>
|
||||||
</div>
|
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
||||||
}>
|
<div class="text-center py-4 text-muted-foreground text-[0.625rem] italic border border-dashed border-border/40 rounded-xl bg-muted/5 uppercase tracking-widest">
|
||||||
{(task) => {
|
No trashed tasks.
|
||||||
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
</div>
|
||||||
return (
|
}>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
|
{(task) => {
|
||||||
<div class="min-w-0 flex-1 px-1">
|
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
|
return (
|
||||||
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
|
||||||
|
<div class="min-w-0 flex-1 px-1">
|
||||||
|
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
|
||||||
|
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="h-9 px-4 text-[0.625rem] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
|
||||||
|
onClick={() => {
|
||||||
|
restoreTask(task.id);
|
||||||
|
toast.success("Task restored");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Undo2 size={14} class="mr-2" />
|
||||||
|
Recover
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
||||||
|
deleteTaskPermanently(task.id);
|
||||||
|
toast.error("Task permanently deleted");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
|
);
|
||||||
<Button
|
}}
|
||||||
variant="secondary"
|
</For>
|
||||||
size="sm"
|
</div>
|
||||||
class="h-9 px-4 text-[0.625rem] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
|
|
||||||
onClick={() => {
|
{/* Notes Trash */}
|
||||||
restoreTask(task.id);
|
<div class="space-y-2">
|
||||||
toast.success("Task restored");
|
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Notes</h4>
|
||||||
}}
|
<For each={store.notes.filter(n => n.deletedAt)} fallback={
|
||||||
>
|
<div class="text-center py-4 text-muted-foreground text-[0.625rem] italic border border-dashed border-border/40 rounded-xl bg-muted/5 uppercase tracking-widest">
|
||||||
<Undo2 size={14} class="mr-2" />
|
No trashed notes.
|
||||||
Recover
|
</div>
|
||||||
</Button>
|
}>
|
||||||
<Button
|
{(note) => {
|
||||||
variant="ghost"
|
const daysLeft = Math.ceil(((note.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
size="icon"
|
return (
|
||||||
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
|
||||||
onClick={() => {
|
<div class="min-w-0 flex-1 px-1">
|
||||||
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
<p class="font-semibold truncate text-sm sm:text-base">{note.title || "Untitled"}</p>
|
||||||
deleteTaskPermanently(task.id);
|
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
||||||
toast.error("Task permanently deleted");
|
</div>
|
||||||
}
|
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
|
||||||
}}
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
<Trash2 size={14} />
|
size="sm"
|
||||||
</Button>
|
class="h-9 px-4 text-[0.625rem] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
|
||||||
|
onClick={() => {
|
||||||
|
restoreNote(note.id);
|
||||||
|
toast.success("Note restored");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Undo2 size={14} class="mr-2" />
|
||||||
|
Recover
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Permanently delete this note? This cannot be undone.")) {
|
||||||
|
deleteNotePermanently(note.id);
|
||||||
|
toast.error("Note permanently deleted");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}}
|
||||||
}}
|
</For>
|
||||||
</For>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user