web based infinite canvas
at main 858 lines 21 kB view raw
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>