Added new NOTES feature
This commit is contained in:
@@ -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 >
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user