mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
feat(web): categorized template picker with icons (TASK-617) (#149)
Turns the web workspace-creation pickers into category-grouped lists that mirror the CLI picker shipped in TASK-616. Both the full-page new-workspace flow (/console/new) and the create-workspace modal now group templates under Software / People / Research / Content / Operations / Personal headings and render each template's icon. - WorkspaceTemplate TS type gains optional `category` and `icon` (already emitted by /workspaces/templates since TASK-610). - New shared helper at web/src/lib/utils/templates.ts exposes CATEGORY_ORDER (mirrors Go CategoryOrder), categoryLabel, and groupTemplatesByCategory. Keeps CLI and web pickers aligned on ordering + labels without a third source of truth. - /console/new: replaced the flat template grid with a grouped layout; each group has a small category subhead; each button renders tmpl.icon alongside name + description. Offline fallback templates (used when the API call fails) updated to include category='software'. - CreateWorkspaceModal: same grouped layout for the create tab, with the existing "blank" option retained as a trailing category-less button. Icon prefixed on every template card. Tests ----- Go side (existing library tests for grouping behavior via TestGroupTemplatesByCategory cover the shared ordering contract). Web build verified via `npm run build` — clean. Parent: PLAN-609.
This commit is contained in:
@@ -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}
|
||||
<span class="field-label">Template</span>
|
||||
<div class="template-list">
|
||||
{#each templates as tpl (tpl.name)}
|
||||
<button
|
||||
class="template-card"
|
||||
class:selected={selectedTemplate === tpl.name}
|
||||
onclick={() => (selectedTemplate = tpl.name)}
|
||||
>
|
||||
<span class="tpl-name">{tpl.name}</span>
|
||||
<span class="tpl-desc">{tpl.collections.join(', ')}</span>
|
||||
</button>
|
||||
{#each grouped as group (group.category)}
|
||||
<span class="cat-label">{group.label}</span>
|
||||
{#each group.templates as tpl (tpl.name)}
|
||||
<button
|
||||
class="template-card"
|
||||
class:selected={selectedTemplate === tpl.name}
|
||||
onclick={() => (selectedTemplate = tpl.name)}
|
||||
>
|
||||
{#if tpl.icon}
|
||||
<span class="tpl-icon">{tpl.icon}</span>
|
||||
{/if}
|
||||
<span class="tpl-text">
|
||||
<span class="tpl-name">{tpl.name}</span>
|
||||
<span class="tpl-desc">{tpl.collections.join(', ')}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
<button
|
||||
class="template-card"
|
||||
class:selected={selectedTemplate === ''}
|
||||
onclick={() => (selectedTemplate = '')}
|
||||
>
|
||||
<span class="tpl-name">blank</span>
|
||||
<span class="tpl-desc">Empty workspace</span>
|
||||
<span class="tpl-text">
|
||||
<span class="tpl-name">blank</span>
|
||||
<span class="tpl-desc">Empty workspace</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/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); }
|
||||
|
||||
|
||||
@@ -182,7 +182,9 @@ export interface WorkspaceUpdate {
|
||||
|
||||
export interface WorkspaceTemplate {
|
||||
name: string;
|
||||
category?: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
collections: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, WorkspaceTemplate[]>();
|
||||
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;
|
||||
}
|
||||
@@ -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 @@
|
||||
<div class="field">
|
||||
<span class="field-label">Template</span>
|
||||
<div class="template-grid">
|
||||
{#each templates as tmpl (tmpl.name)}
|
||||
<button
|
||||
class="template-option"
|
||||
class:selected={selectedTemplate === tmpl.name}
|
||||
onclick={() => (selectedTemplate = tmpl.name)}
|
||||
disabled={creating}
|
||||
type="button"
|
||||
>
|
||||
<span class="template-name">{tmpl.name}</span>
|
||||
<span class="template-desc">{tmpl.description}</span>
|
||||
</button>
|
||||
{#each grouped as group (group.category)}
|
||||
<div class="category-group">
|
||||
<span class="category-label">{group.label}</span>
|
||||
{#each group.templates as tmpl (tmpl.name)}
|
||||
<button
|
||||
class="template-option"
|
||||
class:selected={selectedTemplate === tmpl.name}
|
||||
onclick={() => (selectedTemplate = tmpl.name)}
|
||||
disabled={creating}
|
||||
type="button"
|
||||
>
|
||||
{#if tmpl.icon}
|
||||
<span class="template-icon">{tmpl.icon}</span>
|
||||
{/if}
|
||||
<span class="template-text">
|
||||
<span class="template-name">{tmpl.name}</span>
|
||||
<span class="template-desc">{tmpl.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user