diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5ae194e..e4101d5 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,10 +1,12 @@ -import { type Component, type JSX, createSignal, Show, lazy, Suspense, onMount } from "solid-js"; +import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount } from "solid-js"; import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation"; -import { store, activeTaskId, setActiveTaskId } from "@/store"; -import { PanelLeftOpen } from "lucide-solid"; +import { store, setStore, activeTaskId, setActiveTaskId } from "@/store"; +import { PanelLeftOpen, PanelLeftClose } from "lucide-solid"; const TaskDetail = lazy(() => import("./TaskDetail").then(m => ({ default: m.TaskDetail }))); const QuickEntry = lazy(() => import("./QuickEntry").then(m => ({ default: m.QuickEntry }))); +const NotepadView = lazy(() => import("../views/NotepadView").then(m => ({ default: m.NotepadView }))); +import { NotesSidebar } from "./NotesSidebar"; import { Button } from "./ui/button"; import { cn } from "@/lib/utils"; import { FilterBar } from "./FilterBar"; @@ -19,6 +21,14 @@ export const Layout: Component = (props) => { const [isSidebarLocked, setIsSidebarLocked] = createSignal(true); const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false); + // Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView + const [selectedNoteId, setSelectedNoteId] = createSignal(null); + + // Make it available globally for QuickEntry auto-tagging without circular deps + createEffect(() => { + (window as any)._activeNoteId = selectedNoteId(); + }); + onMount(() => { // Preload heavy components in background when idle const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 2000)); @@ -26,6 +36,7 @@ export const Layout: Component = (props) => { import("./TaskDetail"); import("./QuickEntry"); import("./TaskEditor"); + import("../views/NotepadView"); }); }); @@ -34,19 +45,81 @@ export const Layout: Component = (props) => { return (
- + {/* Desktop UI Container */} +
!isSidebarLocked() && setIsSidebarPeeking(true)} + onMouseLeave={() => !isSidebarLocked() && setIsSidebarPeeking(false)}> -
+
+ {/* Task Sidebar Wrapper */} +
+ +
+ + {/* Notes Sidebar Wrapper */} +
+
+

+ Tasgrid +

