From 4c096f18d740b18cbf95304f5e64046d198c8a65 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Wed, 4 Mar 2026 18:20:27 -0600 Subject: [PATCH] Images adding in notes and cleanup of orphaned images from notes --- src/components/SlashMenu.tsx | 15 ++++++++--- src/store/index.ts | 50 ++++++++++++++++++++++++++++++++++++ src/views/NotepadView.tsx | 41 +++++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/components/SlashMenu.tsx b/src/components/SlashMenu.tsx index 2b383d7..36f2fa1 100644 --- a/src/components/SlashMenu.tsx +++ b/src/components/SlashMenu.tsx @@ -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...", diff --git a/src/store/index.ts b/src/store/index.ts index 568f7c8..b132adc 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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(null); +export const [activeNoteId, setActiveNoteId] = createSignal(null); export const [currentTaskContext, setCurrentTaskContext] = createSignal('mine'); export type UrgencyLevel = number; @@ -502,6 +503,55 @@ export const uploadTaskAttachment = async (taskId: string, file: File): Promise< } }; +export const uploadNoteAttachment = async (noteId: string, file: File): Promise => { + 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); + } +}; + const mapRecordToShareRule = (r: any): ShareRule => { return { id: r.id, diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx index b2caedb..15eff1b 100644 --- a/src/views/NotepadView.tsx +++ b/src/views/NotepadView.tsx @@ -1,5 +1,5 @@ -import { type Component, createSignal, createMemo, createEffect, For, Show } from "solid-js"; -import { store, renameTagDefinition, updateNote, removeNote, restoreNote } 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"; @@ -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(); @@ -32,10 +64,15 @@ export const NotepadView: Component<{ }; 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 () => { await updateNote(id, { content: html }); + pendingContentUpdate = null; }, 500); };