summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/HomeHeader.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
committerAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
commit754450b539818f9ad94c9586888973b0edd74b68 (patch)
treed852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/ui/HomeHeader.svelte
parent719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff)
downloaddispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz
dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid, replacing the max-w-3xl vertical card list. Pure core (zero DOM/Svelte/fetch, fully unit-tested): - logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed ~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns, row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform (greedy first-fit in fill order, hole-free, render-clamped per geometry); firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/ sortByRecentActivity/sanitizeLayout. Persisted data is orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} — order + spans only, never positions; the same data renders in either geometry (spans clamp for the render, persisted values untouched). - logic/drag-controller.ts — pure pointer/press state machine: mouse threshold-drag + tap-open; touch long-press lifts + selects, pre-lift movement cancels so vertical scrolling never fights the gesture. Shell: - tile-layout-store.svelte.ts — thin reactive wrapper persisting through the injected createLocalStore adapter (dispatch.workspaces.tiles), ops: sync/resize/move/placeFirstEmpty/sortByRecentActivity. - ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll + snap + wheel pan + pan arrow + scroll indicator; tall: native vertical scroll), FLIP-animated reflow, drag ghost, transient resize preview. - ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New workspace popover (same slug validation; creates via create-on-miss and does NOT navigate — the new tile lands in the first empty slot in fill order and enters rename mode), global Plugins button, sort-by-recent overflow; compact icon buttons + bottom sheet on tall screens. - ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count, relative last-activity, live active dots, starred tint); inline rename with the old card's semantics. - ui/TileActionBar.svelte — slides up on select (right-click / long-press): Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm + store call preserved). - ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 / tall 2x2) with arrow/Enter/Escape support and ghost preview on commit. - ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd input + default-computer select (same store calls + error states). - WorkspacesHome.svelte orchestrates; App.svelte untouched (same props). - WorkspaceCard.svelte is removed (replaced by the tile system). Manual tile order rules; starred workspaces get a tint (star is no longer a sort key); workspaces unknown to the layout append at the end. vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate stubs — the pointer logic is the outermost edge for the drag tests. Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice), biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/ui/HomeHeader.svelte')
-rw-r--r--src/features/workspaces/ui/HomeHeader.svelte171
1 files changed, 171 insertions, 0 deletions
diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte
new file mode 100644
index 0000000..01ac63f
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.svelte
@@ -0,0 +1,171 @@
+<script lang="ts">
+ import { isValidSlug } from "../logic/route";
+
+ /** The create outcome the page reports back to the popover. */
+ export type CreateResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+ let {
+ compact = false,
+ withPlugins = false,
+ withSort = false,
+ onCreate,
+ onOpenPlugins,
+ onSort,
+ }: {
+ /** Tall geometry: the controls collapse to compact icon buttons and the
+ * new-workspace popover becomes a bottom sheet. */
+ compact?: boolean;
+ /** The plugins port is present → the Plugins button renders. */
+ withPlugins?: boolean;
+ /** The Sort overflow action renders (recent-activity re-apply). */
+ withSort?: boolean;
+ /** Create the workspace on the backend (does NOT navigate); the page
+ * places the new tile + enters rename mode. */
+ onCreate: (slug: string) => Promise<CreateResult>;
+ onOpenPlugins: () => void;
+ onSort: () => void;
+ } = $props();
+
+ // ── New workspace popover (today's inline form logic, preserved) ──────────
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
+ let popoverOpen = $state(false);
+ let submitting = $state(false);
+ let slugInput = $state<HTMLInputElement | undefined>();
+
+ const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+
+ function openPopover(): void {
+ popoverOpen = true;
+ slugError = null;
+ queueMicrotask(() => slugInput?.focus());
+ }
+
+ function closePopover(): void {
+ popoverOpen = false;
+ newSlug = "";
+ slugError = null;
+ }
+
+ async function submit(): Promise<void> {
+ const slug = newSlug.trim();
+ if (!isValidSlug(slug)) {
+ slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ return;
+ }
+ slugError = null;
+ submitting = true;
+ const result = await onCreate(slug);
+ submitting = false;
+ if (!result.ok) {
+ slugError = result.error;
+ return;
+ }
+ closePopover();
+ }
+</script>
+
+<header class="flex shrink-0 items-center justify-between gap-2 px-6 py-4">
+ <h1 class="text-3xl font-black tracking-tight">Dispatch</h1>
+
+ <div class="flex items-center gap-2" class:gap-1={compact}>
+ <!-- New workspace -->
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ class:btn-circle={compact}
+ aria-label="New workspace"
+ title="New workspace"
+ onclick={() => (popoverOpen ? closePopover() : openPopover())}
+ >
+ {#if compact}
+ <span aria-hidden="true" class="text-lg leading-none">+</span>
+ {:else}
+ New workspace
+ {/if}
+ </button>
+
+ {#if withPlugins}
+ <button
+ type="button"
+ class="btn btn-outline btn-sm"
+ class:btn-circle={compact}
+ aria-label="Plugins"
+ title="Open the plugin manager (load/unload plugins globally)"
+ onclick={onOpenPlugins}
+ >
+ {#if compact}
+ <span aria-hidden="true">⚡</span>
+ {:else}
+ Plugins
+ {/if}
+ </button>
+ {/if}
+
+ {#if withSort}
+ <div class="dropdown dropdown-end">
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="More actions" title="More">
+ ⋯
+ </button>
+ <ul class="dropdown-content menu z-50 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl">
+ <li>
+ <button type="button" onclick={onSort}>Sort by recent activity</button>
+ </li>
+ </ul>
+ </div>
+ {/if}
+ </div>
+</header>
+
+{#if popoverOpen}
+ <!-- Backdrop closes the popover. -->
+ <button
+ type="button"
+ class="fixed inset-0 z-40 cursor-default"
+ aria-label="Close new workspace"
+ onclick={closePopover}></button>
+
+ <!-- One popover body; the geometry only changes its positioning (bottom
+ sheet on tall screens, a corner card on wide ones). -->
+ <div
+ class="fixed z-50 border border-base-content/15 bg-base-100 p-4 shadow-2xl"
+ class:inset-x-0={compact}
+ class:bottom-0={compact}
+ class:rounded-t-box={compact}
+ class:top-14={!compact}
+ class:right-6={!compact}
+ class:w-80={!compact}
+ class:rounded-box={!compact}
+ >
+ <form
+ class="flex flex-col gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void submit();
+ }}
+ >
+ <label for="new-ws" class="text-xs font-semibold uppercase opacity-60">New workspace</label>
+ <input
+ id="new-ws"
+ bind:this={slugInput}
+ class="input input-bordered w-full"
+ placeholder="my-workspace"
+ bind:value={newSlug}
+ autocomplete="off"
+ spellcheck="false"
+ />
+ {#if slugError}<p class="text-xs text-error">{slugError}</p>{/if}
+ <div class="flex justify-end gap-2">
+ {#if compact}
+ <button type="button" class="btn btn-ghost btn-sm" onclick={closePopover}>Cancel</button>
+ {/if}
+ <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid || submitting}>
+ {#if submitting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ Create
+ </button>
+ </div>
+ </form>
+ </div>
+{/if}