@signal9/era-ui 3.9.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apps/index.d.ts +19 -0
- package/dist/apps/index.js +19 -0
- package/dist/apps/notes/editor/bubble-menu.svelte.d.ts +11 -0
- package/dist/apps/notes/editor/bubble-menu.svelte.js +106 -0
- package/dist/apps/notes/editor/bubble-toolbar.svelte +42 -0
- package/dist/apps/notes/editor/bubble-toolbar.svelte.d.ts +22 -0
- package/dist/apps/notes/editor/extensions.d.ts +18 -0
- package/dist/apps/notes/editor/extensions.js +88 -0
- package/dist/apps/notes/editor/floating.d.ts +21 -0
- package/dist/apps/notes/editor/floating.js +44 -0
- package/dist/apps/notes/editor/link.d.ts +9 -0
- package/dist/apps/notes/editor/link.js +22 -0
- package/dist/apps/notes/editor/list-cleanup-rule.d.ts +14 -0
- package/dist/apps/notes/editor/list-cleanup-rule.js +47 -0
- package/dist/apps/notes/editor/slash-command.svelte.d.ts +22 -0
- package/dist/apps/notes/editor/slash-command.svelte.js +215 -0
- package/dist/apps/notes/editor/slash-menu.svelte +63 -0
- package/dist/apps/notes/editor/slash-menu.svelte.d.ts +23 -0
- package/dist/apps/notes/index.d.ts +11 -0
- package/dist/apps/notes/index.js +13 -0
- package/dist/apps/notes/markdown.d.ts +12 -0
- package/dist/apps/notes/markdown.js +165 -0
- package/dist/apps/notes/note-editor.svelte +456 -0
- package/dist/apps/notes/note-editor.svelte.d.ts +35 -0
- package/dist/apps/notes/notes-store.svelte.d.ts +64 -0
- package/dist/apps/notes/notes-store.svelte.js +234 -0
- package/dist/apps/notes/notes.svelte +377 -0
- package/dist/apps/notes/notes.svelte.d.ts +22 -0
- package/dist/apps/notes/tree.d.ts +70 -0
- package/dist/apps/notes/tree.js +185 -0
- package/dist/apps/notes/types.d.ts +49 -0
- package/dist/apps/notes/types.js +11 -0
- package/dist/dev/audit/audits/index.d.ts +2 -1
- package/dist/dev/audit/audits/index.js +3 -1
- package/dist/dev/audit/audits/resting-gap.d.ts +2 -0
- package/dist/dev/audit/audits/resting-gap.js +78 -0
- package/dist/docs/notes.md +82 -0
- package/dist/era-ui.css +1 -1
- package/dist/generated-docs/llms-full.txt +92 -1
- package/dist/generated-docs/llms.txt +1 -1
- package/dist/generated-docs/manifest.json +16 -1
- package/dist/generated-docs/notes.md +86 -0
- package/dist/generated-docs/utilities.json +1 -1
- package/dist/generated-docs/utilities.md +1 -1
- package/dist/styles/index.css +2 -1
- package/dist/styles/themes.css +70 -52
- package/dist/ui/badge/badge.svelte.d.ts +17 -17
- package/dist/ui/bar/bar.svelte.d.ts +11 -11
- package/dist/ui/button/variants.d.ts +44 -44
- package/dist/ui/card/card.svelte.d.ts +20 -20
- package/dist/ui/chip/chip.svelte.d.ts +14 -14
- package/dist/ui/pane/pane-root.svelte.d.ts +1 -1
- package/dist/ui/pane/pane.svelte.d.ts +1 -1
- package/dist/ui/sheet/sheet-content.svelte.d.ts +14 -14
- package/dist/ui/skeleton/skeleton.svelte.d.ts +11 -11
- package/dist/utils/index.js +11 -0
- package/package.json +31 -5
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Notes data layer — reactive state plus a pluggable persistence adapter.
|
|
3
|
+
*
|
|
4
|
+
* The store owns the notes; the adapter owns where they live. That split is
|
|
5
|
+
* deliberate: era ships a browser-only app (localStorage), but the same
|
|
6
|
+
* component is meant to drop into a host that persists server-side. Such a host
|
|
7
|
+
* supplies its own adapter and changes nothing else — which is why `load`/`save`
|
|
8
|
+
* are allowed to return promises even though the bundled adapters are sync.
|
|
9
|
+
*
|
|
10
|
+
* Writes are debounced and coalesced: every mutation marks the store dirty and
|
|
11
|
+
* re-arms one timer, so a burst of keystrokes costs a single `save`.
|
|
12
|
+
*/
|
|
13
|
+
import { tiptapToMarkdown, textContent } from './markdown.js';
|
|
14
|
+
import { EMPTY_DOC } from './types.js';
|
|
15
|
+
export const DEFAULT_STORAGE_KEY = 'era-ui:notes';
|
|
16
|
+
/** Loose structural check — storage is untrusted (stale schema, another tab, a
|
|
17
|
+
* user editing devtools), so a malformed entry is dropped rather than crashing
|
|
18
|
+
* the list on first render. */
|
|
19
|
+
function isNote(value) {
|
|
20
|
+
if (typeof value !== 'object' || value === null)
|
|
21
|
+
return false;
|
|
22
|
+
const n = value;
|
|
23
|
+
return (typeof n.id === 'string' &&
|
|
24
|
+
typeof n.title === 'string' &&
|
|
25
|
+
typeof n.content === 'object' &&
|
|
26
|
+
n.content !== null &&
|
|
27
|
+
Array.isArray(n.content.content));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Persist to `localStorage`. Safe to construct during SSR — it only touches
|
|
31
|
+
* storage inside `load`/`save`, and returns empty / no-ops when there is none.
|
|
32
|
+
*/
|
|
33
|
+
export function localStorageAdapter(key = DEFAULT_STORAGE_KEY) {
|
|
34
|
+
const available = () => typeof localStorage !== 'undefined';
|
|
35
|
+
return {
|
|
36
|
+
load() {
|
|
37
|
+
if (!available())
|
|
38
|
+
return [];
|
|
39
|
+
try {
|
|
40
|
+
const raw = localStorage.getItem(key);
|
|
41
|
+
if (!raw)
|
|
42
|
+
return [];
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
return Array.isArray(parsed) ? parsed.filter(isNote) : [];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
save(notes) {
|
|
51
|
+
if (!available())
|
|
52
|
+
return;
|
|
53
|
+
// Let a quota/private-mode failure reach the store so the UI can say so,
|
|
54
|
+
// rather than silently dropping the user's writing.
|
|
55
|
+
localStorage.setItem(key, JSON.stringify(notes));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** In-memory only — for demos, tests, and SSR snapshots. */
|
|
60
|
+
export function memoryAdapter(seed = []) {
|
|
61
|
+
let notes = seed;
|
|
62
|
+
return {
|
|
63
|
+
load: () => notes,
|
|
64
|
+
save: (next) => {
|
|
65
|
+
notes = next;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function newId() {
|
|
70
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto)
|
|
71
|
+
return crypto.randomUUID();
|
|
72
|
+
return `note-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
73
|
+
}
|
|
74
|
+
export class NotesStore {
|
|
75
|
+
#adapter;
|
|
76
|
+
#debounce;
|
|
77
|
+
#statusLinger;
|
|
78
|
+
#saveTimer = null;
|
|
79
|
+
#statusTimer = null;
|
|
80
|
+
notes = $state([]);
|
|
81
|
+
/** True until the first `load()` settles — the list renders a skeleton on it. */
|
|
82
|
+
loading = $state(true);
|
|
83
|
+
status = $state('idle');
|
|
84
|
+
lastError = $state(null);
|
|
85
|
+
/** Pinned first, then most-recently-touched. The order the sidebar renders. */
|
|
86
|
+
ordered = $derived([...this.notes].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt));
|
|
87
|
+
constructor(options = {}) {
|
|
88
|
+
this.#adapter = options.adapter ?? localStorageAdapter();
|
|
89
|
+
this.#debounce = options.debounce ?? 400;
|
|
90
|
+
this.#statusLinger = options.statusLinger ?? 1600;
|
|
91
|
+
}
|
|
92
|
+
async load() {
|
|
93
|
+
this.loading = true;
|
|
94
|
+
try {
|
|
95
|
+
const loaded = await this.#adapter.load();
|
|
96
|
+
this.notes = loaded;
|
|
97
|
+
this.lastError = null;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
this.notes = [];
|
|
101
|
+
this.#fail(error);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.loading = false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
get(id) {
|
|
108
|
+
if (!id)
|
|
109
|
+
return undefined;
|
|
110
|
+
return this.notes.find((n) => n.id === id);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Case-insensitive match over title AND body. Matching the body is why
|
|
114
|
+
* `plainText` is denormalised — the alternative is walking every document's
|
|
115
|
+
* AST on every keystroke.
|
|
116
|
+
*/
|
|
117
|
+
search(query) {
|
|
118
|
+
const q = query.trim().toLowerCase();
|
|
119
|
+
if (!q)
|
|
120
|
+
return this.ordered;
|
|
121
|
+
return this.ordered.filter((n) => n.title.toLowerCase().includes(q) || n.plainText.toLowerCase().includes(q));
|
|
122
|
+
}
|
|
123
|
+
create(init = {}) {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const content = init.content ?? structuredClone(EMPTY_DOC);
|
|
126
|
+
const note = {
|
|
127
|
+
id: newId(),
|
|
128
|
+
title: init.title ?? 'Untitled',
|
|
129
|
+
icon: init.icon ?? null,
|
|
130
|
+
content,
|
|
131
|
+
plainText: content.content.map(textContent).join(' ').trim(),
|
|
132
|
+
pinned: init.pinned ?? false,
|
|
133
|
+
createdAt: now,
|
|
134
|
+
updatedAt: now
|
|
135
|
+
};
|
|
136
|
+
this.notes.push(note);
|
|
137
|
+
this.#schedule();
|
|
138
|
+
return note;
|
|
139
|
+
}
|
|
140
|
+
update(id, patch) {
|
|
141
|
+
const note = this.get(id);
|
|
142
|
+
if (!note)
|
|
143
|
+
return;
|
|
144
|
+
if (patch.title !== undefined)
|
|
145
|
+
note.title = patch.title;
|
|
146
|
+
if (patch.icon !== undefined)
|
|
147
|
+
note.icon = patch.icon;
|
|
148
|
+
if (patch.pinned !== undefined)
|
|
149
|
+
note.pinned = patch.pinned;
|
|
150
|
+
if (patch.content !== undefined) {
|
|
151
|
+
note.content = patch.content;
|
|
152
|
+
note.plainText = patch.content.content.map(textContent).join(' ').trim();
|
|
153
|
+
}
|
|
154
|
+
note.updatedAt = Date.now();
|
|
155
|
+
this.#schedule();
|
|
156
|
+
}
|
|
157
|
+
remove(id) {
|
|
158
|
+
const index = this.notes.findIndex((n) => n.id === id);
|
|
159
|
+
if (index < 0)
|
|
160
|
+
return;
|
|
161
|
+
this.notes.splice(index, 1);
|
|
162
|
+
this.#schedule();
|
|
163
|
+
}
|
|
164
|
+
togglePin(id) {
|
|
165
|
+
const note = this.get(id);
|
|
166
|
+
if (!note)
|
|
167
|
+
return;
|
|
168
|
+
// Pinning is an ordering change, not an edit — leave `updatedAt` alone so a
|
|
169
|
+
// pin doesn't shuffle the note to the top of the recency list as well.
|
|
170
|
+
note.pinned = !note.pinned;
|
|
171
|
+
this.#schedule();
|
|
172
|
+
}
|
|
173
|
+
setIcon(id, icon) {
|
|
174
|
+
this.update(id, { icon });
|
|
175
|
+
}
|
|
176
|
+
/** The note as markdown — for copy-out, export, or handing to a model. */
|
|
177
|
+
toMarkdown(id) {
|
|
178
|
+
const note = this.get(id);
|
|
179
|
+
if (!note)
|
|
180
|
+
return '';
|
|
181
|
+
const heading = note.title ? `# ${note.title}\n\n` : '';
|
|
182
|
+
return heading + tiptapToMarkdown(note.content);
|
|
183
|
+
}
|
|
184
|
+
/** Persist immediately, cancelling any pending debounce. Safe to call spuriously. */
|
|
185
|
+
async flush() {
|
|
186
|
+
if (this.#saveTimer) {
|
|
187
|
+
clearTimeout(this.#saveTimer);
|
|
188
|
+
this.#saveTimer = null;
|
|
189
|
+
}
|
|
190
|
+
await this.#persist();
|
|
191
|
+
}
|
|
192
|
+
/** Clear timers and write out whatever is pending. Call from `onDestroy`. */
|
|
193
|
+
destroy() {
|
|
194
|
+
if (this.#statusTimer)
|
|
195
|
+
clearTimeout(this.#statusTimer);
|
|
196
|
+
this.#statusTimer = null;
|
|
197
|
+
void this.flush();
|
|
198
|
+
}
|
|
199
|
+
#schedule() {
|
|
200
|
+
this.status = 'saving';
|
|
201
|
+
if (this.#saveTimer)
|
|
202
|
+
clearTimeout(this.#saveTimer);
|
|
203
|
+
this.#saveTimer = setTimeout(() => {
|
|
204
|
+
this.#saveTimer = null;
|
|
205
|
+
void this.#persist();
|
|
206
|
+
}, this.#debounce);
|
|
207
|
+
}
|
|
208
|
+
async #persist() {
|
|
209
|
+
try {
|
|
210
|
+
await this.#adapter.save($state.snapshot(this.notes));
|
|
211
|
+
this.lastError = null;
|
|
212
|
+
this.#flash('saved');
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.#fail(error);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
#flash(status) {
|
|
219
|
+
this.status = status;
|
|
220
|
+
if (this.#statusTimer)
|
|
221
|
+
clearTimeout(this.#statusTimer);
|
|
222
|
+
this.#statusTimer = setTimeout(() => {
|
|
223
|
+
this.#statusTimer = null;
|
|
224
|
+
this.status = 'idle';
|
|
225
|
+
}, this.#statusLinger);
|
|
226
|
+
}
|
|
227
|
+
#fail(error) {
|
|
228
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
229
|
+
this.status = 'error';
|
|
230
|
+
if (this.#statusTimer)
|
|
231
|
+
clearTimeout(this.#statusTimer);
|
|
232
|
+
this.#statusTimer = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Notes — a complete, client-side notes app.
|
|
4
|
+
*
|
|
5
|
+
* An era port of the term/web notes app: same TipTap editor, same slash menu
|
|
6
|
+
* and selection toolbar, same heading-tree index. Two things changed.
|
|
7
|
+
*
|
|
8
|
+
* PERSISTENCE. The original round-tripped every edit through a server via
|
|
9
|
+
* TanStack Query, which forced a whole dirty-override layer — local edits had
|
|
10
|
+
* to out-rank refetched query data, or an in-flight save clobbered what you
|
|
11
|
+
* were typing. Here the store IS the source of truth and persistence is a
|
|
12
|
+
* debounced write behind it, so the editor writes straight through and there
|
|
13
|
+
* is nothing to reconcile.
|
|
14
|
+
*
|
|
15
|
+
* CHROME. Everything visible is an era primitive (Bar, Button, Input, Badge,
|
|
16
|
+
* Popover, ScrollArea, AlertDialog, Skeleton) on era tokens, so the app
|
|
17
|
+
* follows the density, surface, corners, motion, and font axes like any other
|
|
18
|
+
* component in the library.
|
|
19
|
+
*/
|
|
20
|
+
import { onDestroy, onMount, untrack } from 'svelte';
|
|
21
|
+
import FileText from '@lucide/svelte/icons/file-text';
|
|
22
|
+
import Plus from '@lucide/svelte/icons/plus';
|
|
23
|
+
import Search from '@lucide/svelte/icons/search';
|
|
24
|
+
import Pin from '@lucide/svelte/icons/pin';
|
|
25
|
+
import PinOff from '@lucide/svelte/icons/pin-off';
|
|
26
|
+
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
27
|
+
import { Bar } from '../../ui/bar/index.js';
|
|
28
|
+
import { Badge } from '../../ui/badge/index.js';
|
|
29
|
+
import { Button } from '../../ui/button/index.js';
|
|
30
|
+
import { Input } from '../../ui/input/index.js';
|
|
31
|
+
import { Skeleton } from '../../ui/skeleton/index.js';
|
|
32
|
+
import * as AlertDialog from '../../ui/alert-dialog/index.js';
|
|
33
|
+
import * as Popover from '../../ui/popover/index.js';
|
|
34
|
+
import * as ScrollArea from '../../ui/scroll-area/index.js';
|
|
35
|
+
import { cn, keys } from '../../utils/index.js';
|
|
36
|
+
import NoteEditor from './note-editor.svelte';
|
|
37
|
+
import { NotesStore, localStorageAdapter, type NotesAdapter } from './notes-store.svelte.js';
|
|
38
|
+
import type { NoteInit, TiptapDocument } from './types.js';
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/**
|
|
42
|
+
* Bring your own store — for a custom adapter, or to share one set of
|
|
43
|
+
* notes between two mounted views. When supplied, the host owns its
|
|
44
|
+
* lifecycle: this component will not destroy it on unmount.
|
|
45
|
+
*/
|
|
46
|
+
store?: NotesStore;
|
|
47
|
+
/** Persistence for the store this component creates. Ignored if `store` is set. */
|
|
48
|
+
adapter?: NotesAdapter;
|
|
49
|
+
/** `localStorage` key for the default adapter. Ignored if `store`/`adapter` is set. */
|
|
50
|
+
storageKey?: string;
|
|
51
|
+
/** The open note. Bindable, so a host can deep-link or restore a session. */
|
|
52
|
+
noteId?: string | null;
|
|
53
|
+
/** Notes created only when storage comes back empty — a first-run sample. */
|
|
54
|
+
seed?: NoteInit[];
|
|
55
|
+
class?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let {
|
|
59
|
+
store: providedStore,
|
|
60
|
+
adapter,
|
|
61
|
+
storageKey,
|
|
62
|
+
noteId = $bindable(null),
|
|
63
|
+
seed,
|
|
64
|
+
class: className
|
|
65
|
+
}: Props = $props();
|
|
66
|
+
|
|
67
|
+
// Resolved once, on purpose — untrack() is the declaration of that intent, not
|
|
68
|
+
// a workaround. A store swapped in mid-flight would strand the notes currently
|
|
69
|
+
// on screen and the editor mounted over them.
|
|
70
|
+
const owned = untrack(() => providedStore === undefined);
|
|
71
|
+
const store = untrack(
|
|
72
|
+
() => providedStore ?? new NotesStore({ adapter: adapter ?? localStorageAdapter(storageKey) })
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
let query = $state('');
|
|
76
|
+
let pendingDelete = $state<string | null>(null);
|
|
77
|
+
let iconPickerOpen = $state(false);
|
|
78
|
+
|
|
79
|
+
const visible = $derived(store.search(query));
|
|
80
|
+
const selected = $derived(store.get(noteId));
|
|
81
|
+
const pendingDeleteNote = $derived(store.get(pendingDelete));
|
|
82
|
+
|
|
83
|
+
/** A compact glyph set — enough to tell notes apart at a glance, no picker UI. */
|
|
84
|
+
const ICON_ROWS = [
|
|
85
|
+
['📝', '📋', '📌', '💡', '🔥', '⭐'],
|
|
86
|
+
['🎯', '🚀', '🔧', '📦', '🔒', '🔑'],
|
|
87
|
+
['💻', '🤖', '🧠', '⚡', '🔍', '🌐']
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
onMount(async () => {
|
|
91
|
+
// `loading` is only true before a first successful load, so this is also the
|
|
92
|
+
// flag for "this mount is the one that hydrated the store". Seeding keys off
|
|
93
|
+
// it rather than off `owned`: a second view sharing an already-loaded store
|
|
94
|
+
// must not seed again, but a HOST-supplied store that comes back empty
|
|
95
|
+
// should still get its first-run notes.
|
|
96
|
+
const hydrating = store.loading;
|
|
97
|
+
if (hydrating) await store.load();
|
|
98
|
+
if (hydrating && seed?.length && store.notes.length === 0) {
|
|
99
|
+
for (const init of seed) store.create(init);
|
|
100
|
+
}
|
|
101
|
+
if (!store.get(noteId)) noteId = store.ordered[0]?.id ?? null;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
onDestroy(() => {
|
|
105
|
+
if (owned) store.destroy();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function createNote() {
|
|
109
|
+
const note = store.create();
|
|
110
|
+
noteId = note.id;
|
|
111
|
+
query = '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function confirmDelete() {
|
|
115
|
+
const id = pendingDelete;
|
|
116
|
+
pendingDelete = null;
|
|
117
|
+
if (!id) return;
|
|
118
|
+
store.remove(id);
|
|
119
|
+
if (noteId === id) noteId = store.ordered[0]?.id ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Short relative age — the list has a single cell for it, so it must stay narrow. */
|
|
123
|
+
function formatAge(ts: number) {
|
|
124
|
+
const d = Date.now() - ts;
|
|
125
|
+
if (d < 60_000) return 'now';
|
|
126
|
+
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m`;
|
|
127
|
+
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h`;
|
|
128
|
+
return new Date(ts).toLocaleDateString();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onRowKeydown(event: KeyboardEvent) {
|
|
132
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
const row = event.currentTarget as HTMLElement;
|
|
135
|
+
const next = event.key === 'ArrowDown' ? row.nextElementSibling : row.previousElementSibling;
|
|
136
|
+
(next as HTMLElement | null)?.focus();
|
|
137
|
+
}
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<div
|
|
141
|
+
class={cn('flex h-full min-h-0 text-body text-fg', className)}
|
|
142
|
+
use:keys={{
|
|
143
|
+
// Persistence is already debounced; Ctrl/Cmd-S is the reassurance gesture
|
|
144
|
+
// people press anyway, wired to a real immediate flush.
|
|
145
|
+
'$mod+s': (e) => {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
void store.flush();
|
|
148
|
+
}
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<!-- Sidebar: filter + note list -->
|
|
152
|
+
<div class="flex w-56 shrink-0 flex-col border-r border-divider-faded">
|
|
153
|
+
<Bar class="shrink-0 rounded-none border-b border-divider-faded">
|
|
154
|
+
<div
|
|
155
|
+
class="flex h-md w-full items-center gap-gutter rounded-md bg-elevated px-field text-body era-text-trim shadow-well focus-within:bg-highlight"
|
|
156
|
+
>
|
|
157
|
+
<Search class="size-xs shrink-0 text-muted" />
|
|
158
|
+
<input
|
|
159
|
+
class="h-full w-full min-w-0 bg-transparent text-fg outline-none placeholder:text-muted"
|
|
160
|
+
type="text"
|
|
161
|
+
placeholder="Filter…"
|
|
162
|
+
aria-label="Filter notes"
|
|
163
|
+
bind:value={query}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
<Button icon aria-label="New note" title="New note" onclick={createNote}>
|
|
167
|
+
<Plus />
|
|
168
|
+
</Button>
|
|
169
|
+
</Bar>
|
|
170
|
+
|
|
171
|
+
{#if store.loading}
|
|
172
|
+
<div class="flex flex-col gap-gutter p-gutter">
|
|
173
|
+
{#each Array.from({ length: 5 }, (_, i) => i) as i (i)}
|
|
174
|
+
<Skeleton class="h-md rounded-item" />
|
|
175
|
+
{/each}
|
|
176
|
+
</div>
|
|
177
|
+
{:else if visible.length === 0}
|
|
178
|
+
<!-- No call to action here: the document pane already carries the primary
|
|
179
|
+
one, and the bar's + is always within reach. Two competing CTAs for
|
|
180
|
+
an empty list reads as indecision. -->
|
|
181
|
+
<div
|
|
182
|
+
class="flex flex-1 flex-col items-center justify-center gap-gutter p-content text-center"
|
|
183
|
+
>
|
|
184
|
+
<p class="text-muted">{query ? 'No matches' : 'No notes yet'}</p>
|
|
185
|
+
</div>
|
|
186
|
+
{:else}
|
|
187
|
+
<ScrollArea.Root class="min-h-0 flex-1 rounded-none">
|
|
188
|
+
<ScrollArea.Viewport>
|
|
189
|
+
<div class="flex flex-col gap-gutter p-gutter" role="listbox" aria-label="Notes">
|
|
190
|
+
{#each visible as note (note.id)}
|
|
191
|
+
{@const current = note.id === noteId}
|
|
192
|
+
<!-- The row is the selectable option; the pin/delete buttons inside it
|
|
193
|
+
are separate actions, so they stop propagation rather than
|
|
194
|
+
selecting the note out from under the click. -->
|
|
195
|
+
<div
|
|
196
|
+
class="group flex h-md cursor-pointer items-center gap-(--era-xs-inset-md) rounded-item px-(--era-xs-inset-md) era-text-trim {current
|
|
197
|
+
? 'bg-hover text-bright shadow-(--era-shadow-pressed)'
|
|
198
|
+
: 'hover:bg-highlight hover:shadow-highlight'}"
|
|
199
|
+
role="option"
|
|
200
|
+
aria-selected={current}
|
|
201
|
+
tabindex="0"
|
|
202
|
+
onclick={() => (noteId = note.id)}
|
|
203
|
+
onkeydown={(e) => {
|
|
204
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
noteId = note.id;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
onRowKeydown(e);
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<span class="flex size-xs shrink-0 items-center justify-center">
|
|
213
|
+
{#if note.icon}
|
|
214
|
+
{note.icon}
|
|
215
|
+
{:else}
|
|
216
|
+
<FileText class="size-xs text-muted" />
|
|
217
|
+
{/if}
|
|
218
|
+
</span>
|
|
219
|
+
|
|
220
|
+
<span class="min-w-0 flex-1 truncate">{note.title || 'Untitled'}</span>
|
|
221
|
+
|
|
222
|
+
<!-- One cell, three occupants: the age at rest, the actions on
|
|
223
|
+
hover. Swapping them in place keeps the row from reflowing. -->
|
|
224
|
+
<span class="shrink-0 text-muted group-hover:hidden">
|
|
225
|
+
{#if note.pinned}<Pin class="size-xs" />{:else}{formatAge(note.updatedAt)}{/if}
|
|
226
|
+
</span>
|
|
227
|
+
<span class="hidden shrink-0 items-center gap-(--era-xs-inset-md) group-hover:flex">
|
|
228
|
+
<Button
|
|
229
|
+
icon
|
|
230
|
+
size="icon"
|
|
231
|
+
aria-label={note.pinned ? 'Unpin note' : 'Pin note'}
|
|
232
|
+
title={note.pinned ? 'Unpin note' : 'Pin note'}
|
|
233
|
+
active={note.pinned}
|
|
234
|
+
onclick={(e: MouseEvent) => {
|
|
235
|
+
e.stopPropagation();
|
|
236
|
+
store.togglePin(note.id);
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{#if note.pinned}<PinOff />{:else}<Pin />{/if}
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
icon
|
|
243
|
+
size="icon"
|
|
244
|
+
tone="destructive"
|
|
245
|
+
aria-label="Delete note"
|
|
246
|
+
title="Delete note"
|
|
247
|
+
onclick={(e: MouseEvent) => {
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
pendingDelete = note.id;
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
<Trash2 />
|
|
253
|
+
</Button>
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
{/each}
|
|
257
|
+
</div>
|
|
258
|
+
</ScrollArea.Viewport>
|
|
259
|
+
<ScrollArea.Scrollbar orientation="vertical">
|
|
260
|
+
<ScrollArea.Thumb />
|
|
261
|
+
</ScrollArea.Scrollbar>
|
|
262
|
+
</ScrollArea.Root>
|
|
263
|
+
{/if}
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<!-- Document pane -->
|
|
267
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
268
|
+
{#if selected}
|
|
269
|
+
<Bar class="shrink-0 rounded-none border-b border-divider-faded">
|
|
270
|
+
<Popover.Root bind:open={iconPickerOpen}>
|
|
271
|
+
<Popover.Trigger icon aria-label="Change icon" title="Change icon">
|
|
272
|
+
{#if selected.icon}
|
|
273
|
+
<span aria-hidden="true">{selected.icon}</span>
|
|
274
|
+
{:else}
|
|
275
|
+
<FileText />
|
|
276
|
+
{/if}
|
|
277
|
+
</Popover.Trigger>
|
|
278
|
+
<Popover.Content align="start" class="w-max">
|
|
279
|
+
<div class="flex flex-col gap-gutter">
|
|
280
|
+
{#each ICON_ROWS as row, r (r)}
|
|
281
|
+
<div class="flex gap-gutter">
|
|
282
|
+
{#each row as glyph (glyph)}
|
|
283
|
+
<Button
|
|
284
|
+
icon
|
|
285
|
+
size="default"
|
|
286
|
+
active={selected.icon === glyph}
|
|
287
|
+
aria-label={glyph}
|
|
288
|
+
onclick={() => {
|
|
289
|
+
store.setIcon(selected.id, glyph);
|
|
290
|
+
iconPickerOpen = false;
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{glyph}
|
|
294
|
+
</Button>
|
|
295
|
+
{/each}
|
|
296
|
+
</div>
|
|
297
|
+
{/each}
|
|
298
|
+
{#if selected.icon}
|
|
299
|
+
<Button
|
|
300
|
+
size="sm"
|
|
301
|
+
class="w-full"
|
|
302
|
+
onclick={() => {
|
|
303
|
+
store.setIcon(selected.id, null);
|
|
304
|
+
iconPickerOpen = false;
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
Clear icon
|
|
308
|
+
</Button>
|
|
309
|
+
{/if}
|
|
310
|
+
</div>
|
|
311
|
+
</Popover.Content>
|
|
312
|
+
</Popover.Root>
|
|
313
|
+
|
|
314
|
+
<Input
|
|
315
|
+
class="min-w-0 flex-1 bg-transparent shadow-none"
|
|
316
|
+
aria-label="Note title"
|
|
317
|
+
placeholder="Untitled"
|
|
318
|
+
value={selected.title}
|
|
319
|
+
oninput={(e: Event & { currentTarget: HTMLInputElement }) =>
|
|
320
|
+
store.update(selected.id, { title: e.currentTarget.value })}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
<!-- Fixed-width so the badge appearing and clearing never nudges the
|
|
324
|
+
title field; `saving` is deliberately silent — the badge only
|
|
325
|
+
reports a settled outcome. -->
|
|
326
|
+
<span class="flex w-16 shrink-0 justify-end">
|
|
327
|
+
{#if store.status === 'saved'}
|
|
328
|
+
<Badge tone="success">saved</Badge>
|
|
329
|
+
{:else if store.status === 'error'}
|
|
330
|
+
<Badge tone="destructive" title={store.lastError ?? undefined}>error</Badge>
|
|
331
|
+
{/if}
|
|
332
|
+
</span>
|
|
333
|
+
</Bar>
|
|
334
|
+
|
|
335
|
+
<div class="min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-content">
|
|
336
|
+
<!-- ProseMirror owns the document once running, so switching notes
|
|
337
|
+
rebuilds the editor rather than pushing new content into it. -->
|
|
338
|
+
{#key selected.id}
|
|
339
|
+
<NoteEditor
|
|
340
|
+
content={selected.content}
|
|
341
|
+
onUpdate={(json) => store.update(selected.id, { content: json as TiptapDocument })}
|
|
342
|
+
/>
|
|
343
|
+
{/key}
|
|
344
|
+
</div>
|
|
345
|
+
{:else}
|
|
346
|
+
<div class="flex flex-1 flex-col items-center justify-center gap-gutter p-content">
|
|
347
|
+
<p class="text-muted">
|
|
348
|
+
{store.notes.length === 0 ? 'Create a note to get started' : 'Select a note'}
|
|
349
|
+
</p>
|
|
350
|
+
{#if store.notes.length === 0}
|
|
351
|
+
<Button onclick={createNote}>
|
|
352
|
+
<Plus />
|
|
353
|
+
New note
|
|
354
|
+
</Button>
|
|
355
|
+
{/if}
|
|
356
|
+
</div>
|
|
357
|
+
{/if}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<AlertDialog.Root
|
|
362
|
+
open={pendingDelete !== null}
|
|
363
|
+
onOpenChange={(open) => {
|
|
364
|
+
if (!open) pendingDelete = null;
|
|
365
|
+
}}
|
|
366
|
+
>
|
|
367
|
+
<AlertDialog.Content>
|
|
368
|
+
<AlertDialog.Title>Delete note</AlertDialog.Title>
|
|
369
|
+
<AlertDialog.Description>
|
|
370
|
+
“{pendingDeleteNote?.title || 'Untitled'}” will be removed. This cannot be undone.
|
|
371
|
+
</AlertDialog.Description>
|
|
372
|
+
<div class="flex justify-end gap-gutter">
|
|
373
|
+
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
|
374
|
+
<AlertDialog.Action tone="destructive" onclick={confirmDelete}>Delete</AlertDialog.Action>
|
|
375
|
+
</div>
|
|
376
|
+
</AlertDialog.Content>
|
|
377
|
+
</AlertDialog.Root>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NotesStore, type NotesAdapter } from './notes-store.svelte.js';
|
|
2
|
+
import type { NoteInit } from './types.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
/**
|
|
5
|
+
* Bring your own store — for a custom adapter, or to share one set of
|
|
6
|
+
* notes between two mounted views. When supplied, the host owns its
|
|
7
|
+
* lifecycle: this component will not destroy it on unmount.
|
|
8
|
+
*/
|
|
9
|
+
store?: NotesStore;
|
|
10
|
+
/** Persistence for the store this component creates. Ignored if `store` is set. */
|
|
11
|
+
adapter?: NotesAdapter;
|
|
12
|
+
/** `localStorage` key for the default adapter. Ignored if `store`/`adapter` is set. */
|
|
13
|
+
storageKey?: string;
|
|
14
|
+
/** The open note. Bindable, so a host can deep-link or restore a session. */
|
|
15
|
+
noteId?: string | null;
|
|
16
|
+
/** Notes created only when storage comes back empty — a first-run sample. */
|
|
17
|
+
seed?: NoteInit[];
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
declare const Notes: import("svelte").Component<Props, {}, "noteId">;
|
|
21
|
+
type Notes = ReturnType<typeof Notes>;
|
|
22
|
+
export default Notes;
|