Added new NOTES feature
This commit is contained in:
+143
-24
@@ -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,19 +45,81 @@ 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">
|
||||
<Sidebar
|
||||
currentView={props.currentView}
|
||||
setView={props.setView}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
setIsPeeking={setIsSidebarPeeking}
|
||||
/>
|
||||
{/* 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={cn(
|
||||
"flex-1 flex flex-col h-full transition-all duration-300 ease-in-out relative",
|
||||
isSidebarLocked() ? "md:ml-48" : "ml-0"
|
||||
)}>
|
||||
<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}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
setIsPeeking={setIsSidebarPeeking}
|
||||
isEmbed={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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() && (
|
||||
<div class="fixed top-4 left-4 z-[60] md:flex hidden">
|
||||
@@ -67,18 +140,64 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
<ContextSwitcher isMobile={true} />
|
||||
</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">
|
||||
<Show when={props.currentView.toLowerCase() !== "settings"}>
|
||||
<FilterBar />
|
||||
</Show>
|
||||
{/* 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"
|
||||
)} />
|
||||
|
||||
{props.children}
|
||||
<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={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 */}
|
||||
<BottomNav currentView={props.currentView} setView={props.setView} />
|
||||
<div class={cn("md:hidden", store.isNotepadMode ? "hidden" : "block")}>
|
||||
<BottomNav currentView={props.currentView} setView={props.setView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense>
|
||||
@@ -94,6 +213,6 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) || "",
|
||||
|
||||
Reference in New Issue
Block a user