894 lines
62 KiB
TypeScript
894 lines
62 KiB
TypeScript
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 { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, MoreHorizontal, Type, FileText, Search, Star, ChevronRight } 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";
|
|
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
|
|
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
|
|
|
|
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<any>(null);
|
|
const [noteActionsOpen, setNoteActionsOpen] = createSignal(false);
|
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = createSignal(false);
|
|
|
|
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<Note>) => {
|
|
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 (
|
|
<div
|
|
ref={sortable.ref}
|
|
{...(sortable.dragActivators as object)}
|
|
style={sortable.transform ? `transform: translate3d(${sortable.transform.x}px, ${sortable.transform.y}px, 0); z-index: 50;` : undefined}
|
|
class={cn(
|
|
"py-2 px-3 flex flex-row items-center justify-start relative group rounded-l-xl shadow-sm text-left truncate flex-none cursor-pointer select-none",
|
|
!isDragging() && "transition-all",
|
|
sProps.isSelected
|
|
? "bg-background border-y border-l border-border text-primary font-semibold w-full z-30"
|
|
: cn(
|
|
"bg-muted/40 border-y border-l border-border/40 text-muted-foreground z-0 w-10/12",
|
|
!isDragging() && "hover:bg-muted hover:w-full hover:border-border/80"
|
|
),
|
|
sortable.isActiveDraggable && "opacity-50 ring-2 ring-primary"
|
|
)}
|
|
onClick={() => {
|
|
if (props.selectedNoteId !== sProps.tab.id) {
|
|
props.setSelectedNoteId(sProps.tab.id);
|
|
}
|
|
}}
|
|
title={sProps.tab.title || "Untitled Tab"}
|
|
>
|
|
<Show when={sProps.isSelected} fallback={
|
|
<span class="text-xs truncate w-full">{sProps.tab.title || "Untitled Tab"}</span>
|
|
}>
|
|
<input
|
|
class="text-xs bg-transparent border-none outline-none focus:ring-0 px-0 min-w-0 w-full truncate font-semibold"
|
|
value={sProps.tab.title}
|
|
placeholder="Untitled Tab"
|
|
readOnly={!sProps.canEdit}
|
|
onClick={(e) => 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);
|
|
}}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div class="flex flex-col md:flex-row h-full bg-background md:bg-transparent overflow-visible relative">
|
|
{/* Mobile Notes List */}
|
|
<Show when={!props.hideNavigation}>
|
|
<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>
|
|
</Show>
|
|
|
|
{/* Note Editor (Main Panel) */}
|
|
<div class={cn(
|
|
"flex-1 min-w-0 transition-all duration-200 ease-out h-full overflow-y-auto md:overflow-visible 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-card md:bg-muted/5 md:border md:border-border md:rounded-xl md:shadow-sm overflow-hidden" : "opacity-100 translate-x-0 flex flex-col md:flex-row bg-transparent md:justify-center"
|
|
)}>
|
|
<Show when={activeNote()} fallback={
|
|
<div class="text-center space-y-6 opacity-40 select-none flex flex-col items-center justify-center h-full">
|
|
<div class="h-16 w-16 rounded-3xl bg-muted flex items-center justify-center mb-2 animate-pulse">
|
|
<Search size={32} class="text-muted-foreground/50" />
|
|
</div>
|
|
<Show when={props.selectedNoteId && store.notes.length === 0} fallback={
|
|
<div class="space-y-2">
|
|
<p class="text-sm font-bold tracking-widest uppercase">No Note Selected</p>
|
|
<p class="text-[0.8rem] text-muted-foreground max-w-[200px] leading-relaxed">
|
|
Select an existing note from the sidebar or create a new one to get started.
|
|
</p>
|
|
</div>
|
|
}>
|
|
<p class="text-sm font-medium tracking-wide italic">Loading note...</p>
|
|
</Show>
|
|
</div>
|
|
}>
|
|
{(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('<div data-type="file-viewer-wrapper"') ||
|
|
(s.startsWith('<div data-type="file-attachment"') && s.includes('data-filename') && s.toLowerCase().includes('.pdf'));
|
|
});
|
|
const isChildNote = createMemo(() => {
|
|
return (note()?.tags || []).some(t => t.startsWith("child-of-"));
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{/* Desktop Style Tabs (Left Side) - Ribbon Bookmark Style */}
|
|
<div class="hidden md:flex flex-col w-32 shrink-0 bg-transparent pt-12 items-end gap-1.5 z-30 transition-all relative border-0 -mr-[1px]">
|
|
|
|
<DragDropProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd} collisionDetector={closestCenter}>
|
|
<DragDropSensors />
|
|
|
|
<Show when={rootParentNote()}>
|
|
{(root) => (
|
|
<div
|
|
class={cn(
|
|
"py-2 px-3 flex flex-row items-center justify-start relative group rounded-l-xl shadow-sm text-left truncate flex-none cursor-pointer",
|
|
!isDragging() && "transition-all",
|
|
props.selectedNoteId === root().id
|
|
? "bg-background border-y border-l border-border text-primary font-semibold w-full z-30"
|
|
: cn(
|
|
"bg-muted/40 border-y border-l border-border/40 text-muted-foreground z-0 w-10/12",
|
|
!isDragging() && "hover:bg-muted hover:w-full hover:border-border/80"
|
|
)
|
|
)}
|
|
onClick={() => {
|
|
if (props.selectedNoteId !== root().id) props.setSelectedNoteId(root().id);
|
|
}}
|
|
>
|
|
<span class="text-xs truncate w-full">General</span>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
|
|
<SortableProvider ids={childTabs().map(t => t.id)}>
|
|
<For each={childTabs()}>
|
|
{(tab) => (
|
|
<SortableDesktopTab
|
|
tab={tab}
|
|
isSelected={props.selectedNoteId === tab.id}
|
|
canEdit={canEdit}
|
|
/>
|
|
)}
|
|
</For>
|
|
</SortableProvider>
|
|
</DragDropProvider>
|
|
|
|
<div class="w-8 h-8 rounded-xl bg-transparent hover:bg-muted border border-dashed border-border flex items-center justify-center text-muted-foreground transition-colors mt-2 mr-3 cursor-pointer"
|
|
onClick={() => handleCreateTab()}
|
|
title="Add Tab"
|
|
>
|
|
<Plus size={16} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Editor Area Container */}
|
|
<div class="flex-1 md:flex-initial md:shrink w-full md:w-auto flex flex-col md:flex-row min-w-0 md:h-full md:bg-card md:border md:border-border md:rounded-xl md:shadow-[0_4px_12px_rgba(0,0,0,0.02)] overflow-visible md:overflow-hidden z-20 relative shadow-[-4px_0_12px_rgba(0,0,0,0.02)] transition-all duration-300">
|
|
|
|
{/* Editor Area */}
|
|
<div class={cn(
|
|
"flex-1 md:flex-initial md:shrink flex flex-col min-w-0 md:w-[960px] lg:w-[1120px] md:h-full overflow-visible md:overflow-y-auto relative scroll-smooth bg-background z-20 pt-0",
|
|
hasTopViewer() ? "p-0 space-y-0" : "px-4 md:px-6 pb-4 md:pb-6 space-y-4 md:space-y-6"
|
|
)}>
|
|
|
|
<div class={cn(
|
|
"sticky top-0 z-30 bg-card/95 backdrop-blur-sm pb-2",
|
|
hasTopViewer() ? "px-4 md:px-6 pt-4 md:pt-6 border-b border-border/50" : "space-y-4 pt-4 -mx-4 px-4 md:pt-6 md:-mx-6 md:px-6 md:pb-2"
|
|
)}>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex flex-col min-w-0 flex-1 gap-1">
|
|
<Show when={rootParentNote()}>
|
|
{(root) => (
|
|
<textarea
|
|
class={cn(
|
|
"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 resize-none overflow-hidden h-auto text-3xl sm:text-4xl w-full text-foreground"
|
|
)}
|
|
value={root().title}
|
|
placeholder="Note Title"
|
|
readOnly={!canEdit}
|
|
rows={1}
|
|
onBlur={(e) => {
|
|
const val = e.currentTarget.value.trim() || 'Untitled';
|
|
if (val !== root().title) handleRenameNote(root().id, root().title, val);
|
|
}}
|
|
onInput={(e) => {
|
|
e.currentTarget.style.height = 'auto';
|
|
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
|
}}
|
|
ref={(el) => {
|
|
createEffect(() => {
|
|
root().title;
|
|
el.style.height = 'auto';
|
|
el.style.height = el.scrollHeight + 'px';
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
</Show>
|
|
|
|
{/* Editor Commands Row */}
|
|
<div class="flex items-center gap-2 mt-1 px-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
|
onClick={() => {
|
|
const instance = editorInstance();
|
|
if (instance) instance.chain().focus().insertContent("/").run();
|
|
}}
|
|
title="Text Commands"
|
|
>
|
|
<Type size={14} class="opacity-70" />
|
|
<span>Commands</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
|
onClick={() => {
|
|
const instance = editorInstance();
|
|
if (instance) instance.chain().focus().uploadMedia().run();
|
|
}}
|
|
title="Upload"
|
|
>
|
|
<FileText size={14} class="opacity-70" />
|
|
<span>Upload</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0 pt-1">
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class={cn(
|
|
"h-8 w-8 p-0 transition-all rounded-xl border border-border/40 shadow-sm",
|
|
note().tags?.includes(getFavoriteTag()) ? "bg-amber-500/10 text-amber-500 hover:bg-amber-500/20" : "text-muted-foreground hover:bg-muted"
|
|
)}
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
const favTag = getFavoriteTag();
|
|
const currentTags = note().tags || [];
|
|
const isFav = currentTags.includes(favTag);
|
|
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
|
|
await handleUpdateNote(note().id, { tags: newTags });
|
|
}}
|
|
title="Toggle Favorite"
|
|
>
|
|
<Star size={14} class={cn(note().tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
|
|
</Button>
|
|
<Show when={isOwner}>
|
|
<Popover>
|
|
<PopoverTrigger
|
|
as={Button}
|
|
variant="ghost"
|
|
size="sm"
|
|
class="hidden md:flex h-8 w-8 p-0 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40 shadow-sm shrink-0"
|
|
>
|
|
<MoreHorizontal size={14} />
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-48 p-2" align="end">
|
|
<div class="flex flex-col gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class={cn("w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2", note().isPrivate ? "text-orange-500 hover:bg-orange-500/10" : "text-muted-foreground hover:bg-muted")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleUpdateNote(note().id, { isPrivate: !note().isPrivate });
|
|
}}
|
|
>
|
|
<Show when={note().isPrivate} fallback={<><Unlock size={14} /> Public</>}>
|
|
<><Lock size={14} /> Private</>
|
|
</Show>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteNote(note().id);
|
|
}}
|
|
>
|
|
<Trash2 size={14} />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
<Show when={editorInstance()}>
|
|
{(editor) => (
|
|
<div class="mt-4 -mx-4 px-4 md:-mx-6 md:px-6 relative border-t border-border/40 pt-1">
|
|
<TextBubbleMenu editor={editor()} />
|
|
<TableBubbleMenu editor={editor()} />
|
|
</div>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */}
|
|
|
|
{/* Editor Instance */}
|
|
<div class={cn(
|
|
"grow shrink-0 flex flex-col",
|
|
hasTopViewer() ? "border-none rounded-none p-0 shadow-none mb-0 min-h-full" : "min-h-[300px] mb-4 px-4 pt-0 pb-4 border border-border/50 rounded-xl bg-background shadow-sm"
|
|
)}>
|
|
<TaskEditor
|
|
content={note().content}
|
|
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
|
onEditorReady={setEditorInstance}
|
|
editable={canEdit}
|
|
class={hasTopViewer() ? "[&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:px-4 md:[&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:px-6 [&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:max-w-3xl [&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:mx-auto [&>.file-viewer-wrapper:first-child]:h-[80vh] [&>.file-viewer-wrapper:first-child]:min-h-0 [&>.file-attachment-wrapper:first-child]:h-[80vh] [&>.file-attachment-wrapper:first-child]:min-h-0 pb-20" : ""}
|
|
/>
|
|
</div>
|
|
<Show when={!hasTopViewer()}>
|
|
<div class="shrink-0 h-16 md:h-8" />
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Linked Tasks Sidebar */}
|
|
<div class={cn(
|
|
"border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col shrink-0 transition-all duration-300 ease-in-out overflow-hidden",
|
|
isLinkedTasksOpen() ? "w-full md:w-80 lg:w-96 h-auto md:h-full" : "w-full md:w-12 h-12 md:h-full cursor-pointer hover:bg-muted/10"
|
|
)}
|
|
onClick={() => {
|
|
if (!isLinkedTasksOpen()) {
|
|
setIsLinkedTasksOpen(true);
|
|
}
|
|
}}>
|
|
<div class={cn("p-4 border-b border-border bg-card/50 flex flex-col gap-3", !isLinkedTasksOpen() && "items-center justify-center p-0 h-full md:py-4")}>
|
|
<div
|
|
class="flex items-center justify-between cursor-pointer group"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsLinkedTasksOpen(!isLinkedTasksOpen());
|
|
}}
|
|
>
|
|
<div class={cn("flex items-center gap-2", !isLinkedTasksOpen() && "md:flex-col")}>
|
|
<ChevronRight size={14} class={cn("text-muted-foreground transition-all duration-300 group-hover:text-foreground", !isLinkedTasksOpen() ? "-rotate-90 md:rotate-180" : "rotate-90 md:rotate-0")} />
|
|
<Show when={isLinkedTasksOpen()} fallback={
|
|
<div class="hidden md:flex items-center justify-center -rotate-90 origin-center whitespace-nowrap mt-8 text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground/50">
|
|
<LinkIcon size={10} class="mr-2 rotate-90" />
|
|
Linked Tasks
|
|
</div>
|
|
}>
|
|
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
|
<LinkIcon size={12} />
|
|
Linked Tasks
|
|
</h3>
|
|
</Show>
|
|
<Show when={!isLinkedTasksOpen()}>
|
|
<div class="md:hidden flex items-center justify-center text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground">
|
|
<LinkIcon size={12} class="mr-2" />
|
|
Linked Tasks
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
<Show when={isLinkedTasksOpen()}>
|
|
<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={(e) => {
|
|
e.stopPropagation();
|
|
setIsLinking(!isLinking());
|
|
}}
|
|
title="Link Existing Task"
|
|
>
|
|
<LinkIcon 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={(e) => {
|
|
e.stopPropagation();
|
|
openQuickEntry();
|
|
}}
|
|
title="Create Task via Quick Entry"
|
|
>
|
|
+ Task
|
|
</Button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<Show when={isLinkedTasksOpen() && 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>
|
|
<Show when={isLinkedTasksOpen()}>
|
|
<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">
|
|
<LinkIcon 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>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
{/* Sticky Footer Actions (Match TaskDetail style) */}
|
|
<div class="px-4 py-4 border-t border-border/50 md:hidden flex items-center justify-between shrink-0 bg-background/95 backdrop-blur-sm z-40 w-full sticky bottom-0">
|
|
<div class="flex items-center gap-2 w-16 shrink-0">
|
|
{/* "..." popover menu containing Public/Private toggle and Delete */}
|
|
<Show when={isOwner}>
|
|
<Popover
|
|
open={noteActionsOpen()}
|
|
onOpenChange={(nextOpen) => {
|
|
setNoteActionsOpen(nextOpen);
|
|
if (!nextOpen) setConfirmDeleteOpen(false);
|
|
}}
|
|
>
|
|
<PopoverTrigger
|
|
as={Button}
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-9 w-9 p-0 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40 shrink-0"
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-48 p-2" align="start">
|
|
<div class="flex flex-col gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class={cn("w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2", note().isPrivate ? "text-orange-500 hover:bg-orange-500/10" : "text-muted-foreground hover:bg-muted")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleUpdateNote(note().id, { isPrivate: !note().isPrivate });
|
|
}}
|
|
>
|
|
<Show when={note().isPrivate} fallback={<><Unlock size={14} /> Public</>}>
|
|
<><Lock size={14} /> Private</>
|
|
</Show>
|
|
</Button>
|
|
<Show
|
|
when={!confirmDeleteOpen() || isChildNote()}
|
|
fallback={
|
|
<div class="rounded-lg border border-border/50 bg-muted/30 p-2 text-[0.625rem] uppercase tracking-widest text-muted-foreground flex flex-col gap-2">
|
|
<span>Delete note? This cannot be undone.</span>
|
|
<div class="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="flex-1 h-7 text-[0.625rem] uppercase tracking-widest"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setConfirmDeleteOpen(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="flex-1 h-7 text-[0.625rem] uppercase tracking-widest hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteNote(note().id);
|
|
setConfirmDeleteOpen(false);
|
|
setNoteActionsOpen(false);
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (isChildNote()) {
|
|
handleDeleteNote(note().id);
|
|
} else {
|
|
setConfirmDeleteOpen(true);
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 size={14} />
|
|
{isChildNote() ? "Delete Tab" : "Delete Note"}
|
|
</Button>
|
|
</Show>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Tab dropdown moved to middle */}
|
|
<div class="flex-1 flex justify-center px-2 min-w-0">
|
|
<Show when={rootParentNote()}>
|
|
<Popover>
|
|
<PopoverTrigger
|
|
as={Button}
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-9 px-3 text-xs gap-1 max-w-[150px] bg-muted/30 hover:bg-muted/50 rounded-xl w-full"
|
|
>
|
|
<FileText size={14} class="shrink-0" />
|
|
<span class="truncate">Tabs ({childTabs().length + (rootParentNote() ? 1 : 0)})</span>
|
|
<ChevronDown size={14} class="opacity-50 shrink-0" />
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-[200px] p-2 bg-popover text-popover-foreground rounded-xl shadow-lg border-border" align="center">
|
|
<div class="flex flex-col gap-1 max-h-60 overflow-y-auto">
|
|
<Show when={rootParentNote()}>
|
|
{(root) => (
|
|
<Button
|
|
variant="ghost"
|
|
class={cn(
|
|
"w-full justify-start px-3 py-2 text-sm hover:bg-muted border-border/50 truncate flex items-center h-auto font-normal rounded-lg",
|
|
props.selectedNoteId === root().id && "bg-muted font-medium text-primary border"
|
|
)}
|
|
onClick={() => props.setSelectedNoteId(root().id)}
|
|
>
|
|
<span class="truncate">General</span>
|
|
</Button>
|
|
)}
|
|
</Show>
|
|
<For each={childTabs()}>
|
|
{(tab) => (
|
|
<Button
|
|
variant="ghost"
|
|
class={cn(
|
|
"w-full justify-start px-3 py-2 text-sm hover:bg-muted border-border/50 truncate flex items-center h-auto font-normal rounded-lg group",
|
|
props.selectedNoteId === tab.id && "bg-muted font-medium text-primary border"
|
|
)}
|
|
onClick={() => props.setSelectedNoteId(tab.id)}
|
|
>
|
|
<Show when={props.selectedNoteId === tab.id} fallback={
|
|
<span class="truncate">{tab.title || "Untitled Tab"}</span>
|
|
}>
|
|
<input
|
|
class="text-sm bg-transparent border-none outline-none focus:ring-0 px-0 min-w-0 w-full truncate font-semibold cursor-text"
|
|
value={tab.title}
|
|
placeholder="Untitled Tab"
|
|
readOnly={!canEdit}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onBlur={(e) => {
|
|
const val = e.currentTarget.value.trim() || 'Untitled Tab';
|
|
if (val !== tab.title) handleRenameNote(tab.id, tab.title, val);
|
|
}}
|
|
/>
|
|
</Show>
|
|
</Button>
|
|
)}
|
|
</For>
|
|
<Button
|
|
variant="ghost"
|
|
class="w-full justify-start px-3 py-2 text-sm hover:bg-primary/10 text-primary font-medium transition-colors gap-2 h-auto rounded-lg mt-1"
|
|
onClick={handleCreateTab}
|
|
>
|
|
<Plus size={14} /> Add Tab
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
<Show when={childTabs().length === 0 && !rootParentNote()}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-9 px-3 text-xs gap-1 bg-muted/30 hover:bg-muted/50 rounded-xl"
|
|
onClick={() => handleCreateTab()}
|
|
>
|
|
<Plus size={14} /> Add Tab
|
|
</Button>
|
|
</Show>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 w-16 justify-end shrink-0">
|
|
<Show when={!props.hideNavigation}>
|
|
{/* Close button (Mobile only) */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="md:hidden h-9 px-3 gap-2 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40 shrink-0"
|
|
onClick={() => props.setSelectedNoteId(null)}
|
|
>
|
|
<X size={16} />
|
|
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
|
|
</Button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}}
|
|
</Show >
|
|
</div>
|
|
</div >
|
|
);
|
|
};
|