import { type Component, createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js"; import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } from "@/store"; import { pb } from "@/lib/pocketbase"; import { type Note } from "@/store"; import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight, ChevronDown, FileText, Plus, Star, MoreHorizontal, Type, Image as ImageIcon, Film } from "lucide-solid"; import { Button } from "@/components/ui/button"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter } from "@thisbeyond/solid-dnd"; import { cn } from "@/lib/utils"; import { NOTES_COLLECTION } from "@/lib/constants"; import { TaskEditor } from "@/components/TaskEditor"; import { TaskCard } from "@/components/TaskCard"; import { NotesSidebar } from "@/components/NotesSidebar"; import { toast } from "solid-sonner"; export const NotepadView: Component<{ selectedNoteId: string | null; setSelectedNoteId: (id: string | null) => void; hideNavigation?: boolean; }> = (props) => { const [isLinking, setIsLinking] = createSignal(false); const [linkSearchQuery, setLinkSearchQuery] = createSignal(""); const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(false); const [isDragging, setIsDragging] = createSignal(false); const [editorInstance, setEditorInstance] = createSignal(null); 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(); return store.notes.find(n => n.id === id) || null; }); const handleUpdateNote = async (id: string, data: Partial) => { 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 () => { await updateNote(id, { content: html }); pendingContentUpdate = null; }, 500); }; const handleRenameNote = async (id: string, oldTitle: string, newTitle: string) => { // 1. Update the note title await handleUpdateNote(id, { title: newTitle }); // 2. Rename the TagDefinition (preserves color) and propagate to all tagged tasks // Use '#' prefix for note tags await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`); }; const handleDeleteNote = async (id: string) => { 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) => { const note = activeNote(); if (!note) return; const newTasks = [...(note.tasks || []), taskId]; await handleUpdateNote(note.id, { tasks: newTasks }); setIsLinking(false); setLinkSearchQuery(""); }; const handleUnlinkTask = async (taskId: string) => { const note = activeNote(); if (!note) return; const newTasks = (note.tasks || []).filter(id => id !== taskId); await handleUpdateNote(note.id, { tasks: newTasks }); }; // Linked tasks calculation const linkedTasks = createMemo(() => { const note = activeNote(); if (!note) return []; const noteTagName = `#${note.title}`; return store.tasks.filter(t => { // Explicit relation if (note.tasks && note.tasks.includes(t.id)) return true; // Tag relation (case-insensitive title match with # prefix) if (t.tags && t.tags.some(tag => tag.toLowerCase() === noteTagName.toLowerCase())) return true; return false; }); }); const unlinkedTasks = createMemo(() => { const note = activeNote(); if (!note) return []; const q = linkSearchQuery().toLowerCase(); // Get names of pinned/subscribed buckets const pinnedBucketNames = store.buckets .filter(b => store.subscribedBuckets.includes(b.id)) .map(b => b.name.toLowerCase()); const allBucketNames = store.buckets.map(b => b.name.toLowerCase()); return store.tasks.filter(t => { if (t.deletedAt) return false; // Don't show trashed tasks // Filter out tasks from unpinned buckets const taskBucketTags = (t.tags || []).filter(tag => allBucketNames.includes(tag.toLowerCase())); if (taskBucketTags.length > 0) { // If it belongs to a bucket, at least one must be pinned const hasPinnedBucket = taskBucketTags.some(tag => pinnedBucketNames.includes(tag.toLowerCase())); if (!hasPinnedBucket) return false; } if (note.tasks && note.tasks.includes(t.id)) return false; // Already linked if (!q) return false; // Don't show all by default return t.title.toLowerCase().includes(q); }).slice(0, 10); }); // --- Tabs Logic --- const rootParentNote = createMemo(() => { let current = activeNote(); let count = 0; while (current && count < 10) { const parentTag = current.tags?.find(t => t.startsWith("child-of-")); if (!parentTag) break; const parentId = parentTag.replace("child-of-", ""); const parent = store.notes.find(n => n.id === parentId); if (!parent) break; current = parent; count++; } return current; }); const childTabs = createMemo(() => { const root = rootParentNote(); if (!root) return []; const childTag = `child-of-${root.id}`; const tabs = store.notes.filter(n => !n.deletedAt && n.tags?.includes(childTag)); tabs.sort((a, b) => { const getOrder = (tags: string[]) => { const orderTag = (tags || []).find(t => t.startsWith('taborder:')); return orderTag ? parseInt(orderTag.split(':')[1]) : 999; }; return getOrder(a.tags) - getOrder(b.tags); }); return tabs; }); const handleCreateTab = async () => { const root = rootParentNote(); if (!root || !currentUserId) return; try { const result = await pb.collection(NOTES_COLLECTION).create({ title: "New Tab", content: "", tags: [`child-of-${root.id}`], isPrivate: root.isPrivate, // inherit privacy user: currentUserId, }); props.setSelectedNoteId(result.id); } catch (e) { console.error("Failed to create tab", e); toast.error("Failed to create tab"); } }; const openQuickEntry = () => { window.dispatchEvent(new KeyboardEvent('keydown', { metaKey: true, key: 'k' })); }; const handleDragStart = () => { setIsDragging(true); }; const handleDragEnd = (event: any) => { setIsDragging(false); const { draggable, droppable } = event; if (draggable && droppable && draggable.id !== droppable.id) { const newTabs = [...childTabs()]; const oldIndex = newTabs.findIndex(t => t.id === draggable.id); const newIndex = newTabs.findIndex(t => t.id === droppable.id); if (oldIndex !== -1 && newIndex !== -1) { const [item] = newTabs.splice(oldIndex, 1); newTabs.splice(newIndex, 0, item); // Fire off updates newTabs.forEach((tab, index) => { const cleanedTags = (tab.tags || []).filter(t => !t.startsWith('taborder:')); cleanedTags.push(`taborder:${index}`); handleUpdateNote(tab.id, { tags: cleanedTags }); }); } } }; // Sub-component for sortable tab const SortableDesktopTab: Component<{ tab: Note, isSelected: boolean, canEdit: boolean }> = (sProps) => { const sortable = createSortable(sProps.tab.id); return (
{ if (props.selectedNoteId !== sProps.tab.id) { props.setSelectedNoteId(sProps.tab.id); } }} title={sProps.tab.title || "Untitled Tab"} > {sProps.tab.title || "Untitled Tab"} }> e.stopPropagation()} onBlur={(e) => { const val = e.currentTarget.value.trim() || 'Untitled Tab'; if (val !== sProps.tab.title) handleRenameNote(sProps.tab.id, sProps.tab.title, val); }} />
); }; return (
{/* Mobile Notes List */}
{/* Note Editor (Main Panel) */}

No Note Selected

Select an existing note from the sidebar or create a new one to get started.

}>

Loading note...

}> {(note) => { const isOwner = note().user === currentUserId; const canEdit = isOwner || !note().isPrivate; const hasTopViewer = createMemo(() => { const c = note()?.content; if (!c) return false; const s = c.trim(); return s.startsWith('
{/* Desktop Style Tabs (Left Side) - Ribbon Bookmark Style */} {/* Editor Area Container */}
{/* Editor Area */}
{(root) => (