+ + + +
+ +
+ +
+
+
+
+ +
{/* Expand Trigger Button (visible when not locked) */} {!isSidebarLocked() && ( - {/* Main Content Area */} -
-
- - - + {/* Mode Switcher */} +
+
+ {/* Sliding Indicator */} +
- {props.children} + + +
+
+ + {/* Main Content Area */} +
+
+
+ + + + {props.children} +
+
+ + + +
{/* Mobile Bottom Nav */} - +
+ +
@@ -94,6 +213,6 @@ export const Layout: Component = (props) => { /> -
+
); }; diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 161ba2a..4d63e70 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -299,6 +299,7 @@ export const Sidebar: Component<{ setIsLocked: (v: boolean) => void; isPeeking: boolean; setIsPeeking: (v: boolean) => void; + isEmbed?: boolean; }> = (props) => { const [switcherOpen, setSwitcherOpen] = createSignal(false); @@ -311,9 +312,11 @@ export const Sidebar: Component<{ } }} class={cn( - "hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-300 ease-in-out z-[50]", - props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full", - !props.isLocked && props.isPeeking && "translate-x-0 shadow-2xl ring-1 ring-border" + "hidden md:flex flex-col border-r border-border bg-card h-screen z-[50]", + !props.isEmbed && "fixed left-0 top-0 transition-all duration-150 ease-in-out", + !props.isEmbed && (props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full"), + !props.isEmbed && !props.isLocked && props.isPeeking && "translate-x-0 shadow-2xl ring-1 ring-border", + props.isEmbed && "w-full h-full border-r-0 bg-transparent" )} >
diff --git a/src/components/NotesSidebar.tsx b/src/components/NotesSidebar.tsx new file mode 100644 index 0000000..69e9e9d --- /dev/null +++ b/src/components/NotesSidebar.tsx @@ -0,0 +1,101 @@ +import { type Component, createSignal, createMemo, For, Show } from "solid-js"; +import { store } from "@/store"; +import { pb } from "@/lib/pocketbase"; +import { Plus, Lock, Search } from "lucide-solid"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { NOTES_COLLECTION } from "@/lib/constants"; + +export const NotesSidebar: Component<{ + selectedNoteId: string | null; + setSelectedNoteId: (id: string | null) => void; +}> = (props) => { + const [searchQuery, setSearchQuery] = createSignal(""); + const currentUserId = pb.authStore.model?.id; + + const filteredNotes = createMemo(() => { + let n = store.notes; + if (searchQuery()) { + const q = searchQuery().toLowerCase(); + n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q))); + } + return n; + }); + + const handleCreateNote = async () => { + if (!currentUserId) return; + try { + const result = await pb.collection(NOTES_COLLECTION).create({ + title: "New Note", + content: "", + tags: [], + isPrivate: false, + user: currentUserId, + }); + props.setSelectedNoteId(result.id); + } catch (e) { + console.error("Failed to create note", e); + } + }; + + return ( +
+ {/* Header */} +
+
+ + setSearchQuery(e.currentTarget.value)} + class="w-full bg-muted/50 border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30" + /> +
+ +
+ + {/* List */} +
+ No notes found.
}> + {(note) => ( + + )} + +
+
+ ); +}; diff --git a/src/components/QuickEntry.tsx b/src/components/QuickEntry.tsx index cb1029e..fc63999 100644 --- a/src/components/QuickEntry.tsx +++ b/src/components/QuickEntry.tsx @@ -12,6 +12,7 @@ import { lazy, Suspense } from "solid-js"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { toast } from "solid-sonner"; import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants"; +import { cn } from "@/lib/utils"; const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); @@ -34,7 +35,20 @@ export const QuickEntry: Component = () => { createEffect(() => { if (isOpen()) { const ctx = currentTaskContext(); - if (typeof ctx === 'object') { + if (store.isNotepadMode) { + // Not in a specific context, but in Notes mode. + // We use global window variable set by Layout to get the active note id + const activeNoteId = (window as any)._activeNoteId; + if (activeNoteId) { + const note = store.notes.find(n => n.id === activeNoteId); + if (note && note.title) { + const noteTagName = note.title.trim(); + if (noteTagName && !tags().includes(noteTagName)) { + setTags(prev => [...prev, noteTagName]); + } + } + } + } else if (typeof ctx === 'object') { if ('bucketId' in ctx) { // Bucket View: Autofill bucket name const bucketName = ctx.name; @@ -274,7 +288,10 @@ export const QuickEntry: Component = () => { diff --git a/src/components/TagPicker.tsx b/src/components/TagPicker.tsx index 2dedeab..4a82546 100644 --- a/src/components/TagPicker.tsx +++ b/src/components/TagPicker.tsx @@ -17,10 +17,23 @@ export const TagPicker: Component = (props) => { const [valueScore, setValueScore] = createSignal(5); // Filter tags for the "Name" part + const localTagNames = () => { + const localDef = store.tagDefinitions.filter(d => !d.isUser && !d.isBucket).map(d => d.name); + return [...new Set(localDef)].sort(); + }; + const allTagNames = () => { const definedTags = store.tagDefinitions.map(d => d.name); const bucketTags = store.buckets.map(b => b.name); - return [...new Set([...definedTags, ...bucketTags])].sort(); + const noteTags = store.notes.map(n => n.title); + return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort(); + }; + + const suggestedTags = () => { + if (selectedTagName().trim().length === 0) { + return localTagNames(); + } + return allTagNames(); }; // When a tag name is selected, update the value score to match its current definition @@ -73,7 +86,7 @@ export const TagPicker: Component = (props) => { class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" /> - + {(tag) => diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 8fdf640..0797d10 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -71,8 +71,8 @@ export const TaskEditor: Component = (props) => { class: "rounded-lg border border-border max-w-full h-auto", }, }), - Underline, - MobileIndent, + Underline.configure({}), + MobileIndent.configure({}), ], // Use untrack so the editor doesn't re-initialize when props.content changes content: untrack(() => props.content) || "", diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9b131a8..824d574 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -40,6 +40,7 @@ export const TASGRID_COLLECTION = 'TasGrid'; export const TAGS_COLLECTION = 'TasGrid_Tags'; export const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules'; export const BUCKETS_COLLECTION = 'buckets'; +export const NOTES_COLLECTION = 'TasGrid_Notes'; export const SIZE_OPTIONS = [ { value: "10", label: "10 - Two weeks" }, diff --git a/src/store/index.ts b/src/store/index.ts index a9e1b88..77184a4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store"; import { createSignal, createEffect, createRoot } from "solid-js"; import { pb } from "@/lib/pocketbase"; import { toast } from "solid-sonner"; -import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants"; +import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, NOTES_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants"; const LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,user,size,recurrence,sharedWith,deletedAt'; @@ -136,6 +136,18 @@ export interface TagDefinition { isBucket?: boolean; // New flag to identify bucket-based tags } +export interface Note { + id: string; + title: string; + content?: string; + tags: string[]; + isPrivate: boolean; + user: string; + tasks: string[]; // List of related task IDs + created: string; + updated: string; +} + export interface FilterTag { name: string; excluded: boolean; @@ -187,6 +199,8 @@ interface TaskStore { filterTemplates: FilterTemplate[]; buckets: Bucket[]; subscribedBuckets: string[]; // IDs of buckets I'm subscribed to + notes: Note[]; + isNotepadMode: boolean; } // Initial empty state @@ -209,7 +223,9 @@ export const [store, setStore] = createStore({ shareRules: [], filterTemplates: [], buckets: [], - subscribedBuckets: [] + subscribedBuckets: [], + notes: [], + isNotepadMode: false }); export const matchesFilter = (task: Task) => { @@ -472,6 +488,20 @@ const mapRecordToShareRule = (r: any): ShareRule => { }; }; +const mapRecordToNote = (r: any): Note => { + return { + id: r.id, + title: r.title, + content: r.content, + tags: r.tags || [], + isPrivate: r.isPrivate || false, + user: r.user, + tasks: r.tasks || [], + created: r.created, + updated: r.updated + }; +}; + // Helper to check if a task should be visible based on ownership, shares, or rules const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => { // Own task @@ -627,6 +657,33 @@ export const subscribeToRealtime = async () => { } }); + // Subscribe to Notes + await pb.collection(NOTES_COLLECTION).unsubscribe('*'); + pb.collection(NOTES_COLLECTION).subscribe('*', (e) => { + const currentUserId = pb.authStore.model?.id; + + // Only process if public or owned by current user + const isVisible = !e.record.isPrivate || e.record.user === currentUserId; + + if (e.action === 'create' || e.action === 'update') { + if (isVisible) { + const updatedNote = mapRecordToNote(e.record); + setStore("notes", prev => { + const exists = prev.find(n => n.id === updatedNote.id); + if (exists) return prev.map(n => n.id === updatedNote.id ? updatedNote : n); + return [updatedNote, ...prev]; + }); + } else { + // If it became private and we're not the owner, remove it + setStore("notes", prev => prev.filter(n => n.id !== e.record.id)); + } + } + + if (e.action === 'delete') { + setStore("notes", prev => prev.filter(n => n.id !== e.record.id)); + } + }); + // Subscribe to Buckets // We need to know if buckets are added/removed/renamed await pb.collection(BUCKETS_COLLECTION).unsubscribe('*'); @@ -771,6 +828,18 @@ export const initStore = async () => { console.warn("Failed to load buckets (collection might not exist yet):", bucketErr); } + // 1.3 Fetch Notes + try { + const currentUserId = pb.authStore.model?.id; + const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({ + filter: `isPrivate = false || user = "${currentUserId}"`, + sort: '-created' + }); + setStore("notes", notesRecords.map(mapRecordToNote)); + } catch (notesErr) { + console.warn("Failed to load notes (collection might not exist yet):", notesErr); + } + // 1.5. Fetch Tag Definitions try { const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({ diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx new file mode 100644 index 0000000..2a8a3ff --- /dev/null +++ b/src/views/NotepadView.tsx @@ -0,0 +1,340 @@ +import { type Component, createSignal, createMemo, For, Show } from "solid-js"; +import { store, renameTagDefinition } from "@/store"; +import { pb } from "@/lib/pocketbase"; +import { type Note } from "@/store"; +import { Trash2, Lock, Unlock, Hash, StickyNote, Search, X, Link } from "lucide-solid"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { TaskEditor } from "@/components/TaskEditor"; +import { TagPicker } from "@/components/TagPicker"; +import { NOTES_COLLECTION } from "@/lib/constants"; +import { TaskCard } from "@/components/TaskCard"; +import { Badge } from "@/components/ui/badge"; +import { NotesSidebar } from "@/components/NotesSidebar"; + +export const NotepadView: Component<{ + selectedNoteId: string | null; + setSelectedNoteId: (id: string | null) => void; +}> = (props) => { + const [isLinking, setIsLinking] = createSignal(false); + const [linkSearchQuery, setLinkSearchQuery] = createSignal(""); + + const currentUserId = pb.authStore.model?.id; + + // Derived states + const activeNote = createMemo(() => store.notes.find(n => n.id === props.selectedNoteId) || null); + + 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); + } + } + }; + + 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); + } + } + }, 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 + 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); + } + }; + + 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 []; + return store.tasks.filter(t => { + // Explicit relation + if (note.tasks && note.tasks.includes(t.id)) return true; + // Tag relation (case-insensitive title match) + if (t.tags && t.tags.some(tag => tag.toLowerCase() === note.title.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); + }); + + const openQuickEntry = () => { + window.dispatchEvent(new KeyboardEvent('keydown', { metaKey: true, key: 'k' })); + }; + + return ( +
+ {/* Mobile Notes List */} +
+ +
+ + {/* Note Editor (Main Panel) */} +
+ + +

