feat: add go-backend design system and tablos UI

This commit is contained in:
Arthur Belleville 2026-05-09 20:18:24 +02:00
parent bea78ffca7
commit 4ac33c77b9
No known key found for this signature in database
74 changed files with 9314 additions and 625 deletions

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Badges</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link is-active">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Badges</h1><p>Semantic status labels for todo, in-progress, success, and destructive states.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Status set</h2><p>The four semantic badge tones used across the app.</p></div><div class="catalog-example-preview"><div class="catalog-inline"><span class="ui-badge ui-badge-info">À faire</span></div><div class="catalog-inline"><span class="ui-badge ui-badge-warning">En cours</span></div><div class="catalog-inline"><span class="ui-badge ui-badge-success">Terminé</span></div><div class="catalog-inline"><span class="ui-badge ui-badge-danger">Erreur</span></div></div><pre class="catalog-example-snippet"><code>@ui.Badge(ui.BadgeProps{Label: &#34;En cours&#34;, Variant: ui.BadgeVariantWarning})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Buttons</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link is-active">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Buttons</h1><p>Primary, secondary, ghost, and destructive actions built from shared templ primitives.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Primary action</h2><p>Used for the main action in a page section or modal footer.</p></div><div class="catalog-example-preview"><button type="button" class="ui-button ui-button-primary ui-button-md">Nouveau projet</button></div><pre class="catalog-example-snippet"><code>@ui.Button(ui.ButtonProps{
Label: &#34;Nouveau projet&#34;,
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: &#34;button&#34;,
})</code></pre></section><section class="catalog-example"><div class="catalog-example-copy"><h2>Danger action</h2><p>Used for irreversible actions after explicit confirmation.</p></div><div class="catalog-example-preview"><button type="submit" class="ui-button ui-button-danger ui-button-lg">Supprimer</button></div><pre class="catalog-example-snippet"><code>@ui.Button(ui.ButtonProps{
Label: &#34;Supprimer&#34;,
Variant: ui.ButtonVariantDanger,
Size: ui.SizeLG,
Type: &#34;submit&#34;,
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cards</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link is-active">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Cards</h1><p>Reusable bordered surfaces with optional header, body, and footer regions.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Surface card</h2><p>Generic elevated surface with optional header and footer.</p></div><div class="catalog-example-preview"><section class="ui-card"><div class="ui-card-header">Header</div><div class="ui-card-body">Body</div><div class="ui-card-footer">Footer</div></section></div><pre class="catalog-example-snippet"><code>@ui.Card(ui.CardProps{
Header: textComponent(&#34;Header&#34;),
Body: textComponent(&#34;Body&#34;),
Footer: textComponent(&#34;Footer&#34;),
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Empty States</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link is-active">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Empty States</h1><p>Centered fallback messaging with optional icon and action.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Centered empty state</h2><p>Used when a list has no rows yet and the next action should stay obvious.</p></div><div class="catalog-example-preview"><section class="ui-empty-state"><div class="ui-empty-state-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"></rect> <path d="M3 9h18"></path> <path d="M3 15h18"></path> <path d="M9 3v18"></path> <path d="M15 3v18"></path></svg></div><h3 class="ui-empty-state-title">Aucun projet trouvé</h3><p class="ui-empty-state-description">Créez votre premier projet</p><div class="ui-empty-state-action"><button type="button" class="ui-button ui-button-primary ui-button-md"><span class="ui-button-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"></path> <path d="M12 5v14"></path></svg></span> Nouveau projet</button></div></section></div><pre class="catalog-example-snippet"><code>@ui.EmptyState(ui.EmptyStateProps{
Title: &#34;Aucun projet trouvé&#34;,
Description: &#34;Créez votre premier projet&#34;,
Icon: ui.UIIcon(&#34;grid3x3&#34;),
Action: ui.Button(...),
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Form Fields</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link is-active">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Form Fields</h1><p>Labeled controls with optional hint and error messaging.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Field with validation</h2><p>Wraps a control with label and inline error feedback.</p></div><div class="catalog-example-preview"><div class="ui-form-field"><label for="catalog-name" class="ui-form-label">Nom</label> <input id="catalog-name" type="text" name="name" value="" placeholder="Nom du projet" class="ui-input"><p class="ui-form-error">Le nom est requis</p></div></div><pre class="catalog-example-snippet"><code>@ui.FormField(ui.FormFieldProps{
Label: &#34;Nom&#34;,
For: &#34;catalog-name&#34;,
Field: ui.Input(ui.InputProps{
ID: &#34;catalog-name&#34;,
Name: &#34;name&#34;,
Placeholder: &#34;Nom du projet&#34;,
Type: &#34;text&#34;,
}),
Error: &#34;Le nom est requis&#34;,
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Icon Buttons</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link is-active">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Icon Buttons</h1><p>Compact icon-only actions for destructive and neutral controls.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Borderless destructive action</h2><p>Used for delete controls inside project cards and list rows.</p></div><div class="catalog-example-preview"><button type="button" class="borderless-icon-button" aria-label="Supprimer le projet"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 w-4 h-4" aria-hidden="true"><path d="M3 6h18"></path> <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path> <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path> <line x1="10" x2="10" y1="11" y2="17"></line> <line x1="14" x2="14" y1="11" y2="17"></line></svg></button></div><pre class="catalog-example-snippet"><code>@ui.IconButton(ui.IconButtonProps{
Label: &#34;Supprimer le projet&#34;,
Icon: &#34;trash&#34;,
Variant: ui.IconButtonVariantDangerGhost,
Type: &#34;button&#34;,
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Design System</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Component Catalog</h1><p>Static documentation generated from the same templ primitives used by the Go application.</p></header><div class="catalog-page-list"><a href="./tokens.html" class="catalog-page-link-card"><h2>Tokens</h2><p>Semantic colors and status roles used by the Go design system.</p><p class="catalog-page-link">/tokens.html</p></a><a href="./buttons.html" class="catalog-page-link-card"><h2>Buttons</h2><p>Primary, secondary, ghost, and destructive actions built from shared templ primitives.</p><p class="catalog-page-link">/buttons.html</p></a><a href="./badges.html" class="catalog-page-link-card"><h2>Badges</h2><p>Semantic status labels for todo, in-progress, success, and destructive states.</p><p class="catalog-page-link">/badges.html</p></a><a href="./icon-buttons.html" class="catalog-page-link-card"><h2>Icon Buttons</h2><p>Compact icon-only actions for destructive and neutral controls.</p><p class="catalog-page-link">/icon-buttons.html</p></a><a href="./inputs.html" class="catalog-page-link-card"><h2>Inputs</h2><p>Shared single-line and multiline text controls.</p><p class="catalog-page-link">/inputs.html</p></a><a href="./form-fields.html" class="catalog-page-link-card"><h2>Form Fields</h2><p>Labeled controls with optional hint and error messaging.</p><p class="catalog-page-link">/form-fields.html</p></a><a href="./modals.html" class="catalog-page-link-card"><h2>Modals</h2><p>Shared modal shell for focused create, edit, and confirm flows.</p><p class="catalog-page-link">/modals.html</p></a><a href="./tables.html" class="catalog-page-link-card"><h2>Tables</h2><p>Shared table shell for server-rendered list views.</p><p class="catalog-page-link">/tables.html</p></a><a href="./empty-states.html" class="catalog-page-link-card"><h2>Empty States</h2><p>Centered fallback messaging with optional icon and action.</p><p class="catalog-page-link">/empty-states.html</p></a><a href="./cards.html" class="catalog-page-link-card"><h2>Cards</h2><p>Reusable bordered surfaces with optional header, body, and footer regions.</p><p class="catalog-page-link">/cards.html</p></a></div></main>
</body>
</html>

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inputs</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link is-active">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Inputs</h1><p>Shared single-line and multiline text controls.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Text input</h2><p>Single-line input for names, titles, and short labels.</p></div><div class="catalog-example-preview"><input id="name" type="text" name="name" value="Projet Atlas" placeholder="Nom du projet" class="ui-input"></div><pre class="catalog-example-snippet"><code>@ui.Input(ui.InputProps{
Name: &#34;name&#34;,
Value: &#34;Projet Atlas&#34;,
Placeholder: &#34;Nom du projet&#34;,
Type: &#34;text&#34;,
})</code></pre></section><section class="catalog-example"><div class="catalog-example-copy"><h2>Textarea</h2><p>Multiline field for longer project notes and descriptions.</p></div><div class="catalog-example-preview"><textarea id="description" name="description" placeholder="Description" rows="4" class="ui-textarea">Une description de projet plus détaillée.</textarea></div><pre class="catalog-example-snippet"><code>@ui.Textarea(ui.TextareaProps{
Name: &#34;description&#34;,
Value: &#34;Une description de projet plus détaillée.&#34;,
Placeholder: &#34;Description&#34;,
Rows: 4,
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Modals</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link is-active">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Modals</h1><p>Shared modal shell for focused create, edit, and confirm flows.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Create modal</h2><p>Shared modal shell with a form body and action footer.</p></div><div class="catalog-example-preview"><div class="ui-modal-backdrop"><div class="ui-modal-panel"><div class="ui-modal-header"><h2>Créer un projet</h2></div><div class="ui-modal-body"><div class="ui-form-field"><label for="modal-name" class="ui-form-label">Nom du projet</label> <input id="modal-name" type="text" name="name" value="" placeholder="Nom du projet" class="ui-input"></div></div><div class="ui-modal-actions"><button type="button" class="ui-button ui-button-secondary ui-button-md">Annuler</button><button type="submit" class="ui-button ui-button-primary ui-button-md">Créer le projet</button></div></div></div></div><pre class="catalog-example-snippet"><code>@ui.Modal(ui.ModalProps{
Title: &#34;Créer un projet&#34;,
Body: ui.FormField(...),
Actions: ui.Button(...),
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tables</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link is-active">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Tables</h1><p>Shared table shell for server-rendered list views.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>List shell</h2><p>Shared wrapper for server-rendered resource tables.</p></div><div class="catalog-example-preview"><div class="ui-table-shell"><table class="ui-table"><thead><tr><th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Projet</th><th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Statut</th></tr></thead> <tbody><tr><td class="px-6 py-4">Table View</td><td class="px-6 py-4">En cours</td></tr></tbody></table></div></div><pre class="catalog-example-snippet"><code>@ui.Table(ui.TableProps{
Head: TabloListHead(),
Body: TabloListBody(tablos),
})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tokens</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
<main class="catalog-page"><nav class="catalog-nav" aria-label="Catalog navigation"><a href="./index.html" class="catalog-home-link">Catalog</a><div class="catalog-nav-links"><a href="./tokens.html" class="catalog-nav-link is-active">Tokens</a><a href="./buttons.html" class="catalog-nav-link">Buttons</a><a href="./badges.html" class="catalog-nav-link">Badges</a><a href="./icon-buttons.html" class="catalog-nav-link">Icon Buttons</a><a href="./inputs.html" class="catalog-nav-link">Inputs</a><a href="./form-fields.html" class="catalog-nav-link">Form Fields</a><a href="./modals.html" class="catalog-nav-link">Modals</a><a href="./tables.html" class="catalog-nav-link">Tables</a><a href="./empty-states.html" class="catalog-nav-link">Empty States</a><a href="./cards.html" class="catalog-nav-link">Cards</a></div></nav><header class="catalog-page-header"><p class="catalog-eyebrow">Design System</p><h1>Tokens</h1><p>Semantic colors and status roles used by the Go design system.</p></header><div class="catalog-example-list"><section class="catalog-example"><div class="catalog-example-copy"><h2>Status tones</h2><p>Shared semantic badges for info, warning, success, and danger states.</p></div><div class="catalog-example-preview"><div class="catalog-inline"><span class="ui-badge ui-badge-info">À faire</span></div><div class="catalog-inline"><span class="ui-badge ui-badge-warning">En cours</span></div><div class="catalog-inline"><span class="ui-badge ui-badge-success">Terminé</span></div><div class="catalog-inline"><span class="ui-badge ui-badge-danger">Erreur</span></div></div><pre class="catalog-example-snippet"><code>@ui.Badge(ui.BadgeProps{Label: &#34;En cours&#34;, Variant: ui.BadgeVariantWarning})</code></pre></section></div></main>
</body>
</html>

View file

@ -0,0 +1,624 @@
# Go Backend Tablos Index CRUD Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the first real `/tablos` vertical slice in the Go rewrite with server-rendered list, modal create, soft delete, and functional URL-backed grid/list, search, and status filters.
**Architecture:** Extend the current Go app from auth-only pages into a small app domain for `tablos`. Keep data access in Postgres via sqlc and the repository, keep request parsing and ownership checks in handlers, and keep HTMX fragment boundaries explicit in `templ` views so full-page and partial-page responses share the same rendering units.
**Tech Stack:** Go, chi, templ, HTMX, PostgreSQL, pgx, sqlc, Go standard `net/http` testing
---
## File Structure
**Existing files to modify**
- `go-backend/internal/db/schema.sql`
- Add the `public.tablos` table and any required index.
- `go-backend/internal/db/queries.sql`
- Add list/create/delete SQL queries for tablos.
- `go-backend/internal/db/repository.go`
- Extend the repository with `tablos` methods backed by sqlc. Take the time to split this file and create a new repository package that contains the tablos.go and users.go
- `go-backend/router.go`
- Register `POST /tablos` and `DELETE /tablos/{id}`.
- `go-backend/internal/web/handlers/auth.go`
- Update `GetTablosPage()` to use real data instead of placeholder content.
- `go-backend/internal/web/handlers/in_memory_auth_repository.go`
- Extend the in-memory test repository with tablo storage and filter/delete behavior.
- `go-backend/internal/web/views/pages.templ`
- Wire the `/tablos` page to real tablos content if needed by the final component split.
- Wire the `/` page to real tablos content
- `go-backend/internal/web/views/home.go`
- Replace hard-coded overview/tablo placeholder helpers with real tablos view models where needed.
- `go-backend/router_test.go`
- Add full-router tests for `/tablos` page load and HTMX fragment behavior.
**New files to create**
- `go-backend/internal/web/handlers/tablos.go`
- Parse query state, validate create input, validate ownership for delete, and render page/modal fragments.
- `go-backend/internal/web/handlers/tablos_test.go`
- Focused handler tests for `/tablos` filtering, create, and delete behavior.
- `go-backend/internal/web/views/tablos.templ`
- Main `/tablos` page content, grid/list variants, filter bar, and modal fragments.
- `go-backend/internal/web/views/tablos_view.go`
- View models, formatting helpers, status labels, safe defaults, and query-state helpers for the tablos page.
**Generated files expected to change**
- `go-backend/internal/db/sqlc/*.go`
- Regenerated by `just generate` after schema/query updates.
- `go-backend/internal/web/views/*_templ.go`
- Regenerated by `just generate` after `templ` changes.
**Test and verification commands**
- `cd go-backend && go test ./internal/web/handlers -run Tablos`
- `cd go-backend && go test ./...`
- `cd go-backend && just generate`
- `cd go-backend && just build`
## Chunk 1: Data Model And Repository
### Task 1: Add the failing repository tests for tablos storage behavior
**Files:**
- Modify: `go-backend/internal/web/handlers/in_memory_auth_repository.go`
- Create: `go-backend/internal/web/handlers/tablos_test.go`
- [ ] **Step 1: Write the failing tests for create/list/delete semantics**
Add handler/repository-level tests that prove the repository contract before production code exists:
```go
func TestInMemoryTablosListExcludesSoftDeletedRows(t *testing.T) {
repo := NewInMemoryAuthRepository()
user, _ := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
first, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Visible",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatal(err)
}
_, err = repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Hidden",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatal(err)
}
if err := repo.SoftDeleteTablo(context.Background(), first.ID, user.ID); err != nil {
t.Fatal(err)
}
tablos, err := repo.ListTablos(context.Background(), ListTablosInput{OwnerID: user.ID})
if err != nil {
t.Fatal(err)
}
if len(tablos) != 1 {
t.Fatalf("expected 1 visible tablo, got %d", len(tablos))
}
}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos'`
Expected: FAIL with missing tablo types or repository methods.
- [ ] **Step 3: Add the minimal in-memory domain types and storage**
Add the minimum runtime types used by both handlers and repository implementations:
```go
type TabloStatus string
const (
TabloStatusTodo TabloStatus = "todo"
TabloStatusInProgress TabloStatus = "in_progress"
TabloStatusDone TabloStatus = "done"
)
type TabloRecord struct {
ID uuid.UUID
OwnerID uuid.UUID
Name string
Status TabloStatus
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
```
Extend the in-memory repo with:
- `tablos map[uuid.UUID]TabloRecord`
- `CreateTablo`
- `ListTablos`
- `SoftDeleteTablo`
- [ ] **Step 4: Re-run the focused tests to verify they pass**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos'`
Expected: PASS for the new in-memory storage behavior tests.
- [ ] **Step 5: Commit**
```bash
git add go-backend/internal/web/handlers/in_memory_auth_repository.go go-backend/internal/web/handlers/tablos_test.go
git commit -m "feat: add in-memory tablo repository support"
```
### Task 2: Add the SQL schema and query contract through tests
**Files:**
- Modify: `go-backend/internal/db/schema.sql`
- Modify: `go-backend/internal/db/queries.sql`
- Modify: `go-backend/internal/db/repository.go`
- Generated: `go-backend/internal/db/sqlc/*`
- [ ] **Step 1: Write a failing repository integration-shaped test for query semantics**
Add a focused repository test near the handler test seam if there is no separate db test package yet. The test should document the needed query contract:
```go
func TestListTablosInputSupportsSearchAndStatus(t *testing.T) {
input := ListTablosInput{
OwnerID: ownerID,
Query: "hell",
Status: TabloStatusTodo,
}
_ = input
// This test initially fails because the production repository contract does not exist yet.
}
```
The purpose is to drive the API shape before editing SQL.
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos'`
Expected: FAIL due to missing repository input types or methods.
- [ ] **Step 3: Add the SQL schema and queries**
Update `schema.sql` with:
```sql
CREATE TABLE IF NOT EXISTS public.tablos (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
CREATE INDEX IF NOT EXISTS tablos_owner_created_idx
ON public.tablos (owner_id, created_at DESC)
WHERE deleted_at IS NULL;
```
Add sqlc queries:
```sql
-- name: CreateTablo :one
INSERT INTO public.tablos (
id, owner_id, name, status, created_at, updated_at
) VALUES (
$1, $2, $3, $4, now(), now()
)
RETURNING id, owner_id, name, status, created_at, updated_at, deleted_at;
-- name: ListTablos :many
SELECT id, owner_id, name, status, created_at, updated_at, deleted_at
FROM public.tablos
WHERE owner_id = sqlc.arg(owner_id)
AND deleted_at IS NULL
AND (
sqlc.narg(status)::text IS NULL OR status = sqlc.narg(status)::text
)
AND (
sqlc.narg(query)::text IS NULL OR name ILIKE '%' || sqlc.narg(query)::text || '%'
)
ORDER BY created_at DESC;
-- name: SoftDeleteTablo :execrows
UPDATE public.tablos
SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL;
```
- [ ] **Step 4: Regenerate sqlc and templ artifacts**
Run: `cd go-backend && just generate`
Expected: PASS and regenerated `internal/db/sqlc` plus any `templ` outputs with no generation errors.
- [ ] **Step 5: Implement the Postgres repository methods**
Extend `repository.go` with the real methods:
```go
type ListTablosInput struct {
OwnerID uuid.UUID
Query string
Status *TabloStatus
}
type CreateTabloInput struct {
OwnerID uuid.UUID
Name string
Status TabloStatus
}
```
Implement:
- `CreateTablo(ctx, input)`
- `ListTablos(ctx, input)`
- `SoftDeleteTablo(ctx, tabloID, ownerID)`
Normalize empty search strings before passing them to sqlc. Return a domain-level not-found/unauthorized error when `SoftDeleteTablo` affects zero rows.
- [ ] **Step 6: Run the focused tests to verify they pass**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos'`
Expected: PASS with the new contract compiling and the in-memory implementation still satisfying it.
- [ ] **Step 7: Commit**
```bash
git add go-backend/internal/db/schema.sql go-backend/internal/db/queries.sql go-backend/internal/db/repository.go go-backend/internal/db/sqlc go-backend/internal/web/handlers/in_memory_auth_repository.go go-backend/internal/web/handlers/tablos_test.go
git commit -m "feat: add tablo persistence contract"
```
## Chunk 2: Handler Routing And URL State
### Task 3: Add failing handler tests for `/tablos` query state, create, and delete
**Files:**
- Create: `go-backend/internal/web/handlers/tablos.go`
- Modify: `go-backend/internal/web/handlers/tablos_test.go`
- Modify: `go-backend/router.go`
- Modify: `go-backend/router_test.go`
- [ ] **Step 1: Write failing handler tests for GET/POST/DELETE**
Add tests that document the handler contract:
```go
func TestGetTablosPageDefaultsToGridAndAllStatus(t *testing.T) {}
func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) {}
func TestPostTablosCreatesTodoTablo(t *testing.T) {}
func TestPostTablosWithEmptyNameReturns422(t *testing.T) {}
func TestDeleteTabloSoftDeletesOwnedRow(t *testing.T) {}
func TestDeleteTabloRejectsDifferentOwner(t *testing.T) {}
```
Also extend `router_test.go` with an end-to-end page assertion:
```go
func TestTablosPageRendersRealProjectsHeading(t *testing.T) {}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run Tablos`
Expected: FAIL with missing handler methods and route wiring.
- [ ] **Step 3: Implement query parsing and safe defaults**
Create `tablos.go` and add request parsing helpers:
```go
type TablosQueryState struct {
View string
Query string
Status string
}
func parseTablosQueryState(r *http.Request) TablosQueryState {
q := strings.TrimSpace(r.URL.Query().Get("q"))
view := r.URL.Query().Get("view")
status := r.URL.Query().Get("status")
if view != "list" {
view = "grid"
}
switch status {
case "todo", "in_progress", "done":
default:
status = "all"
}
return TablosQueryState{
View: view,
Query: q,
Status: status,
}
}
```
- [ ] **Step 4: Implement the minimal handler methods**
Add handler methods:
- `GetTablosPage()`
- `PostTablos()`
- `DeleteTablo()`
Behavior:
- `GET /tablos` loads current user, parses query state, lists tablos, renders full page or HTMX fragment
- `POST /tablos` trims `name`, rejects empty names with `422`, creates with `status=todo`
- `DELETE /tablos/{id}` verifies ownership through the repository delete contract and preserves query state
- [ ] **Step 5: Wire the routes**
Update `router.go`:
```go
mux.Get("/tablos", authHandler.GetTablosPage())
mux.Post("/tablos", authHandler.PostTablos())
mux.Delete("/tablos/{tabloID}", authHandler.DeleteTablo())
```
- [ ] **Step 6: Re-run focused tests to verify they pass**
Run: `cd go-backend && go test ./internal/web/handlers -run Tablos`
Expected: PASS for handler-specific tests.
- [ ] **Step 7: Commit**
```bash
git add go-backend/internal/web/handlers/tablos.go go-backend/internal/web/handlers/tablos_test.go go-backend/router.go go-backend/router_test.go
git commit -m "feat: add tablo handlers and routes"
```
## Chunk 3: Templ Views, Modal, And Functional Controls
### Task 4: Add failing rendering tests for the `/tablos` page contract
**Files:**
- Create: `go-backend/internal/web/views/tablos.templ`
- Create: `go-backend/internal/web/views/tablos_view.go`
- Modify: `go-backend/internal/web/views/pages.templ`
- Modify: `go-backend/internal/web/views/home.go`
- Modify: `go-backend/internal/web/views/icons.templ`
- Modify: `go-backend/router_test.go`
- [ ] **Step 1: Write failing page-render tests for the agreed UI contract**
Add assertions for:
- `Mes Projets`
- `Nouveau projet`
- `Vue en grille`
- `Vue en liste`
- `Rechercher...`
- `Tous`, `Pas commencé`, `En cours`, `Terminé`
- grid markup in default mode
- list markup in `?view=list`
- modal markup or modal target container
Example:
```go
if !strings.Contains(body, "Mes Projets") {
t.Fatalf("expected tablos page heading")
}
if !strings.Contains(body, "Nouveau projet") {
t.Fatalf("expected create trigger")
}
```
- [ ] **Step 2: Run the router tests to verify they fail**
Run: `cd go-backend && go test ./... -run 'TablosPage|TablosHTMX'`
Expected: FAIL because the placeholder page still renders.
- [ ] **Step 3: Add view models and formatting helpers**
Create `tablos_view.go` with focused helpers:
- `TabloCardView`
- `TablosPageViewModel`
- French status label mapping:
- `todo -> À faire`
- `in_progress -> En cours`
- `done -> Terminé`
- date formatting for French-style labels
- progress mapping:
- `todo -> 0`
- `in_progress -> 50`
- `done -> 100`
- [ ] **Step 4: Add the new templ components**
Create `tablos.templ` with:
- `TablosMainContent(vm TablosPageViewModel)`
- `TablosToolbar(vm TablosPageViewModel)`
- `TablosGrid(vm TablosPageViewModel)`
- `TablosList(vm TablosPageViewModel)`
- `TabloCard(item TabloCardView, state TablosQueryState)`
- `CreateTabloModal(vm TablosPageViewModel)`
The top-level content should follow the user-provided HTML structure closely:
```html
<main class="flex-1 overflow-auto">
<div class="px-4 pt-8 pb-6">
...
</div>
</main>
```
Use HTMX attributes for:
- view toggle `hx-get`
- search `hx-get` with trigger debounce
- status pill filtering `hx-get`
- modal form `hx-post="/tablos"`
- delete buttons `hx-delete="/tablos/{id}"`
- [ ] **Step 5: Add any missing icons or class helpers**
Extend `icons.templ` only if the provided `/tablos` structure needs icons not already present:
- `plus`
- `grid3x3`
- `list`
- `search`
- `filter`
Keep icons centralized rather than inlining large SVGs repeatedly.
- [ ] **Step 6: Regenerate templ artifacts**
Run: `cd go-backend && just generate`
Expected: PASS with new `*_templ.go` output and no templ generation errors.
- [ ] **Step 7: Re-run the page tests to verify they pass**
Run: `cd go-backend && go test ./... -run 'TablosPage|TablosHTMX'`
Expected: PASS with the real `/tablos` page structure rendered.
- [ ] **Step 8: Commit**
```bash
git add go-backend/internal/web/views/tablos.templ go-backend/internal/web/views/tablos_view.go go-backend/internal/web/views/pages.templ go-backend/internal/web/views/home.go go-backend/internal/web/views/icons.templ go-backend/internal/web/views/*_templ.go go-backend/router_test.go
git commit -m "feat: render real tablos page"
```
## Chunk 4: Modal Validation, Delete UX, And Full Verification
### Task 5: Tighten validation and modal error behavior with failing tests first
**Files:**
- Modify: `go-backend/internal/web/handlers/tablos.go`
- Modify: `go-backend/internal/web/handlers/tablos_test.go`
- Modify: `go-backend/internal/web/views/tablos.templ`
- [ ] **Step 1: Write the failing tests for modal error and state preservation**
Add tests that verify:
- empty `name` returns `422`
- returned body still contains the modal and inline error
- create success preserves current `view`, `q`, and `status`
- delete success preserves current `view`, `q`, and `status`
Example:
```go
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Le nom du projet est requis") {
t.Fatalf("expected inline validation message")
}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'PostTablos|DeleteTablo'`
Expected: FAIL until modal error and state-preservation behavior is implemented.
- [ ] **Step 3: Implement inline validation and state preservation**
Update `PostTablos()` and `DeleteTablo()` to:
- read current query state from request values
- reuse the same state when rendering success fragments
- return modal fragment with inline error text on `422`
- include confirm copy on delete buttons
Keep the logic minimal; do not add a client-side state store.
- [ ] **Step 4: Re-run the focused tests to verify they pass**
Run: `cd go-backend && go test ./internal/web/handlers -run 'PostTablos|DeleteTablo'`
Expected: PASS for create/delete edge cases.
- [ ] **Step 5: Commit**
```bash
git add go-backend/internal/web/handlers/tablos.go go-backend/internal/web/handlers/tablos_test.go go-backend/internal/web/views/tablos.templ
git commit -m "feat: finalize tablo modal and delete UX"
```
### Task 6: Run full verification and record any gaps
**Files:**
- Modify: `docs/superpowers/plans/2026-05-08-go-backend-tablos-index-crud.md`
- [ ] **Step 1: Run full project tests**
Run: `cd go-backend && go test ./...`
Expected: PASS across handlers, router, and repository compile paths.
- [ ] **Step 2: Run generation and build verification**
Run: `cd go-backend && just generate`
Expected: PASS with no dirty generated output after generation.
Run: `cd go-backend && just build`
Expected: PASS with successful binary build.
- [ ] **Step 3: Re-read the approved spec and verify acceptance criteria manually**
Check:
- `/tablos` uses the agreed layout direction
- create opens a modal
- list, search, status filters, and view toggle are functional
- delete is soft delete and owner-scoped
- no custom app JavaScript was introduced
- [ ] **Step 4: Update the plan checklist and note any verification gaps**
If any command fails, record the exact failure under this plan before making completion claims.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/plans/2026-05-08-go-backend-tablos-index-crud.md
git commit -m "docs: update tablo implementation plan status"
```
## Notes For Execution
- Prefer creating `go-backend/internal/web/handlers/tablos.go` rather than continuing to grow `auth.go`; the current codebase is still small enough to benefit from a clearer split.
- Keep the first slice owner-scoped only. Do not pull in organization/shared-access behavior from the legacy app.
- Keep `status` constrained in Go only. Do not add a DB enum or check constraint for this milestone.
- Preserve HTMX fragment boundaries carefully so the modal can fail inline without forcing a full page reload.
- If CSS support for the exact class contract is incomplete, implement the semantic structure first, then add the necessary stylesheet updates in a follow-up execution pass inside the same task chunk.
Plan complete and saved to `docs/superpowers/plans/2026-05-08-go-backend-tablos-index-crud.md`. Ready to execute?

View file

@ -0,0 +1,457 @@
# Go Backend Design System Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a repo-owned `templ` design system for `go-backend`, generate a static catalog outside the app, and migrate `/tablos` to the new shared primitives.
**Architecture:** Keep Tailwind as the styling/token foundation, but move reusable UI into `go-backend/internal/web/ui/` as `templ` primitives plus small Go helpers. Add a Go-driven static catalog generator that renders documentation and previews from the same component implementations, then migrate `/tablos` to consume those primitives instead of ad hoc view markup.
**Tech Stack:** Go, templ, HTMX, Tailwind CSS v4, Go `net/http` tests, standard library filesystem APIs
---
## File Structure
**Existing files to modify**
- `go-backend/tailwind.input.css`
- Add design token definitions and any shared theme aliases needed by the primitives.
- `go-backend/static/styles.css`
- Keep only app-shell styles, semantic shared component classes, and any small non-Tailwind glue that the primitives require.
- `go-backend/internal/web/views/tablos.templ`
- Replace ad hoc button/badge/modal/table markup with `ui` primitives.
- `go-backend/internal/web/views/tablos_view.go`
- Adapt the tablos view models to feed the new shared component APIs.
- `go-backend/internal/web/views/dashboard_components.templ`
- Replace any reused button or badge markup that should be shared in the first migration pass.
- `go-backend/internal/web/views/home.go`
- Adjust any overview helper data needed to feed shared UI primitives.
- `go-backend/internal/web/handlers/tablos_test.go`
- Update assertions to check shared component contracts.
- `go-backend/router_test.go`
- Add or update full-page assertions proving shared primitives appear in rendered pages.
- `go-backend/justfile`
- Add a command for generating the design-system catalog.
**New files to create**
- `go-backend/internal/web/ui/variants.go`
- Shared enums/constants/helpers for variants and sizes.
- `go-backend/internal/web/ui/tokens.go`
- Token names, semantic aliases, and any helper mappings needed by components.
- `go-backend/internal/web/ui/button.templ`
- Shared button primitive.
- `go-backend/internal/web/ui/icon_button.templ`
- Shared icon-only button primitive, including destructive/borderless cases.
- `go-backend/internal/web/ui/badge.templ`
- Shared badge primitive for statuses.
- `go-backend/internal/web/ui/input.templ`
- Shared text input primitive.
- `go-backend/internal/web/ui/textarea.templ`
- Shared textarea primitive.
- `go-backend/internal/web/ui/form_field.templ`
- Shared labeled field wrapper with error state.
- `go-backend/internal/web/ui/card.templ`
- Shared card shell and subregions where justified.
- `go-backend/internal/web/ui/modal.templ`
- Shared modal shell and actions.
- `go-backend/internal/web/ui/table.templ`
- Shared table wrappers/helpers for headers and row actions.
- `go-backend/internal/web/ui/empty_state.templ`
- Shared empty-state primitive.
- `go-backend/internal/web/ui/ui_test.go`
- Rendering tests for core primitive contracts.
- `go-backend/internal/web/ui/catalog/pages.go`
- Catalog page registry and page metadata.
- `go-backend/internal/web/ui/catalog/examples.go`
- Rendered example fixtures used by the catalog.
- `go-backend/internal/web/ui/catalog/catalog.templ`
- Shared catalog page templates.
- `go-backend/internal/web/ui/catalog/catalog_test.go`
- Catalog rendering and registration tests.
- `go-backend/cmd/designsystem/main.go`
- Static catalog generator entrypoint.
- `go-backend/cmd/designsystem/main_test.go`
- Generator output tests.
**Generated files expected to change**
- `go-backend/internal/web/ui/*_templ.go`
- Generated from the new `templ` primitives.
- `go-backend/internal/web/ui/catalog/*_templ.go`
- Generated from catalog templates.
- `go-backend/internal/web/views/*_templ.go`
- Regenerated after migrating views to shared primitives.
- `docs/design-system/*.html`
- Generated static catalog output.
- `go-backend/static/tailwind.css`
- Regenerated compiled Tailwind output.
**Verification commands**
- `cd go-backend && go test ./internal/web/ui -v`
- `cd go-backend && go test ./cmd/designsystem -v`
- `cd go-backend && go test ./internal/web/handlers -run 'Tablos|HomePage'`
- `cd go-backend && just generate`
- `cd go-backend && go test ./...`
- `cd go-backend && just build`
## Chunk 1: UI Foundation
### Task 1: Introduce failing tests for shared variant and primitive contracts
**Files:**
- Create: `go-backend/internal/web/ui/ui_test.go`
- [ ] **Step 1: Write failing rendering tests for the first primitive contracts**
Add focused tests for:
- button variants and sizes
- icon button destructive variant
- badge variant rendering
- modal shell structure
Example starter shape:
```go
func TestIconButtonRendersBorderlessDestructiveMarkup(t *testing.T) {
component := IconButton(IconButtonProps{
Label: "Supprimer le projet",
Icon: "trash",
Variant: IconButtonVariantDangerGhost,
})
html := renderToString(t, component)
for _, want := range []string{
`class="borderless-icon-button"`,
`aria-label="Supprimer le projet"`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
```
- [ ] **Step 2: Run the focused UI tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/ui -v`
Expected: FAIL because the `ui` package or primitives do not exist yet.
- [ ] **Step 3: Add the minimal `ui` package structure**
Create:
- `variants.go`
- `tokens.go`
- empty `templ` primitive files for the first component set
Keep the first implementation minimal: just enough types and entrypoints to satisfy the tests.
- [ ] **Step 4: Re-run the focused UI tests to verify the first primitives compile and pass**
Run: `cd go-backend && just generate && go test ./internal/web/ui -v`
Expected: PASS for the initial primitive contract tests.
- [ ] **Step 5: Commit**
```bash
git add go-backend/internal/web/ui go-backend/tailwind.input.css go-backend/static/styles.css
git commit -m "feat: add design system ui foundation"
```
### Task 2: Add design tokens and shared semantic class rules
**Files:**
- Modify: `go-backend/tailwind.input.css`
- Modify: `go-backend/static/styles.css`
- Test: `go-backend/internal/web/ui/ui_test.go`
- [ ] **Step 1: Write a failing token-level test**
Add a test that renders a primitive and proves semantic classes/tokens are in use rather than page-local class strings.
- [ ] **Step 2: Run the focused test to verify it fails**
Run: `cd go-backend && go test ./internal/web/ui -run 'Token|Button|Badge' -v`
Expected: FAIL due to missing token-backed class behavior.
- [ ] **Step 3: Define shared tokens and semantic CSS**
Add:
- semantic token aliases in `tailwind.input.css`
- shared borderless/destructive button styles
- any tiny semantic CSS hooks the primitives need
Do not add page-specific classes here.
- [ ] **Step 4: Re-run the focused UI tests**
Run: `cd go-backend && just generate && go test ./internal/web/ui -run 'Token|Button|Badge' -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add go-backend/tailwind.input.css go-backend/static/styles.css go-backend/internal/web/ui
git commit -m "feat: add design system tokens"
```
## Chunk 2: Primitive Components
### Task 3: Build the first reusable primitives
**Files:**
- Create/Modify: `go-backend/internal/web/ui/button.templ`
- Create/Modify: `go-backend/internal/web/ui/icon_button.templ`
- Create/Modify: `go-backend/internal/web/ui/badge.templ`
- Create/Modify: `go-backend/internal/web/ui/input.templ`
- Create/Modify: `go-backend/internal/web/ui/textarea.templ`
- Create/Modify: `go-backend/internal/web/ui/form_field.templ`
- Create/Modify: `go-backend/internal/web/ui/card.templ`
- Create/Modify: `go-backend/internal/web/ui/modal.templ`
- Create/Modify: `go-backend/internal/web/ui/table.templ`
- Create/Modify: `go-backend/internal/web/ui/empty_state.templ`
- Modify: `go-backend/internal/web/ui/ui_test.go`
- [ ] **Step 1: Expand the test suite with one failing test per primitive**
Add one focused contract test per primitive. Examples:
- `Button` renders `primary`, `ghost`, and `danger`
- `IconButton` renders icon-only action buttons
- `Badge` renders status-like visual variants
- `FormField` renders label + error text
- `Modal` renders shell/body/actions
- `Table` renders shared header/body wrappers
- [ ] **Step 2: Run the primitive tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/ui -run 'Button|IconButton|Badge|Modal|Table|FormField' -v`
Expected: FAIL due to missing or incomplete primitive implementations.
- [ ] **Step 3: Implement the minimal primitives to satisfy the tests**
Keep APIs tight:
- semantic variants
- `sm`, `md`, `lg` sizes
- pass-through HTMX attrs only where needed
- no arbitrary class-first API
- [ ] **Step 4: Re-run the primitive tests**
Run: `cd go-backend && just generate && go test ./internal/web/ui -run 'Button|IconButton|Badge|Modal|Table|FormField' -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add go-backend/internal/web/ui
git commit -m "feat: add initial design system primitives"
```
## Chunk 3: Static Catalog Generator
### Task 4: Build the catalog registry and page templates
**Files:**
- Create: `go-backend/internal/web/ui/catalog/pages.go`
- Create: `go-backend/internal/web/ui/catalog/examples.go`
- Create: `go-backend/internal/web/ui/catalog/catalog.templ`
- Create: `go-backend/internal/web/ui/catalog/catalog_test.go`
- [ ] **Step 1: Write failing catalog tests**
Add tests for:
- component pages are registered
- a tokens page exists
- component examples render through the real primitives
- [ ] **Step 2: Run the focused catalog tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/ui/catalog -v`
Expected: FAIL because catalog registry/templates do not exist yet.
- [ ] **Step 3: Implement the catalog registry and page templates**
Add:
- page metadata
- component examples
- shared catalog layout
- snippet sections
Use the real primitives from `internal/web/ui/`.
- [ ] **Step 4: Re-run the focused catalog tests**
Run: `cd go-backend && just generate && go test ./internal/web/ui/catalog -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add go-backend/internal/web/ui/catalog
git commit -m "feat: add design system catalog pages"
```
### Task 5: Add the static generator command and generated output
**Files:**
- Create: `go-backend/cmd/designsystem/main.go`
- Create: `go-backend/cmd/designsystem/main_test.go`
- Modify: `go-backend/justfile`
- Generated: `docs/design-system/*.html`
- [ ] **Step 1: Write failing generator tests**
Add tests that:
- create a temp output dir
- run the generator
- assert `index.html`, `tokens.html`, and at least one component page exist
- [ ] **Step 2: Run the generator tests to verify they fail**
Run: `cd go-backend && go test ./cmd/designsystem -v`
Expected: FAIL because the generator command does not exist yet.
- [ ] **Step 3: Implement the generator and `just` entrypoint**
Add:
- generator command
- output directory creation
- page rendering loop
- `just design-system` recipe
- [ ] **Step 4: Generate the static catalog and verify tests pass**
Run:
- `cd go-backend && go test ./cmd/designsystem -v`
- `cd go-backend && just design-system`
Expected:
- tests PASS
- `docs/design-system/` contains generated HTML pages
- [ ] **Step 5: Commit**
```bash
git add go-backend/cmd/designsystem go-backend/justfile docs/design-system
git commit -m "feat: add static design system generator"
```
## Chunk 4: Migrate `/tablos`
### Task 6: Replace ad hoc `/tablos` UI markup with shared primitives
**Files:**
- Modify: `go-backend/internal/web/views/tablos.templ`
- Modify: `go-backend/internal/web/views/tablos_view.go`
- Modify: `go-backend/internal/web/views/dashboard_components.templ`
- Modify: `go-backend/internal/web/views/home.go`
- Modify: `go-backend/internal/web/handlers/tablos_test.go`
- Modify: `go-backend/router_test.go`
- [ ] **Step 1: Add failing migration tests**
Add or update tests so they explicitly expect shared component contracts in `/tablos`, for example:
- shared `Button` class contract in toolbar or modal
- shared `IconButton` contract for delete actions
- shared `Badge` contract for statuses
- shared `Modal` shell contract
- shared `Table` wrapper contract
- [ ] **Step 2: Run the focused `/tablos` tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|HomePage' -v`
Expected: FAIL because views still render ad hoc markup.
- [ ] **Step 3: Migrate `/tablos` to `ui` primitives**
Replace local markup with shared components:
- primary actions -> `Button`
- delete actions -> `IconButton`
- statuses -> `Badge`
- create form shell -> `Modal` + `FormField` + `Input`
- list view shell -> `Table`
- empty state -> `EmptyState`
Keep the page-specific layout, filters, and HTMX flows intact.
- [ ] **Step 4: Re-run focused `/tablos` tests**
Run: `cd go-backend && just generate && go test ./internal/web/handlers -run 'Tablos|HomePage' -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add go-backend/internal/web/views go-backend/internal/web/handlers/tablos_test.go go-backend/router_test.go
git commit -m "refactor: migrate tablos to design system primitives"
```
## Chunk 5: Final Verification And Docs Output
### Task 7: Regenerate docs, verify the full app, and lock in usage rules
**Files:**
- Modify if needed: `docs/design-system/*.html`
- Modify if needed: `go-backend/internal/web/ui/catalog/*`
- Modify if needed: `go-backend/internal/web/ui/ui_test.go`
- [ ] **Step 1: Generate the final catalog output**
Run: `cd go-backend && just design-system`
Expected: refreshed static catalog pages under `docs/design-system/`.
- [ ] **Step 2: Run the full verification suite**
Run:
- `cd go-backend && just generate`
- `cd go-backend && go test ./...`
- `cd go-backend && just build`
Expected:
- all tests PASS
- app builds cleanly
- [ ] **Step 3: Do a final plan-vs-spec review pass**
Check:
- primitives exist
- catalog exists and is generated outside the app
- `/tablos` uses shared primitives
- no duplicate delete button implementation remains
- [ ] **Step 4: Commit**
```bash
git add go-backend docs/design-system
git commit -m "feat: add go-backend design system"
```

View file

@ -0,0 +1,82 @@
package main
import (
"bytes"
"context"
"flag"
"fmt"
"html/template"
"os"
"path/filepath"
"github.com/a-h/templ"
"xtablo-backend/internal/web/ui/catalog"
)
func main() {
outputDir := flag.String("output", filepath.Join("..", "docs", "design-system"), "output directory for generated catalog pages")
flag.Parse()
if err := GenerateSite(*outputDir); err != nil {
fmt.Fprintf(os.Stderr, "generate design system: %v\n", err)
os.Exit(1)
}
}
func GenerateSite(outputDir string) error {
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
if err := writePage(filepath.Join(outputDir, "index.html"), "Design System", catalog.CatalogIndex(catalog.Pages())); err != nil {
return err
}
for _, page := range catalog.Pages() {
target := filepath.Join(outputDir, page.Slug+".html")
if err := writePage(target, page.Title, catalog.CatalogPage(page)); err != nil {
return err
}
}
return nil
}
func writePage(path string, title string, component templ.Component) error {
body, err := renderComponent(component)
if err != nil {
return fmt.Errorf("render %s: %w", path, err)
}
doc := buildHTMLDocument(title, body)
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
func renderComponent(component templ.Component) (template.HTML, error) {
var buf bytes.Buffer
if err := component.Render(context.Background(), &buf); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
}
func buildHTMLDocument(title string, body template.HTML) string {
return fmt.Sprintf(`<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<link rel="stylesheet" href="../../go-backend/static/tailwind.css">
<link rel="stylesheet" href="../../go-backend/static/styles.css">
</head>
<body>
%s
</body>
</html>
`, template.HTMLEscapeString(title), body)
}

View file

@ -0,0 +1,34 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestGenerateSiteWritesExpectedPages(t *testing.T) {
outputDir := t.TempDir()
if err := GenerateSite(outputDir); err != nil {
t.Fatalf("generate site: %v", err)
}
for _, name := range []string{
"index.html",
"tokens.html",
"buttons.html",
"badges.html",
"icon-buttons.html",
"inputs.html",
"form-fields.html",
"modals.html",
"tables.html",
"empty-states.html",
"cards.html",
} {
path := filepath.Join(outputDir, name)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected generated file %q: %v", path, err)
}
}
}

View file

@ -54,3 +54,41 @@ LIMIT 1;
-- name: DeleteSessionByToken :execrows
DELETE FROM auth.sessions
WHERE session_token = $1;
-- name: CreateTablo :one
INSERT INTO public.tablos (
id,
owner_id,
name,
status,
created_at,
updated_at
) VALUES (
$1,
$2,
$3,
$4,
now(),
now()
)
RETURNING id, owner_id, name, status, created_at, updated_at, deleted_at;
-- name: ListTablos :many
SELECT id, owner_id, name, status, created_at, updated_at, deleted_at
FROM public.tablos
WHERE owner_id = sqlc.arg(owner_id)
AND deleted_at IS NULL
AND (
sqlc.narg(status)::text IS NULL OR status = sqlc.narg(status)::text
)
AND (
sqlc.narg(query)::text IS NULL OR name ILIKE '%' || sqlc.narg(query)::text || '%'
)
ORDER BY created_at DESC;
-- name: SoftDeleteTablo :execrows
UPDATE public.tablos
SET deleted_at = now(), updated_at = now()
WHERE id = $1
AND owner_id = $2
AND deleted_at IS NULL;

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@ -14,6 +15,7 @@ import (
"github.com/rs/zerolog/log"
sqlcdb "xtablo-backend/internal/db/sqlc"
tablomodel "xtablo-backend/internal/tablos"
"xtablo-backend/internal/web/handlers"
)
@ -142,6 +144,83 @@ func (r *PostgresAuthRepository) DeleteSessionByToken(ctx context.Context, token
return nil
}
func (r *PostgresAuthRepository) CreateTablo(ctx context.Context, input tablomodel.CreateInput) (tablomodel.Record, error) {
row, err := r.queries.CreateTablo(ctx, sqlcdb.CreateTabloParams{
ID: uuid.New(),
OwnerID: input.OwnerID,
Name: strings.TrimSpace(input.Name),
Status: string(input.Status),
})
if err != nil {
return tablomodel.Record{}, err
}
return mapTabloRecord(row), nil
}
func (r *PostgresAuthRepository) ListTablos(ctx context.Context, input tablomodel.ListInput) ([]tablomodel.Record, error) {
params := sqlcdb.ListTablosParams{
OwnerID: input.OwnerID,
Query: nullableText(strings.TrimSpace(input.Query)),
Status: nullableStatus(input.Status),
}
rows, err := r.queries.ListTablos(ctx, params)
if err != nil {
return nil, err
}
tablos := make([]tablomodel.Record, 0, len(rows))
for _, row := range rows {
tablos = append(tablos, mapTabloRecord(row))
}
return tablos, nil
}
func (r *PostgresAuthRepository) SoftDeleteTablo(ctx context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error {
rows, err := r.queries.SoftDeleteTablo(ctx, sqlcdb.SoftDeleteTabloParams{
ID: tabloID,
OwnerID: ownerID,
})
if err != nil {
return err
}
if rows == 0 {
return tablomodel.ErrNotFound
}
return nil
}
func pgtypeTimestamptz(value time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: value, Valid: true}
}
func nullableText(value string) pgtype.Text {
if value == "" {
return pgtype.Text{}
}
return pgtype.Text{String: value, Valid: true}
}
func nullableStatus(value *tablomodel.Status) pgtype.Text {
if value == nil {
return pgtype.Text{}
}
return nullableText(string(*value))
}
func mapTabloRecord(row sqlcdb.Tablo) tablomodel.Record {
record := tablomodel.Record{
ID: row.ID,
OwnerID: row.OwnerID,
Name: row.Name,
Status: tablomodel.Status(row.Status),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
if row.DeletedAt.Valid {
deletedAt := row.DeletedAt.Time
record.DeletedAt = &deletedAt
}
return record
}

View file

@ -28,6 +28,20 @@ CREATE TABLE IF NOT EXISTS auth.sessions (
CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth.sessions(user_id);
CREATE TABLE IF NOT EXISTS public.tablos (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
CREATE INDEX IF NOT EXISTS tablos_owner_created_idx
ON public.tablos (owner_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER

View file

@ -27,6 +27,16 @@ type AuthUser struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
}
type Tablo struct {
ID uuid.UUID `db:"id"`
OwnerID uuid.UUID `db:"owner_id"`
Name string `db:"name"`
Status string `db:"status"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at"`
}
type User struct {
ID uuid.UUID `db:"id"`
Email string `db:"email"`

View file

@ -13,10 +13,13 @@ import (
type Querier interface {
CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error)
CreateSession(ctx context.Context, arg CreateSessionParams) error
CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo, error)
DeleteSessionByToken(ctx context.Context, sessionToken string) (int64, error)
GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error)
GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error)
ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo, error)
SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams) (int64, error)
}
var _ Querier = (*Queries)(nil)

View file

@ -85,6 +85,52 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er
return err
}
const createTablo = `-- name: CreateTablo :one
INSERT INTO public.tablos (
id,
owner_id,
name,
status,
created_at,
updated_at
) VALUES (
$1,
$2,
$3,
$4,
now(),
now()
)
RETURNING id, owner_id, name, status, created_at, updated_at, deleted_at
`
type CreateTabloParams struct {
ID uuid.UUID `db:"id"`
OwnerID uuid.UUID `db:"owner_id"`
Name string `db:"name"`
Status string `db:"status"`
}
func (q *Queries) CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo, error) {
row := q.db.QueryRow(ctx, createTablo,
arg.ID,
arg.OwnerID,
arg.Name,
arg.Status,
)
var i Tablo
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
)
return i, err
}
const deleteSessionByToken = `-- name: DeleteSessionByToken :execrows
DELETE FROM auth.sessions
WHERE session_token = $1
@ -166,3 +212,72 @@ func (q *Queries) GetSessionByToken(ctx context.Context, sessionToken string) (A
)
return i, err
}
const listTablos = `-- name: ListTablos :many
SELECT id, owner_id, name, status, created_at, updated_at, deleted_at
FROM public.tablos
WHERE owner_id = $1
AND deleted_at IS NULL
AND (
$2::text IS NULL OR status = $2::text
)
AND (
$3::text IS NULL OR name ILIKE '%' || $3::text || '%'
)
ORDER BY created_at DESC
`
type ListTablosParams struct {
OwnerID uuid.UUID `db:"owner_id"`
Status pgtype.Text `db:"status"`
Query pgtype.Text `db:"query"`
}
func (q *Queries) ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo, error) {
rows, err := q.db.Query(ctx, listTablos, arg.OwnerID, arg.Status, arg.Query)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Tablo
for rows.Next() {
var i Tablo
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Name,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const softDeleteTablo = `-- name: SoftDeleteTablo :execrows
UPDATE public.tablos
SET deleted_at = now(), updated_at = now()
WHERE id = $1
AND owner_id = $2
AND deleted_at IS NULL
`
type SoftDeleteTabloParams struct {
ID uuid.UUID `db:"id"`
OwnerID uuid.UUID `db:"owner_id"`
}
func (q *Queries) SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams) (int64, error) {
result, err := q.db.Exec(ctx, softDeleteTablo, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}

View file

@ -0,0 +1,40 @@
package tablos
import (
"errors"
"time"
"github.com/google/uuid"
)
var ErrNotFound = errors.New("tablo not found")
type Status string
const (
StatusTodo Status = "todo"
StatusInProgress Status = "in_progress"
StatusDone Status = "done"
)
type Record struct {
ID uuid.UUID
OwnerID uuid.UUID
Name string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
type CreateInput struct {
OwnerID uuid.UUID
Name string
Status Status
}
type ListInput struct {
OwnerID uuid.UUID
Query string
Status *Status
}

View file

@ -32,6 +32,9 @@ type AuthRepository interface {
CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error
GetSessionByToken(ctx context.Context, token string) (Session, error)
DeleteSessionByToken(ctx context.Context, token string) error
CreateTablo(ctx context.Context, input CreateTabloInput) (TabloRecord, error)
ListTablos(ctx context.Context, input ListTablosInput) ([]TabloRecord, error)
SoftDeleteTablo(ctx context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error
}
type CreateAuthUserInput struct {
@ -73,9 +76,43 @@ func NewAuthHandler(repo AuthRepository) *AuthHandler {
}
func (h *AuthHandler) GetHome() http.HandlerFunc {
return h.renderAppPage("/", func(user PublicUser) templ.Component {
return views.OverviewMainContent(user.DisplayName, user.Email)
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tablos, err := h.repo.ListTablos(context.Background(), ListTablosInput{
OwnerID: user.ID,
})
if err != nil {
http.Error(w, "failed to load projects", http.StatusInternalServerError)
return
}
showAllProjects := r.URL.Query().Get("show_projects") == "all"
projects := views.OverviewProjectsFromTablos(tablos)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if isHXRequest(r) && targetsOverviewProjectsSection(r) {
if err := views.OverviewProjectsSection(projects, showAllProjects).Render(r.Context(), w); err != nil {
http.Error(w, "failed to render overview projects", http.StatusInternalServerError)
}
return
}
content := views.OverviewMainContent(user.DisplayName, user.Email, projects, showAllProjects)
var renderErr error
if isHXRequest(r) {
renderErr = views.DashboardContentSwap("/", content).Render(r.Context(), w)
} else {
renderErr = views.DashboardPage("/", content).Render(r.Context(), w)
}
if renderErr != nil {
http.Error(w, "failed to render app page", http.StatusInternalServerError)
}
}
}
func (h *AuthHandler) GetTasksPage() http.HandlerFunc {
@ -85,9 +122,9 @@ func (h *AuthHandler) GetTasksPage() http.HandlerFunc {
}
func (h *AuthHandler) GetTablosPage() http.HandlerFunc {
return h.renderAppPage("/tablos", func(user PublicUser) templ.Component {
return views.TablosMainContent()
})
return func(w http.ResponseWriter, r *http.Request) {
h.renderTablosPage(w, r)
}
}
func (h *AuthHandler) GetPlanningPage() http.HandlerFunc {
@ -397,3 +434,11 @@ func logStoreMutation(action string, email string, sessionID string, usersCount
func isHXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
func targetsOverviewProjectsSection(r *http.Request) bool {
target := strings.TrimSpace(r.Header.Get("HX-Target"))
if target == "" {
return false
}
return target == "overview-projects-section" || strings.Contains(target, "#overview-projects-section")
}

View file

@ -15,6 +15,7 @@ type InMemoryAuthRepository struct {
authUsers map[string]AuthUser
publicUsers map[uuid.UUID]PublicUser
sessions map[string]Session
tablos map[uuid.UUID]TabloRecord
}
// NewInMemoryAuthRepository creates a testing-only auth repository.
@ -24,6 +25,7 @@ func NewInMemoryAuthRepository() *InMemoryAuthRepository {
authUsers: map[string]AuthUser{},
publicUsers: map[uuid.UUID]PublicUser{},
sessions: map[string]Session{},
tablos: map[uuid.UUID]TabloRecord{},
}
demoHash, err := hashPassword("xtablo-demo")

View file

@ -0,0 +1,369 @@
package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
"xtablo-backend/internal/web/views"
)
var ErrTabloNotFound = tablomodel.ErrNotFound
type TabloStatus = tablomodel.Status
const (
TabloStatusTodo = tablomodel.StatusTodo
TabloStatusInProgress = tablomodel.StatusInProgress
TabloStatusDone = tablomodel.StatusDone
)
type TabloRecord = tablomodel.Record
type CreateTabloInput = tablomodel.CreateInput
type ListTablosInput = tablomodel.ListInput
type TablosPageState struct {
View string
Query string
Status string
ModalOpen bool
}
func normalizeTabloQuery(query string) string {
return strings.ToLower(strings.TrimSpace(query))
}
func parseTablosPageState(values interface {
Get(string) string
}) TablosPageState {
view := strings.TrimSpace(values.Get("view"))
if view != "list" {
view = "grid"
}
status := strings.TrimSpace(values.Get("status"))
switch status {
case "todo", "in_progress", "done":
default:
status = "all"
}
return TablosPageState{
View: view,
Query: strings.TrimSpace(values.Get("q")),
Status: status,
ModalOpen: strings.TrimSpace(values.Get("modal")) == "create",
}
}
func (s TablosPageState) statusFilter() *TabloStatus {
switch s.Status {
case string(TabloStatusTodo):
status := TabloStatusTodo
return &status
case string(TabloStatusInProgress):
status := TabloStatusInProgress
return &status
case string(TabloStatusDone):
status := TabloStatusDone
return &status
default:
return nil
}
}
func (h *AuthHandler) PostTablos() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
state := parseTablosPageState(r.Form)
state.ModalOpen = true
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, nil, name, "Le nom du projet est requis"), http.StatusUnprocessableEntity)
return
}
if _, err := h.repo.CreateTablo(r.Context(), CreateTabloInput{
OwnerID: user.ID,
Name: name,
Status: TabloStatusTodo,
}); err != nil {
http.Error(w, "failed to create tablo", http.StatusInternalServerError)
return
}
state.ModalOpen = false
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
Query: state.Query,
Status: state.statusFilter(),
})
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK)
}
}
func (h *AuthHandler) DeleteTablo() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tabloID, err := uuid.Parse(r.PathValue("tabloID"))
if err != nil {
http.Error(w, "invalid tablo id", http.StatusBadRequest)
return
}
if err := h.repo.SoftDeleteTablo(r.Context(), tabloID, user.ID); err != nil {
if errors.Is(err, ErrTabloNotFound) {
http.Error(w, "tablo not found", http.StatusNotFound)
return
}
http.Error(w, "failed to delete tablo", http.StatusInternalServerError)
return
}
state := parseTablosPageState(r.URL.Query())
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
Query: state.Query,
Status: state.statusFilter(),
})
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK)
}
}
func (h *AuthHandler) renderTablosPage(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
state := parseTablosPageState(r.URL.Query())
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
Query: state.Query,
Status: state.statusFilter(),
})
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK)
}
func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloRecord, formName string, errorMessage string) views.TablosPageViewModel {
return views.NewTablosPageViewModel(
user.DisplayName,
state.View,
state.Query,
state.Status,
state.ModalOpen,
formName,
errorMessage,
buildTabloCardViews(tablos, state),
)
}
func renderTablosResponse(w http.ResponseWriter, r *http.Request, activePath string, vm views.TablosPageViewModel, statusCode int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusCode)
var err error
content := views.TablosPageContent(vm)
if isHXRequest(r) {
err = views.DashboardContentSwapWithMainClass(activePath, "flex-1 overflow-auto", content).Render(r.Context(), w)
} else {
err = views.DashboardPageWithMainClass(activePath, "flex-1 overflow-auto", content).Render(r.Context(), w)
}
if err != nil {
http.Error(w, "failed to render tablos page", http.StatusInternalServerError)
}
}
func (r *InMemoryAuthRepository) CreateTablo(_ context.Context, input CreateTabloInput) (TabloRecord, error) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
tablo := TabloRecord{
ID: uuid.New(),
OwnerID: input.OwnerID,
Name: strings.TrimSpace(input.Name),
Status: input.Status,
CreatedAt: now,
UpdatedAt: now,
}
r.tablos[tablo.ID] = tablo
return tablo, nil
}
func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosInput) ([]TabloRecord, error) {
r.mu.RLock()
defer r.mu.RUnlock()
query := normalizeTabloQuery(input.Query)
var tablos []TabloRecord
for _, tablo := range r.tablos {
if tablo.OwnerID != input.OwnerID {
continue
}
if tablo.DeletedAt != nil {
continue
}
if input.Status != nil && tablo.Status != *input.Status {
continue
}
if query != "" && !strings.Contains(strings.ToLower(tablo.Name), query) {
continue
}
tablos = append(tablos, tablo)
}
sortTablosByCreatedAtDesc(tablos)
return tablos, nil
}
func (r *InMemoryAuthRepository) SoftDeleteTablo(_ context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
tablo, ok := r.tablos[tabloID]
if !ok || tablo.OwnerID != ownerID || tablo.DeletedAt != nil {
return ErrTabloNotFound
}
now := time.Now().UTC()
tablo.DeletedAt = &now
tablo.UpdatedAt = now
r.tablos[tabloID] = tablo
return nil
}
func sortTablosByCreatedAtDesc(tablos []TabloRecord) {
for i := 0; i < len(tablos); i++ {
for j := i + 1; j < len(tablos); j++ {
if tablos[j].CreatedAt.After(tablos[i].CreatedAt) {
tablos[i], tablos[j] = tablos[j], tablos[i]
}
}
}
}
func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.TabloCardView {
items := make([]views.TabloCardView, 0, len(tablos))
for _, tablo := range tablos {
statusLabel, statusClass, progress, statusTone := tabloStatusPresentation(tablo.Status)
iconKind, bgClass, fgClass, accent := tabloIconPresentation(tablo.Name)
items = append(items, views.TabloCardView{
ID: tablo.ID.String(),
Name: tablo.Name,
Status: string(tablo.Status),
StatusLabel: statusLabel,
StatusClass: statusClass,
StatusTone: statusTone,
Progress: progress,
CreatedAtLabel: formatFrenchDate(tablo.CreatedAt),
CardDateLabel: formatCardDate(tablo.CreatedAt),
ProgressLabel: fmt.Sprintf("%d%%", progress),
DeleteURL: "/tablos/" + tablo.ID.String(),
DeleteRequestURL: buildDeleteRequestURL("/tablos/"+tablo.ID.String(), state),
IconKind: iconKind,
IconBgClass: bgClass,
IconFgClass: fgClass,
Accent: accent,
Initial: projectInitial(tablo.Name),
})
}
return items
}
func buildDeleteRequestURL(path string, state TablosPageState) string {
values := url.Values{}
values.Set("view", state.View)
values.Set("status", state.Status)
if strings.TrimSpace(state.Query) != "" {
values.Set("q", strings.TrimSpace(state.Query))
}
encoded := values.Encode()
if encoded == "" {
return path
}
return path + "?" + encoded
}
func tabloStatusPresentation(status TabloStatus) (string, string, int, string) {
switch status {
case TabloStatusInProgress:
return "En cours", "bg-[#FFF4E2] text-[#DB9729] border border-[#DB9729]", 50, "warning"
case TabloStatusDone:
return "Terminé", "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800", 100, "success"
default:
return "À faire", "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800", 0, "info"
}
}
func tabloIconPresentation(name string) (string, string, string, string) {
switch len(strings.TrimSpace(name)) % 3 {
case 1:
return "gem", "bg-purple-500", "text-white", "purple"
case 2:
return "sparkles", "bg-cyan-500", "text-gray-700", "red"
default:
return "bolt", "bg-blue-500", "text-white", "blue"
}
}
func formatFrenchDate(value time.Time) string {
months := []string{"janv.", "fevr.", "mars", "avr.", "mai", "juin", "juil.", "aout", "sept.", "oct.", "nov.", "dec."}
month := months[int(value.Month())-1]
return fmt.Sprintf("%02d %s %d", value.Day(), month, value.Year())
}
func formatCardDate(value time.Time) string {
return value.Format("Jan 02, 2006")
}
func projectInitial(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "P"
}
runes := []rune(trimmed)
return strings.ToUpper(string(runes[0]))
}

View file

@ -0,0 +1,632 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestInMemoryTablosListExcludesSoftDeletedRows(t *testing.T) {
repo := NewInMemoryAuthRepository()
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("expected demo user, got error %v", err)
}
deletedTablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Visible",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create deleted tablo: %v", err)
}
keptTablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Kept",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create kept tablo: %v", err)
}
if err := repo.SoftDeleteTablo(context.Background(), deletedTablo.ID, user.ID); err != nil {
t.Fatalf("soft delete tablo: %v", err)
}
tablos, err := repo.ListTablos(context.Background(), ListTablosInput{
OwnerID: user.ID,
})
if err != nil {
t.Fatalf("list tablos: %v", err)
}
if len(tablos) != 1 {
t.Fatalf("expected 1 visible tablo, got %d", len(tablos))
}
if tablos[0].ID != keptTablo.ID {
t.Fatalf("expected kept tablo %s, got %s", keptTablo.ID, tablos[0].ID)
}
}
func TestInMemoryTablosListFiltersBySearchAndStatus(t *testing.T) {
repo := NewInMemoryAuthRepository()
user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("expected demo user, got error %v", err)
}
_, err = repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Hello Product",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create todo tablo: %v", err)
}
expectedTablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Hello Delivery",
Status: TabloStatusInProgress,
})
if err != nil {
t.Fatalf("create in progress tablo: %v", err)
}
tablos, err := repo.ListTablos(context.Background(), ListTablosInput{
OwnerID: user.ID,
Query: "delivery",
Status: &[]TabloStatus{TabloStatusInProgress}[0],
})
if err != nil {
t.Fatalf("list filtered tablos: %v", err)
}
if len(tablos) != 1 {
t.Fatalf("expected 1 filtered tablo, got %d", len(tablos))
}
if tablos[0].ID != expectedTablo.ID {
t.Fatalf("expected tablo %s, got %s", expectedTablo.ID, tablos[0].ID)
}
}
func TestInMemoryTablosSoftDeleteRejectsDifferentOwner(t *testing.T) {
repo := NewInMemoryAuthRepository()
owner, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("expected demo user, got error %v", err)
}
otherUserID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
Email: "other@xtablo.com",
EncryptedPassword: "hash",
DisplayName: "other",
})
if err != nil {
t.Fatalf("create other user: %v", err)
}
tablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: owner.ID,
Name: "Owned",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create owned tablo: %v", err)
}
if err := repo.SoftDeleteTablo(context.Background(), tablo.ID, otherUserID); err == nil {
t.Fatal("expected deleting another user's tablo to fail")
}
}
func TestGetTablosPageDefaultsToGridAndAllStatus(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/tablos", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Mes Projets",
"Nouveau projet",
"Vue en grille",
"Rechercher...",
"Tous",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q", want)
}
}
}
func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Alpha Draft",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create todo tablo: %v", err)
}
_, err = repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Beta Delivery",
Status: TabloStatusInProgress,
})
if err != nil {
t.Fatalf("create filtered tablo: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tablos?q=delivery&status=in_progress", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, pageReq)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Beta Delivery") {
t.Fatalf("expected filtered tablo to be visible, got %q", body)
}
if strings.Contains(body, "Alpha Draft") {
t.Fatalf("expected non-matching tablo to be filtered out, got %q", body)
}
}
func TestGetTablosPageUsesSharedToolbarButtonAndStatusBadge(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Shared UI",
Status: TabloStatusInProgress,
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tablos", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, pageReq)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`ui-button ui-button-primary ui-button-md`,
`ui-badge ui-badge-warning`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected shared primitive markup %q, got %q", want, body)
}
}
}
func TestGetTablosPageModalUsesSharedFormPrimitives(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/tablos?modal=create", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`ui-modal-panel`,
`ui-form-field`,
`class="ui-input"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected modal primitive markup %q, got %q", want, body)
}
}
}
func TestGetTablosPageListViewRendersTableLayout(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Table View",
Status: TabloStatusInProgress,
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tablos?view=list", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, pageReq)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`class="ui-table-shell"`,
`<table class="ui-table">`,
"Progression",
"Table View",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected list view to contain %q, got %q", want, body)
}
}
}
func TestGetTablosPageEmptyStateUsesSharedPrimitive(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/tablos", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`ui-empty-state`,
`Aucun projet trouvé`,
`Créez votre premier projet`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected empty state primitive markup %q, got %q", want, body)
}
}
}
func TestGetTablosPageListViewUsesDirectTableIconMarkup(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Markup Check",
Status: TabloStatusInProgress,
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tablos?view=list", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, pageReq)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`class="flex items-center gap-1.5 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0"><svg viewBox="0 0 24 24"`,
`class="borderless-icon-button"`,
`class="lucide lucide-trash2 w-4 h-4"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected list view markup to contain %q, got %q", want, body)
}
}
}
func TestGetTablosPageGridUsesProjectDateRowMarkup(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Calendar Check",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tablos", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, pageReq)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), `class="project-date-row"><svg viewBox="0 0 24 24"`) {
t.Fatalf("expected grid card calendar icon to render inside project-date-row markup, got %q", rec.Body.String())
}
}
func TestGetTablosPageGridUsesProjectCardMarkup(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
_, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Hello",
Status: TabloStatusInProgress,
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
pageReq := httptest.NewRequest(http.MethodGet, "/tablos", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTablosPage().ServeHTTP(rec, pageReq)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`<article class="project-card">`,
`class="project-card-top"`,
`class="borderless-icon-button"`,
`class="project-card-title-row"`,
`class="project-avatar project-accent-`,
`class="project-date-row"`,
`class="project-progress-track"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected grid card markup to contain %q, got %q", want, body)
}
}
}
func TestPostTablosCreatesTodoTablo(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
form := url.Values{}
form.Set("name", "Roadmap")
form.Set("view", "grid")
form.Set("status", "all")
req := httptest.NewRequest(http.MethodPost, "/tablos", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.PostTablos().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Roadmap") {
t.Fatalf("expected created tablo in response, got %q", body)
}
if !strings.Contains(body, "À faire") {
t.Fatalf("expected todo status label in response, got %q", body)
}
}
func TestPostTablosWithEmptyNameReturns422(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
form := url.Values{}
req := httptest.NewRequest(http.MethodPost, "/tablos", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.PostTablos().ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected status 422, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Le nom du projet est requis") {
t.Fatalf("expected validation error, got %q", rec.Body.String())
}
}
func TestDeleteTabloSoftDeletesOwnedRow(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected user session")
}
tablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: userID,
Name: "Disposable",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/tablos/"+tablo.ID.String(), nil)
deleteReq.SetPathValue("tabloID", tablo.ID.String())
deleteReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.DeleteTablo().ServeHTTP(rec, deleteReq)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
list, err := repo.ListTablos(context.Background(), ListTablosInput{OwnerID: userID})
if err != nil {
t.Fatalf("list tablos: %v", err)
}
if len(list) != 0 {
t.Fatalf("expected deleted tablo to be hidden, got %d rows", len(list))
}
}
func TestDeleteTabloRejectsDifferentOwner(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
ownerCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
ownerReq := httptest.NewRequest(http.MethodGet, "/", nil)
ownerReq.AddCookie(ownerCookie)
ownerID, ok := handler.currentUserID(ownerReq.Context(), ownerReq)
if !ok {
t.Fatal("expected owner session")
}
tablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: ownerID,
Name: "Owned",
Status: TabloStatusTodo,
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
passwordHash, err := hashPassword("xtablo-demo")
if err != nil {
t.Fatalf("hash password: %v", err)
}
_, err = repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
Email: "other@xtablo.com",
EncryptedPassword: passwordHash,
DisplayName: "other",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
otherCookie := loginTestUser(t, handler, "other@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodDelete, "/tablos/"+tablo.ID.String(), nil)
req.SetPathValue("tabloID", tablo.ID.String())
req.AddCookie(otherCookie)
rec := httptest.NewRecorder()
handler.DeleteTablo().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
}
func loginTestUser(t *testing.T, handler *AuthHandler, email string, password string) *http.Cookie {
t.Helper()
form := url.Values{}
form.Set("email", email)
form.Set("password", password)
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.PostLogin().ServeHTTP(rec, req)
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == "xtablo_session" {
return cookie
}
}
t.Fatal("expected session cookie to be set")
return nil
}

View file

@ -0,0 +1,10 @@
package ui
type BadgeProps struct {
Label string
Variant BadgeVariant
}
templ Badge(props BadgeProps) {
<span class={ badgeClass(props.Variant) }>{ props.Label }</span>
}

View file

@ -0,0 +1,76 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type BadgeProps struct {
Label string
Variant BadgeVariant
}
func Badge(props BadgeProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{badgeClass(props.Variant)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/badge.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/badge.templ`, Line: 9, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,21 @@
package ui
type ButtonProps struct {
Label string
Variant ButtonVariant
Size Size
Type string
Icon string
Attrs templ.Attributes
}
templ Button(props ButtonProps) {
<button type={ buttonType(props.Type) } class={ buttonClass(props.Variant, props.Size) } { props.Attrs... }>
if props.Icon != "" {
<span class="ui-button-icon">
@UIIcon(props.Icon)
</span>
}
{ props.Label }
</button>
}

View file

@ -0,0 +1,115 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type ButtonProps struct {
Label string
Variant ButtonVariant
Size Size
Type string
Icon string
Attrs templ.Attributes
}
func Button(props ButtonProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{buttonClass(props.Variant, props.Size)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<button type=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(buttonType(props.Type))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/button.templ`, Line: 13, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, props.Attrs)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Icon != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span class=\"ui-button-icon\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = UIIcon(props.Icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(props.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/button.templ`, Line: 19, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,27 @@
package ui
type CardProps struct {
Header templ.Component
Body templ.Component
Footer templ.Component
}
templ Card(props CardProps) {
<section class="ui-card">
if props.Header != nil {
<div class="ui-card-header">
@props.Header
</div>
}
if props.Body != nil {
<div class="ui-card-body">
@props.Body
</div>
}
if props.Footer != nil {
<div class="ui-card-footer">
@props.Footer
</div>
}
</section>
}

View file

@ -0,0 +1,92 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type CardProps struct {
Header templ.Component
Body templ.Component
Footer templ.Component
}
func Card(props CardProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"ui-card\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Header != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"ui-card-header\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = props.Header.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if props.Body != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"ui-card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = props.Body.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if props.Footer != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"ui-card-footer\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = props.Footer.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,61 @@
package catalog
templ CatalogPage(page Page) {
<main class="catalog-page">
<nav class="catalog-nav" aria-label="Catalog navigation">
<a href="./index.html" class="catalog-home-link">Catalog</a>
<div class="catalog-nav-links">
for _, navPage := range Pages() {
<a
href={ "./" + navPage.Slug + ".html" }
class={ catalogNavLinkClass(navPage.Slug == page.Slug) }
>
{ navPage.Title }
</a>
}
</div>
</nav>
<header class="catalog-page-header">
<p class="catalog-eyebrow">Design System</p>
<h1>{ page.Title }</h1>
<p>{ page.Description }</p>
</header>
<div class="catalog-example-list">
for _, example := range page.Examples {
<section class="catalog-example">
<div class="catalog-example-copy">
<h2>{ example.Title }</h2>
if example.Description != "" {
<p>{ example.Description }</p>
}
</div>
<div class="catalog-example-preview">
@example.Preview
</div>
if example.Snippet != "" {
<pre class="catalog-example-snippet"><code>{ example.Snippet }</code></pre>
}
</section>
}
</div>
</main>
}
templ CatalogIndex(pages []Page) {
<main class="catalog-page">
<header class="catalog-page-header">
<p class="catalog-eyebrow">Design System</p>
<h1>Component Catalog</h1>
<p>Static documentation generated from the same templ primitives used by the Go application.</p>
</header>
<div class="catalog-page-list">
for _, page := range pages {
<a href={ "./" + page.Slug + ".html" } class="catalog-page-link-card">
<h2>{ page.Title }</h2>
<p>{ page.Description }</p>
<p class="catalog-page-link">/{ page.Slug }.html</p>
</a>
}
</div>
</main>
}

View file

@ -0,0 +1,288 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package catalog
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func CatalogPage(page Page) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"catalog-page\"><nav class=\"catalog-nav\" aria-label=\"Catalog navigation\"><a href=\"./index.html\" class=\"catalog-home-link\">Catalog</a><div class=\"catalog-nav-links\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, navPage := range Pages() {
var templ_7745c5c3_Var2 = []any{catalogNavLinkClass(navPage.Slug == page.Slug)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs("./" + navPage.Slug + ".html")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 10, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(navPage.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 13, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></nav><header class=\"catalog-page-header\"><p class=\"catalog-eyebrow\">Design System</p><h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(page.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 20, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h1><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(page.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 21, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p></header><div class=\"catalog-example-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, example := range page.Examples {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<section class=\"catalog-example\"><div class=\"catalog-example-copy\"><h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(example.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 27, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if example.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(example.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 29, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div class=\"catalog-example-preview\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = example.Preview.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if example.Snippet != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<pre class=\"catalog-example-snippet\"><code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(example.Snippet)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 36, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</code></pre>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CatalogIndex(pages []Page) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<main class=\"catalog-page\"><header class=\"catalog-page-header\"><p class=\"catalog-eyebrow\">Design System</p><h1>Component Catalog</h1><p>Static documentation generated from the same templ primitives used by the Go application.</p></header><div class=\"catalog-page-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, page := range pages {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs("./" + page.Slug + ".html")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 53, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"catalog-page-link-card\"><h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(page.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 54, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</h2><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(page.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 55, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><p class=\"catalog-page-link\">/")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(page.Slug)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 56, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ".html</p></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,174 @@
package catalog
import (
"bytes"
"context"
"strings"
"testing"
"github.com/a-h/templ"
)
func TestPagesIncludeTokensAndButtons(t *testing.T) {
pages := Pages()
if len(pages) == 0 {
t.Fatal("expected catalog pages")
}
var hasTokens bool
var hasButtons bool
for _, page := range pages {
switch page.Slug {
case "tokens":
hasTokens = true
case "buttons":
hasButtons = true
}
}
if !hasTokens {
t.Fatal("expected tokens page")
}
if !hasButtons {
t.Fatal("expected buttons page")
}
}
func TestPagesIncludePrimitiveCatalogCoverage(t *testing.T) {
pages := Pages()
for _, slug := range []string{
"badges",
"icon-buttons",
"inputs",
"form-fields",
"modals",
"tables",
"empty-states",
"cards",
} {
if _, ok := FindPage(slug); !ok {
t.Fatalf("expected catalog page %q", slug)
}
}
if len(pages) < 10 {
t.Fatalf("expected expanded primitive catalog, got %d pages", len(pages))
}
}
func TestButtonPageExamplesRenderRealPrimitives(t *testing.T) {
page, ok := FindPage("buttons")
if !ok {
t.Fatal("expected buttons page")
}
if len(page.Examples) == 0 {
t.Fatal("expected button examples")
}
html := renderToString(t, page.Examples[0].Preview)
for _, want := range []string{
`ui-button`,
`ui-button-primary`,
`Nouveau projet`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestCatalogPageRendersMetadataAndExamples(t *testing.T) {
page, ok := FindPage("tokens")
if !ok {
t.Fatal("expected tokens page")
}
html := renderToString(t, CatalogPage(page))
for _, want := range []string{
`Design System`,
page.Title,
`catalog-example`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestCatalogIndexLinksToPrimitivePages(t *testing.T) {
html := renderToString(t, CatalogIndex(Pages()))
for _, want := range []string{
`href="./inputs.html"`,
`href="./buttons.html"`,
`class="catalog-page-link-card"`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestCatalogPageRendersSharedNavigationWithActivePage(t *testing.T) {
page, ok := FindPage("inputs")
if !ok {
t.Fatal("expected inputs page")
}
html := renderToString(t, CatalogPage(page))
for _, want := range []string{
`href="./index.html"`,
`href="./buttons.html"`,
`href="./inputs.html"`,
`catalog-nav-link is-active`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestPrimitiveExamplesRenderRealMarkup(t *testing.T) {
testCases := []struct {
slug string
want []string
}{
{slug: "badges", want: []string{`ui-badge`, `En cours`}},
{slug: "icon-buttons", want: []string{`borderless-icon-button`, `aria-label="Supprimer le projet"`}},
{slug: "inputs", want: []string{`class="ui-input"`, `placeholder="Nom du projet"`}},
{slug: "form-fields", want: []string{`ui-form-field`, `ui-form-label`}},
{slug: "modals", want: []string{`ui-modal-panel`, `Créer le projet`}},
{slug: "tables", want: []string{`class="ui-table"`, `Table View`}},
{slug: "empty-states", want: []string{`ui-empty-state`, `Aucun projet trouvé`}},
{slug: "cards", want: []string{`ui-card`, `Header`}},
}
for _, tt := range testCases {
page, ok := FindPage(tt.slug)
if !ok {
t.Fatalf("expected page %q", tt.slug)
}
if len(page.Examples) == 0 {
t.Fatalf("expected examples for %q", tt.slug)
}
html := renderToString(t, page.Examples[0].Preview)
for _, want := range tt.want {
if !strings.Contains(html, want) {
t.Fatalf("page %q expected %q in %q", tt.slug, want, html)
}
}
}
}
func renderToString(t *testing.T, component templ.Component) string {
t.Helper()
var buf bytes.Buffer
if err := component.Render(context.Background(), &buf); err != nil {
t.Fatalf("render component: %v", err)
}
return buf.String()
}

View file

@ -0,0 +1,326 @@
package catalog
import (
"context"
"io"
"github.com/a-h/templ"
"xtablo-backend/internal/web/ui"
)
type anyComponent = templ.Component
func buttonExamples() []Example {
return []Example{
{
Title: "Primary action",
Description: "Used for the main action in a page section or modal footer.",
Preview: ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "button",
}),
Snippet: `@ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "button",
})`,
},
{
Title: "Danger action",
Description: "Used for irreversible actions after explicit confirmation.",
Preview: ui.Button(ui.ButtonProps{
Label: "Supprimer",
Variant: ui.ButtonVariantDanger,
Size: ui.SizeLG,
Type: "submit",
}),
Snippet: `@ui.Button(ui.ButtonProps{
Label: "Supprimer",
Variant: ui.ButtonVariantDanger,
Size: ui.SizeLG,
Type: "submit",
})`,
},
}
}
func tokenExamples() []Example {
return []Example{
{
Title: "Status tones",
Description: "Shared semantic badges for info, warning, success, and danger states.",
Preview: componentFunc(func(ctx context.Context, w io.Writer) error {
for _, component := range []templ.Component{
ui.Badge(ui.BadgeProps{Label: "À faire", Variant: ui.BadgeVariantInfo}),
ui.Badge(ui.BadgeProps{Label: "En cours", Variant: ui.BadgeVariantWarning}),
ui.Badge(ui.BadgeProps{Label: "Terminé", Variant: ui.BadgeVariantSuccess}),
ui.Badge(ui.BadgeProps{Label: "Erreur", Variant: ui.BadgeVariantDanger}),
} {
if _, err := io.WriteString(w, `<div class="catalog-inline">`); err != nil {
return err
}
if err := component.Render(ctx, w); err != nil {
return err
}
if _, err := io.WriteString(w, `</div>`); err != nil {
return err
}
}
return nil
}),
Snippet: `@ui.Badge(ui.BadgeProps{Label: "En cours", Variant: ui.BadgeVariantWarning})`,
},
}
}
func badgeExamples() []Example {
return []Example{
{
Title: "Status set",
Description: "The four semantic badge tones used across the app.",
Preview: componentFunc(func(ctx context.Context, w io.Writer) error {
return renderInlineComponents(ctx, w,
ui.Badge(ui.BadgeProps{Label: "À faire", Variant: ui.BadgeVariantInfo}),
ui.Badge(ui.BadgeProps{Label: "En cours", Variant: ui.BadgeVariantWarning}),
ui.Badge(ui.BadgeProps{Label: "Terminé", Variant: ui.BadgeVariantSuccess}),
ui.Badge(ui.BadgeProps{Label: "Erreur", Variant: ui.BadgeVariantDanger}),
)
}),
Snippet: `@ui.Badge(ui.BadgeProps{Label: "En cours", Variant: ui.BadgeVariantWarning})`,
},
}
}
func iconButtonExamples() []Example {
return []Example{
{
Title: "Borderless destructive action",
Description: "Used for delete controls inside project cards and list rows.",
Preview: ui.IconButton(ui.IconButtonProps{
Label: "Supprimer le projet",
Icon: "trash",
Variant: ui.IconButtonVariantDangerGhost,
Type: "button",
}),
Snippet: `@ui.IconButton(ui.IconButtonProps{
Label: "Supprimer le projet",
Icon: "trash",
Variant: ui.IconButtonVariantDangerGhost,
Type: "button",
})`,
},
}
}
func inputExamples() []Example {
return []Example{
{
Title: "Text input",
Description: "Single-line input for names, titles, and short labels.",
Preview: ui.Input(ui.InputProps{
Name: "name",
Value: "Projet Atlas",
Placeholder: "Nom du projet",
Type: "text",
}),
Snippet: `@ui.Input(ui.InputProps{
Name: "name",
Value: "Projet Atlas",
Placeholder: "Nom du projet",
Type: "text",
})`,
},
{
Title: "Textarea",
Description: "Multiline field for longer project notes and descriptions.",
Preview: ui.Textarea(ui.TextareaProps{
Name: "description",
Value: "Une description de projet plus détaillée.",
Placeholder: "Description",
Rows: 4,
}),
Snippet: `@ui.Textarea(ui.TextareaProps{
Name: "description",
Value: "Une description de projet plus détaillée.",
Placeholder: "Description",
Rows: 4,
})`,
},
}
}
func formFieldExamples() []Example {
return []Example{
{
Title: "Field with validation",
Description: "Wraps a control with label and inline error feedback.",
Preview: ui.FormField(ui.FormFieldProps{
Label: "Nom",
For: "catalog-name",
Field: ui.Input(ui.InputProps{
ID: "catalog-name",
Name: "name",
Placeholder: "Nom du projet",
Type: "text",
}),
Error: "Le nom est requis",
}),
Snippet: `@ui.FormField(ui.FormFieldProps{
Label: "Nom",
For: "catalog-name",
Field: ui.Input(ui.InputProps{
ID: "catalog-name",
Name: "name",
Placeholder: "Nom du projet",
Type: "text",
}),
Error: "Le nom est requis",
})`,
},
}
}
func modalExamples() []Example {
return []Example{
{
Title: "Create modal",
Description: "Shared modal shell with a form body and action footer.",
Preview: ui.Modal(ui.ModalProps{
Title: "Créer un projet",
Body: ui.FormField(ui.FormFieldProps{
Label: "Nom du projet",
For: "modal-name",
Field: ui.Input(ui.InputProps{
ID: "modal-name",
Name: "name",
Placeholder: "Nom du projet",
Type: "text",
}),
}),
Actions: componentFunc(func(ctx context.Context, w io.Writer) error {
return renderComponents(ctx, w,
ui.Button(ui.ButtonProps{
Label: "Annuler",
Variant: ui.ButtonVariantSecondary,
Size: ui.SizeMD,
Type: "button",
}),
ui.Button(ui.ButtonProps{
Label: "Créer le projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "submit",
}),
)
}),
}),
Snippet: `@ui.Modal(ui.ModalProps{
Title: "Créer un projet",
Body: ui.FormField(...),
Actions: ui.Button(...),
})`,
},
}
}
func tableExamples() []Example {
return []Example{
{
Title: "List shell",
Description: "Shared wrapper for server-rendered resource tables.",
Preview: ui.Table(ui.TableProps{
Head: textComponent(`<tr><th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Projet</th><th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Statut</th></tr>`),
Body: textComponent(`<tr><td class="px-6 py-4">Table View</td><td class="px-6 py-4">En cours</td></tr>`),
}),
Snippet: `@ui.Table(ui.TableProps{
Head: TabloListHead(),
Body: TabloListBody(tablos),
})`,
},
}
}
func emptyStateExamples() []Example {
return []Example{
{
Title: "Centered empty state",
Description: "Used when a list has no rows yet and the next action should stay obvious.",
Preview: ui.EmptyState(ui.EmptyStateProps{
Title: "Aucun projet trouvé",
Description: "Créez votre premier projet",
Icon: ui.UIIcon("grid3x3"),
Action: ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "button",
Icon: "plus",
}),
}),
Snippet: `@ui.EmptyState(ui.EmptyStateProps{
Title: "Aucun projet trouvé",
Description: "Créez votre premier projet",
Icon: ui.UIIcon("grid3x3"),
Action: ui.Button(...),
})`,
},
}
}
func cardExamples() []Example {
return []Example{
{
Title: "Surface card",
Description: "Generic elevated surface with optional header and footer.",
Preview: ui.Card(ui.CardProps{
Header: textComponent("Header"),
Body: textComponent("Body"),
Footer: textComponent("Footer"),
}),
Snippet: `@ui.Card(ui.CardProps{
Header: textComponent("Header"),
Body: textComponent("Body"),
Footer: textComponent("Footer"),
})`,
},
}
}
func componentFunc(fn func(context.Context, io.Writer) error) templ.Component {
return templ.ComponentFunc(fn)
}
func textComponent(text string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := io.WriteString(w, text)
return err
})
}
func renderInlineComponents(ctx context.Context, w io.Writer, components ...templ.Component) error {
for _, component := range components {
if _, err := io.WriteString(w, `<div class="catalog-inline">`); err != nil {
return err
}
if err := component.Render(ctx, w); err != nil {
return err
}
if _, err := io.WriteString(w, `</div>`); err != nil {
return err
}
}
return nil
}
func renderComponents(ctx context.Context, w io.Writer, components ...templ.Component) error {
for _, component := range components {
if err := component.Render(ctx, w); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,99 @@
package catalog
import "slices"
type Example struct {
Title string
Description string
Preview anyComponent
Snippet string
}
type Page struct {
Slug string
Title string
Description string
Examples []Example
}
func Pages() []Page {
return []Page{
{
Slug: "tokens",
Title: "Tokens",
Description: "Semantic colors and status roles used by the Go design system.",
Examples: tokenExamples(),
},
{
Slug: "buttons",
Title: "Buttons",
Description: "Primary, secondary, ghost, and destructive actions built from shared templ primitives.",
Examples: buttonExamples(),
},
{
Slug: "badges",
Title: "Badges",
Description: "Semantic status labels for todo, in-progress, success, and destructive states.",
Examples: badgeExamples(),
},
{
Slug: "icon-buttons",
Title: "Icon Buttons",
Description: "Compact icon-only actions for destructive and neutral controls.",
Examples: iconButtonExamples(),
},
{
Slug: "inputs",
Title: "Inputs",
Description: "Shared single-line and multiline text controls.",
Examples: inputExamples(),
},
{
Slug: "form-fields",
Title: "Form Fields",
Description: "Labeled controls with optional hint and error messaging.",
Examples: formFieldExamples(),
},
{
Slug: "modals",
Title: "Modals",
Description: "Shared modal shell for focused create, edit, and confirm flows.",
Examples: modalExamples(),
},
{
Slug: "tables",
Title: "Tables",
Description: "Shared table shell for server-rendered list views.",
Examples: tableExamples(),
},
{
Slug: "empty-states",
Title: "Empty States",
Description: "Centered fallback messaging with optional icon and action.",
Examples: emptyStateExamples(),
},
{
Slug: "cards",
Title: "Cards",
Description: "Reusable bordered surfaces with optional header, body, and footer regions.",
Examples: cardExamples(),
},
}
}
func FindPage(slug string) (Page, bool) {
index := slices.IndexFunc(Pages(), func(page Page) bool {
return page.Slug == slug
})
if index == -1 {
return Page{}, false
}
return Pages()[index], true
}
func catalogNavLinkClass(active bool) string {
if active {
return "catalog-nav-link is-active"
}
return "catalog-nav-link"
}

View file

@ -0,0 +1,27 @@
package ui
type EmptyStateProps struct {
Title string
Description string
Icon templ.Component
Action templ.Component
}
templ EmptyState(props EmptyStateProps) {
<section class="ui-empty-state">
if props.Icon != nil {
<div class="ui-empty-state-icon">
@props.Icon
</div>
}
<h3 class="ui-empty-state-title">{ props.Title }</h3>
if props.Description != "" {
<p class="ui-empty-state-description">{ props.Description }</p>
}
if props.Action != nil {
<div class="ui-empty-state-action">
@props.Action
</div>
}
</section>
}

View file

@ -0,0 +1,115 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type EmptyStateProps struct {
Title string
Description string
Icon templ.Component
Action templ.Component
}
func EmptyState(props EmptyStateProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"ui-empty-state\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Icon != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"ui-empty-state-icon\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = props.Icon.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h3 class=\"ui-empty-state-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(props.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/empty_state.templ`, Line: 17, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p class=\"ui-empty-state-description\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(props.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/empty_state.templ`, Line: 19, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if props.Action != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"ui-empty-state-action\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = props.Action.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,26 @@
package ui
type FormFieldProps struct {
Label string
For string
Field templ.Component
Error string
Hint string
}
templ FormField(props FormFieldProps) {
<div class="ui-form-field">
if props.Label != "" {
<label for={ props.For } class="ui-form-label">{ props.Label }</label>
}
if props.Field != nil {
@props.Field
}
if props.Hint != "" {
<p class="ui-form-hint">{ props.Hint }</p>
}
if props.Error != "" {
<p class="ui-form-error">{ props.Error }</p>
}
</div>
}

View file

@ -0,0 +1,128 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type FormFieldProps struct {
Label string
For string
Field templ.Component
Error string
Hint string
}
func FormField(props FormFieldProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"ui-form-field\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Label != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(props.For)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/form_field.templ`, Line: 14, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"ui-form-label\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(props.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/form_field.templ`, Line: 14, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if props.Field != nil {
templ_7745c5c3_Err = props.Field.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if props.Hint != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p class=\"ui-form-hint\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.Hint)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/form_field.templ`, Line: 20, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if props.Error != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"ui-form-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(props.Error)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/form_field.templ`, Line: 23, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,31 @@
package ui
import "strconv"
func buttonType(value string) string {
if value == "" {
return "button"
}
return value
}
func inputType(value string) string {
if value == "" {
return "text"
}
return value
}
func inputID(id string, name string) string {
if id != "" {
return id
}
return name
}
func textareaRows(rows int) string {
if rows <= 0 {
rows = 4
}
return strconv.Itoa(rows)
}

View file

@ -0,0 +1,68 @@
package ui
type IconButtonProps struct {
Label string
Icon string
Variant IconButtonVariant
Type string
Attrs templ.Attributes
}
templ IconButton(props IconButtonProps) {
<button type={ buttonType(props.Type) } class={ iconButtonClass(props.Variant) } aria-label={ props.Label } { props.Attrs... }>
@UIIcon(props.Icon)
</button>
}
templ UIIcon(kind string) {
switch kind {
case "plus":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
case "grid3x3":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M3 9h18"></path>
<path d="M3 15h18"></path>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
</svg>
case "list":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12h.01"></path>
<path d="M3 18h.01"></path>
<path d="M3 6h.01"></path>
<path d="M8 12h13"></path>
<path d="M8 18h13"></path>
<path d="M8 6h13"></path>
</svg>
case "filter":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
case "search":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
case "calendar":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 2v4"></path>
<path d="M16 2v4"></path>
<rect width="18" height="18" x="3" y="4" rx="2"></rect>
<path d="M3 10h18"></path>
</svg>
case "trash":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 w-4 h-4" aria-hidden="true">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" x2="10" y1="11" y2="17"></line>
<line x1="14" x2="14" y1="11" y2="17"></line>
</svg>
default:
<span aria-hidden="true">{ kind }</span>
}
}

View file

@ -0,0 +1,188 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type IconButtonProps struct {
Label string
Icon string
Variant IconButtonVariant
Type string
Attrs templ.Attributes
}
func IconButton(props IconButtonProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{iconButtonClass(props.Variant)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<button type=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(buttonType(props.Type))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 12, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(props.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 12, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, props.Attrs)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = UIIcon(props.Icon).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func UIIcon(kind string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch kind {
case "plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 12h14\"></path> <path d=\"M12 5v14\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "grid3x3":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"></rect> <path d=\"M3 9h18\"></path> <path d=\"M3 15h18\"></path> <path d=\"M9 3v18\"></path> <path d=\"M15 3v18\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "list":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12h.01\"></path> <path d=\"M3 18h.01\"></path> <path d=\"M3 6h.01\"></path> <path d=\"M8 12h13\"></path> <path d=\"M8 18h13\"></path> <path d=\"M8 6h13\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "filter":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><polygon points=\"22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3\"></polygon></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "search":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"11\" cy=\"11\" r=\"8\"></circle> <path d=\"m21 21-4.3-4.3\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "calendar":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M8 2v4\"></path> <path d=\"M16 2v4\"></path> <rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\"></rect> <path d=\"M3 10h18\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "trash":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-trash2 w-4 h-4\" aria-hidden=\"true\"><path d=\"M3 6h18\"></path> <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"></path> <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"></path> <line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"></line> <line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span aria-hidden=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 66, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,22 @@
package ui
type InputProps struct {
ID string
Name string
Value string
Placeholder string
Type string
Attrs templ.Attributes
}
templ Input(props InputProps) {
<input
id={ inputID(props.ID, props.Name) }
type={ inputType(props.Type) }
name={ props.Name }
value={ props.Value }
placeholder={ props.Placeholder }
class="ui-input"
{ props.Attrs... }
/>
}

View file

@ -0,0 +1,122 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type InputProps struct {
ID string
Name string
Value string
Placeholder string
Type string
Attrs templ.Attributes
}
func Input(props InputProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<input id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(inputID(props.ID, props.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 14, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" type=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(inputType(props.Type))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 15, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 16, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(props.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 17, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" placeholder=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(props.Placeholder)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 18, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"ui-input\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, props.Attrs)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,27 @@
package ui
type ModalProps struct {
Title string
Body templ.Component
Actions templ.Component
}
templ Modal(props ModalProps) {
<div class="ui-modal-backdrop">
<div class="ui-modal-panel">
<div class="ui-modal-header">
<h2>{ props.Title }</h2>
</div>
if props.Body != nil {
<div class="ui-modal-body">
@props.Body
</div>
}
if props.Actions != nil {
<div class="ui-modal-actions">
@props.Actions
</div>
}
</div>
</div>
}

View file

@ -0,0 +1,91 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type ModalProps struct {
Title string
Body templ.Component
Actions templ.Component
}
func Modal(props ModalProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"ui-modal-backdrop\"><div class=\"ui-modal-panel\"><div class=\"ui-modal-header\"><h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(props.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/modal.templ`, Line: 13, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h2></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Body != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"ui-modal-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = props.Body.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if props.Actions != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"ui-modal-actions\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = props.Actions.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,23 @@
package ui
type TableProps struct {
Head templ.Component
Body templ.Component
}
templ Table(props TableProps) {
<div class="ui-table-shell">
<table class="ui-table">
<thead>
if props.Head != nil {
@props.Head
}
</thead>
<tbody>
if props.Body != nil {
@props.Body
}
</tbody>
</table>
</div>
}

View file

@ -0,0 +1,65 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type TableProps struct {
Head templ.Component
Body templ.Component
}
func Table(props TableProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"ui-table-shell\"><table class=\"ui-table\"><thead>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Head != nil {
templ_7745c5c3_Err = props.Head.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if props.Body != nil {
templ_7745c5c3_Err = props.Body.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,21 @@
package ui
type TextareaProps struct {
ID string
Name string
Value string
Placeholder string
Rows int
Attrs templ.Attributes
}
templ Textarea(props TextareaProps) {
<textarea
id={ inputID(props.ID, props.Name) }
name={ props.Name }
placeholder={ props.Placeholder }
rows={ textareaRows(props.Rows) }
class="ui-textarea"
{ props.Attrs... }
>{ props.Value }</textarea>
}

View file

@ -0,0 +1,122 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
type TextareaProps struct {
ID string
Name string
Value string
Placeholder string
Rows int
Attrs templ.Attributes
}
func Textarea(props TextareaProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<textarea id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(inputID(props.ID, props.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 14, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(props.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 15, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" placeholder=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.Placeholder)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 16, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" rows=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(textareaRows(props.Rows))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 17, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"ui-textarea\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, props.Attrs)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(props.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 20, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</textarea>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,8 @@
package ui
const (
TokenPrimary = "primary"
TokenDanger = "danger"
TokenWarning = "warning"
TokenInfo = "info"
)

View file

@ -0,0 +1,313 @@
package ui
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/a-h/templ"
)
func TestButtonRendersPrimaryMediumMarkup(t *testing.T) {
component := Button(ButtonProps{
Label: "Nouveau projet",
Variant: ButtonVariantPrimary,
Size: SizeMD,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`type="button"`,
`Nouveau projet`,
`ui-button`,
`ui-button-primary`,
`ui-button-md`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestIconButtonRendersBorderlessDestructiveMarkup(t *testing.T) {
component := IconButton(IconButtonProps{
Label: "Supprimer le projet",
Icon: "trash",
Variant: IconButtonVariantDangerGhost,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`type="button"`,
`aria-label="Supprimer le projet"`,
`borderless-icon-button`,
`lucide-trash2`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestBadgeRendersSemanticStatusVariant(t *testing.T) {
component := Badge(BadgeProps{
Label: "En cours",
Variant: BadgeVariantWarning,
})
html := renderToString(t, component)
for _, want := range []string{
`ui-badge`,
`ui-badge-warning`,
`En cours`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestModalRendersShellStructure(t *testing.T) {
component := Modal(ModalProps{
Title: "Nouveau projet",
Body: textComponent("Body copy"),
Actions: textComponent("Actions"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-modal-backdrop`,
`ui-modal-panel`,
`Nouveau projet`,
`Body copy`,
`Actions`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestButtonUsesSharedTokenBackedClasses(t *testing.T) {
component := Button(ButtonProps{
Label: "Create",
Variant: ButtonVariantPrimary,
Size: SizeSM,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`ui-button`,
`ui-button-primary`,
`ui-button-sm`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestSharedSemanticClassesExistInStylesheet(t *testing.T) {
cssPath := filepath.Join("..", "..", "..", "static", "styles.css")
body, err := os.ReadFile(cssPath)
if err != nil {
t.Fatalf("read stylesheet: %v", err)
}
css := string(body)
for _, want := range []string{
`.ui-button-primary`,
`.ui-button-sm`,
`.ui-badge-warning`,
`.ui-modal-panel`,
`.borderless-icon-button`,
} {
if !strings.Contains(css, want) {
t.Fatalf("expected stylesheet to contain %q", want)
}
}
}
func TestButtonRendersDangerLargeMarkup(t *testing.T) {
component := Button(ButtonProps{
Label: "Supprimer",
Variant: ButtonVariantDanger,
Size: SizeLG,
Type: "submit",
})
html := renderToString(t, component)
for _, want := range []string{
`type="submit"`,
`ui-button-danger`,
`ui-button-lg`,
`Supprimer`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestInputRendersSharedControlMarkup(t *testing.T) {
component := Input(InputProps{
Name: "name",
Value: "My project",
Placeholder: "Nom du projet",
Type: "text",
})
html := renderToString(t, component)
for _, want := range []string{
`name="name"`,
`value="My project"`,
`placeholder="Nom du projet"`,
`class="ui-input"`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestTextareaRendersSharedControlMarkup(t *testing.T) {
component := Textarea(TextareaProps{
Name: "description",
Value: "Longer copy",
Placeholder: "Description",
Rows: 4,
})
html := renderToString(t, component)
for _, want := range []string{
`name="description"`,
`placeholder="Description"`,
`rows="4"`,
`class="ui-textarea"`,
`Longer copy`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestFormFieldRendersLabelAndError(t *testing.T) {
component := FormField(FormFieldProps{
Label: "Nom",
For: "tablo-name",
Field: Input(InputProps{Name: "name", Type: "text"}),
Error: "Le nom est requis",
})
html := renderToString(t, component)
for _, want := range []string{
`ui-form-field`,
`for="tablo-name"`,
`Nom`,
`ui-form-error`,
`Le nom est requis`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestCardRendersSharedRegions(t *testing.T) {
component := Card(CardProps{
Header: textComponent("Header"),
Body: textComponent("Body"),
Footer: textComponent("Footer"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-card`,
`ui-card-header`,
`ui-card-body`,
`ui-card-footer`,
`Body`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestTableRendersSharedShell(t *testing.T) {
component := Table(TableProps{
Head: textComponent("<tr><th>Projet</th></tr>"),
Body: textComponent("<tr><td>Hello</td></tr>"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-table-shell`,
`class="ui-table"`,
`<thead>`,
`<tbody>`,
`Projet`,
`Hello`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestEmptyStateRendersTitleDescriptionAndAction(t *testing.T) {
component := EmptyState(EmptyStateProps{
Title: "Aucun projet",
Description: "Créez votre premier projet.",
Action: textComponent("Créer"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-empty-state`,
`Aucun projet`,
`Créez votre premier projet.`,
`Créer`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func renderToString(t *testing.T, component templ.Component) string {
t.Helper()
var buf bytes.Buffer
if err := component.Render(context.Background(), &buf); err != nil {
t.Fatalf("render component: %v", err)
}
return buf.String()
}
func textComponent(text string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := w.Write([]byte(text))
return err
})
}

View file

@ -0,0 +1,78 @@
package ui
type Size string
const (
SizeSM Size = "sm"
SizeMD Size = "md"
SizeLG Size = "lg"
)
type ButtonVariant string
const (
ButtonVariantPrimary ButtonVariant = "primary"
ButtonVariantSecondary ButtonVariant = "secondary"
ButtonVariantGhost ButtonVariant = "ghost"
ButtonVariantDanger ButtonVariant = "danger"
)
type IconButtonVariant string
const (
IconButtonVariantNeutral IconButtonVariant = "neutral"
IconButtonVariantDangerGhost IconButtonVariant = "danger-ghost"
)
type BadgeVariant string
const (
BadgeVariantInfo BadgeVariant = "info"
BadgeVariantWarning BadgeVariant = "warning"
BadgeVariantSuccess BadgeVariant = "success"
BadgeVariantDanger BadgeVariant = "danger"
)
func buttonClass(variant ButtonVariant, size Size) string {
return "ui-button ui-button-" + string(normalizedButtonVariant(variant)) + " ui-button-" + string(normalizedSize(size))
}
func iconButtonClass(variant IconButtonVariant) string {
switch variant {
case IconButtonVariantDangerGhost:
return "borderless-icon-button"
default:
return "ui-icon-button"
}
}
func badgeClass(variant BadgeVariant) string {
return "ui-badge ui-badge-" + string(normalizedBadgeVariant(variant))
}
func normalizedSize(size Size) Size {
switch size {
case SizeSM, SizeLG:
return size
default:
return SizeMD
}
}
func normalizedButtonVariant(variant ButtonVariant) ButtonVariant {
switch variant {
case ButtonVariantSecondary, ButtonVariantGhost, ButtonVariantDanger:
return variant
default:
return ButtonVariantPrimary
}
}
func normalizedBadgeVariant(variant BadgeVariant) BadgeVariant {
switch variant {
case BadgeVariantWarning, BadgeVariantSuccess, BadgeVariantDanger:
return variant
default:
return BadgeVariantInfo
}
}

View file

@ -1,6 +1,10 @@
package views
templ DashboardPage(activePath string, content templ.Component) {
@DashboardPageWithMainClass(activePath, "dashboard-main flex-1 overflow-auto", content)
}
templ DashboardPageWithMainClass(activePath string, mainClass string, content templ.Component) {
<!DOCTYPE html>
<html lang="fr">
<head>
@ -8,12 +12,13 @@ templ DashboardPage(activePath string, content templ.Component) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>XTablo</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
<link rel="stylesheet" href="/static/tailwind.css"/>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
<div class="dashboard-shell">
@DashboardSidebar(activePath)
@DashboardMainContent(content)
@DashboardMainContentWithClass(mainClass, content)
</div>
</body>
</html>
@ -24,13 +29,21 @@ templ DashboardNotFoundPage(displayName string, email string) {
}
templ DashboardMainContent(content templ.Component) {
<main id="app-main-content" class="dashboard-main">
@DashboardMainContentWithClass("dashboard-main flex-1 overflow-auto", content)
}
templ DashboardMainContentWithClass(mainClass string, content templ.Component) {
<main id="app-main-content" class={ mainClass }>
@content
</main>
}
templ DashboardContentSwap(activePath string, content templ.Component) {
@DashboardMainContent(content)
@DashboardContentSwapWithMainClass(activePath, "dashboard-main flex-1 overflow-auto", content)
}
templ DashboardContentSwapWithMainClass(activePath string, mainClass string, content templ.Component) {
@DashboardMainContentWithClass(mainClass, content)
@DashboardNavOOB(activePath)
}
@ -51,7 +64,8 @@ templ DashboardSidebar(activePath string) {
<div class="sidebar-primary">
<ul class="sidebar-list" role="list">
for _, item := range sidebarPrimaryNavItems(activePath) {
<li>@SidebarNavItem(item)
<li>
@SidebarNavItem(item)
</li>
if item.DividerAfter {
<li class="sidebar-divider"><hr role="separator"/></li>
@ -63,14 +77,16 @@ templ DashboardSidebar(activePath string) {
<div class="sidebar-section-label">Projets</div>
<ul class="sidebar-project-list">
for _, item := range sidebarProjectItems() {
<li>@SidebarProjectItem(item)
<li>
@SidebarProjectItem(item)
</li>
}
</ul>
</div>
<ul class="sidebar-list sidebar-footer-links" role="list">
for _, item := range sidebarFooterNavItems(activePath) {
<li>@SidebarNavItem(item)
<li>
@SidebarNavItem(item)
</li>
}
</ul>
@ -103,11 +119,11 @@ templ SidebarOrganization() {
</div>
}
templ OverviewMainContent(displayName string, email string) {
templ OverviewMainContent(displayName string, email string, tablos []TabloCardView, showAllProjects bool) {
<div class="overview-page">
@OverviewHeader(displayName)
@OverviewActions(overviewQuickActions())
@OverviewProjects(overviewProjects())
@OverviewProjectsSection(tablos, showAllProjects)
@OverviewTasks(overviewTasks())
</div>
}
@ -190,25 +206,37 @@ templ OverviewActions(actions []quickAction) {
</section>
}
templ OverviewProjects(projects []dashboardProject) {
<section class="overview-section">
templ OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) {
<section id="overview-projects-section" class="overview-section">
<div class="overview-section-heading">
<h3>Mes Projets</h3>
</div>
<div class="project-grid">
for _, project := range projects {
@ProjectCard(project)
for _, project := range visibleOverviewProjects(projects, showAllProjects) {
@TabloGridCard(project)
}
</div>
@SeeMoreProjects(hiddenOverviewProjectsCount(projects, showAllProjects))
</section>
}
templ SeeMoreProjects(hiddenCount int) {
if hiddenCount > 0 {
<div class="overview-more-row">
<button type="button" class="overview-more-button">
Voir 11 de plus
<button
type="button"
class="overview-more-button"
hx-get="/?show_projects=all"
hx-target="#overview-projects-section"
hx-swap="outerHTML"
>
Voir { hiddenCount } de plus
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
</div>
</section>
}
}
templ OverviewTasks(tasks []dashboardTask) {
@ -243,36 +271,6 @@ templ QuickActionCard(action quickAction) {
</button>
}
templ ProjectCard(project dashboardProject) {
<article class="project-card">
<div class="project-card-top">
<span class={ "project-status " + toneClass(project.StatusTone) }>{ project.Status }</span>
<button class="project-delete-button" type="button" aria-label="Supprimer le projet">
@ActionIcon("trash")
</button>
</div>
<div class="project-card-title-row">
<div class={ "project-avatar " + projectAccentClass(project.Accent) }>
<span>{ project.Initial }</span>
</div>
<h4>{ project.Title }</h4>
</div>
<div class="project-date-row">
@ActionIcon("calendar")
<span>{ project.Date }</span>
</div>
<div class="project-progress">
<div class="project-progress-label">
<span>Progression:</span>
<strong>{ progressPercentLabel(project.Progress) }</strong>
</div>
<div class="project-progress-track">
<div class={ "project-progress-bar " + projectAccentClass(project.Accent) } style={ progressInlineStyle(project.Progress) }></div>
</div>
</div>
</article>
}
templ TaskRow(task dashboardTask) {
<div class={ taskRowClass(task.Completed) }>
<button class={ taskCheckClass(task.Completed) } type="button" aria-label="Marquer la tâche">

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,11 @@ import (
"time"
"github.com/a-h/templ"
tablomodel "xtablo-backend/internal/tablos"
)
const overviewProjectsPreviewLimit = 6
func sidebarNavItemClass(active bool) string {
if active {
return "sidebar-nav-item is-active"
@ -52,16 +55,6 @@ type sidebarProjectItem struct {
Icon string
}
type dashboardProject struct {
Title string
Status string
StatusTone string
Initial string
Accent string
Date string
Progress int
}
type dashboardTask struct {
Title string
Project string
@ -101,17 +94,6 @@ func overviewQuickActions() []quickAction {
}
}
func overviewProjects() []dashboardProject {
return []dashboardProject{
{Title: "Hello", Status: "En cours", StatusTone: "warning", Initial: "H", Accent: "blue", Date: "Apr 15, 2026", Progress: 50},
{Title: "Jean Macon interet pour le produit de ta mere", Status: "En cours", StatusTone: "warning", Initial: "J", Accent: "purple", Date: "Nov 18, 2025", Progress: 50},
{Title: "bikip56648 / Arthur Belleville", Status: "En cours", StatusTone: "warning", Initial: "B", Accent: "blue", Date: "Nov 06, 2025", Progress: 50},
{Title: "lsdkfjsl / Arthur Belleville", Status: "À faire", StatusTone: "info", Initial: "L", Accent: "blue", Date: "Oct 26, 2025", Progress: 0},
{Title: "Hello / Arthur Belleville", Status: "À faire", StatusTone: "info", Initial: "H", Accent: "blue", Date: "Oct 26, 2025", Progress: 0},
{Title: "Wes Ocif / Arthur", Status: "À faire", StatusTone: "info", Initial: "W", Accent: "blue", Date: "Oct 20, 2025", Progress: 0},
}
}
func overviewTasks() []dashboardTask {
return []dashboardTask{
{Title: "yo", Project: "Hello", ProjectKey: "H", ProjectHue: "blue", Date: "Apr 16, 2026", Status: "À faire", StatusTone: "info", Completed: false},
@ -124,6 +106,41 @@ func overviewTasks() []dashboardTask {
}
}
func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView {
projects := make([]TabloCardView, 0, len(tablos))
for _, tablo := range tablos {
statusLabel, statusTone, progress := overviewProjectStatus(tablo.Status)
projects = append(projects, TabloCardView{
ID: tablo.ID.String(),
Name: tablo.Name,
Status: string(tablo.Status),
StatusLabel: statusLabel,
StatusTone: statusTone,
Initial: projectInitial(tablo.Name),
Accent: overviewProjectAccent(tablo.Name),
CardDateLabel: tablo.CreatedAt.Format("Jan 02, 2006"),
Progress: progress,
ProgressLabel: progressPercentLabel(progress),
DeleteRequestURL: "/tablos/" + tablo.ID.String(),
})
}
return projects
}
func visibleOverviewProjects(projects []TabloCardView, showAll bool) []TabloCardView {
if showAll || len(projects) <= overviewProjectsPreviewLimit {
return projects
}
return projects[:overviewProjectsPreviewLimit]
}
func hiddenOverviewProjectsCount(projects []TabloCardView, showAll bool) int {
if showAll || len(projects) <= overviewProjectsPreviewLimit {
return 0
}
return len(projects) - overviewProjectsPreviewLimit
}
func sidebarPrimaryNavItems(activePath string) []sidebarNavItem {
return []sidebarNavItem{
{Href: "/", Label: "Aperçu", Icon: "panels", Active: isActivePath(activePath, "/"), DividerAfter: true},
@ -193,3 +210,33 @@ func progressPercentLabel(progress int) string {
func progressInlineStyle(progress int) templ.SafeCSS {
return templ.SanitizeCSS("width", templ.SafeCSSProperty(progressPercentLabel(progress)))
}
func overviewProjectStatus(status tablomodel.Status) (string, string, int) {
switch status {
case tablomodel.StatusInProgress:
return "En cours", "warning", 50
case tablomodel.StatusDone:
return "Terminé", "success", 100
default:
return "À faire", "info", 0
}
}
func overviewProjectAccent(name string) string {
switch len(strings.TrimSpace(name)) % 3 {
case 1:
return "purple"
case 2:
return "red"
default:
return "blue"
}
}
func projectInitial(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "P"
}
return strings.ToUpper(name[:1])
}

View file

@ -2,6 +2,11 @@ package views
templ ActionIcon(kind string) {
switch kind {
case "plus":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
case "folder-plus":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 10v6"></path>
@ -36,6 +41,32 @@ templ ActionIcon(kind string) {
<rect width="18" height="18" x="3" y="4" rx="2"></rect>
<path d="M3 10h18"></path>
</svg>
case "grid3x3":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M3 9h18"></path>
<path d="M3 15h18"></path>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
</svg>
case "list":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12h.01"></path>
<path d="M3 18h.01"></path>
<path d="M3 6h.01"></path>
<path d="M8 12h13"></path>
<path d="M8 18h13"></path>
<path d="M8 6h13"></path>
</svg>
case "search":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
case "filter":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
case "check-circle":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>

View file

@ -30,33 +30,58 @@ func ActionIcon(kind string) templ.Component {
}
ctx = templ.ClearChildren(ctx)
switch kind {
case "plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 12h14\"></path> <path d=\"M12 5v14\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "folder-plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 10v6\"></path> <path d=\"M9 13h6\"></path> <path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 10v6\"></path> <path d=\"M9 13h6\"></path> <path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "circle-plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"M8 12h8\"></path> <path d=\"M12 8v8\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"M8 12h8\"></path> <path d=\"M12 8v8\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "user-plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"></path> <circle cx=\"9\" cy=\"7\" r=\"4\"></circle> <line x1=\"19\" x2=\"19\" y1=\"8\" y2=\"14\"></line> <line x1=\"22\" x2=\"16\" y1=\"11\" y2=\"11\"></line></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"></path> <circle cx=\"9\" cy=\"7\" r=\"4\"></circle> <line x1=\"19\" x2=\"19\" y1=\"8\" y2=\"14\"></line> <line x1=\"22\" x2=\"16\" y1=\"11\" y2=\"11\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "trash":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 6h18\"></path> <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"></path> <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"></path> <line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"></line> <line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"></line></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 6h18\"></path> <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"></path> <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"></path> <line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"></line> <line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "calendar":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M8 2v4\"></path> <path d=\"M16 2v4\"></path> <rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\"></rect> <path d=\"M3 10h18\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M8 2v4\"></path> <path d=\"M16 2v4\"></path> <rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\"></rect> <path d=\"M3 10h18\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "grid3x3":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"></rect> <path d=\"M3 9h18\"></path> <path d=\"M3 15h18\"></path> <path d=\"M9 3v18\"></path> <path d=\"M15 3v18\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "list":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12h.01\"></path> <path d=\"M3 18h.01\"></path> <path d=\"M3 6h.01\"></path> <path d=\"M8 12h13\"></path> <path d=\"M8 18h13\"></path> <path d=\"M8 6h13\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "search":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"11\" cy=\"11\" r=\"8\"></circle> <path d=\"m21 21-4.3-4.3\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "filter":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><polygon points=\"22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3\"></polygon></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "check-circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"m9 12 2 2 4-4\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"m9 12 2 2 4-4\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -93,47 +118,47 @@ func SidebarIcon(kind string) templ.Component {
ctx = templ.ClearChildren(ctx)
switch kind {
case "panels":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"></rect> <path d=\"M3 9h18\"></path> <path d=\"M9 21V9\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"></rect> <path d=\"M3 9h18\"></path> <path d=\"M9 21V9\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "tasks":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"3\" y=\"5\" width=\"6\" height=\"6\" rx=\"1\"></rect> <path d=\"m3 17 2 2 4-4\"></path> <path d=\"M13 6h8\"></path> <path d=\"M13 12h8\"></path> <path d=\"M13 18h8\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"3\" y=\"5\" width=\"6\" height=\"6\" rx=\"1\"></rect> <path d=\"m3 17 2 2 4-4\"></path> <path d=\"M13 6h8\"></path> <path d=\"M13 12h8\"></path> <path d=\"M13 18h8\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "layers":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z\"></path> <path d=\"m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65\"></path> <path d=\"m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z\"></path> <path d=\"m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65\"></path> <path d=\"m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "planning":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"></rect> <path d=\"M8 7v7\"></path> <path d=\"M12 7v4\"></path> <path d=\"M16 7v9\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"></rect> <path d=\"M8 7v7\"></path> <path d=\"M12 7v4\"></path> <path d=\"M16 7v9\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chat":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M7.9 20A9 9 0 1 0 4 16.1L2 22Z\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M7.9 20A9 9 0 1 0 4 16.1L2 22Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "files":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "send":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z\"></path> <path d=\"m21.854 2.147-10.94 10.939\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z\"></path> <path d=\"m21.854 2.147-10.94 10.939\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "gem":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M6 3h12l4 6-10 13L2 9Z\"></path> <path d=\"M11 3 8 9l4 13 4-13-3-6\"></path> <path d=\"M2 9h20\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M6 3h12l4 6-10 13L2 9Z\"></path> <path d=\"M11 3 8 9l4 13 4-13-3-6\"></path> <path d=\"M2 9h20\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z\"></path></svg>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -1,7 +1,7 @@
package views
templ AuthPage(content templ.Component) {
<!doctype html>
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8"/>
@ -15,6 +15,7 @@ templ AuthPage(content templ.Component) {
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
<title>XTablo</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
<link rel="stylesheet" href="/static/tailwind.css"/>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
@ -64,7 +65,3 @@ templ LoginPage() {
templ SignupPage() {
@AuthPage(SignupScreen())
}
templ HomePage(displayName string, email string) {
@DashboardPage("/", OverviewMainContent(displayName, email))
}

View file

@ -29,7 +29,7 @@ func AuthPage(content templ.Component) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"light\"><head><meta charset=\"UTF-8\"><link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/pwa-icons/favicon-32x32.png\"><link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/pwa-icons/favicon-16x16.png\"><link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/pwa-icons/apple-touch-icon-180x180.png\"><link rel=\"manifest\" href=\"/manifest.webmanifest\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\"><meta name=\"theme-color\" content=\"#1e1b2e\"><meta name=\"apple-mobile-web-app-capable\" content=\"yes\"><meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\"><title>XTablo</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js\"></script><link rel=\"stylesheet\" href=\"/static/styles.css\"></head><body><div id=\"root\"><section aria-label=\"Notifications alt+T\" tabindex=\"-1\" aria-live=\"polite\" aria-relevant=\"additions text\" aria-atomic=\"false\"></section><div class=\"app-shell\"><div class=\"login-screen\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"light\"><head><meta charset=\"UTF-8\"><link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/pwa-icons/favicon-32x32.png\"><link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/pwa-icons/favicon-16x16.png\"><link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/pwa-icons/apple-touch-icon-180x180.png\"><link rel=\"manifest\" href=\"/manifest.webmanifest\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\"><meta name=\"theme-color\" content=\"#1e1b2e\"><meta name=\"apple-mobile-web-app-capable\" content=\"yes\"><meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\"><title>XTablo</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js\"></script><link rel=\"stylesheet\" href=\"/static/tailwind.css\"><link rel=\"stylesheet\" href=\"/static/styles.css\"></head><body><div id=\"root\"><section aria-label=\"Notifications alt+T\" tabindex=\"-1\" aria-live=\"polite\" aria-relevant=\"additions text\" aria-atomic=\"false\"></section><div class=\"app-shell\"><div class=\"login-screen\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -111,33 +111,4 @@ func SignupPage() templ.Component {
})
}
func HomePage(displayName string, email string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = DashboardPage("/", OverviewMainContent(displayName, email)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,289 @@
package views
import "xtablo-backend/internal/web/ui"
templ TablosPageContent(vm TablosPageViewModel) {
<div class="px-4 pt-8 pb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Mes Projets</h1>
@ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "button",
Icon: "plus",
Attrs: templ.Attributes{
"hx-get": vm.CreateModalHref(),
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
"hx-push-url": "true",
},
})
</div>
<div class="flex items-center gap-6 mb-6 border-b border-[#EAECF0] dark:border-gray-700">
<a
href={ templ.SafeURL(vm.ViewHref("grid")) }
hx-get={ vm.ViewHref("grid") }
hx-target="#app-main-content"
hx-swap="outerHTML"
hx-push-url="true"
class={ gridToggleClass(vm.IsGridView()) }
>
<span class="w-5 h-5">
@ActionIcon("grid3x3")
</span>
<span class="font-medium">Vue en grille</span>
</a>
<a
href={ templ.SafeURL(vm.ViewHref("list")) }
hx-get={ vm.ViewHref("list") }
hx-target="#app-main-content"
hx-swap="outerHTML"
hx-push-url="true"
class={ listToggleClass(vm.IsGridView()) }
>
<span class="w-5 h-5">
@ActionIcon("list")
</span>
<span class="font-medium">Vue en liste</span>
</a>
</div>
<div class="flex flex-col md:flex-row gap-4 mb-6">
<form
class="relative md:w-[350px]"
hx-get={ vm.SearchHref() }
hx-target="#app-main-content"
hx-swap="outerHTML"
hx-push-url="true"
hx-trigger="input changed delay:300ms from:input[name='q']"
>
<input type="hidden" name="view" value={ vm.View }/>
<input type="hidden" name="status" value={ vm.Status }/>
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5 pointer-events-none">
@ActionIcon("search")
</span>
<input
name="q"
value={ vm.Query }
placeholder="Rechercher..."
class="w-full pl-10 pr-4 py-3 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
type="text"
/>
</form>
<div class="flex items-center gap-2 flex-wrap">
@StatusPill(vm, "all", "Tous")
@StatusPill(vm, "todo", "Pas commencé")
@StatusPill(vm, "in_progress", "En cours")
@StatusPill(vm, "done", "Terminé")
</div>
</div>
if vm.HasTablos() {
if vm.IsGridView() {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
for _, tablo := range vm.Tablos {
@TabloGridCard(tablo)
}
</div>
} else {
<div class="bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-x-auto -mx-4 sm:mx-0">
@ui.Table(ui.TableProps{
Head: TabloListHead(),
Body: TabloListBody(vm.Tablos),
})
</div>
}
} else {
@ui.EmptyState(ui.EmptyStateProps{
Title: "Aucun projet trouvé",
Description: "Créez votre premier projet",
Icon: ui.UIIcon("grid3x3"),
Action: ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "button",
Icon: "plus",
Attrs: templ.Attributes{
"hx-get": vm.CreateModalHref(),
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
"hx-push-url": "true",
},
}),
})
}
if vm.ModalOpen {
@CreateTabloModal(vm)
}
</div>
}
templ StatusPill(vm TablosPageViewModel, status string, label string) {
<a
href={ templ.SafeURL(vm.StatusHref(status)) }
hx-get={ vm.StatusHref(status) }
hx-target="#app-main-content"
hx-swap="outerHTML"
hx-push-url="true"
class={ statusPillClass(vm.Status == status) }
>
if status == "all" {
<span class="w-4 h-4">
@ActionIcon("filter")
</span>
}
{ label }
</a>
}
templ BorderlessDeleteButton(deleteRequestURL string) {
@ui.IconButton(ui.IconButtonProps{
Label: "Supprimer le projet",
Icon: "trash",
Variant: ui.IconButtonVariantDangerGhost,
Type: "button",
Attrs: templ.Attributes{
"hx-delete": deleteRequestURL,
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
"hx-confirm": "Supprimer ce projet ?",
},
})
}
templ TabloGridCard(tablo TabloCardView) {
<article class="project-card">
<div class="project-card-top">
@ui.Badge(ui.BadgeProps{
Label: tablo.StatusLabel,
Variant: badgeVariantForTone(tablo.StatusTone),
})
@BorderlessDeleteButton(tablo.DeleteRequestURL)
</div>
<div class="project-card-title-row">
<div class={ "project-avatar " + projectAccentClass(tablo.Accent) }>
<span>{ tablo.Initial }</span>
</div>
<h4>{ tablo.Name }</h4>
</div>
<div class="project-date-row">
@ActionIcon("calendar")
<span>{ tablo.CardDateLabel }</span>
</div>
<div class="project-progress">
<div class="project-progress-label">
<span>Progression:</span>
<strong>{ tablo.ProgressLabel }</strong>
</div>
<div class="project-progress-track">
<div class={ "project-progress-bar " + projectAccentClass(tablo.Accent) } style={ progressInlineStyle(tablo.Progress) }></div>
</div>
</div>
</article>
}
templ TabloListRow(tablo TabloCardView) {
<tr class="border-t border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class={ "w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden [&>svg]:w-4 [&>svg]:h-4 " + tablo.IconBgClass + " " + tablo.IconFgClass }>
@ActionIcon(tablo.IconKind)
</div>
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">{ tablo.Name }</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@ui.Badge(ui.BadgeProps{
Label: tablo.StatusLabel,
Variant: badgeVariantForTone(tablo.StatusTone),
})
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-1.5 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0">
@ActionIcon("calendar")
{ tablo.CreatedAtLabel }
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 min-w-[80px]">
<div class="bg-green-500 h-2 rounded-full transition-all" style={ progressInlineStyle(tablo.Progress) }></div>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right">{ tablo.ProgressLabel }</span>
</div>
</td>
<td class="px-6 py-4 text-right">
@BorderlessDeleteButton(tablo.DeleteRequestURL)
</td>
</tr>
}
templ CreateTabloModal(vm TablosPageViewModel) {
@ui.Modal(ui.ModalProps{
Title: "Nouveau projet",
Body: CreateTabloModalBody(vm),
})
}
templ TabloListHead() {
<tr class="bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700">
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Projet</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Statut</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Créé le</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Progression</th>
<th class="px-6 py-3 w-12"></th>
</tr>
}
templ TabloListBody(tablos []TabloCardView) {
for _, tablo := range tablos {
@TabloListRow(tablo)
}
}
templ CreateTabloModalBody(vm TablosPageViewModel) {
<form
hx-post="/tablos"
hx-target="#app-main-content"
hx-swap="outerHTML"
class="flex flex-col gap-4"
>
<input type="hidden" name="view" value={ vm.View }/>
<input type="hidden" name="status" value={ vm.Status }/>
<input type="hidden" name="q" value={ vm.Query }/>
<input type="hidden" name="modal" value="create"/>
if vm.ErrorMessage != "" {
<div class="mb-1 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{ vm.ErrorMessage }</div>
}
@ui.FormField(ui.FormFieldProps{
Label: "Nom du projet",
For: "tablo-name",
Field: ui.Input(ui.InputProps{
ID: "tablo-name",
Name: "name",
Value: vm.FormName,
Placeholder: "Nom du projet",
Type: "text",
}),
Error: vm.ErrorMessage,
})
<div class="flex items-center justify-end gap-3">
<a
href={ templ.SafeURL(vm.CloseModalHref()) }
hx-get={ vm.CloseModalHref() }
hx-target="#app-main-content"
hx-swap="outerHTML"
hx-push-url="true"
class="ui-button ui-button-secondary ui-button-md"
>
Annuler
</a>
@ui.Button(ui.ButtonProps{
Label: "Créer le projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "submit",
})
</div>
</form>
}

View file

@ -0,0 +1,992 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "xtablo-backend/internal/web/ui"
func TablosPageContent(vm TablosPageViewModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"px-4 pt-8 pb-6\"><div class=\"flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4\"><h1 class=\"text-2xl font-bold text-gray-900 dark:text-gray-100\">Mes Projets</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "button",
Icon: "plus",
Attrs: templ.Attributes{
"hx-get": vm.CreateModalHref(),
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
"hx-push-url": "true",
},
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"flex items-center gap-6 mb-6 border-b border-[#EAECF0] dark:border-gray-700\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 = []any{gridToggleClass(vm.IsGridView())}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(vm.ViewHref("grid")))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 25, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ViewHref("grid"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 26, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><span class=\"w-5 h-5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ActionIcon("grid3x3").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> <span class=\"font-medium\">Vue en grille</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 = []any{listToggleClass(vm.IsGridView())}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(vm.ViewHref("list")))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 38, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ViewHref("list"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 39, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><span class=\"w-5 h-5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ActionIcon("list").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> <span class=\"font-medium\">Vue en liste</span></a></div><div class=\"flex flex-col md:flex-row gap-4 mb-6\"><form class=\"relative md:w-[350px]\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(vm.SearchHref())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 54, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" hx-trigger=\"input changed delay:300ms from:input[name='q']\"><input type=\"hidden\" name=\"view\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(vm.View)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 60, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"> <input type=\"hidden\" name=\"status\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 61, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"> <span class=\"absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5 pointer-events-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ActionIcon("search").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> <input name=\"q\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Query)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 67, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" placeholder=\"Rechercher...\" class=\"w-full pl-10 pr-4 py-3 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400\" type=\"text\"></form><div class=\"flex items-center gap-2 flex-wrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = StatusPill(vm, "all", "Tous").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = StatusPill(vm, "todo", "Pas commencé").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = StatusPill(vm, "in_progress", "En cours").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = StatusPill(vm, "done", "Terminé").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if vm.HasTablos() {
if vm.IsGridView() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, tablo := range vm.Tablos {
templ_7745c5c3_Err = TabloGridCard(tablo).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-x-auto -mx-4 sm:mx-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ui.Table(ui.TableProps{
Head: TabloListHead(),
Body: TabloListBody(vm.Tablos),
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = ui.EmptyState(ui.EmptyStateProps{
Title: "Aucun projet trouvé",
Description: "Créez votre premier projet",
Icon: ui.UIIcon("grid3x3"),
Action: ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "button",
Icon: "plus",
Attrs: templ.Attributes{
"hx-get": vm.CreateModalHref(),
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
"hx-push-url": "true",
},
}),
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if vm.ModalOpen {
templ_7745c5c3_Err = CreateTabloModal(vm).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func StatusPill(vm TablosPageViewModel, status string, label string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
if templ_7745c5c3_Var14 == nil {
templ_7745c5c3_Var14 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var15 = []any{statusPillClass(vm.Status == status)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(vm.StatusHref(status)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 123, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(vm.StatusHref(status))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 124, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if status == "all" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"w-4 h-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ActionIcon("filter").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 135, Col: 9}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func BorderlessDeleteButton(deleteRequestURL string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
if templ_7745c5c3_Var20 == nil {
templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{
Label: "Supprimer le projet",
Icon: "trash",
Variant: ui.IconButtonVariantDangerGhost,
Type: "button",
Attrs: templ.Attributes{
"hx-delete": deleteRequestURL,
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
"hx-confirm": "Supprimer ce projet ?",
},
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func TabloGridCard(tablo TabloCardView) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
if templ_7745c5c3_Var21 == nil {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<article class=\"project-card\"><div class=\"project-card-top\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ui.Badge(ui.BadgeProps{
Label: tablo.StatusLabel,
Variant: badgeVariantForTone(tablo.StatusTone),
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BorderlessDeleteButton(tablo.DeleteRequestURL).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div class=\"project-card-title-row\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 = []any{"project-avatar " + projectAccentClass(tablo.Accent)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var22).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Initial)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 165, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span></div><h4>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 167, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</h4></div><div class=\"project-date-row\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ActionIcon("calendar").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.CardDateLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 171, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></div><div class=\"project-progress\"><div class=\"project-progress-label\"><span>Progression:</span> <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.ProgressLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 176, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</strong></div><div class=\"project-progress-track\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 = []any{"project-progress-bar " + projectAccentClass(tablo.Accent)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var28).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressInlineStyle(tablo.Progress))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 179, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\"></div></div></div></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func TabloListRow(tablo TabloCardView) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var31 := templ.GetChildren(ctx)
if templ_7745c5c3_Var31 == nil {
templ_7745c5c3_Var31 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<tr class=\"border-t border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer\"><td class=\"px-6 py-4\"><div class=\"flex items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 = []any{"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden [&>svg]:w-4 [&>svg]:h-4 " + tablo.IconBgClass + " " + tablo.IconFgClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var32).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ActionIcon(tablo.IconKind).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div><span class=\"font-medium text-gray-900 dark:text-gray-100 truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 192, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></div></td><td class=\"px-6 py-4 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ui.Badge(ui.BadgeProps{
Label: tablo.StatusLabel,
Variant: badgeVariantForTone(tablo.StatusTone),
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</td><td class=\"px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400\"><div class=\"flex items-center gap-1.5 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ActionIcon("calendar").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.CreatedAtLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 204, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></td><td class=\"px-6 py-4\"><div class=\"flex items-center gap-3\"><div class=\"flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 min-w-[80px]\"><div class=\"bg-green-500 h-2 rounded-full transition-all\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressInlineStyle(tablo.Progress))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 210, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"></div></div><span class=\"text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.ProgressLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 212, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></div></td><td class=\"px-6 py-4 text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BorderlessDeleteButton(tablo.DeleteRequestURL).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CreateTabloModal(vm TablosPageViewModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
if templ_7745c5c3_Var38 == nil {
templ_7745c5c3_Var38 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = ui.Modal(ui.ModalProps{
Title: "Nouveau projet",
Body: CreateTabloModalBody(vm),
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func TabloListHead() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
if templ_7745c5c3_Var39 == nil {
templ_7745c5c3_Var39 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<tr class=\"bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700\"><th class=\"px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider\">Projet</th><th class=\"px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider\">Statut</th><th class=\"px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider\">Créé le</th><th class=\"px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider\">Progression</th><th class=\"px-6 py-3 w-12\"></th></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func TabloListBody(tablos []TabloCardView) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var40 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
for _, tablo := range tablos {
templ_7745c5c3_Err = TabloListRow(tablo).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func CreateTabloModalBody(vm TablosPageViewModel) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var41 := templ.GetChildren(ctx)
if templ_7745c5c3_Var41 == nil {
templ_7745c5c3_Var41 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form hx-post=\"/tablos\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" class=\"flex flex-col gap-4\"><input type=\"hidden\" name=\"view\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(vm.View)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 251, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"> <input type=\"hidden\" name=\"status\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 252, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"> <input type=\"hidden\" name=\"q\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Query)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 253, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"> <input type=\"hidden\" name=\"modal\" value=\"create\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if vm.ErrorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"mb-1 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ErrorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 256, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = ui.FormField(ui.FormFieldProps{
Label: "Nom du projet",
For: "tablo-name",
Field: ui.Input(ui.InputProps{
ID: "tablo-name",
Name: "name",
Value: vm.FormName,
Placeholder: "Nom du projet",
Type: "text",
}),
Error: vm.ErrorMessage,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"flex items-center justify-end gap-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 templ.SafeURL
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(vm.CloseModalHref()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 272, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(vm.CloseModalHref())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 273, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"ui-button ui-button-secondary ui-button-md\">Annuler</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ui.Button(ui.ButtonProps{
Label: "Créer le projet",
Variant: ui.ButtonVariantPrimary,
Size: ui.SizeMD,
Type: "submit",
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,161 @@
package views
import (
"fmt"
"net/url"
"strings"
"xtablo-backend/internal/web/ui"
)
type TabloCardView struct {
ID string
Name string
Status string
StatusLabel string
StatusClass string
StatusTone string
Progress int
CreatedAtLabel string
CardDateLabel string
ProgressLabel string
DeleteURL string
DeleteRequestURL string
IconKind string
IconBgClass string
IconFgClass string
Accent string
Initial string
}
type TablosPageViewModel struct {
DisplayName string
View string
Query string
Status string
ModalOpen bool
FormName string
ErrorMessage string
Tablos []TabloCardView
}
func NewTablosPageViewModel(displayName string, view string, query string, status string, modalOpen bool, formName string, errorMessage string, tablos []TabloCardView) TablosPageViewModel {
return TablosPageViewModel{
DisplayName: displayName,
View: normalizedView(view),
Query: strings.TrimSpace(query),
Status: normalizedStatus(status),
ModalOpen: modalOpen,
FormName: strings.TrimSpace(formName),
ErrorMessage: strings.TrimSpace(errorMessage),
Tablos: tablos,
}
}
func (vm TablosPageViewModel) IsGridView() bool {
return vm.View != "list"
}
func (vm TablosPageViewModel) HasTablos() bool {
return len(vm.Tablos) > 0
}
func (vm TablosPageViewModel) StatusHref(status string) string {
values := vm.baseValues()
values.Set("status", normalizedStatus(status))
return "/tablos?" + values.Encode()
}
func (vm TablosPageViewModel) ViewHref(view string) string {
values := vm.baseValues()
values.Set("view", normalizedView(view))
return "/tablos?" + values.Encode()
}
func (vm TablosPageViewModel) SearchHref() string {
return "/tablos"
}
func (vm TablosPageViewModel) HiddenStateFields() map[string]string {
return map[string]string{
"view": vm.View,
"status": vm.Status,
"q": vm.Query,
}
}
func (vm TablosPageViewModel) SearchValues() string {
return fmt.Sprintf("view=%s&status=%s", vm.View, vm.Status)
}
func (vm TablosPageViewModel) CreateModalHref() string {
values := vm.baseValues()
values.Set("modal", "create")
return "/tablos?" + values.Encode()
}
func (vm TablosPageViewModel) CloseModalHref() string {
values := vm.baseValues()
return "/tablos?" + values.Encode()
}
func (vm TablosPageViewModel) HasSearch() bool {
return vm.Query != ""
}
func normalizedView(view string) string {
if view == "list" {
return "list"
}
return "grid"
}
func normalizedStatus(status string) string {
switch status {
case "todo", "in_progress", "done":
return status
default:
return "all"
}
}
func (vm TablosPageViewModel) baseValues() url.Values {
values := url.Values{}
values.Set("view", vm.View)
values.Set("status", vm.Status)
if vm.Query != "" {
values.Set("q", vm.Query)
}
return values
}
func gridToggleClass(active bool) string {
if active {
return "flex items-center gap-2 pb-3 border-b-2 transition-colors border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400 font-semibold"
}
return "flex items-center gap-2 pb-3 border-b-2 transition-colors border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
}
func listToggleClass(gridActive bool) string {
return gridToggleClass(!gridActive)
}
func statusPillClass(active bool) string {
if active {
return "flex items-center gap-1.5 px-4 py-2.5 border rounded-[8px] font-medium text-sm transition-colors border-purple-600 bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400"
}
return "flex items-center gap-1.5 px-4 py-2.5 border rounded-[8px] font-medium text-sm transition-colors border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
}
func badgeVariantForTone(tone string) ui.BadgeVariant {
switch tone {
case "warning":
return ui.BadgeVariantWarning
case "success":
return ui.BadgeVariantSuccess
case "danger":
return ui.BadgeVariantDanger
default:
return ui.BadgeVariantInfo
}
}

View file

@ -2,6 +2,8 @@ set shell := ["bash", "-cu"]
database_url := "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable"
compose_config_dir := ".podman-compose"
tailwind_input := "tailwind.input.css"
tailwind_output := "static/tailwind.css"
default:
@just --list
@ -40,9 +42,17 @@ db-logs: machine-up compose-config
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose logs -f postgres
generate:
pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd .
go run github.com/a-h/templ/cmd/templ@latest generate
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
design-system:
just generate
go run ./cmd/designsystem
css-watch:
pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . --watch
fmt:
gofmt -w .
@ -50,12 +60,15 @@ test:
go test ./...
build:
pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd .
go build ./...
check: generate test build
dev: db-up
pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd .
DATABASE_URL='{{database_url}}' air -c .air.toml
run: db-up
pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd .
DATABASE_URL='{{database_url}}' go run .

10
go-backend/package.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "@xtablo/go-backend",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.19.0",
"devDependencies": {
"@tailwindcss/cli": "4.1.15",
"tailwindcss": "4.1.15"
}
}

View file

@ -37,6 +37,8 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
mux.Get("/chat", authHandler.GetChatPage())
mux.Get("/files", authHandler.GetFilesPage())
mux.Get("/feedback", authHandler.GetFeedbackPage())
mux.Post("/tablos", authHandler.PostTablos())
mux.Delete("/tablos/{tabloID}", authHandler.DeleteTablo())
mux.Get("/login", authHandler.GetLoginPage())
mux.Get("/signup", authHandler.GetSignupPage())
mux.Post("/login", authHandler.PostLogin())

View file

@ -1,9 +1,11 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
@ -56,6 +58,7 @@ func TestLoginPageRenders(t *testing.T) {
"Se connecter à Xtablo",
`hx-post="/login"`,
"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js",
`href="/static/tailwind.css"`,
`href="/pwa-icons/favicon-32x32.png"`,
`href="/pwa-icons/favicon-16x16.png"`,
`href="/pwa-icons/apple-touch-icon-180x180.png"`,
@ -72,6 +75,30 @@ func TestLoginPageRenders(t *testing.T) {
}
}
func TestTailwindStylesheetIsServed(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/static/tailwind.css", nil)
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
".text-2xl",
".grid-cols-1",
".whitespace-nowrap",
".justify-end",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected tailwind.css to contain %q", want)
}
}
}
func TestSignupPageRenders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/signup", nil)
rec := httptest.NewRecorder()
@ -239,6 +266,175 @@ func TestTasksPageRendersFullDashboardPage(t *testing.T) {
}
}
func TestHomePageProjectsUseSharedTabloGridCardWithDeleteAction(t *testing.T) {
repo := handlers.NewInMemoryAuthRepository()
authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("expected demo user, got error: %v", err)
}
if _, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{
OwnerID: authUser.ID,
Name: "Hello",
Status: handlers.TabloStatusInProgress,
}); err != nil {
t.Fatalf("expected tablo creation to succeed, got error: %v", err)
}
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newRouterWithHandler(handlers.NewAuthHandler(repo))
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`class="project-card"`,
`class="project-date-row"`,
`hx-delete="/tablos/`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected home page to contain %q", want)
}
}
}
func TestHomePageProjectsCollapseAfterSixByDefault(t *testing.T) {
repo := handlers.NewInMemoryAuthRepository()
authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("expected demo user, got error: %v", err)
}
for i := 0; i < 8; i++ {
if _, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{
OwnerID: authUser.ID,
Name: "Project " + string(rune('A'+i)),
Status: handlers.TabloStatusTodo,
}); err != nil {
t.Fatalf("expected tablo creation to succeed, got error: %v", err)
}
}
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newRouterWithHandler(handlers.NewAuthHandler(repo))
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if count := strings.Count(body, `class="project-card"`); count != 6 {
t.Fatalf("expected 6 visible project cards by default, got %d", count)
}
for _, want := range []string{
`id="overview-projects-section"`,
`Voir 2 de plus`,
`hx-get="/?show_projects=all"`,
`hx-target="#overview-projects-section"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected home page to contain %q", want)
}
}
}
func TestHomePageProjectsExpandViaHTMXSectionSwap(t *testing.T) {
repo := handlers.NewInMemoryAuthRepository()
authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("expected demo user, got error: %v", err)
}
for i := 0; i < 8; i++ {
if _, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{
OwnerID: authUser.ID,
Name: "Project " + string(rune('A'+i)),
Status: handlers.TabloStatusTodo,
}); err != nil {
t.Fatalf("expected tablo creation to succeed, got error: %v", err)
}
}
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newRouterWithHandler(handlers.NewAuthHandler(repo))
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/?show_projects=all", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("HX-Target", "section#overview-projects-section")
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if count := strings.Count(body, `class="project-card"`); count != 8 {
t.Fatalf("expected 8 visible project cards after expansion, got %d", count)
}
if !strings.Contains(body, `id="overview-projects-section"`) {
t.Fatalf("expected section swap root in response, got %q", body)
}
if strings.Contains(body, `id="app-main-content"`) {
t.Fatalf("expected projects section response, got main content swap %q", body)
}
if strings.Contains(body, `Voir 2 de plus`) {
t.Fatalf("expected see more button to disappear after expansion, got %q", body)
}
if strings.Contains(body, `class="sidebar-nav-shell"`) {
t.Fatalf("expected projects section swap to avoid rerendering the full dashboard shell")
}
}
func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
@ -283,6 +479,116 @@ func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) {
}
}
func TestTablosPageRendersFullDashboardPage(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/tablos", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`class="sidebar-nav-shell"`,
`id="app-main-content" class="flex-1 overflow-auto"`,
"Mes Projets",
"Nouveau projet",
"Vue en grille",
"Rechercher...",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected tablos page to contain %q", want)
}
}
}
func TestTablosPageReturnsHTMXMainContentSwap(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/tablos?view=list&status=all", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`id="app-main-content"`,
`hx-swap-oob="outerHTML"`,
`id="sidebar-nav-tablos"`,
"Mes Projets",
"Vue en liste",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected HTMX tablos response to contain %q", want)
}
}
if strings.Contains(body, `class="sidebar-nav-shell"`) {
t.Fatalf("expected HTMX tablos response to avoid rerendering the full sidebar")
}
}
func TestTablosPageUtilityStylesExist(t *testing.T) {
content, err := os.ReadFile("static/tailwind.css")
if err != nil {
t.Fatalf("read tailwind.css: %v", err)
}
css := string(content)
for _, want := range []string{
".flex-1",
".overflow-auto",
".text-2xl",
".bg-purple-600",
".grid-cols-1",
".rounded-xl",
".md\\:flex-row",
".sm\\:grid-cols-2",
".lg\\:grid-cols-3",
".xl\\:grid-cols-4",
} {
if !strings.Contains(css, want) {
t.Fatalf("expected tailwind.css to contain utility %q", want)
}
}
}
func TestSignupCreatesUserSessionAndRedirects(t *testing.T) {
form := url.Values{}
form.Set("email", "new@xtablo.com")

View file

@ -1017,19 +1017,510 @@ input {
color: #16a34a;
}
.project-delete-button {
.ui-button {
align-items: center;
border: 0;
border-radius: 0.75rem;
cursor: pointer;
display: inline-flex;
font-weight: 600;
gap: 0.5rem;
justify-content: center;
line-height: 1;
min-height: 44px;
text-decoration: none;
transition:
background-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
opacity 0.2s ease;
}
.ui-button-icon,
.ui-button-icon svg {
height: 1rem;
width: 1rem;
}
.ui-button:focus-visible,
.ui-icon-button:focus-visible,
.borderless-icon-button:focus-visible {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
outline: none;
}
.ui-button-sm {
font-size: 0.875rem;
min-height: 40px;
padding: 0.625rem 0.9rem;
}
.ui-button-md {
font-size: 0.95rem;
padding: 0.75rem 1.1rem;
}
.ui-button-lg {
font-size: 1rem;
padding: 0.9rem 1.25rem;
}
.ui-button-primary {
background: var(--secondary);
color: #fff;
}
.ui-button-primary:hover {
background: #6d28d9;
}
.ui-button-secondary {
background: #f3f4f6;
color: #111827;
}
.ui-button-secondary:hover {
background: #e5e7eb;
}
.ui-button-ghost {
background: transparent;
color: #4b5563;
}
.ui-button-ghost:hover {
background: #f9fafb;
color: #111827;
}
.ui-button-danger {
background: #dc2626;
color: #fff;
}
.ui-button-danger:hover {
background: #b91c1c;
}
.ui-badge {
border: 1px solid transparent;
border-radius: 999px;
display: inline-flex;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
padding: 0.3rem 0.75rem;
}
.ui-badge-info {
background: #eff6ff;
border-color: #bfdbfe;
color: #2563eb;
}
.ui-badge-warning {
background: #fff4e2;
border-color: #db9729;
color: #db9729;
}
.ui-badge-success {
background: #ecfdf3;
border-color: #bbf7d0;
color: #16a34a;
}
.ui-badge-danger {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.ui-input,
.ui-textarea {
appearance: none;
background: #fff;
border: 1px solid #eaecf0;
border-radius: 0.75rem;
color: #111827;
font: inherit;
line-height: 1.4;
width: 100%;
}
.ui-input {
min-height: 44px;
padding: 0.75rem 0.95rem;
}
.ui-textarea {
min-height: 7rem;
padding: 0.85rem 0.95rem;
resize: vertical;
}
.ui-input::placeholder,
.ui-textarea::placeholder {
color: #9ca3af;
}
.ui-input:focus,
.ui-textarea:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
outline: none;
}
.ui-form-field {
display: grid;
gap: 0.5rem;
}
.ui-form-label {
color: #111827;
font-size: 0.95rem;
font-weight: 600;
}
.ui-form-hint {
color: #6b7280;
font-size: 0.875rem;
margin: 0;
}
.ui-form-error {
color: #dc2626;
font-size: 0.875rem;
margin: 0;
}
.ui-card {
background: #fff;
border: 1px solid #eaecf0;
border-radius: 1rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
}
.ui-card-header,
.ui-card-body,
.ui-card-footer {
padding: 1.25rem 1.5rem;
}
.ui-card-header,
.ui-card-footer {
border-color: #eaecf0;
}
.ui-card-header {
border-bottom-style: solid;
border-bottom-width: 1px;
}
.ui-card-footer {
border-top-style: solid;
border-top-width: 1px;
}
.ui-table-shell {
overflow-x: auto;
width: 100%;
}
.ui-table {
border-collapse: collapse;
min-width: 100%;
width: 100%;
}
.ui-empty-state {
align-items: center;
border: 1px dashed #d0d5dd;
border-radius: 1rem;
color: #6b7280;
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
padding: 3rem 1.5rem;
text-align: center;
}
.ui-empty-state-title {
color: #111827;
font-size: 1.125rem;
font-weight: 700;
margin: 0;
}
.ui-empty-state-icon {
align-items: center;
background: #f3f4f6;
border-radius: 999px;
color: #9ca3af;
display: inline-flex;
height: 4rem;
justify-content: center;
width: 4rem;
}
.ui-empty-state-icon svg {
height: 2rem;
width: 2rem;
}
.ui-empty-state-description {
margin: 0;
max-width: 32rem;
}
.catalog-page {
margin: 0 auto;
max-width: 72rem;
padding: 3rem 1.5rem 4rem;
}
.catalog-nav {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
margin-bottom: 1.5rem;
}
.catalog-home-link,
.catalog-nav-link {
border-radius: 999px;
color: #6b7280;
display: inline-flex;
font-size: 0.9rem;
font-weight: 600;
padding: 0.55rem 0.9rem;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.catalog-home-link:hover,
.catalog-nav-link:hover {
background: #f3f4f6;
color: #111827;
}
.catalog-nav-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.catalog-nav-link.is-active {
background: #ede9fe;
color: #6d28d9;
}
.catalog-page-header {
margin-bottom: 2rem;
}
.catalog-page-header h1 {
color: #111827;
font-size: 2.25rem;
line-height: 1.1;
margin: 0 0 0.75rem;
}
.catalog-page-header p {
color: #6b7280;
margin: 0;
max-width: 42rem;
}
.catalog-eyebrow {
color: #7c3aed !important;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
margin-bottom: 0.75rem !important;
text-transform: uppercase;
}
.catalog-example-list,
.catalog-page-list {
display: grid;
gap: 1.25rem;
}
.catalog-example,
.catalog-page-link-card {
background: #fff;
border: 1px solid #eaecf0;
border-radius: 1rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
padding: 1.5rem;
}
.catalog-page-link-card {
display: block;
}
.catalog-example-copy h2,
.catalog-page-link-card h2 {
color: #111827;
font-size: 1.125rem;
margin: 0 0 0.5rem;
}
.catalog-example-copy p,
.catalog-page-link-card p {
color: #6b7280;
margin: 0;
}
.catalog-example-preview {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
.catalog-inline {
display: inline-flex;
}
.catalog-example-snippet {
background: #111827;
border-radius: 0.875rem;
color: #f9fafb;
margin: 1rem 0 0;
overflow-x: auto;
padding: 1rem;
}
.catalog-example-snippet code {
font-family:
ui-monospace,
SFMono-Regular,
"SF Mono",
Menlo,
Monaco,
Consolas,
"Liberation Mono",
monospace;
font-size: 0.875rem;
}
.catalog-page-link {
color: #7c3aed !important;
font-family:
ui-monospace,
SFMono-Regular,
"SF Mono",
Menlo,
Monaco,
Consolas,
"Liberation Mono",
monospace;
font-size: 0.875rem;
margin-top: 1rem !important;
}
.ui-icon-button {
align-items: center;
appearance: none;
background: transparent;
border: 0;
border-radius: 0.5rem;
color: #6b7280;
cursor: pointer;
display: inline-flex;
justify-content: center;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.ui-icon-button:hover {
background: #f9fafb;
color: #111827;
}
.ui-modal-backdrop {
align-items: center;
background: rgba(17, 24, 39, 0.52);
display: flex;
inset: 0;
justify-content: center;
padding: 1rem;
position: fixed;
z-index: 40;
}
.ui-modal-panel {
background: #fff;
border: 1px solid #eaecf0;
border-radius: 1rem;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
max-width: 32rem;
width: min(100%, 32rem);
}
.ui-modal-header,
.ui-modal-body,
.ui-modal-actions {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.ui-modal-header {
border-bottom: 1px solid #eaecf0;
padding-bottom: 1rem;
padding-top: 1.25rem;
}
.ui-modal-header h2 {
color: #111827;
font-size: 1.125rem;
font-weight: 700;
margin: 0;
}
.ui-modal-body {
padding-bottom: 1.25rem;
padding-top: 1.25rem;
}
.ui-modal-actions {
border-top: 1px solid #eaecf0;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding-bottom: 1rem;
padding-top: 1rem;
}
.borderless-icon-button {
background: transparent;
border: 0;
box-shadow: none;
appearance: none;
color: #9ca3af;
cursor: pointer;
outline: none;
}
.project-card-top .borderless-icon-button {
padding: 0;
}
.project-delete-button:hover {
.project-card-top .borderless-icon-button:hover {
color: #ef4444;
}
.project-delete-button svg,
.borderless-icon-button svg,
.project-date-row svg,
.overview-more-button svg,
.tasks-add-button svg,
@ -1038,6 +1529,22 @@ input {
width: 1rem;
}
td.text-right .borderless-icon-button {
align-items: center;
border-radius: 0.25rem;
color: #9ca3af;
display: inline-flex;
justify-content: center;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
transition: color 0.2s;
}
td.text-right .borderless-icon-button:hover {
color: #ef4444;
}
.project-card-title-row {
align-items: center;
display: flex;

View file

@ -0,0 +1,833 @@
/*! tailwindcss v4.1.15 | MIT License | https://tailwindcss.com */
@layer properties;
:root, :host {
--color-red-50: oklch(97.1% 0.013 17.38);
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-700: oklch(50.5% 0.213 27.518);
--color-green-50: oklch(98.2% 0.018 155.826);
--color-green-200: oklch(92.5% 0.084 155.995);
--color-green-400: oklch(79.2% 0.209 151.711);
--color-green-500: oklch(72.3% 0.219 149.579);
--color-green-600: oklch(62.7% 0.194 149.214);
--color-green-800: oklch(44.8% 0.119 151.328);
--color-green-950: oklch(26.6% 0.065 152.934);
--color-cyan-500: oklch(71.5% 0.143 215.221);
--color-blue-50: oklch(97% 0.014 254.604);
--color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-800: oklch(42.4% 0.199 265.638);
--color-blue-950: oklch(28.2% 0.091 267.935);
--color-purple-50: oklch(97.7% 0.014 308.299);
--color-purple-400: oklch(71.4% 0.203 305.504);
--color-purple-500: oklch(62.7% 0.265 303.9);
--color-purple-600: oklch(55.8% 0.288 302.321);
--color-purple-950: oklch(29.1% 0.149 302.717);
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-300: oklch(87.2% 0.01 258.338);
--color-gray-400: oklch(70.7% 0.022 261.325);
--color-gray-500: oklch(55.1% 0.027 264.364);
--color-gray-600: oklch(44.6% 0.03 256.802);
--color-gray-700: oklch(37.3% 0.034 259.733);
--color-gray-800: oklch(27.8% 0.033 256.848);
--color-gray-900: oklch(21% 0.034 264.665);
--color-white: #fff;
--spacing: 0.25rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5);
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--tracking-wider: 0.05em;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.pointer-events-none {
pointer-events: none;
}
.visible {
visibility: visible;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.static {
position: static;
}
.top-1\/2 {
top: calc(1/2 * 100%);
}
.left-3 {
left: calc(var(--spacing) * 3);
}
.isolate {
isolation: isolate;
}
.-mx-4 {
margin-inline: calc(var(--spacing) * -4);
}
.mb-1 {
margin-bottom: calc(var(--spacing) * 1);
}
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.hidden {
display: none;
}
.inline {
display: inline;
}
.table {
display: table;
}
.size-10 {
width: calc(var(--spacing) * 10);
height: calc(var(--spacing) * 10);
}
.size-11 {
width: calc(var(--spacing) * 11);
height: calc(var(--spacing) * 11);
}
.size-12 {
width: calc(var(--spacing) * 12);
height: calc(var(--spacing) * 12);
}
.size-13 {
width: calc(var(--spacing) * 13);
height: calc(var(--spacing) * 13);
}
.size-14 {
width: calc(var(--spacing) * 14);
height: calc(var(--spacing) * 14);
}
.size-15 {
width: calc(var(--spacing) * 15);
height: calc(var(--spacing) * 15);
}
.size-16 {
width: calc(var(--spacing) * 16);
height: calc(var(--spacing) * 16);
}
.size-18 {
width: calc(var(--spacing) * 18);
height: calc(var(--spacing) * 18);
}
.size-20 {
width: calc(var(--spacing) * 20);
height: calc(var(--spacing) * 20);
}
.h-2 {
height: calc(var(--spacing) * 2);
}
.h-4 {
height: calc(var(--spacing) * 4);
}
.h-5 {
height: calc(var(--spacing) * 5);
}
.h-8 {
height: calc(var(--spacing) * 8);
}
.w-4 {
width: calc(var(--spacing) * 4);
}
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-8 {
width: calc(var(--spacing) * 8);
}
.w-12 {
width: calc(var(--spacing) * 12);
}
.w-full {
width: 100%;
}
.min-w-\[80px\] {
min-width: 80px;
}
.flex-1 {
flex: 1;
}
.shrink-0 {
flex-shrink: 0;
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.cursor-pointer {
cursor: pointer;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.gap-3 {
gap: calc(var(--spacing) * 3);
}
.gap-4 {
gap: calc(var(--spacing) * 4);
}
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.rounded-\[8px\] {
border-radius: 8px;
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-xl {
border-radius: var(--radius-xl);
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-t {
border-top-style: var(--tw-border-style);
border-top-width: 1px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-b-2 {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 2px;
}
.border-\[\#DB9729\] {
border-color: #DB9729;
}
.border-\[\#EAECF0\] {
border-color: #EAECF0;
}
.border-blue-200 {
border-color: var(--color-blue-200);
}
.border-green-200 {
border-color: var(--color-green-200);
}
.border-purple-600 {
border-color: var(--color-purple-600);
}
.border-red-200 {
border-color: var(--color-red-200);
}
.border-transparent {
border-color: transparent;
}
.bg-\[\#FFF4E2\] {
background-color: #FFF4E2;
}
.bg-blue-50 {
background-color: var(--color-blue-50);
}
.bg-blue-500 {
background-color: var(--color-blue-500);
}
.bg-cyan-500 {
background-color: var(--color-cyan-500);
}
.bg-gray-50 {
background-color: var(--color-gray-50);
}
.bg-gray-200 {
background-color: var(--color-gray-200);
}
.bg-green-50 {
background-color: var(--color-green-50);
}
.bg-green-500 {
background-color: var(--color-green-500);
}
.bg-purple-50 {
background-color: var(--color-purple-50);
}
.bg-purple-500 {
background-color: var(--color-purple-500);
}
.bg-purple-600 {
background-color: var(--color-purple-600);
}
.bg-red-50 {
background-color: var(--color-red-50);
}
.bg-white {
background-color: var(--color-white);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-2\.5 {
padding-block: calc(var(--spacing) * 2.5);
}
.py-3 {
padding-block: calc(var(--spacing) * 3);
}
.py-4 {
padding-block: calc(var(--spacing) * 4);
}
.pt-8 {
padding-top: calc(var(--spacing) * 8);
}
.pr-4 {
padding-right: calc(var(--spacing) * 4);
}
.pb-3 {
padding-bottom: calc(var(--spacing) * 3);
}
.pb-6 {
padding-bottom: calc(var(--spacing) * 6);
}
.pl-10 {
padding-left: calc(var(--spacing) * 10);
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-2xl {
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.text-xs {
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
.font-bold {
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.font-semibold {
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
}
.tracking-wider {
--tw-tracking: var(--tracking-wider);
letter-spacing: var(--tracking-wider);
}
.whitespace-nowrap {
white-space: nowrap;
}
.text-\[\#DB9729\] {
color: #DB9729;
}
.text-blue-600 {
color: var(--color-blue-600);
}
.text-gray-400 {
color: var(--color-gray-400);
}
.text-gray-500 {
color: var(--color-gray-500);
}
.text-gray-600 {
color: var(--color-gray-600);
}
.text-gray-700 {
color: var(--color-gray-700);
}
.text-gray-900 {
color: var(--color-gray-900);
}
.text-green-600 {
color: var(--color-green-600);
}
.text-purple-600 {
color: var(--color-purple-600);
}
.text-red-700 {
color: var(--color-red-700);
}
.text-white {
color: var(--color-white);
}
.uppercase {
text-transform: uppercase;
}
.placeholder-gray-400 {
&::placeholder {
color: var(--color-gray-400);
}
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.transition-all {
transition-property: all;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-colors {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.hover\:bg-gray-50 {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-50);
}
}
}
.hover\:text-gray-700 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-700);
}
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus\:ring-purple-500 {
&:focus {
--tw-ring-color: var(--color-purple-500);
}
}
.focus\:outline-none {
&:focus {
--tw-outline-style: none;
outline-style: none;
}
}
.sm\:mx-0 {
@media (width >= 40rem) {
margin-inline: calc(var(--spacing) * 0);
}
}
.sm\:grid-cols-2 {
@media (width >= 40rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sm\:gap-6 {
@media (width >= 40rem) {
gap: calc(var(--spacing) * 6);
}
}
.md\:w-\[350px\] {
@media (width >= 48rem) {
width: 350px;
}
}
.md\:flex-row {
@media (width >= 48rem) {
flex-direction: row;
}
}
.md\:items-center {
@media (width >= 48rem) {
align-items: center;
}
}
.md\:justify-between {
@media (width >= 48rem) {
justify-content: space-between;
}
}
.lg\:grid-cols-3 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.xl\:grid-cols-4 {
@media (width >= 80rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.dark\:border-blue-800 {
&:is(.dark *) {
border-color: var(--color-blue-800);
}
}
.dark\:border-gray-700 {
&:is(.dark *) {
border-color: var(--color-gray-700);
}
}
.dark\:border-green-800 {
&:is(.dark *) {
border-color: var(--color-green-800);
}
}
.dark\:border-purple-400 {
&:is(.dark *) {
border-color: var(--color-purple-400);
}
}
.dark\:bg-blue-950\/30 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(28.2% 0.091 267.935) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
& {
background-color: color-mix(in oklab, var(--color-blue-950) 30%, transparent);
}
}
}
}
.dark\:bg-gray-700 {
&:is(.dark *) {
background-color: var(--color-gray-700);
}
}
.dark\:bg-gray-800 {
&:is(.dark *) {
background-color: var(--color-gray-800);
}
}
.dark\:bg-gray-800\/80 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 80%, transparent);
@supports (color: color-mix(in lab, red, red)) {
& {
background-color: color-mix(in oklab, var(--color-gray-800) 80%, transparent);
}
}
}
}
.dark\:bg-green-950\/30 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(26.6% 0.065 152.934) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
& {
background-color: color-mix(in oklab, var(--color-green-950) 30%, transparent);
}
}
}
}
.dark\:bg-purple-950\/30 {
&:is(.dark *) {
background-color: color-mix(in srgb, oklch(29.1% 0.149 302.717) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
& {
background-color: color-mix(in oklab, var(--color-purple-950) 30%, transparent);
}
}
}
}
.dark\:text-blue-400 {
&:is(.dark *) {
color: var(--color-blue-400);
}
}
.dark\:text-gray-100 {
&:is(.dark *) {
color: var(--color-gray-100);
}
}
.dark\:text-gray-300 {
&:is(.dark *) {
color: var(--color-gray-300);
}
}
.dark\:text-gray-400 {
&:is(.dark *) {
color: var(--color-gray-400);
}
}
.dark\:text-green-400 {
&:is(.dark *) {
color: var(--color-green-400);
}
}
.dark\:text-purple-400 {
&:is(.dark *) {
color: var(--color-purple-400);
}
}
.dark\:hover\:bg-gray-800 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-800);
}
}
}
}
.dark\:hover\:text-gray-200 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-gray-200);
}
}
}
}
.\[\&\>svg\]\:h-4 {
&>svg {
height: calc(var(--spacing) * 4);
}
}
.\[\&\>svg\]\:w-4 {
&>svg {
width: calc(var(--spacing) * 4);
}
}
.\[\&\>svg\]\:shrink-0 {
&>svg {
flex-shrink: 0;
}
}
@property --tw-translate-x {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-y {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-z {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-tracking {
syntax: "*";
inherits: false;
}
@property --tw-blur {
syntax: "*";
inherits: false;
}
@property --tw-brightness {
syntax: "*";
inherits: false;
}
@property --tw-contrast {
syntax: "*";
inherits: false;
}
@property --tw-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-invert {
syntax: "*";
inherits: false;
}
@property --tw-opacity {
syntax: "*";
inherits: false;
}
@property --tw-saturate {
syntax: "*";
inherits: false;
}
@property --tw-sepia {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-drop-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-drop-shadow-size {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
--tw-tracking: initial;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
}
}
}

View file

@ -0,0 +1,28 @@
@import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
@custom-variant dark (&:is(.dark *));
@theme {
--color-surface: #ffffff;
--color-surface-muted: #f9fafb;
--color-text-strong: #111827;
--color-text-muted: #6b7280;
--color-border-subtle: #eaecf0;
--color-primary: #7c3aed;
--color-primary-strong: #6d28d9;
--color-danger: #dc2626;
--color-danger-strong: #b91c1c;
--color-warning-bg: #fff4e2;
--color-warning-fg: #db9729;
--color-warning-border: #db9729;
--color-info-bg: #eff6ff;
--color-info-fg: #2563eb;
--color-info-border: #bfdbfe;
--color-success-bg: #ecfdf3;
--color-success-fg: #16a34a;
--color-success-border: #bbf7d0;
}
@source "./internal/web/views/**/*.templ";
@source "./internal/web/ui/**/*.templ";

View file

@ -720,6 +720,15 @@ importers:
specifier: ^4.24.3
version: 4.44.0(@cloudflare/workers-types@4.20260411.1)
go-backend:
devDependencies:
'@tailwindcss/cli':
specifier: 4.1.15
version: 4.1.15
tailwindcss:
specifier: 4.1.15
version: 4.1.15
packages/auth-ui:
dependencies:
'@xtablo/shared':

View file

@ -1,4 +1,4 @@
packages:
- 'apps/*'
- 'go-backend'
- 'packages/*'