Added new NOTES feature

This commit is contained in:
2026-02-26 19:06:43 -06:00
parent e5c8e42709
commit 3907de048b
9 changed files with 698 additions and 35 deletions
+128 -9
View File
@@ -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<LayoutProps> = (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<string | null>(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<LayoutProps> = (props) => {
import("./TaskDetail");
import("./QuickEntry");
import("./TaskEditor");
import("../views/NotepadView");
});
});
@@ -34,6 +45,25 @@ export const Layout: Component<LayoutProps> = (props) => {
return (
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
{/* Desktop UI Container */}
<div
style={{ "will-change": "width, transform" }}
class={cn(
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-200 ease-in-out z-[50] overflow-hidden",
isSidebarLocked() ? (store.isNotepadMode ? "w-80 translate-x-0" : "w-48 translate-x-0") : (store.isNotepadMode ? "w-80 -translate-x-full" : "w-48 -translate-x-full"),
!isSidebarLocked() && isSidebarPeeking() && "translate-x-0 shadow-2xl ring-1 ring-border"
)}
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
onMouseLeave={() => !isSidebarLocked() && setIsSidebarPeeking(false)}>
<div class="relative flex-1 flex flex-col min-h-0">
{/* Task Sidebar Wrapper */}
<div
style={{ "will-change": "opacity, transform" }}
class={cn(
"absolute inset-0 flex flex-col transition-all duration-200 ease-in-out",
store.isNotepadMode ? "opacity-0 pointer-events-none -translate-x-8" : "opacity-100 translate-x-0"
)}>
<Sidebar
currentView={props.currentView}
setView={props.setView}
@@ -41,11 +71,54 @@ export const Layout: Component<LayoutProps> = (props) => {
setIsLocked={setIsSidebarLocked}
isPeeking={isSidebarPeeking()}
setIsPeeking={setIsSidebarPeeking}
isEmbed={true}
/>
</div>
<div class={cn(
"flex-1 flex flex-col h-full transition-all duration-300 ease-in-out relative",
isSidebarLocked() ? "md:ml-48" : "ml-0"
{/* Notes Sidebar Wrapper */}
<div
style={{ "will-change": "opacity, transform" }}
class={cn(
"absolute inset-0 flex flex-col transition-all duration-200 ease-in-out bg-card",
!store.isNotepadMode ? "opacity-0 pointer-events-none translate-x-8" : "opacity-100 translate-x-0"
)}>
<div class={cn("p-4 flex items-center justify-between transition-all duration-200", !isSidebarLocked() && "pl-14")}>
<h1 class={cn(
"text-xl font-bold tracking-tighter text-foreground transition-all duration-200",
(isSidebarLocked() || isSidebarPeeking())
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-4"
)}>
Tasgrid
</h1>
<Show when={isSidebarLocked()}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => {
setIsSidebarLocked(false);
setIsSidebarPeeking(false);
}}
>
<PanelLeftClose size={16} />
</Button>
</Show>
</div>
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking()) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
<NotesSidebar selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
</div>
</div>
</div>
</div>
<div
style={{ "will-change": "padding-left" }}
class={cn(
"flex-1 flex flex-col min-w-0 transition-all duration-200 ease-in-out h-screen",
// Only apply md padding if locked
isSidebarLocked() ? (store.isNotepadMode ? "md:pl-80" : "md:pl-48") : "md:pl-0"
)}>
{/* Expand Trigger Button (visible when not locked) */}
{!isSidebarLocked() && (
@@ -67,19 +140,65 @@ export const Layout: Component<LayoutProps> = (props) => {
<ContextSwitcher isMobile={true} />
</div>
{/* Mode Switcher */}
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-[60]">
<div class="bg-background/80 backdrop-blur-sm border border-border p-1 rounded-xl flex items-center shadow-lg relative min-w-[160px] h-10">
{/* Sliding Indicator */}
<div
style={{ "will-change": "transform" }}
class={cn(
"absolute top-1 bottom-1 w-[calc(50%-4px)] bg-primary rounded-lg shadow-sm transition-transform duration-200 ease-out z-0",
store.isNotepadMode ? "translate-x-[calc(100%+0px)]" : "translate-x-0"
)} />
<button
class={cn(
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
!store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setStore('isNotepadMode', false)}
>
Tasks
</button>
<button
class={cn(
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setStore('isNotepadMode', true)}
>
Notes
</button>
</div>
</div>
{/* Main Content Area */}
<main class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden pb-32 sm:pb-6 relative scroll-smooth">
<div class="w-full max-w-screen-2xl mx-auto px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8">
<main class={cn(
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
)}>
<div class={cn(
"w-full h-full mx-auto",
!store.isNotepadMode ? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8" : "max-w-screen-2xl md:px-8 lg:px-12 md:pt-20 md:pb-8 pt-14"
)}>
<div class={cn(store.isNotepadMode ? "hidden" : "block")}>
<Show when={props.currentView.toLowerCase() !== "settings"}>
<FilterBar />
</Show>
{props.children}
</div>
<div class={cn(!store.isNotepadMode ? "hidden" : "block", "h-[calc(100vh-3.5rem)] md:h-[calc(100vh-8rem)] w-full")}>
<Suspense>
<NotepadView selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
</Suspense>
</div>
</div>
</main>
{/* Mobile Bottom Nav */}
<div class={cn("md:hidden", store.isNotepadMode ? "hidden" : "block")}>
<BottomNav currentView={props.currentView} setView={props.setView} />
</div>
</div>
<Suspense>
<QuickEntry />
+6 -3
View File
@@ -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"
)}
>
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
+101
View File
@@ -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 (
<div class="flex flex-col h-full bg-card">
{/* Header */}
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
<div class="relative flex-1">
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
<input
type="text"
placeholder="Search notes..."
value={searchQuery()}
onInput={(e) => 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"
/>
</div>
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-8 w-8 p-0 shrink-0 shadow-sm">
<Plus size={16} />
</Button>
</div>
{/* List */}
<div class="flex-1 overflow-y-auto p-2 space-y-1 pb-20 md:pb-2">
<For each={filteredNotes()} fallback={<div class="p-4 text-center text-xs text-muted-foreground">No notes found.</div>}>
{(note) => (
<button
onClick={() => props.setSelectedNoteId(note.id)}
class={cn(
"w-full text-left p-3 rounded-xl transition-all border",
props.selectedNoteId === note.id
? "bg-primary/5 border-primary/20 shadow-sm"
: "bg-card border-transparent hover:border-border hover:bg-muted/50"
)}
>
<div class="font-semibold text-sm truncate flex items-center justify-between">
<span>{note.title || "Untitled"}</span>
<Show when={note.isPrivate}>
<Lock size={12} class="text-orange-500 shrink-0 ml-2" />
</Show>
</div>
<Show when={note.tags && note.tags.length > 0}>
<div class="flex gap-1 mt-2 flex-wrap">
<For each={note.tags.slice(0, 3)}>
{(t) => (
<span class="text-[0.6rem] px-1.5 py-0.5 bg-muted rounded-md text-muted-foreground whitespace-nowrap border border-border/50">
#{t}
</span>
)}
</For>
<Show when={note.tags.length > 3}>
<span class="text-[0.6rem] px-1.5 py-0.5 text-muted-foreground whitespace-nowrap shrink-0">
+{note.tags.length - 3}
</span>
</Show>
</div>
</Show>
</button>
)}
</For>
</div>
</div>
);
};
+19 -2
View File
@@ -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 = () => {
<Button
size="icon"
onClick={() => setIsOpen(true)}
class="fixed bottom-20 left-[80%] -translate-x-1/2 md:bottom-10 md:right-10 md:left-auto md:translate-x-0 w-14 h-14 rounded-full shadow-2xl z-50 group hover:scale-110 transition-all duration-300"
class={cn(
"fixed bottom-20 left-[80%] -translate-x-1/2 md:bottom-10 md:right-10 md:left-auto md:translate-x-0 w-14 h-14 rounded-full shadow-2xl z-50 group hover:scale-110 transition-all duration-300",
store.isNotepadMode ? "hidden md:flex" : "flex"
)}
>
<Plus size={28} class="group-hover:rotate-90 transition-transform duration-300" />
</Button>
+15 -2
View File
@@ -17,10 +17,23 @@ export const TagPicker: Component<TagPickerProps> = (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<TagPickerProps> = (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"
/>
<datalist id="existing-tags">
<For each={allTagNames()}>
<For each={suggestedTags()}>
{(tag) => <option value={tag} />}
</For>
</datalist>
+2 -2
View File
@@ -71,8 +71,8 @@ export const TaskEditor: Component<TaskEditorProps> = (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) || "",
+1
View File
@@ -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" },
+71 -2
View File
@@ -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<TaskStore>({
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({
+340
View File
@@ -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<Note>) => {
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 (
<div class="flex flex-col md:flex-row h-full bg-background md:bg-card md:border md:border-border md:rounded-xl md:shadow-sm overflow-hidden relative">
{/* Mobile Notes List */}
<div class={cn(
"md:hidden absolute inset-0 z-10 bg-card overflow-hidden flex flex-col h-full transition-all duration-200 ease-out",
props.selectedNoteId ? "opacity-0 pointer-events-none -translate-x-8" : "opacity-100 translate-x-0"
)}>
<NotesSidebar selectedNoteId={props.selectedNoteId} setSelectedNoteId={props.setSelectedNoteId} />
</div>
{/* Note Editor (Main Panel) */}
<div class={cn(
"flex-1 bg-card min-w-0 transition-all duration-200 ease-out h-full overflow-y-auto md:overflow-hidden absolute md:relative inset-0 z-20 md:z-0",
!props.selectedNoteId ? "opacity-0 pointer-events-none md:pointer-events-auto md:opacity-100 translate-x-8 md:translate-x-0 md:flex items-center justify-center bg-muted/5" : "opacity-100 translate-x-0 flex flex-col md:flex-row"
)}>
<Show when={activeNote()} fallback={
<div class="text-center space-y-4 opacity-40 select-none">
<StickyNote size={64} class="mx-auto" />
<p class="text-sm font-medium tracking-wide">Select a note or create a new one</p>
</div>
}>
{(note) => {
const canEdit = note().user === currentUserId;
return (
<>
{/* Editor Area */}
<div class="flex-none md: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">
{/* Mobile Back Button */}
<div class="md:hidden mt-2 md:mt-0 mb-2 md:mb-0">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" onClick={() => props.setSelectedNoteId(null)}>
Back to Notes
</Button>
</div>
<div class="space-y-4">
<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"
value={note().title}
placeholder="Note Title"
readOnly={!canEdit}
onBlur={(e) => {
const val = e.currentTarget.value.trim() || 'Untitled';
if (val !== note().title) handleRenameNote(note().id, note().title, val);
}}
/>
<div class="flex items-center gap-2 shrink-0 pt-1">
<Show when={canEdit}>
<Button
variant="ghost"
size="sm"
class={cn("h-8 text-[0.625rem] font-bold uppercase tracking-widest", note().isPrivate ? "text-orange-500 hover:bg-orange-500/10" : "text-muted-foreground")}
onClick={() => handleUpdateNote(note().id, { isPrivate: !note().isPrivate })}
>
<Show when={note().isPrivate} fallback={<><Unlock size={12} class="mr-1.5" /> Public</>}>
<><Lock size={12} class="mr-1.5" /> Private</>
</Show>
</Button>
</Show>
<Show when={canEdit}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg"
onClick={() => handleDeleteNote(note().id)}
>
<Trash2 size={16} />
</Button>
</Show>
</div>
</div>
{/* Tags */}
<div class="flex flex-wrap gap-2 items-center">
<For each={note().tags}>
{(tag) => (
<Badge variant="secondary" class="h-6 px-2 text-xs bg-muted/30 border border-border/50 text-foreground shadow-sm">
<Hash size={10} class="mr-1 opacity-50" />
{tag}
<Show when={canEdit}>
<button
class="ml-2 hover:text-destructive transition-colors shrink-0 outline-none"
onClick={() => {
handleUpdateNote(note().id, { tags: note().tags.filter(t => t !== tag) });
}}
>
<X size={10} />
</button>
</Show>
</Badge>
)}
</For>
<Show when={canEdit}>
<div class="opacity-70 hover:opacity-100 transition-opacity">
<TagPicker
selectedTags={note().tags || []}
onTagsChange={(newTags) => handleUpdateNote(note().id, { tags: newTags })}
/>
</div>
</Show>
</div>
</div>
{/* Editor Instance */}
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm">
<TaskEditor
content={note().content}
onUpdate={(html) => handleUpdateContent(note().id, html)}
editable={canEdit}
/>
</div>
</div>
{/* Linked Tasks Sidebar */}
<div class="w-full md:w-80 lg:w-96 border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col h-auto md:h-full shrink-0">
<div class="p-4 border-b border-border bg-card/50 flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Link size={12} />
Linked Tasks
</h3>
<div class="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
onClick={() => setIsLinking(!isLinking())}
title="Link Existing Task"
>
<Link size={14} />
</Button>
<Button
size="sm"
variant="ghost"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
onClick={openQuickEntry}
title="Create Task via Quick Entry"
>
+ Task
</Button>
</div>
</div>
<Show when={isLinking()}>
<div class="animate-in fade-in slide-in-from-top-2 space-y-2">
<div class="relative">
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
<input
type="text"
placeholder="Search tasks to link..."
value={linkSearchQuery()}
onInput={(e) => 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"
/>
</div>
<Show when={linkSearchQuery() && unlinkedTasks().length > 0}>
<div class="bg-background border border-border rounded-lg shadow-xl overflow-hidden max-h-48 overflow-y-auto">
<For each={unlinkedTasks()}>
{(t) => (
<button
class="w-full text-left p-2 text-xs hover:bg-muted border-b border-border last:border-0 truncate"
onClick={() => handleLinkTask(t.id)}
>
{t.title}
</button>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<For each={linkedTasks()} fallback={
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
<Link size={24} class="opacity-20" />
<p>No tasks linked yet.</p>
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
</div>
}>
{(task) => (
<div class="relative group">
<TaskCard 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. */}
<Show when={note().tasks && note().tasks.includes(task.id)}>
<button
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleUnlinkTask(task.id);
}}
title="Unlink Task"
>
<X size={12} />
</button>
</Show>
</div>
)}
</For>
</div>
</div>
</>
);
}}
</Show>
</div>
</div >
);
};