Compare commits

...

3 Commits

Author SHA1 Message Date
tcardoza 0e85603699 added attachment cleanup in tasks
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m25s
CI / deploy_dev (push) Has been skipped
2026-03-04 18:25:37 -06:00
tcardoza 4c096f18d7 Images adding in notes and cleanup of orphaned images from notes 2026-03-04 18:20:27 -06:00
tcardoza 9cbe7dcb12 Notes layout improvements and soft delete 2026-03-04 17:44:24 -06:00
6 changed files with 350 additions and 82 deletions
+1 -1
View File
@@ -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)));
+11 -4
View File
@@ -10,7 +10,7 @@ import {
Trash2
} from "lucide-solid";
import { cn, convertImageToWebpFile } from "@/lib/utils";
import { activeTaskId, uploadTaskAttachment } from "@/store";
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
export interface CommandItem {
@@ -123,9 +123,12 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
icon: ImageIcon,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).run();
const taskId = activeTaskId();
if (!taskId) {
toast.error("Cannot upload image outside of a task.");
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload image outside of a task or note.");
return;
}
@@ -138,7 +141,11 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
toast.promise(
(async () => {
const webpFile = await convertImageToWebpFile(originalFile);
return uploadTaskAttachment(taskId, webpFile);
if (taskId) {
return uploadTaskAttachment(taskId, webpFile);
} else {
return uploadNoteAttachment(noteId!, webpFile);
}
})(),
{
loading: "Processing & uploading image...",
+25 -3
View File
@@ -1,6 +1,6 @@
import { type Component, createEffect, createSignal, For, createMemo } from "solid-js";
import { type Component, createEffect, createSignal, For, createMemo, onCleanup } from "solid-js";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext } from "@/store";
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments } from "@/store";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings } from "lucide-solid";
@@ -110,9 +110,31 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
}, 1000);
};
// Removed onCleanup to avoid lockup
// Sync activeTaskId for slash commands and run cleanup on unmount/change
createEffect(() => {
const currentTaskId = props.task.id;
onCleanup(() => {
// 1. Immediately flush any pending debounce updates for the outgoing task
if (debounceTimer) {
clearTimeout(debounceTimer);
if (pendingContent !== null) {
updateTask(currentTaskId, { content: pendingContent });
}
}
// 2. Clear pending state
debounceTimer = undefined;
pendingContent = null;
// 3. Trigger cleanup after a tiny delay so the updateTask above processes first
if (currentTaskId) {
setTimeout(() => {
cleanupTaskAttachments(currentTaskId).catch(console.error);
}, 100);
}
});
});
const updateTitle = (val: string) => {
setTitle(val);
updateTask(props.task.id, { title: val });
+133 -1
View File
@@ -15,6 +15,7 @@ export const [now, setNow] = createSignal(Date.now());
// Context: 'mine' = My Bucket (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View
type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string };
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>('mine');
export type UrgencyLevel = number;
@@ -146,6 +147,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 {
@@ -501,6 +503,84 @@ export const uploadTaskAttachment = async (taskId: string, file: File): Promise<
}
};
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<string> => {
try {
const formData = new FormData();
formData.append('attachments', file);
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
if (record.attachments && record.attachments.length > 0) {
const latestFilename = record.attachments[record.attachments.length - 1];
return pb.files.getURL(record, latestFilename);
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading note attachment:", err);
toast.error("Failed to upload image to note.");
throw err;
}
};
export const cleanupNoteAttachments = async (noteId: string) => {
if (!pb.authStore.isValid) return;
try {
const note = store.notes.find(n => n.id === noteId);
if (!note) return;
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
if (!record.attachments || record.attachments.length === 0) return;
const content = note.content || "";
const filesToDelete: string[] = [];
for (const filename of record.attachments) {
if (!content.includes(filename)) {
filesToDelete.push(filename);
}
}
if (filesToDelete.length > 0) {
await pb.collection(NOTES_COLLECTION).update(noteId, {
"attachments-": filesToDelete
});
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`);
}
} catch (err) {
console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err);
}
};
export const cleanupTaskAttachments = async (taskId: string) => {
if (!pb.authStore.isValid) return;
try {
const task = store.tasks.find(t => t.id === taskId);
if (!task) return;
const record = await pb.collection(TASGRID_COLLECTION).getOne(taskId);
if (!record.attachments || record.attachments.length === 0) return;
const content = task.content || "";
const filesToDelete: string[] = [];
for (const filename of record.attachments) {
if (!content.includes(filename)) {
filesToDelete.push(filename);
}
}
if (filesToDelete.length > 0) {
await pb.collection(TASGRID_COLLECTION).update(taskId, {
"attachments-": filesToDelete
});
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for task ${taskId}`);
}
} catch (err) {
console.error("[CLEANUP] Failed to clean up orphaned task attachments:", err);
}
};
const mapRecordToShareRule = (r: any): ShareRule => {
return {
id: r.id,
@@ -522,7 +602,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 +1729,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 () => {
+83 -29
View File
@@ -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, onCleanup, For, Show } from "solid-js";
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments } 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;
@@ -21,6 +21,38 @@ export const NotepadView: Component<{
const currentUserId = pb.authStore.model?.id;
// Sync activeNoteId for slash commands and run cleanup on unmount/change
createEffect(() => {
// By reading the prop inside the effect block, SolidJS tracks it reactively.
const currentId = props.selectedNoteId;
setActiveNoteId(currentId);
// This will run when the effect re-runs (currentId changes) or the component unmounts
onCleanup(() => {
setActiveNoteId(null);
// 1. Immediately flush any pending debounce updates for the outgoing note
if (contentUpdateTimeout) {
clearTimeout(contentUpdateTimeout);
if (pendingContentUpdate) {
// Fire update manually to PocketBase & Store synchronously (fire-and-forget)
updateNote(pendingContentUpdate.id, { content: pendingContentUpdate.html }).catch(console.error);
}
}
// 2. Clear pending state
contentUpdateTimeout = undefined;
pendingContentUpdate = null;
// 3. Trigger cleanup after a tiny delay so the updateNote above processes first
if (currentId) {
setTimeout(() => {
cleanupNoteAttachments(currentId).catch(console.error);
}, 100);
}
});
});
// Derived states
const activeNote = createMemo(() => {
const id = props.selectedNoteId?.trim();
@@ -28,26 +60,19 @@ export const NotepadView: Component<{
});
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
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;
let pendingContentUpdate: { id: string, html: string } | null = null;
const handleUpdateContent = (id: string, html: string) => {
clearTimeout(contentUpdateTimeout);
pendingContentUpdate = { id, html };
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 });
pendingContentUpdate = null;
}, 500);
};
@@ -59,15 +84,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 +206,42 @@ export const NotepadView: Component<{
return (
<>
{/* 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="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="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 -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">
<input
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"
<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 resize-none overflow-hidden h-auto"
value={note().title}
placeholder="Note Title"
readOnly={!canEdit}
rows={1}
onBlur={(e) => {
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';
});
}}
/>
<div class="flex items-center gap-2 shrink-0 pt-1">
<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
variant="ghost"
size="sm"
@@ -206,13 +259,14 @@ export const NotepadView: Component<{
</div>
{/* 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
content={note().content}
onUpdate={(html) => handleUpdateContent(note().id, html)}
editable={canEdit}
/>
</div>
<div class="shrink-0 h-16 md:h-8" />
</div>
{/* Linked Tasks Sidebar */}
+97 -44
View File
@@ -1,6 +1,6 @@
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
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 { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
import { TagPicker } from "@/components/TagPicker";
@@ -826,51 +826,104 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
</div>
<Show when={isTrashOpen()}>
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
Trash is empty.
</div>
}>
{(task) => {
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
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">
<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 class="space-y-6 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Tasks Trash */}
<div class="space-y-2">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Tasks</h4>
<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">
No trashed tasks.
</div>
}>
{(task) => {
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
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">
<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 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>
);
}}
</For>
</div>
{/* Notes Trash */}
<div class="space-y-2">
<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">
No trashed notes.
</div>
}>
{(note) => {
const daysLeft = Math.ceil(((note.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
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">
<div class="min-w-0 flex-1 px-1">
<p class="font-semibold truncate text-sm sm:text-base">{note.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={() => {
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>
);
}}
</For>
);
}}
</For>
</div>
</div>
</Show>
</section>