Select a note or create a new one

+
+ }> + {(note) => { + const canEdit = note().user === currentUserId; + + return ( + <> + {/* Editor Area */} +
+ {/* Mobile Back Button */} +
+ +
+
+
+ { + const val = e.currentTarget.value.trim() || 'Untitled'; + if (val !== note().title) handleRenameNote(note().id, note().title, val); + }} + /> +
+ + + + + + + +
+
+ + {/* Tags */} +
+ + {(tag) => ( + + + {tag} + + + + + )} + + +
+ handleUpdateNote(note().id, { tags: newTags })} + /> +
+
+
+
+ + {/* Editor Instance */} +
+ handleUpdateContent(note().id, html)} + editable={canEdit} + /> +
+
+ + {/* Linked Tasks Sidebar */} +
+
+
+

+ + Linked Tasks +

+
+ + +
+
+ + +
+
+ + setLinkSearchQuery(e.currentTarget.value)} + class="w-full bg-background border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30 shadow-sm" + /> +
+ 0}> +
+ + {(t) => ( + + )} + +
+
+
+
+
+
+ + +

No tasks linked yet.

+

Link an existing task, create a new one, or add the tag #{note().title} to a task.

+
+ }> + {(task) => ( +
+ + {/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */} + + + +
+ )} + +
+
+ + ); + }} + +
+
+ ); +};