diff --git a/.agents/skills/svelte/SKILL.md b/.agents/skills/svelte/SKILL.md index ef644601e4..6b83dfb2eb 100644 --- a/.agents/skills/svelte/SKILL.md +++ b/.agents/skills/svelte/SKILL.md @@ -1,6 +1,6 @@ --- name: svelte -description: Svelte 5 patterns including TanStack Query mutations, shadcn-svelte components, and component composition. Use when writing Svelte components, using TanStack Query, or working with shadcn-svelte UI. +description: Svelte 5 patterns including TanStack Query mutations, SvelteMap reactive state, shadcn-svelte components, and component composition. Use when writing Svelte components, using TanStack Query, working with SvelteMap/fromTable/fromKv, or working with shadcn-svelte UI. metadata: author: epicenter version: '1.0' @@ -19,6 +19,7 @@ Use this pattern when you need to: - Decide between `createMutation` in `.svelte` and `.execute()` in `.ts`. - Follow shadcn-svelte import, composition, and component organization patterns. - Refactor one-off `handle*` wrappers into inline template actions. +- Convert SvelteMap data to arrays for derived state or component props. # `$derived` Value Mapping: Use `satisfies Record`, Not Ternaries @@ -73,6 +74,100 @@ Reserve `$derived.by()` for multi-statement logic where you genuinely need a fun See `docs/articles/record-lookup-over-nested-ternaries.md` for rationale. +# Reactive Table State Pattern + +When a factory function exposes workspace table data via `fromTable`, follow this three-layer convention: + +```typescript +// 1. Map — reactive source (private, suffixed with Map) +const foldersMap = fromTable(workspaceClient.tables.folders); + +// 2. Derived array — cached materialization (private, no suffix) +const folders = $derived(foldersMap.values().toArray()); + +// 3. Getter — public API (matches the derived name) +return { + get folders() { + return folders; + }, +}; +``` + +Naming: `{name}Map` (private source) → `{name}` (cached derived) → `get {name}()` (public getter). + +### With Sort or Filter + +Chain operations inside `$derived` — the entire pipeline is cached: + +```typescript +const tabs = $derived(tabsMap.values().toArray().sort((a, b) => b.savedAt - a.savedAt)); +const notes = $derived(allNotes.filter((n) => n.deletedAt === undefined)); +``` + +See the `typescript` skill for iterator helpers (`.toArray()`, `.filter()`, `.find()` on `IteratorObject`). + +### Template Props + +For component props expecting `T[]`, derive in the script block — never materialize in the template: + +```svelte + + + + + + +``` + +### Why `$derived`, Not a Plain Getter + +Put reactive computations in `$derived`, not inside public getters. + +A getter may still be reactive if it reads reactive state, but it recomputes on every access. `$derived` computes reactively and caches until dependencies change. + +Use `$derived` for the computation. Use the getter only as a pass-through to expose that derived value. + +See `docs/articles/derived-vs-getter-caching-matters.md` for rationale. + +# Reactive State Module Conventions + +State modules use a factory function that returns a flat object with getters and methods, exported as a singleton. + +```typescript +function createBookmarkState() { + const bookmarksMap = fromTable(workspaceClient.tables.bookmarks); + const bookmarks = $derived(bookmarksMap.values().toArray()); + + return { + get bookmarks() { return bookmarks; }, + async add(tab: Tab) { /* ... */ }, + remove(id: BookmarkId) { /* ... */ }, + }; +} + +export const bookmarkState = createBookmarkState(); +``` + +## Naming + +| Concern | Convention | Example | +|---|---|---| +| **Export name** | `xState` for domain state; descriptive noun for utilities | `bookmarkState`, `notesState`, `deviceConfig`, `vadRecorder` | +| **Factory function** | `createX()` matching the export name | `createBookmarkState()` | +| **File name** | Domain name, optionally with `-state` suffix | `bookmark-state.svelte.ts`, `auth.svelte.ts` | + +Use the `State` suffix when the export name would collide with a key property (`bookmarkState.bookmarks`, not `bookmarks.bookmarks`). + +## Accessor Patterns + +| Data Shape | Accessor | Example | +|---|---|---| +| **Collection** | Named getter | `bookmarkState.bookmarks`, `notesState.notes` | +| **Single reactive value** | `.current` (Svelte 5 convention) | `selectedFolderId.current`, `serverUrl.current` | +| **Keyed lookup** | `.get(key)` | `toolTrustState.get(name)`, `deviceConfig.get(key)` | + # Mutation Pattern Preference ## In Svelte Files (.svelte) @@ -163,12 +258,16 @@ Only use `.execute()` in Svelte files when: 2. You're performing a one-off operation 3. You need fine-grained control over async flow -## No `handle*` Functions - Always Inline +## Single-Use Functions: Inline or Document -Never create functions prefixed with `handle` in the script tag. If the function is used only once and the logic isn't deeply nested, inline it directly in the template: +If a function is defined in the script tag and used only once in the template, inline it at the call site. This applies to event handlers, callbacks, and any other single-use logic. + +### Why Inline? + +Single-use extracted functions add indirection — the reader jumps between the function definition and the template to understand what happens on click/keydown/etc. Inlining keeps cause and effect together at the point where the action happens. ```svelte - + + + +
+``` + +Without JSDoc and a meaningful name, extract it anyway — the indirection isn't earning its keep. + +### Multi-Use Functions + +Functions used **2 or more times** should always stay extracted — this rule only applies to single-use functions. # Styling @@ -385,3 +529,150 @@ When building interactive components (especially with dialogs/modals), create se - When you find yourself passing callbacks just to update parent state The key insight: It's perfectly fine to instantiate multiple dialogs (one per row) rather than managing a single shared dialog with complex state. Modern frameworks handle this efficiently, and the code clarity is worth it. + +# Prop-First Data Derivation + +When a component receives a prop that already carries the information needed for a decision, derive from the prop. Never reach into global state for data the component already has. + +```svelte + + + + + +``` + +### Why This Matters + +- **Self-describing**: The component works correctly regardless of which view rendered it. +- **Fewer imports**: Dropping a global state import reduces coupling. +- **Testable**: Pass a note with `deletedAt` set and the component behaves correctly — no need to mock view state. + +### The Rule + +If the data needed for a decision is already on a prop (directly or derivable), **always** derive from the prop. Global state is for information the component genuinely doesn't have. + +# View-Mode Branching Limit + +If a component checks the same boolean flag (like `isRecentlyDeletedView`, `isEditing`, `isCompact`) in **3 or more template locations**, the component is likely serving two purposes and should be considered for extraction. + +```svelte + + + +{#if !isRecentlyDeletedView} +
sort controls...
+{/if} + +{#if isRecentlyDeletedView} + No deleted notes +{:else} + No notes yet +{/if} +``` + +### The Fix: Push Branching Up to the Parent + +Move the view-mode decision to the parent. The child component takes the varying data as props: + +```svelte + +{#if viewState.isRecentlyDeletedView} + +{:else} + +{/if} +``` + +The child becomes dumb — it renders what it's told, with zero awareness of view modes. This keeps the branching in **one place** instead of scattered across the component tree. + +### The Threshold + +- **1–2 checks**: Acceptable — simple conditional rendering. +- **3+ checks on the same flag**: The component is likely two views in one. Consider pushing the varying data up as props. + +# Data-Driven Repetitive Markup + +When **3 or more sequential sibling elements** follow an identical pattern with only data varying, consider extracting the data into an array and using `{#each}` or a `{#snippet}`. + +```svelte + + setSortBy('dateEdited')}> + {#if sortBy === 'dateEdited'}{/if} + Date Edited + + setSortBy('dateCreated')}> + {#if sortBy === 'dateCreated'}{/if} + Date Created + + setSortBy('title')}> + {#if sortBy === 'title'}{/if} + Title + + + + + +{#each sortOptions as option} + setSortBy(option.value)}> + {#if sortBy === option.value} + + {:else} + + {/if} + {option.label} + +{/each} +``` + +For more complex repeated patterns (e.g., toolbar buttons with tooltips), use `{#snippet}` to define the shared structure once: + +```svelte +{#snippet toggleButton(pressed: boolean, onToggle: () => void, icon: typeof BoldIcon, label: string)} + + + + + + + {label} + +{/snippet} + +{@render toggleButton(activeFormats.bold, () => editor?.chain().focus().toggleBold().run(), BoldIcon, 'Bold (⌘B)')} +{@render toggleButton(activeFormats.italic, () => editor?.chain().focus().toggleItalic().run(), ItalicIcon, 'Italic (⌘I)')} +``` + +### When NOT to Extract + +- **2 or fewer** repetitions — extraction adds indirection without meaningful savings. +- **Structurally similar but semantically different** — if the elements serve different purposes and might diverge, keep them separate. diff --git a/.agents/skills/typescript/SKILL.md b/.agents/skills/typescript/SKILL.md index deeacec3cb..b33c05d9ba 100644 --- a/.agents/skills/typescript/SKILL.md +++ b/.agents/skills/typescript/SKILL.md @@ -165,6 +165,23 @@ When the record is used once, inline it. When it's shared or has 5+ entries, ext See `docs/articles/record-lookup-over-nested-ternaries.md` for rationale. +## Iterator Helpers Over Spread + +TS 5.9+ with `lib: ["ESNext"]` includes TC39 Iterator Helpers (Stage 4). `MapIterator`, `SetIterator`, and `ArrayIterator` all extend `IteratorObject`, which provides `.filter()`, `.map()`, `.find()`, `.toArray()`, `.some()`, `.every()`, `.reduce()`, `.take()`, `.drop()`, `.flatMap()`, and `.forEach()`. + +**Prefer `.toArray()` over `[...spread]`** for materializing iterators: + +```typescript +// Bad +const all = [...map.values()]; +const active = [...map.values()].filter((n) => !n.deleted); + +// Good +const all = map.values().toArray(); +const active = map.values().filter((n) => !n.deleted).toArray(); +``` + +`.sort()` is not on `IteratorObject` (requires random access). Materialize first: `map.values().toArray().sort(fn)`. # Type Co-location Principles ## Never Use Generic Type Buckets diff --git a/.agents/skills/workspace-api/SKILL.md b/.agents/skills/workspace-api/SKILL.md index 324107a5ef..d4bba66e12 100644 --- a/.agents/skills/workspace-api/SKILL.md +++ b/.agents/skills/workspace-api/SKILL.md @@ -1,6 +1,6 @@ --- name: workspace-api -description: Workspace API patterns for defineTable, defineKv, versioning, and migrations. Use when defining workspace schemas, adding versions to existing tables, or writing migration functions. +description: Workspace API patterns for defineTable, defineKv, versioning, migrations, and data access (CRUD + observation). Use when defining workspace schemas, reading/writing table data, observing changes, or writing migration functions. metadata: author: epicenter version: '4.0' @@ -10,13 +10,14 @@ metadata: Type-safe schema definitions for tables and KV stores. -> **Related Skills**: See `yjs` for Yjs CRDT patterns and shared types. +> **Related Skills**: See `yjs` for Yjs CRDT patterns and shared types. See `svelte` for reactive wrappers (`fromTable`, `fromKv`). ## When to Apply This Skill - Defining a new table or KV store with `defineTable()` or `defineKv()` - Adding a new version to an existing table definition - Writing table migration functions +- Reading, writing, or observing table/KV data ## Tables @@ -275,6 +276,49 @@ return { ...row, views: 0, _v: 2 }; // Works — contextual narrowing return { ...row, views: 0, _v: 2 as const }; // Also works — redundant ``` +## Reading & Observing Data + +### Table CRUD + +```typescript +table.get(id) // { status: 'valid', row } | { status: 'not_found' } | { status: 'invalid' } +table.getAllValid() // T[] — all rows that pass schema validation +table.set(row) // upsert full row (replaces entire row) +table.update(id, partial) // merge partial fields into existing row +table.delete(id) // remove row +table.has(id) // boolean +table.count() // number +``` + +### KV CRUD + +```typescript +kv.get('key') // returns value (or default from defineKv) +kv.set('key', value) // set value +``` + +### Observation + +Tables and KV stores support change observation for reactive updates: + +```typescript +// Table — callback receives changed row IDs per Y.Transaction +const unsub = tables.posts.observe((changedIds) => { + for (const id of changedIds) { + const result = tables.posts.get(id); + // ... + } +}); + +// KV — per-key observation +const unsub = kv.observe('theme', (change) => { + if (change.type === 'set') { /* change.value */ } + if (change.type === 'delete') { /* fell back to default */ } +}); +``` + +**In Svelte apps**, prefer `fromTable`/`fromKv` from `@epicenter/svelte` instead of raw observers. See the `svelte` skill for the reactive table state pattern. + ## Document Content (Per-Row Y.Docs) Tables with `.withDocument()` create a content Y.Doc per row. Content is stored using a **timeline model**: a `Y.Array('timeline')` inside the Y.Doc, where each entry is a typed `Y.Map` supporting text, richtext, and sheet modes. diff --git a/apps/fuji/package.json b/apps/fuji/package.json index 121c77ab32..46bee21f4b 100644 --- a/apps/fuji/package.json +++ b/apps/fuji/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@epicenter/ui": "workspace:*", + "bun-types": "catalog:", "@lucide/svelte": "catalog:", "@sveltejs/adapter-static": "catalog:", "@sveltejs/kit": "catalog:", @@ -25,7 +26,8 @@ "typescript": "catalog:", "vite": "catalog:" }, - "dependencies": { +"dependencies": { + "@epicenter/svelte": "workspace:*", "@epicenter/workspace": "workspace:*", "@tanstack/svelte-query": "catalog:", "@tanstack/svelte-query-devtools": "catalog:", diff --git a/apps/fuji/src/lib/components/TagInput.svelte b/apps/fuji/src/lib/components/TagInput.svelte index a1046dcece..5f45348ae2 100644 --- a/apps/fuji/src/lib/components/TagInput.svelte +++ b/apps/fuji/src/lib/components/TagInput.svelte @@ -16,19 +16,6 @@ let inputValue = $state(''); - function handleKeydown(e: KeyboardEvent) { - if (e.key === 'Enter' && inputValue.trim()) { - e.preventDefault(); - const value = inputValue.trim().toLowerCase(); - if (!values.includes(value)) { - onAdd(value); - } - inputValue = ''; - } - if (e.key === 'Backspace' && !inputValue && values.length > 0) { - onRemove(values[values.length - 1]!); - } - }
{ + if (e.key === 'Enter' && inputValue.trim()) { + e.preventDefault(); + const value = inputValue.trim().toLowerCase(); + if (!values.includes(value)) { + onAdd(value); + } + inputValue = ''; + } + if (e.key === 'Backspace' && !inputValue && values.length > 0) { + onRemove(values[values.length - 1]!); + } + }} >
diff --git a/apps/fuji/src/routes/+page.svelte b/apps/fuji/src/routes/+page.svelte index 602d8a9c6b..c0c9ced3d6 100644 --- a/apps/fuji/src/routes/+page.svelte +++ b/apps/fuji/src/routes/+page.svelte @@ -3,6 +3,7 @@ import { SidebarProvider } from '@epicenter/ui/sidebar'; import type { DocumentHandle } from '@epicenter/workspace'; import { dateTimeStringNow, generateId } from '@epicenter/workspace'; + import { fromKv, fromTable } from '@epicenter/svelte'; import ClockIcon from '@lucide/svelte/icons/clock'; import TableIcon from '@lucide/svelte/icons/table-2'; import type * as Y from 'yjs'; @@ -14,9 +15,10 @@ // ─── Reactive State ──────────────────────────────────────────────────────────── - let entries = $state([]); - let selectedEntryId = $state(null); - let viewMode = $state<'table' | 'timeline'>('table'); + const entries = fromTable(workspaceClient.tables.entries); + const entriesArray = $derived(entries.values().toArray()); + const selectedEntryId = fromKv(workspaceClient.kv, 'selectedEntryId'); + const viewMode = fromKv(workspaceClient.kv, 'viewMode'); let currentYText = $state(null); let currentDocHandle = $state(null); @@ -26,48 +28,15 @@ let activeTagFilter = $state(null); let searchQuery = $state(''); - // ─── Workspace Observation ─────────────────────────────────────────────────── - - $effect(() => { - entries = workspaceClient.tables.entries.getAllValid(); - - const kvEntryId = workspaceClient.kv.get('selectedEntryId'); - selectedEntryId = kvEntryId.status === 'valid' ? kvEntryId.value : null; - - const kvViewMode = workspaceClient.kv.get('viewMode'); - viewMode = kvViewMode.status === 'valid' ? kvViewMode.value : 'table'; - - const unsubEntries = workspaceClient.tables.entries.observe(() => { - entries = workspaceClient.tables.entries.getAllValid(); - }); - - const unsubSelectedEntry = workspaceClient.kv.observe( - 'selectedEntryId', - (change) => { - selectedEntryId = change.type === 'set' ? change.value : null; - }, - ); - - const unsubViewMode = workspaceClient.kv.observe('viewMode', (change) => { - viewMode = change.type === 'set' ? change.value : 'table'; - }); - - return () => { - unsubEntries(); - unsubSelectedEntry(); - unsubViewMode(); - }; - }); - // ─── Derived State ─────────────────────────────────────────────────────────── const selectedEntry = $derived( - entries.find((e) => e.id === selectedEntryId) ?? null, + selectedEntryId.current ? entries.get(selectedEntryId.current) ?? null : null, ); /** Entries filtered by sidebar type/tag filters. */ const filteredEntries = $derived.by(() => { - let result = entries; + let result = entriesArray; const typeFilter = activeTypeFilter; const tagFilter = activeTagFilter; if (typeFilter) { @@ -81,7 +50,7 @@ // ─── Actions ───────────────────────────────────────────────────────────────── - function createEntry() { +function createEntry() { const id = generateId() as unknown as EntryId; workspaceClient.tables.entries.set({ id, @@ -91,38 +60,21 @@ updatedAt: dateTimeStringNow(), _v: 2, }); - workspaceClient.kv.set('selectedEntryId', id); + selectedEntryId.current = id; } function toggleViewMode() { - const next = viewMode === 'table' ? 'timeline' : 'table'; - workspaceClient.kv.set('viewMode', next); + const next = viewMode.current === 'table' ? 'timeline' : 'table'; + viewMode.current = next; } // ─── Keyboard Shortcuts ─────────────────────────────────────────────────────── - function handleKeydown(event: KeyboardEvent) { - const isInputFocused = - event.target instanceof HTMLInputElement || - event.target instanceof HTMLTextAreaElement || - (event.target instanceof HTMLElement && event.target.isContentEditable); - - if (event.key === 'n' && event.metaKey) { - event.preventDefault(); - createEntry(); - return; - } - - if (event.key === 'Escape' && !isInputFocused && selectedEntryId) { - event.preventDefault(); - workspaceClient.kv.set('selectedEntryId', null); - } - } // ─── Document Handle (Y.Text) ──────────────────────────────────────────────── $effect(() => { - const entryId = selectedEntryId; + const entryId = selectedEntryId.current; if (!entryId) { currentYText = null; currentDocHandle = null; @@ -147,35 +99,60 @@ }); - + { + const isInputFocused = + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable); + + if (event.key === 'n' && event.metaKey) { + event.preventDefault(); + const id = generateId() as unknown as EntryId; + workspaceClient.tables.entries.set({ + id, + title: '', + preview: '', + createdAt: dateTimeStringNow(), + updatedAt: dateTimeStringNow(), + _v: 2, + }); + selectedEntryId.current = id; + return; + } + + if (event.key === 'Escape' && !isInputFocused && selectedEntryId.current) { + event.preventDefault(); + selectedEntryId.current = null; + } +}} /> (activeTypeFilter = type)} onFilterByTag={(tag) => (activeTagFilter = tag)} onSearchChange={(query) => (searchQuery = query)} - onSelectEntry={(id) => workspaceClient.kv.set('selectedEntryId', id)} + onSelectEntry={(id) => (selectedEntryId.current = id)} />
{#if selectedEntry && currentYText} - {#key selectedEntryId} + {#key selectedEntryId.current} { - if (!selectedEntryId) return; - workspaceClient.tables.entries.update(selectedEntryId, updates); + if (!selectedEntryId.current) return; + workspaceClient.tables.entries.update(selectedEntryId.current, updates); }} onPreviewChange={(preview) => { - if (!selectedEntryId) return; - workspaceClient.tables.entries.update(selectedEntryId, { preview }); + if (!selectedEntryId.current) return; + workspaceClient.tables.entries.update(selectedEntryId.current, { preview }); }} - onBack={() => workspaceClient.kv.set('selectedEntryId', null)} + onBack={() => (selectedEntryId.current = null)} /> {/key} {:else if selectedEntry} @@ -190,9 +167,9 @@ size="icon" class="size-7" onclick={toggleViewMode} - title={viewMode === 'table' ? 'Switch to timeline' : 'Switch to table'} + title={viewMode.current === 'table' ? 'Switch to timeline' : 'Switch to table'} > - {#if viewMode === 'table'} + {#if viewMode.current === 'table'} {:else} @@ -200,19 +177,19 @@
- {#if viewMode === 'table'} + {#if viewMode.current === 'table'} workspaceClient.kv.set('selectedEntryId', id)} + selectedEntryId={selectedEntryId.current} + onSelectEntry={(id) => (selectedEntryId.current = id)} onAddEntry={createEntry} /> {:else} workspaceClient.kv.set('selectedEntryId', id)} + selectedEntryId={selectedEntryId.current} + onSelectEntry={(id) => (selectedEntryId.current = id)} onAddEntry={createEntry} /> {/if} diff --git a/apps/fuji/tsconfig.json b/apps/fuji/tsconfig.json index 6c0eae8f0c..643a459564 100644 --- a/apps/fuji/tsconfig.json +++ b/apps/fuji/tsconfig.json @@ -1,9 +1,6 @@ { "extends": ["../../tsconfig.base.json", "./.svelte-kit/tsconfig.json"], "compilerOptions": { - "allowJs": true, "checkJs": true, - "resolveJsonModule": true, - "sourceMap": true - } + "types": ["bun-types"] } diff --git a/apps/honeycrisp/package.json b/apps/honeycrisp/package.json index 7de73ca868..ec5bbcc402 100644 --- a/apps/honeycrisp/package.json +++ b/apps/honeycrisp/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@epicenter/ui": "workspace:*", + "bun-types": "catalog:", "@lucide/svelte": "catalog:", "@sveltejs/adapter-static": "catalog:", "@sveltejs/kit": "catalog:", @@ -25,7 +26,8 @@ "typescript": "catalog:", "vite": "catalog:" }, - "dependencies": { +"dependencies": { + "@epicenter/svelte": "workspace:*", "@epicenter/workspace": "workspace:*", "@tanstack/svelte-query": "catalog:", "@tanstack/svelte-query-devtools": "catalog:", diff --git a/apps/honeycrisp/src/lib/components/CommandPalette.svelte b/apps/honeycrisp/src/lib/components/CommandPalette.svelte index 86af051e4d..9cca0769c8 100644 --- a/apps/honeycrisp/src/lib/components/CommandPalette.svelte +++ b/apps/honeycrisp/src/lib/components/CommandPalette.svelte @@ -4,7 +4,7 @@ import FolderIcon from '@lucide/svelte/icons/folder'; import FolderPlusIcon from '@lucide/svelte/icons/folder-plus'; import PlusIcon from '@lucide/svelte/icons/plus'; - import { notesState } from '$lib/state/notes.svelte'; + import { foldersState, notesState, viewState } from '$lib/state'; let { open = $bindable(false) }: { open: boolean } = $props(); @@ -17,17 +17,17 @@ { - notesState.selectFolder(null); + viewState.selectFolder(null); open = false; }} > All Notes - {#each notesState.folders as folder (folder.id)} + {#each foldersState.folders as folder (folder.id)} { - notesState.selectFolder(folder.id); + viewState.selectFolder(folder.id); open = false; }} > @@ -47,7 +47,7 @@ {#each notesState.notes as note (note.id)} { - notesState.selectNote(note.id); + viewState.selectNote(note.id); open = false; }} > @@ -78,7 +78,7 @@ { - notesState.createFolder(); + foldersState.createFolder(); open = false; }} > diff --git a/apps/honeycrisp/src/lib/components/Editor.svelte b/apps/honeycrisp/src/lib/components/Editor.svelte deleted file mode 100644 index 00ee8f2530..0000000000 --- a/apps/honeycrisp/src/lib/components/Editor.svelte +++ /dev/null @@ -1,326 +0,0 @@ - - -
- {#if editor} -
- - - editor?.chain().focus().toggleBold().run()} - > - - - - Bold (⌘B) - - - - editor?.chain().focus().toggleItalic().run()} - > - - - - Italic (⌘I) - - - - editor?.chain().focus().toggleUnderline().run()} - > - - - - Underline (⌘U) - - - - editor?.chain().focus().toggleStrike().run()} - > - - - - Strikethrough (⌘⇧S) - - - - - { - if (v === 'h1') editor?.chain().focus().toggleHeading({ level: 1 }).run(); - else if (v === 'h2') editor?.chain().focus().toggleHeading({ level: 2 }).run(); - else if (v === 'h3') editor?.chain().focus().toggleHeading({ level: 3 }).run(); - }} - > - - - - - Heading 1 - - - - - - Heading 2 - - - - - - Heading 3 - - - - - - { - if (v === 'bullet') editor?.chain().focus().toggleBulletList().run(); - else if (v === 'ordered') editor?.chain().focus().toggleOrderedList().run(); - else if (v === 'task') editor?.chain().focus().toggleTaskList().run(); - }} - > - - - - - Bullet List - - - - - - Ordered List - - - - - - Checklist - - - - - - - - editor?.chain().focus().toggleBlockquote().run()} - > - - - - Blockquote (⌘⇧B) - -
- {/if} -
-
- - diff --git a/apps/honeycrisp/src/lib/components/FolderMenuItem.svelte b/apps/honeycrisp/src/lib/components/FolderMenuItem.svelte new file mode 100644 index 0000000000..0a7dd80076 --- /dev/null +++ b/apps/honeycrisp/src/lib/components/FolderMenuItem.svelte @@ -0,0 +1,115 @@ + + + + {#if isEditing} +
+ + { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + isEditing = false; + editingName = ''; + } + }} + onblur={commitRename} + autofocus + > +
+ {:else} + viewState.selectFolder(folder.id)} + > + {#if folder.icon} + {folder.icon} + {:else} + + {/if} + {folder.name} + + {notesState.noteCounts[folder.id] ?? 0} + + + + + {#snippet child({ props })} + + + Folder actions + + {/snippet} + + + { + isEditing = true; + editingName = folder.name; + }} + > + + Rename + + + (confirmingDelete = true)} + > + + Delete + + + + {/if} +
+ + + + + Delete Folder? + + Notes in this folder will be moved to All Notes. + + + + Cancel + foldersState.deleteFolder(folder.id)} + > + Delete + + + + diff --git a/apps/honeycrisp/src/lib/components/NoteCard.svelte b/apps/honeycrisp/src/lib/components/NoteCard.svelte index 0e6af4a143..23e6f2740c 100644 --- a/apps/honeycrisp/src/lib/components/NoteCard.svelte +++ b/apps/honeycrisp/src/lib/components/NoteCard.svelte @@ -8,13 +8,22 @@ import PinIcon from '@lucide/svelte/icons/pin'; import TrashIcon from '@lucide/svelte/icons/trash-2'; import { format } from 'date-fns'; - import type { Note } from '$lib/workspace'; - import { notesState } from '$lib/state/notes.svelte'; + import { foldersState, notesState } from '$lib/state'; import { parseDateTime } from '$lib/utils/date'; + import type { Note } from '$lib/workspace'; - let { note }: { note: Note } = $props(); + let { + note, + isSelected, + onSelect, + }: { + note: Note; + isSelected: boolean; + onSelect: () => void; + } = $props(); - const isSelected = $derived(note.id === notesState.selectedNoteId); + /** Derive deleted status from the note itself — no need to check view mode. */ + const isDeleted = $derived(note.deletedAt !== undefined); let confirmingPermanentDelete = $state(false); @@ -27,7 +36,7 @@ class="group relative flex cursor-pointer flex-col gap-0.5 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-accent/30 {isSelected ? 'bg-accent' : ''}" - onclick={() => notesState.selectNote(note.id)} + onclick={onSelect} >
@@ -44,7 +53,7 @@ {note.preview || 'No content'}

- {#if notesState.isRecentlyDeletedView} + {#if isDeleted}