diff --git a/web/src/lib/components/layout/CreateWorkspaceModal.svelte b/web/src/lib/components/layout/CreateWorkspaceModal.svelte index 9afa8e4b..264393b3 100644 --- a/web/src/lib/components/layout/CreateWorkspaceModal.svelte +++ b/web/src/lib/components/layout/CreateWorkspaceModal.svelte @@ -5,6 +5,7 @@ import { api } from '$lib/api/client'; import { toastStore } from '$lib/stores/toast.svelte'; import type { WorkspaceTemplate } from '$lib/types'; + import { groupTemplatesByCategory } from '$lib/utils/templates'; let mode = $state<'create' | 'import'>('create'); let newName = $state(''); @@ -18,6 +19,8 @@ let dragging = $state(false); let dragCounter = 0; + let grouped = $derived(groupTemplatesByCategory(templates)); + $effect(() => { if (uiStore.createWorkspaceOpen) { // Reset state on open @@ -146,23 +149,33 @@ {#if templates.length > 0} Template
- {#each templates as tpl (tpl.name)} - + {#each grouped as group (group.category)} + {group.label} + {#each group.templates as tpl (tpl.name)} + + {/each} {/each}
{/if} @@ -309,8 +322,19 @@ .modal-body input:focus { outline: none; border-color: var(--accent-blue); } .template-list { display: flex; flex-direction: column; gap: 4px; } + .cat-label { + font-size: 0.72em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + opacity: 0.8; + margin-top: var(--space-2); + } + .cat-label:first-child { margin-top: 0; } .template-card { - display: flex; flex-direction: column; gap: 1px; + display: flex; flex-direction: row; align-items: center; + gap: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: var(--bg-tertiary); @@ -323,6 +347,12 @@ border-color: var(--accent-blue); background: color-mix(in srgb, var(--accent-blue) 8%, var(--bg-tertiary)); } + .tpl-icon { + font-size: 1.1em; + margin-right: var(--space-2); + flex-shrink: 0; + } + .tpl-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .tpl-name { font-size: 0.88em; font-weight: 600; color: var(--text-primary); text-transform: capitalize; } .tpl-desc { font-size: 0.78em; color: var(--text-muted); } diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 15a219cb..74a55895 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -182,7 +182,9 @@ export interface WorkspaceUpdate { export interface WorkspaceTemplate { name: string; + category?: string; description: string; + icon?: string; collections: string[]; } diff --git a/web/src/lib/utils/templates.ts b/web/src/lib/utils/templates.ts new file mode 100644 index 00000000..3efe6ae0 --- /dev/null +++ b/web/src/lib/utils/templates.ts @@ -0,0 +1,64 @@ +// Shared helpers for grouping workspace templates by category in pickers. +// Mirrors internal/collections/templates.go (CategoryOrder + CategoryLabel) +// so the CLI and web UI use the same canonical ordering and labels. + +import type { WorkspaceTemplate } from '$lib/types'; + +export const CATEGORY_ORDER = [ + 'software', + 'people', + 'research', + 'content', + 'operations', + 'personal', +] as const; + +const CATEGORY_LABELS: Record = { + software: 'Software', + people: 'People', + research: 'Research', + content: 'Content', + operations: 'Operations', + personal: 'Personal', +}; + +export function categoryLabel(slug: string | undefined): string { + if (!slug) return 'Other'; + return CATEGORY_LABELS[slug] ?? slug; +} + +export interface TemplateGroup { + category: string; + label: string; + templates: WorkspaceTemplate[]; +} + +// groupTemplatesByCategory returns templates bucketed by category in +// CATEGORY_ORDER. Any template with a category not in CATEGORY_ORDER +// (including undefined) is collected into a trailing "other" group so +// nothing is hidden from the picker. +export function groupTemplatesByCategory(templates: WorkspaceTemplate[]): TemplateGroup[] { + const byCat = new Map(); + for (const t of templates) { + const cat = t.category ?? ''; + const bucket = byCat.get(cat); + if (bucket) bucket.push(t); + else byCat.set(cat, [t]); + } + + const groups: TemplateGroup[] = []; + for (const cat of CATEGORY_ORDER) { + const items = byCat.get(cat); + if (items && items.length > 0) { + groups.push({ category: cat, label: categoryLabel(cat), templates: items }); + byCat.delete(cat); + } + } + // Append any leftover categories (custom or empty) in insertion order. + for (const [cat, items] of byCat) { + if (items.length > 0) { + groups.push({ category: cat, label: categoryLabel(cat), templates: items }); + } + } + return groups; +} diff --git a/web/src/routes/console/new/+page.svelte b/web/src/routes/console/new/+page.svelte index 5f7360d4..44e47879 100644 --- a/web/src/routes/console/new/+page.svelte +++ b/web/src/routes/console/new/+page.svelte @@ -4,6 +4,7 @@ import { api } from '$lib/api/client'; import { authStore } from '$lib/stores/auth.svelte'; import type { WorkspaceTemplate } from '$lib/types'; + import { groupTemplatesByCategory } from '$lib/utils/templates'; let name = $state(''); let selectedTemplate = $state('startup'); @@ -11,6 +12,8 @@ let creating = $state(false); let error = $state(''); + let grouped = $derived(groupTemplatesByCategory(templates)); + let slug = $derived( name .toLowerCase() @@ -28,9 +31,9 @@ } catch { // Templates are optional; fall back to defaults templates = [ - { name: 'startup', description: 'Default template for general projects', collections: [] }, - { name: 'scrum', description: 'Scrum-style sprints and backlogs', collections: [] }, - { name: 'product', description: 'Product development workflow', collections: [] } + { name: 'startup', description: 'Default template for general projects', collections: [], category: 'software' }, + { name: 'scrum', description: 'Scrum-style sprints and backlogs', collections: [], category: 'software' }, + { name: 'product', description: 'Product development workflow', collections: [], category: 'software' } ]; } }); @@ -92,17 +95,27 @@
Template
- {#each templates as tmpl (tmpl.name)} - + {#each grouped as group (group.category)} +
+ {group.label} + {#each group.templates as tmpl (tmpl.name)} + + {/each} +
{/each}
@@ -198,10 +211,30 @@ gap: var(--space-2); } - .template-option { + .category-group { display: flex; flex-direction: column; - gap: 2px; + gap: var(--space-2); + } + + .category-group + .category-group { + margin-top: var(--space-3); + } + + .category-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + opacity: 0.85; + } + + .template-option { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--space-3); padding: var(--space-3) var(--space-4); background: var(--bg-tertiary); border: 1px solid var(--border); @@ -211,6 +244,18 @@ transition: border-color 0.15s; } + .template-icon { + font-size: 1.2em; + flex-shrink: 0; + } + + .template-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + .template-option:hover { border-color: var(--text-muted); }