web based infinite canvas
1<script lang="ts">
2 import Icon from '$lib/components/Icon.svelte';
3 import Sheet from '$lib/components/Sheet.svelte';
4 import type { DesktopDocRepo } from '$lib/persistence/desktop';
5 import type {
6 BoardInspectorData,
7 BoardMeta,
8 FileBrowserViewModel,
9 InkfiniteDB
10 } from 'inkfinite-core';
11 import { BoardStatsOps, FileBrowserVM } from 'inkfinite-core';
12 import type { Snippet } from 'svelte';
13
14 type Props = {
15 vm: FileBrowserViewModel;
16 onUpdate?: (vm: FileBrowserViewModel) => void;
17 fetchInspectorData?: (
18 boardId: string,
19 webDb: InkfiniteDB | null
20 ) => Promise<BoardInspectorData>;
21 open?: boolean;
22 onClose?: () => void;
23 children?: Snippet;
24 desktopRepo?: DesktopDocRepo | null;
25 };
26
27 let {
28 vm = $bindable(),
29 onUpdate,
30 fetchInspectorData,
31 open = $bindable(false),
32 onClose: handleClose,
33 children: _children,
34 desktopRepo = null
35 }: Props = $props();
36
37 let searchQuery = $derived(vm.query);
38 let inspectorOpen = $state(false);
39 let inspectorData = $state<BoardInspectorData | null>(null);
40 let inspectorLoading = $state(false);
41 let inspectorError = $state<string | null>(null);
42
43 let isCreating = $state(false);
44 let newBoardName = $state('');
45 let editingBoardId = $state<string | null>(null);
46 let editingBoardName = $state('');
47
48 let workspaceDir = $state<string | null>(null);
49
50 $effect(() => {
51 if (desktopRepo && open) {
52 desktopRepo.getWorkspaceDir().then((dir) => {
53 workspaceDir = dir;
54 });
55 }
56 });
57
58 function applySearchQuery(nextQuery: string) {
59 searchQuery = nextQuery;
60 const updated = FileBrowserVM.setQuery(vm, nextQuery);
61 vm = updated;
62 onUpdate?.(updated);
63 }
64
65 function handleSearchInput(event: Event) {
66 const target = event.target as HTMLInputElement;
67 applySearchQuery(target.value);
68 }
69
70 function handleSearchChange() {
71 applySearchQuery(searchQuery);
72 }
73
74 function closeBrowser() {
75 open = false;
76 handleClose?.();
77 }
78
79 async function handleOpenBoard(boardId: string) {
80 try {
81 await vm.actions.open(boardId);
82 closeBrowser();
83 } catch (error) {
84 console.error('Failed to open board:', error);
85 }
86 }
87
88 async function handleCreateBoard() {
89 if (!newBoardName.trim()) return;
90 try {
91 const boardId = await vm.actions.create(newBoardName);
92 isCreating = false;
93 newBoardName = '';
94 onUpdate?.(vm);
95 await handleOpenBoard(boardId);
96 } catch (error) {
97 console.error('Failed to create board:', error);
98 }
99 }
100
101 async function handleRenameBoard(boardId: string) {
102 if (!editingBoardName.trim()) return;
103 try {
104 await vm.actions.rename(boardId, editingBoardName);
105 editingBoardId = null;
106 editingBoardName = '';
107 onUpdate?.(vm);
108 } catch (error) {
109 console.error('Failed to rename board:', error);
110 }
111 }
112
113 async function handleDeleteBoard(boardId: string) {
114 if (!confirm('Are you sure you want to delete this board? This action cannot be undone.')) {
115 return;
116 }
117 try {
118 await vm.actions.delete(boardId);
119 if (inspectorOpen && vm.selectedId === boardId) {
120 inspectorOpen = false;
121 inspectorData = null;
122 }
123 onUpdate?.(vm);
124 } catch (error) {
125 console.error('Failed to delete board:', error);
126 }
127 }
128
129 async function handleInspectBoard(board: BoardMeta) {
130 if (!fetchInspectorData) {
131 console.warn('Inspector data fetcher not provided');
132 return;
133 }
134
135 inspectorOpen = true;
136 inspectorLoading = true;
137 inspectorError = null;
138
139 try {
140 inspectorData = await fetchInspectorData(board.id, null);
141 } catch (error) {
142 inspectorError = error instanceof Error ? error.message : 'Failed to load inspector data';
143 inspectorData = null;
144 } finally {
145 inspectorLoading = false;
146 }
147 }
148
149 function formatTimestamp(timestamp: number): string {
150 return new Date(timestamp).toLocaleString();
151 }
152
153 function startRename(board: BoardMeta) {
154 editingBoardId = board.id;
155 editingBoardName = board.name;
156 }
157
158 function cancelRename() {
159 editingBoardId = null;
160 editingBoardName = '';
161 }
162
163 async function handlePickWorkspace() {
164 if (!desktopRepo) return;
165 try {
166 const dir = await desktopRepo.pickWorkspaceDir();
167 if (dir) {
168 workspaceDir = dir;
169 onUpdate?.(vm);
170 }
171 } catch (error) {
172 console.error('Failed to pick workspace:', error);
173 }
174 }
175
176 async function handleClearWorkspace() {
177 if (!desktopRepo) return;
178 try {
179 await desktopRepo.setWorkspaceDir(null);
180 workspaceDir = null;
181 onUpdate?.(vm);
182 } catch (error) {
183 console.error('Failed to clear workspace:', error);
184 }
185 }
186</script>
187
188<Sheet bind:open onClose={closeBrowser} title="Boards" side="left" class="filebrowser-sheet">
189 <!-- svelte-ignore a11y_autofocus -->
190 <div class="filebrowser">
191 <div class="filebrowser__header">
192 <div class="filebrowser__title-row">
193 <h2 class="filebrowser__title">Boards</h2>
194 <button
195 class="filebrowser__close"
196 type="button"
197 onclick={closeBrowser}
198 aria-label="Close board browser">
199 <Icon name="close" size={20} color="#e27878" />
200 </button>
201 </div>
202 <button
203 class="filebrowser__action filebrowser__action--create"
204 onclick={() => (isCreating = true)}
205 aria-label="Create new board">
206 + New
207 </button>
208 </div>
209
210 {#if desktopRepo}
211 <div class="filebrowser__workspace">
212 {#if workspaceDir}
213 <div class="filebrowser__workspace-info">
214 <Icon name="folder" size={16} />
215 <span class="filebrowser__workspace-path" title={workspaceDir}>
216 {workspaceDir.split('/').pop() || workspaceDir}
217 </span>
218 <button
219 class="filebrowser__workspace-change"
220 onclick={handlePickWorkspace}
221 aria-label="Change workspace">
222 Change
223 </button>
224 <button
225 class="filebrowser__workspace-clear"
226 onclick={handleClearWorkspace}
227 aria-label="Clear workspace">
228 ×
229 </button>
230 </div>
231 {:else}
232 <button
233 class="filebrowser__workspace-pick"
234 onclick={handlePickWorkspace}
235 aria-label="Pick workspace folder">
236 <Icon name="folder" size={16} />
237 Pick Workspace Folder
238 </button>
239 <div class="filebrowser__workspace-hint">Recent files mode</div>
240 {/if}
241 </div>
242 {/if}
243
244 <div class="filebrowser__search">
245 <input
246 type="search"
247 class="filebrowser__search-input"
248 placeholder="Search boards..."
249 bind:value={searchQuery}
250 oninput={handleSearchInput}
251 onchange={handleSearchChange}
252 aria-label="Search boards" />
253 </div>
254
255 {#if isCreating}
256 <div class="filebrowser__create-form">
257 <input
258 type="text"
259 class="filebrowser__input"
260 placeholder="Board name"
261 bind:value={newBoardName}
262 aria-label="New board name"
263 autofocus />
264 <div class="filebrowser__create-actions">
265 <button class="filebrowser__btn filebrowser__btn--primary" onclick={handleCreateBoard}>
266 Create
267 </button>
268 <button
269 class="filebrowser__btn filebrowser__btn--secondary"
270 onclick={() => {
271 isCreating = false;
272 newBoardName = '';
273 }}>
274 Cancel
275 </button>
276 </div>
277 </div>
278 {/if}
279
280 <div class="filebrowser__list">
281 {#if vm.filteredBoards.length === 0}
282 <div class="filebrowser__empty">
283 {vm.query ? 'No boards match your search' : 'No boards yet'}
284 </div>
285 {:else}
286 {#each vm.filteredBoards as board (board.id)}
287 <div class="filebrowser__board">
288 {#if editingBoardId === board.id}
289 <div class="filebrowser__edit-form">
290 <input
291 type="text"
292 class="filebrowser__input"
293 bind:value={editingBoardName}
294 aria-label="Board name"
295 autofocus />
296 <div class="filebrowser__edit-actions">
297 <button
298 class="filebrowser__btn filebrowser__btn--primary"
299 onclick={() => handleRenameBoard(board.id)}>
300 Save
301 </button>
302 <button
303 class="filebrowser__btn filebrowser__btn--secondary"
304 onclick={cancelRename}>
305 Cancel
306 </button>
307 </div>
308 </div>
309 {:else}
310 <!-- svelte-ignore a11y_click_events_have_key_events -->
311 <!-- svelte-ignore a11y_no_static_element_interactions -->
312 <div class="filebrowser__board-info" onclick={() => handleOpenBoard(board.id)}>
313 <div class="filebrowser__board-name">{board.name}</div>
314 <div class="filebrowser__board-meta">
315 Updated: {formatTimestamp(board.updatedAt)}
316 </div>
317 </div>
318 <div class="filebrowser__board-actions">
319 <button
320 class="filebrowser__board-action"
321 onclick={(e) => {
322 e.stopPropagation();
323 handleInspectBoard(board);
324 }}
325 aria-label="Inspect board">
326 <Icon name="info-circle" size={16} />
327 </button>
328 <button
329 class="filebrowser__board-action"
330 onclick={(e) => {
331 e.stopPropagation();
332 startRename(board);
333 }}
334 aria-label="Rename board">
335 <Icon name="pencil" size={16} />
336 </button>
337 <button
338 class="filebrowser__board-action"
339 onclick={(e) => {
340 e.stopPropagation();
341 handleDeleteBoard(board.id);
342 }}
343 aria-label="Delete board">
344 <Icon name="trash" size={16} />
345 </button>
346 </div>
347 {/if}
348 </div>
349 {/each}
350 {/if}
351 </div>
352 </div>
353</Sheet>
354
355<Sheet bind:open={inspectorOpen} title="Board Inspector" side="right">
356 <div class="inspector">
357 <div class="inspector__header">
358 <h3 class="inspector__title">Board Inspector</h3>
359 <button
360 class="inspector__close"
361 onclick={() => (inspectorOpen = false)}
362 aria-label="Close inspector">
363 <Icon name="close" size={20} color="#e27878" />
364 </button>
365 </div>
366
367 {#if inspectorLoading}
368 <div class="inspector__loading">Loading...</div>
369 {:else if inspectorError}
370 <div class="inspector__error">{inspectorError}</div>
371 {:else if inspectorData}
372 <div class="inspector__content">
373 <section class="inspector__section">
374 <h4 class="inspector__section-title">Storage</h4>
375 <div class="inspector__item">
376 <span class="inspector__label">Storage Type:</span>
377 <!-- TODO: local? browser? -->
378 <span class="inspector__value">IndexedDB (Dexie)</span>
379 </div>
380 </section>
381
382 <section class="inspector__section">
383 <h4 class="inspector__section-title">Schema</h4>
384 <div class="inspector__item">
385 <span class="inspector__label">Declared Version:</span>
386 <span class="inspector__value">{inspectorData.schema.declaredVersion}</span>
387 </div>
388 <div class="inspector__item">
389 <span class="inspector__label">Installed Version:</span>
390 <span class="inspector__value">{inspectorData.schema.installedVersion}</span>
391 </div>
392 </section>
393
394 <section class="inspector__section">
395 <h4 class="inspector__section-title">Statistics</h4>
396 <div class="inspector__item">
397 <span class="inspector__label">Pages:</span>
398 <span class="inspector__value">{inspectorData.stats.pageCount}</span>
399 </div>
400 <div class="inspector__item">
401 <span class="inspector__label">Shapes:</span>
402 <span class="inspector__value">{inspectorData.stats.shapeCount}</span>
403 </div>
404 <div class="inspector__item">
405 <span class="inspector__label">Bindings:</span>
406 <span class="inspector__value">{inspectorData.stats.bindingCount}</span>
407 </div>
408 <div class="inspector__item">
409 <span class="inspector__label">Doc Size:</span>
410 <span class="inspector__value"
411 >{BoardStatsOps.formatDocSize(inspectorData.stats.docSizeBytes)}</span>
412 </div>
413 <div class="inspector__item">
414 <span class="inspector__label">Last Updated:</span>
415 <span class="inspector__value">
416 {formatTimestamp(inspectorData.stats.lastUpdated)}
417 </span>
418 </div>
419 </section>
420
421 <section class="inspector__section">
422 <h4 class="inspector__section-title">Migrations</h4>
423 {#if inspectorData.migrations.length === 0}
424 <div class="inspector__empty">No migrations applied yet</div>
425 {:else}
426 <div class="inspector__migrations">
427 {#each inspectorData.migrations as migration (migration.id)}
428 <div class="inspector__migration">
429 <span class="inspector__migration-id">{migration.id}</span>
430 <span class="inspector__migration-date">
431 {formatTimestamp(migration.appliedAt)}
432 </span>
433 </div>
434 {/each}
435 </div>
436 {/if}
437 {#if inspectorData.pendingMigrations.length > 0}
438 <div class="inspector__pending">
439 <h5 class="inspector__pending-title">Pending Migrations:</h5>
440 {#each inspectorData.pendingMigrations as migrationId (migrationId)}
441 <div class="inspector__pending-migration">{migrationId}</div>
442 {/each}
443 </div>
444 {/if}
445 </section>
446 </div>
447 {/if}
448 </div>
449</Sheet>
450
451<style>
452 :global(.filebrowser-sheet) {
453 padding: 0;
454 width: min(520px, 90vw);
455 }
456
457 .filebrowser {
458 display: flex;
459 flex-direction: column;
460 height: 100%;
461 background-color: var(--surface);
462 color: var(--text);
463 }
464
465 .filebrowser__header {
466 display: flex;
467 align-items: center;
468 justify-content: space-between;
469 padding: 1rem;
470 border-bottom: 1px solid var(--border, #e0e0e0);
471 }
472
473 .filebrowser__title-row {
474 display: flex;
475 align-items: center;
476 gap: 8px;
477 }
478
479 .filebrowser__title {
480 margin: 0;
481 font-size: 1.25rem;
482 font-weight: 600;
483 }
484
485 .filebrowser__close {
486 background: none;
487 border: 1px solid transparent;
488 color: var(--text-secondary, #666);
489 font-size: 1.5rem;
490 cursor: pointer;
491 padding: 0.25rem;
492 border-radius: 0.25rem;
493 display: flex;
494 align-items: center;
495 }
496
497 .filebrowser__close:hover,
498 .filebrowser__close:focus-visible {
499 background-color: rgba(0, 0, 0, 0.05);
500 color: var(--text);
501 border: 1px solid #e27878;
502 }
503
504 .filebrowser__action {
505 padding: 0.5rem 1rem;
506 background-color: var(--primary, #007bff);
507 color: white;
508 border: none;
509 border-radius: 0.25rem;
510 cursor: pointer;
511 font-size: 0.875rem;
512 font-weight: 500;
513 }
514
515 .filebrowser__action:hover {
516 background-color: var(--primary-hover, #0056b3);
517 }
518
519 .filebrowser__workspace {
520 padding: 0.75rem 1rem;
521 border-bottom: 1px solid var(--border, #e0e0e0);
522 background-color: var(--surface-secondary, #f9f9f9);
523 }
524
525 .filebrowser__workspace-info {
526 display: flex;
527 align-items: center;
528 gap: 0.5rem;
529 font-size: 0.875rem;
530 }
531
532 .filebrowser__workspace-path {
533 flex: 1;
534 overflow: hidden;
535 text-overflow: ellipsis;
536 white-space: nowrap;
537 font-family: monospace;
538 color: var(--text);
539 }
540
541 .filebrowser__workspace-change,
542 .filebrowser__workspace-clear {
543 padding: 0.25rem 0.5rem;
544 background-color: transparent;
545 border: 1px solid var(--border, #e0e0e0);
546 border-radius: 0.25rem;
547 cursor: pointer;
548 font-size: 0.75rem;
549 color: var(--text);
550 }
551
552 .filebrowser__workspace-change:hover,
553 .filebrowser__workspace-clear:hover {
554 background-color: var(--surface-hover, #f5f5f5);
555 }
556
557 .filebrowser__workspace-pick {
558 display: flex;
559 align-items: center;
560 gap: 0.5rem;
561 width: 100%;
562 padding: 0.5rem;
563 background-color: var(--primary, #007bff);
564 color: white;
565 border: none;
566 border-radius: 0.25rem;
567 cursor: pointer;
568 font-size: 0.875rem;
569 }
570
571 .filebrowser__workspace-pick:hover {
572 background-color: var(--primary-hover, #0056b3);
573 }
574
575 .filebrowser__workspace-hint {
576 margin-top: 0.5rem;
577 font-size: 0.75rem;
578 color: var(--text-muted, #6c757d);
579 text-align: center;
580 }
581
582 .filebrowser__search {
583 padding: 0.5rem 1rem;
584 border-bottom: 1px solid var(--border, #e0e0e0);
585 }
586
587 .filebrowser__search-input {
588 width: 100%;
589 padding: 0.5rem;
590 border: 1px solid var(--border, #e0e0e0);
591 border-radius: 0.25rem;
592 font-size: 0.875rem;
593 background-color: var(--input-bg, white);
594 color: var(--text);
595 }
596
597 .filebrowser__search-input:focus {
598 outline: none;
599 border-color: var(--primary, #007bff);
600 }
601
602 .filebrowser__create-form,
603 .filebrowser__edit-form {
604 padding: 1rem;
605 border-bottom: 1px solid var(--border, #e0e0e0);
606 background-color: var(--surface-hover, #f5f5f5);
607 }
608
609 .filebrowser__input {
610 width: 100%;
611 padding: 0.5rem;
612 border: 1px solid var(--border, #e0e0e0);
613 border-radius: 0.25rem;
614 font-size: 0.875rem;
615 margin-bottom: 0.5rem;
616 background-color: var(--input-bg, white);
617 color: var(--text);
618 }
619
620 .filebrowser__input:focus {
621 outline: none;
622 border-color: var(--primary, #007bff);
623 }
624
625 .filebrowser__create-actions,
626 .filebrowser__edit-actions {
627 display: flex;
628 gap: 0.5rem;
629 }
630
631 .filebrowser__btn {
632 padding: 0.375rem 0.75rem;
633 border: none;
634 border-radius: 0.25rem;
635 cursor: pointer;
636 font-size: 0.875rem;
637 }
638
639 .filebrowser__btn--primary {
640 background-color: var(--primary, #007bff);
641 color: white;
642 }
643
644 .filebrowser__btn--primary:hover {
645 background-color: var(--primary-hover, #0056b3);
646 }
647
648 .filebrowser__btn--secondary {
649 background-color: var(--secondary, #6c757d);
650 color: white;
651 }
652
653 .filebrowser__btn--secondary:hover {
654 background-color: var(--secondary-hover, #5a6268);
655 }
656
657 .filebrowser__list {
658 flex: 1;
659 overflow-y: auto;
660 }
661
662 .filebrowser__empty {
663 padding: 2rem;
664 text-align: center;
665 color: var(--text-muted, #6c757d);
666 }
667
668 .filebrowser__board {
669 display: flex;
670 align-items: center;
671 justify-content: space-between;
672 padding: 0.75rem 1rem;
673 border-bottom: 1px solid var(--border, #e0e0e0);
674 cursor: pointer;
675 transition: background-color 0.15s;
676 }
677
678 .filebrowser__board:hover {
679 background-color: var(--surface-hover, #f5f5f5);
680 }
681
682 .filebrowser__board-info {
683 flex: 1;
684 }
685
686 .filebrowser__board-name {
687 font-weight: 500;
688 margin-bottom: 0.25rem;
689 }
690
691 .filebrowser__board-meta {
692 font-size: 0.75rem;
693 color: var(--text-muted, #6c757d);
694 }
695
696 .filebrowser__board-actions {
697 display: flex;
698 gap: 0.5rem;
699 }
700
701 .filebrowser__board-action {
702 padding: 0.25rem 0.5rem;
703 background: transparent;
704 border: none;
705 cursor: pointer;
706 font-size: 1rem;
707 opacity: 0.7;
708 transition: opacity 0.15s;
709 }
710
711 .filebrowser__board-action:hover {
712 opacity: 1;
713 }
714
715 /* Inspector styles */
716 .inspector {
717 display: flex;
718 flex-direction: column;
719 height: 100%;
720 background-color: var(--surface);
721 color: var(--text);
722 }
723
724 .inspector__header {
725 display: flex;
726 align-items: center;
727 justify-content: space-between;
728 padding: 1rem;
729 border-bottom: 1px solid var(--border, #e0e0e0);
730 }
731
732 .inspector__title {
733 margin: 0;
734 font-size: 1.125rem;
735 font-weight: 600;
736 }
737
738 .inspector__close {
739 background: transparent;
740 border: none;
741 font-size: 1.5rem;
742 cursor: pointer;
743 padding: 0;
744 width: 2rem;
745 height: 2rem;
746 display: flex;
747 align-items: center;
748 justify-content: center;
749 border-radius: 0.25rem;
750 transition: background-color 0.15s;
751 }
752
753 .inspector__close:hover {
754 background-color: var(--surface-hover, #f5f5f5);
755 }
756
757 .inspector__loading {
758 padding: 2rem;
759 text-align: center;
760 color: var(--text-muted, #6c757d);
761 }
762
763 .inspector__error {
764 padding: 1rem;
765 margin: 1rem;
766 background-color: var(--error-bg, #f8d7da);
767 color: var(--error-text, #721c24);
768 border-radius: 0.25rem;
769 border: 1px solid var(--error-border, #f5c6cb);
770 }
771
772 .inspector__content {
773 flex: 1;
774 overflow-y: auto;
775 padding: 1rem;
776 }
777
778 .inspector__section {
779 margin-bottom: 1.5rem;
780 }
781
782 .inspector__section-title {
783 margin: 0 0 0.75rem 0;
784 font-size: 0.875rem;
785 font-weight: 600;
786 text-transform: uppercase;
787 color: var(--text-muted, #6c757d);
788 }
789
790 .inspector__item {
791 display: flex;
792 justify-content: space-between;
793 padding: 0.5rem 0;
794 border-bottom: 1px solid var(--border-light, #f0f0f0);
795 }
796
797 .inspector__label {
798 font-weight: 500;
799 color: var(--text);
800 }
801
802 .inspector__value {
803 color: var(--text-muted, #6c757d);
804 }
805
806 .inspector__empty {
807 padding: 1rem;
808 text-align: center;
809 color: var(--text-muted, #6c757d);
810 font-size: 0.875rem;
811 }
812
813 .inspector__migrations {
814 display: flex;
815 flex-direction: column;
816 gap: 0.5rem;
817 }
818
819 .inspector__migration {
820 display: flex;
821 justify-content: space-between;
822 padding: 0.5rem;
823 background-color: var(--surface-hover, #f5f5f5);
824 border-radius: 0.25rem;
825 }
826
827 .inspector__migration-id {
828 font-weight: 500;
829 font-family: monospace;
830 }
831
832 .inspector__migration-date {
833 font-size: 0.75rem;
834 color: var(--text-muted, #6c757d);
835 }
836
837 .inspector__pending {
838 margin-top: 1rem;
839 padding: 0.75rem;
840 background-color: var(--warning-bg, #fff3cd);
841 border: 1px solid var(--warning-border, #ffeaa7);
842 border-radius: 0.25rem;
843 }
844
845 .inspector__pending-title {
846 margin: 0 0 0.5rem 0;
847 font-size: 0.875rem;
848 font-weight: 600;
849 color: var(--warning-text, #856404);
850 }
851
852 .inspector__pending-migration {
853 font-family: monospace;
854 font-size: 0.875rem;
855 padding: 0.25rem 0;
856 color: var(--warning-text, #856404);
857 }
858</style>