From 4ac33c77b9c2784104c3e2f1dd4fb1412cbff05f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 9 May 2026 20:18:24 +0200 Subject: [PATCH] feat: add go-backend design system and tablos UI --- docs/design-system/badges.html | 13 + docs/design-system/buttons.html | 23 + docs/design-system/cards.html | 17 + docs/design-system/empty-states.html | 18 + docs/design-system/form-fields.html | 23 + docs/design-system/icon-buttons.html | 18 + docs/design-system/index.html | 13 + docs/design-system/inputs.html | 23 + docs/design-system/modals.html | 17 + docs/design-system/tables.html | 16 + docs/design-system/tokens.html | 13 + ...2026-05-08-go-backend-tablos-index-crud.md | 624 +++++++++++ .../2026-05-09-go-backend-design-system.md | 457 ++++++++ go-backend/cmd/designsystem/main.go | 82 ++ go-backend/cmd/designsystem/main_test.go | 34 + go-backend/internal/db/queries.sql | 38 + go-backend/internal/db/repository.go | 79 ++ go-backend/internal/db/schema.sql | 14 + go-backend/internal/db/sqlc/models.go | 10 + go-backend/internal/db/sqlc/querier.go | 3 + go-backend/internal/db/sqlc/queries.sql.go | 115 ++ go-backend/internal/tablos/model.go | 40 + go-backend/internal/web/handlers/auth.go | 57 +- .../web/handlers/in_memory_auth_repository.go | 2 + go-backend/internal/web/handlers/tablos.go | 369 +++++++ .../internal/web/handlers/tablos_test.go | 632 +++++++++++ go-backend/internal/web/ui/badge.templ | 10 + go-backend/internal/web/ui/badge_templ.go | 76 ++ go-backend/internal/web/ui/button.templ | 21 + go-backend/internal/web/ui/button_templ.go | 115 ++ go-backend/internal/web/ui/card.templ | 27 + go-backend/internal/web/ui/card_templ.go | 92 ++ .../internal/web/ui/catalog/catalog.templ | 61 ++ .../internal/web/ui/catalog/catalog_templ.go | 288 +++++ .../internal/web/ui/catalog/catalog_test.go | 174 +++ .../internal/web/ui/catalog/examples.go | 326 ++++++ go-backend/internal/web/ui/catalog/pages.go | 99 ++ go-backend/internal/web/ui/empty_state.templ | 27 + .../internal/web/ui/empty_state_templ.go | 115 ++ go-backend/internal/web/ui/form_field.templ | 26 + .../internal/web/ui/form_field_templ.go | 128 +++ go-backend/internal/web/ui/helpers.go | 31 + go-backend/internal/web/ui/icon_button.templ | 68 ++ .../internal/web/ui/icon_button_templ.go | 188 ++++ go-backend/internal/web/ui/input.templ | 22 + go-backend/internal/web/ui/input_templ.go | 122 +++ go-backend/internal/web/ui/modal.templ | 27 + go-backend/internal/web/ui/modal_templ.go | 91 ++ go-backend/internal/web/ui/table.templ | 23 + go-backend/internal/web/ui/table_templ.go | 65 ++ go-backend/internal/web/ui/textarea.templ | 21 + go-backend/internal/web/ui/textarea_templ.go | 122 +++ go-backend/internal/web/ui/tokens.go | 8 + go-backend/internal/web/ui/ui_test.go | 313 ++++++ go-backend/internal/web/ui/variants.go | 78 ++ .../web/views/dashboard_components.templ | 94 +- .../web/views/dashboard_components_templ.go | 960 ++++++++--------- go-backend/internal/web/views/home.go | 89 +- go-backend/internal/web/views/icons.templ | 31 + go-backend/internal/web/views/icons_templ.go | 55 +- go-backend/internal/web/views/pages.templ | 7 +- go-backend/internal/web/views/pages_templ.go | 31 +- go-backend/internal/web/views/tablos.templ | 289 +++++ go-backend/internal/web/views/tablos_templ.go | 992 ++++++++++++++++++ go-backend/internal/web/views/tablos_view.go | 161 +++ go-backend/justfile | 13 + go-backend/package.json | 10 + go-backend/router.go | 2 + go-backend/router_test.go | 306 ++++++ go-backend/static/styles.css | 513 ++++++++- go-backend/static/tailwind.css | 833 +++++++++++++++ go-backend/tailwind.input.css | 28 + pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 2 +- 74 files changed, 9314 insertions(+), 625 deletions(-) create mode 100644 docs/design-system/badges.html create mode 100644 docs/design-system/buttons.html create mode 100644 docs/design-system/cards.html create mode 100644 docs/design-system/empty-states.html create mode 100644 docs/design-system/form-fields.html create mode 100644 docs/design-system/icon-buttons.html create mode 100644 docs/design-system/index.html create mode 100644 docs/design-system/inputs.html create mode 100644 docs/design-system/modals.html create mode 100644 docs/design-system/tables.html create mode 100644 docs/design-system/tokens.html create mode 100644 docs/superpowers/plans/2026-05-08-go-backend-tablos-index-crud.md create mode 100644 docs/superpowers/plans/2026-05-09-go-backend-design-system.md create mode 100644 go-backend/cmd/designsystem/main.go create mode 100644 go-backend/cmd/designsystem/main_test.go create mode 100644 go-backend/internal/tablos/model.go create mode 100644 go-backend/internal/web/handlers/tablos.go create mode 100644 go-backend/internal/web/handlers/tablos_test.go create mode 100644 go-backend/internal/web/ui/badge.templ create mode 100644 go-backend/internal/web/ui/badge_templ.go create mode 100644 go-backend/internal/web/ui/button.templ create mode 100644 go-backend/internal/web/ui/button_templ.go create mode 100644 go-backend/internal/web/ui/card.templ create mode 100644 go-backend/internal/web/ui/card_templ.go create mode 100644 go-backend/internal/web/ui/catalog/catalog.templ create mode 100644 go-backend/internal/web/ui/catalog/catalog_templ.go create mode 100644 go-backend/internal/web/ui/catalog/catalog_test.go create mode 100644 go-backend/internal/web/ui/catalog/examples.go create mode 100644 go-backend/internal/web/ui/catalog/pages.go create mode 100644 go-backend/internal/web/ui/empty_state.templ create mode 100644 go-backend/internal/web/ui/empty_state_templ.go create mode 100644 go-backend/internal/web/ui/form_field.templ create mode 100644 go-backend/internal/web/ui/form_field_templ.go create mode 100644 go-backend/internal/web/ui/helpers.go create mode 100644 go-backend/internal/web/ui/icon_button.templ create mode 100644 go-backend/internal/web/ui/icon_button_templ.go create mode 100644 go-backend/internal/web/ui/input.templ create mode 100644 go-backend/internal/web/ui/input_templ.go create mode 100644 go-backend/internal/web/ui/modal.templ create mode 100644 go-backend/internal/web/ui/modal_templ.go create mode 100644 go-backend/internal/web/ui/table.templ create mode 100644 go-backend/internal/web/ui/table_templ.go create mode 100644 go-backend/internal/web/ui/textarea.templ create mode 100644 go-backend/internal/web/ui/textarea_templ.go create mode 100644 go-backend/internal/web/ui/tokens.go create mode 100644 go-backend/internal/web/ui/ui_test.go create mode 100644 go-backend/internal/web/ui/variants.go create mode 100644 go-backend/internal/web/views/tablos.templ create mode 100644 go-backend/internal/web/views/tablos_templ.go create mode 100644 go-backend/internal/web/views/tablos_view.go create mode 100644 go-backend/package.json create mode 100644 go-backend/static/tailwind.css create mode 100644 go-backend/tailwind.input.css diff --git a/docs/design-system/badges.html b/docs/design-system/badges.html new file mode 100644 index 0000000..8a9e0de --- /dev/null +++ b/docs/design-system/badges.html @@ -0,0 +1,13 @@ + + + + + + Badges + + + + +

Design System

Badges

Semantic status labels for todo, in-progress, success, and destructive states.

Status set

The four semantic badge tones used across the app.

À faire
En cours
Terminé
Erreur
@ui.Badge(ui.BadgeProps{Label: "En cours", Variant: ui.BadgeVariantWarning})
+ + diff --git a/docs/design-system/buttons.html b/docs/design-system/buttons.html new file mode 100644 index 0000000..8160a1a --- /dev/null +++ b/docs/design-system/buttons.html @@ -0,0 +1,23 @@ + + + + + + Buttons + + + + +

Design System

Buttons

Primary, secondary, ghost, and destructive actions built from shared templ primitives.

Primary action

Used for the main action in a page section or modal footer.

@ui.Button(ui.ButtonProps{
+	Label:   "Nouveau projet",
+	Variant: ui.ButtonVariantPrimary,
+	Size:    ui.SizeMD,
+	Type:    "button",
+})

Danger action

Used for irreversible actions after explicit confirmation.

@ui.Button(ui.ButtonProps{
+	Label:   "Supprimer",
+	Variant: ui.ButtonVariantDanger,
+	Size:    ui.SizeLG,
+	Type:    "submit",
+})
+ + diff --git a/docs/design-system/cards.html b/docs/design-system/cards.html new file mode 100644 index 0000000..386336d --- /dev/null +++ b/docs/design-system/cards.html @@ -0,0 +1,17 @@ + + + + + + Cards + + + + +

Design System

Cards

Reusable bordered surfaces with optional header, body, and footer regions.

Surface card

Generic elevated surface with optional header and footer.

Header
Body
@ui.Card(ui.CardProps{
+	Header: textComponent("Header"),
+	Body:   textComponent("Body"),
+	Footer: textComponent("Footer"),
+})
+ + diff --git a/docs/design-system/empty-states.html b/docs/design-system/empty-states.html new file mode 100644 index 0000000..d8cb259 --- /dev/null +++ b/docs/design-system/empty-states.html @@ -0,0 +1,18 @@ + + + + + + Empty States + + + + +

Design System

Empty States

Centered fallback messaging with optional icon and action.

Centered empty state

Used when a list has no rows yet and the next action should stay obvious.

Aucun projet trouvé

Créez votre premier projet

@ui.EmptyState(ui.EmptyStateProps{
+	Title:       "Aucun projet trouvé",
+	Description: "Créez votre premier projet",
+	Icon:        ui.UIIcon("grid3x3"),
+	Action:      ui.Button(...),
+})
+ + diff --git a/docs/design-system/form-fields.html b/docs/design-system/form-fields.html new file mode 100644 index 0000000..a397c96 --- /dev/null +++ b/docs/design-system/form-fields.html @@ -0,0 +1,23 @@ + + + + + + Form Fields + + + + +

Design System

Form Fields

Labeled controls with optional hint and error messaging.

Field with validation

Wraps a control with label and inline error feedback.

Le nom est requis

@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",
+})
+ + diff --git a/docs/design-system/icon-buttons.html b/docs/design-system/icon-buttons.html new file mode 100644 index 0000000..bb555d2 --- /dev/null +++ b/docs/design-system/icon-buttons.html @@ -0,0 +1,18 @@ + + + + + + Icon Buttons + + + + +

Design System

Icon Buttons

Compact icon-only actions for destructive and neutral controls.

Borderless destructive action

Used for delete controls inside project cards and list rows.

@ui.IconButton(ui.IconButtonProps{
+	Label:   "Supprimer le projet",
+	Icon:    "trash",
+	Variant: ui.IconButtonVariantDangerGhost,
+	Type:    "button",
+})
+ + diff --git a/docs/design-system/index.html b/docs/design-system/index.html new file mode 100644 index 0000000..fc9632a --- /dev/null +++ b/docs/design-system/index.html @@ -0,0 +1,13 @@ + + + + + + Design System + + + + +

Design System

Component Catalog

Static documentation generated from the same templ primitives used by the Go application.

+ + diff --git a/docs/design-system/inputs.html b/docs/design-system/inputs.html new file mode 100644 index 0000000..a1dc5c3 --- /dev/null +++ b/docs/design-system/inputs.html @@ -0,0 +1,23 @@ + + + + + + Inputs + + + + +

Design System

Inputs

Shared single-line and multiline text controls.

Text input

Single-line input for names, titles, and short labels.

@ui.Input(ui.InputProps{
+	Name:        "name",
+	Value:       "Projet Atlas",
+	Placeholder: "Nom du projet",
+	Type:        "text",
+})

Textarea

Multiline field for longer project notes and descriptions.

@ui.Textarea(ui.TextareaProps{
+	Name:        "description",
+	Value:       "Une description de projet plus détaillée.",
+	Placeholder: "Description",
+	Rows:        4,
+})
+ + diff --git a/docs/design-system/modals.html b/docs/design-system/modals.html new file mode 100644 index 0000000..91f74cd --- /dev/null +++ b/docs/design-system/modals.html @@ -0,0 +1,17 @@ + + + + + + Modals + + + + +

Design System

Modals

Shared modal shell for focused create, edit, and confirm flows.

Create modal

Shared modal shell with a form body and action footer.

Créer un projet

@ui.Modal(ui.ModalProps{
+	Title: "Créer un projet",
+	Body: ui.FormField(...),
+	Actions: ui.Button(...),
+})
+ + diff --git a/docs/design-system/tables.html b/docs/design-system/tables.html new file mode 100644 index 0000000..8d6f797 --- /dev/null +++ b/docs/design-system/tables.html @@ -0,0 +1,16 @@ + + + + + + Tables + + + + +

Design System

Tables

Shared table shell for server-rendered list views.

List shell

Shared wrapper for server-rendered resource tables.

ProjetStatut
Table ViewEn cours
@ui.Table(ui.TableProps{
+	Head: TabloListHead(),
+	Body: TabloListBody(tablos),
+})
+ + diff --git a/docs/design-system/tokens.html b/docs/design-system/tokens.html new file mode 100644 index 0000000..02e7b03 --- /dev/null +++ b/docs/design-system/tokens.html @@ -0,0 +1,13 @@ + + + + + + Tokens + + + + +

Design System

Tokens

Semantic colors and status roles used by the Go design system.

Status tones

Shared semantic badges for info, warning, success, and danger states.

À faire
En cours
Terminé
Erreur
@ui.Badge(ui.BadgeProps{Label: "En cours", Variant: ui.BadgeVariantWarning})
+ + diff --git a/docs/superpowers/plans/2026-05-08-go-backend-tablos-index-crud.md b/docs/superpowers/plans/2026-05-08-go-backend-tablos-index-crud.md new file mode 100644 index 0000000..d0a5cc6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-go-backend-tablos-index-crud.md @@ -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 +
+
+ ... +
+
+``` + +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? diff --git a/docs/superpowers/plans/2026-05-09-go-backend-design-system.md b/docs/superpowers/plans/2026-05-09-go-backend-design-system.md new file mode 100644 index 0000000..3670149 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-go-backend-design-system.md @@ -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" +``` diff --git a/go-backend/cmd/designsystem/main.go b/go-backend/cmd/designsystem/main.go new file mode 100644 index 0000000..c7d83d6 --- /dev/null +++ b/go-backend/cmd/designsystem/main.go @@ -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(` + + + + + %s + + + + +%s + + +`, template.HTMLEscapeString(title), body) +} diff --git a/go-backend/cmd/designsystem/main_test.go b/go-backend/cmd/designsystem/main_test.go new file mode 100644 index 0000000..ca0cef4 --- /dev/null +++ b/go-backend/cmd/designsystem/main_test.go @@ -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) + } + } +} diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql index c272095..b10df7c 100644 --- a/go-backend/internal/db/queries.sql +++ b/go-backend/internal/db/queries.sql @@ -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; diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go index 8402262..2e7b32c 100644 --- a/go-backend/internal/db/repository.go +++ b/go-backend/internal/db/repository.go @@ -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 +} diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql index af9cbfa..9370d34 100644 --- a/go-backend/internal/db/schema.sql +++ b/go-backend/internal/db/schema.sql @@ -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 diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go index 953ca89..3d23e7d 100644 --- a/go-backend/internal/db/sqlc/models.go +++ b/go-backend/internal/db/sqlc/models.go @@ -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"` diff --git a/go-backend/internal/db/sqlc/querier.go b/go-backend/internal/db/sqlc/querier.go index 0eb3521..1024771 100644 --- a/go-backend/internal/db/sqlc/querier.go +++ b/go-backend/internal/db/sqlc/querier.go @@ -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) diff --git a/go-backend/internal/db/sqlc/queries.sql.go b/go-backend/internal/db/sqlc/queries.sql.go index c10df4f..9fa94aa 100644 --- a/go-backend/internal/db/sqlc/queries.sql.go +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -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 +} diff --git a/go-backend/internal/tablos/model.go b/go-backend/internal/tablos/model.go new file mode 100644 index 0000000..db60831 --- /dev/null +++ b/go-backend/internal/tablos/model.go @@ -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 +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go index 2b3f79b..a73c553 100644 --- a/go-backend/internal/web/handlers/auth.go +++ b/go-backend/internal/web/handlers/auth.go @@ -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") +} diff --git a/go-backend/internal/web/handlers/in_memory_auth_repository.go b/go-backend/internal/web/handlers/in_memory_auth_repository.go index c9d1d17..94a8e7b 100644 --- a/go-backend/internal/web/handlers/in_memory_auth_repository.go +++ b/go-backend/internal/web/handlers/in_memory_auth_repository.go @@ -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") diff --git a/go-backend/internal/web/handlers/tablos.go b/go-backend/internal/web/handlers/tablos.go new file mode 100644 index 0000000..9688630 --- /dev/null +++ b/go-backend/internal/web/handlers/tablos.go @@ -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])) +} diff --git a/go-backend/internal/web/handlers/tablos_test.go b/go-backend/internal/web/handlers/tablos_test.go new file mode 100644 index 0000000..d520392 --- /dev/null +++ b/go-backend/internal/web/handlers/tablos_test.go @@ -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"`, + ``, + "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">`, + `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 +} diff --git a/go-backend/internal/web/ui/badge.templ b/go-backend/internal/web/ui/badge.templ new file mode 100644 index 0000000..08ff572 --- /dev/null +++ b/go-backend/internal/web/ui/badge.templ @@ -0,0 +1,10 @@ +package ui + +type BadgeProps struct { + Label string + Variant BadgeVariant +} + +templ Badge(props BadgeProps) { + { props.Label } +} diff --git a/go-backend/internal/web/ui/badge_templ.go b/go-backend/internal/web/ui/badge_templ.go new file mode 100644 index 0000000..d9b8261 --- /dev/null +++ b/go-backend/internal/web/ui/badge_templ.go @@ -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, "") + 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/button.templ b/go-backend/internal/web/ui/button.templ new file mode 100644 index 0000000..90d7fb6 --- /dev/null +++ b/go-backend/internal/web/ui/button.templ @@ -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) { + +} diff --git a/go-backend/internal/web/ui/button_templ.go b/go-backend/internal/web/ui/button_templ.go new file mode 100644 index 0000000..39757e3 --- /dev/null +++ b/go-backend/internal/web/ui/button_templ.go @@ -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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/card.templ b/go-backend/internal/web/ui/card.templ new file mode 100644 index 0000000..c896cae --- /dev/null +++ b/go-backend/internal/web/ui/card.templ @@ -0,0 +1,27 @@ +package ui + +type CardProps struct { + Header templ.Component + Body templ.Component + Footer templ.Component +} + +templ Card(props CardProps) { +
+ if props.Header != nil { +
+ @props.Header +
+ } + if props.Body != nil { +
+ @props.Body +
+ } + if props.Footer != nil { + + } +
+} diff --git a/go-backend/internal/web/ui/card_templ.go b/go-backend/internal/web/ui/card_templ.go new file mode 100644 index 0000000..e6ed134 --- /dev/null +++ b/go-backend/internal/web/ui/card_templ.go @@ -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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.Header != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if props.Body != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if props.Footer != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/catalog/catalog.templ b/go-backend/internal/web/ui/catalog/catalog.templ new file mode 100644 index 0000000..7dfa3cb --- /dev/null +++ b/go-backend/internal/web/ui/catalog/catalog.templ @@ -0,0 +1,61 @@ +package catalog + +templ CatalogPage(page Page) { +
+ +
+

Design System

+

{ page.Title }

+

{ page.Description }

+
+
+ for _, example := range page.Examples { +
+
+

{ example.Title }

+ if example.Description != "" { +

{ example.Description }

+ } +
+
+ @example.Preview +
+ if example.Snippet != "" { +
{ example.Snippet }
+ } +
+ } +
+
+} + +templ CatalogIndex(pages []Page) { +
+
+

Design System

+

Component Catalog

+

Static documentation generated from the same templ primitives used by the Go application.

+
+ +
+} diff --git a/go-backend/internal/web/ui/catalog/catalog_templ.go b/go-backend/internal/web/ui/catalog/catalog_templ.go new file mode 100644 index 0000000..7fc8085 --- /dev/null +++ b/go-backend/internal/web/ui/catalog/catalog_templ.go @@ -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, "

Design System

") + 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, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, example := range page.Examples { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if example.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if example.Snippet != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
")
+				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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + 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, "

Design System

Component Catalog

Static documentation generated from the same templ primitives used by the Go application.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, page := range pages { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + 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, "

") + 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, "

/") + 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

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/catalog/catalog_test.go b/go-backend/internal/web/ui/catalog/catalog_test.go new file mode 100644 index 0000000..63819e1 --- /dev/null +++ b/go-backend/internal/web/ui/catalog/catalog_test.go @@ -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() +} diff --git a/go-backend/internal/web/ui/catalog/examples.go b/go-backend/internal/web/ui/catalog/examples.go new file mode 100644 index 0000000..0c41b87 --- /dev/null +++ b/go-backend/internal/web/ui/catalog/examples.go @@ -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, `
`); err != nil { + return err + } + if err := component.Render(ctx, w); err != nil { + return err + } + if _, err := io.WriteString(w, `
`); 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(``), + Body: textComponent(``), + }), + 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, `
`); err != nil { + return err + } + if err := component.Render(ctx, w); err != nil { + return err + } + if _, err := io.WriteString(w, `
`); 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 +} diff --git a/go-backend/internal/web/ui/catalog/pages.go b/go-backend/internal/web/ui/catalog/pages.go new file mode 100644 index 0000000..bce8cf7 --- /dev/null +++ b/go-backend/internal/web/ui/catalog/pages.go @@ -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" +} diff --git a/go-backend/internal/web/ui/empty_state.templ b/go-backend/internal/web/ui/empty_state.templ new file mode 100644 index 0000000..22a975d --- /dev/null +++ b/go-backend/internal/web/ui/empty_state.templ @@ -0,0 +1,27 @@ +package ui + +type EmptyStateProps struct { + Title string + Description string + Icon templ.Component + Action templ.Component +} + +templ EmptyState(props EmptyStateProps) { +
+ if props.Icon != nil { +
+ @props.Icon +
+ } +

{ props.Title }

+ if props.Description != "" { +

{ props.Description }

+ } + if props.Action != nil { +
+ @props.Action +
+ } +
+} diff --git a/go-backend/internal/web/ui/empty_state_templ.go b/go-backend/internal/web/ui/empty_state_templ.go new file mode 100644 index 0000000..51f310f --- /dev/null +++ b/go-backend/internal/web/ui/empty_state_templ.go @@ -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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.Icon != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + 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, "
") + 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_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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if props.Action != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/form_field.templ b/go-backend/internal/web/ui/form_field.templ new file mode 100644 index 0000000..01f2efb --- /dev/null +++ b/go-backend/internal/web/ui/form_field.templ @@ -0,0 +1,26 @@ +package ui + +type FormFieldProps struct { + Label string + For string + Field templ.Component + Error string + Hint string +} + +templ FormField(props FormFieldProps) { +
+ if props.Label != "" { + + } + if props.Field != nil { + @props.Field + } + if props.Hint != "" { +

{ props.Hint }

+ } + if props.Error != "" { +

{ props.Error }

+ } +
+} diff --git a/go-backend/internal/web/ui/form_field_templ.go b/go-backend/internal/web/ui/form_field_templ.go new file mode 100644 index 0000000..8abf32d --- /dev/null +++ b/go-backend/internal/web/ui/form_field_templ.go @@ -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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.Label != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " ") + 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, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if props.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/helpers.go b/go-backend/internal/web/ui/helpers.go new file mode 100644 index 0000000..37c13c7 --- /dev/null +++ b/go-backend/internal/web/ui/helpers.go @@ -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) +} diff --git a/go-backend/internal/web/ui/icon_button.templ b/go-backend/internal/web/ui/icon_button.templ new file mode 100644 index 0000000..1e1c510 --- /dev/null +++ b/go-backend/internal/web/ui/icon_button.templ @@ -0,0 +1,68 @@ +package ui + +type IconButtonProps struct { + Label string + Icon string + Variant IconButtonVariant + Type string + Attrs templ.Attributes +} + +templ IconButton(props IconButtonProps) { + +} + +templ UIIcon(kind string) { + switch kind { + case "plus": + + case "grid3x3": + + case "list": + + case "filter": + + case "search": + + case "calendar": + + case "trash": + + default: + + } +} diff --git a/go-backend/internal/web/ui/icon_button_templ.go b/go-backend/internal/web/ui/icon_button_templ.go new file mode 100644 index 0000000..ee69b69 --- /dev/null +++ b/go-backend/internal/web/ui/icon_button_templ.go @@ -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, "") + 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, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "grid3x3": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "list": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "filter": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "search": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "calendar": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "trash": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + default: + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/input.templ b/go-backend/internal/web/ui/input.templ new file mode 100644 index 0000000..b8b879b --- /dev/null +++ b/go-backend/internal/web/ui/input.templ @@ -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) { + +} diff --git a/go-backend/internal/web/ui/input_templ.go b/go-backend/internal/web/ui/input_templ.go new file mode 100644 index 0000000..b6d057e --- /dev/null +++ b/go-backend/internal/web/ui/input_templ.go @@ -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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/modal.templ b/go-backend/internal/web/ui/modal.templ new file mode 100644 index 0000000..f3ac5d4 --- /dev/null +++ b/go-backend/internal/web/ui/modal.templ @@ -0,0 +1,27 @@ +package ui + +type ModalProps struct { + Title string + Body templ.Component + Actions templ.Component +} + +templ Modal(props ModalProps) { +
+
+
+

{ props.Title }

+
+ if props.Body != nil { +
+ @props.Body +
+ } + if props.Actions != nil { +
+ @props.Actions +
+ } +
+
+} diff --git a/go-backend/internal/web/ui/modal_templ.go b/go-backend/internal/web/ui/modal_templ.go new file mode 100644 index 0000000..231e5b2 --- /dev/null +++ b/go-backend/internal/web/ui/modal_templ.go @@ -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, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.Body != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if props.Actions != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + 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, "
") + 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 diff --git a/go-backend/internal/web/ui/table.templ b/go-backend/internal/web/ui/table.templ new file mode 100644 index 0000000..dead420 --- /dev/null +++ b/go-backend/internal/web/ui/table.templ @@ -0,0 +1,23 @@ +package ui + +type TableProps struct { + Head templ.Component + Body templ.Component +} + +templ Table(props TableProps) { +
+
ProjetStatut
Table ViewEn cours
+ + if props.Head != nil { + @props.Head + } + + + if props.Body != nil { + @props.Body + } + +
+ +} diff --git a/go-backend/internal/web/ui/table_templ.go b/go-backend/internal/web/ui/table_templ.go new file mode 100644 index 0000000..c26c359 --- /dev/null +++ b/go-backend/internal/web/ui/table_templ.go @@ -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, "
") + 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, "") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/textarea.templ b/go-backend/internal/web/ui/textarea.templ new file mode 100644 index 0000000..acffaf9 --- /dev/null +++ b/go-backend/internal/web/ui/textarea.templ @@ -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) { + +} diff --git a/go-backend/internal/web/ui/textarea_templ.go b/go-backend/internal/web/ui/textarea_templ.go new file mode 100644 index 0000000..a454eed --- /dev/null +++ b/go-backend/internal/web/ui/textarea_templ.go @@ -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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/tokens.go b/go-backend/internal/web/ui/tokens.go new file mode 100644 index 0000000..12f3e69 --- /dev/null +++ b/go-backend/internal/web/ui/tokens.go @@ -0,0 +1,8 @@ +package ui + +const ( + TokenPrimary = "primary" + TokenDanger = "danger" + TokenWarning = "warning" + TokenInfo = "info" +) diff --git a/go-backend/internal/web/ui/ui_test.go b/go-backend/internal/web/ui/ui_test.go new file mode 100644 index 0000000..0df3a25 --- /dev/null +++ b/go-backend/internal/web/ui/ui_test.go @@ -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("Projet"), + Body: textComponent("Hello"), + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-table-shell`, + `class="ui-table"`, + ``, + ``, + `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 + }) +} diff --git a/go-backend/internal/web/ui/variants.go b/go-backend/internal/web/ui/variants.go new file mode 100644 index 0000000..4e738b5 --- /dev/null +++ b/go-backend/internal/web/ui/variants.go @@ -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 + } +} diff --git a/go-backend/internal/web/views/dashboard_components.templ b/go-backend/internal/web/views/dashboard_components.templ index eb464dd..e52efbc 100644 --- a/go-backend/internal/web/views/dashboard_components.templ +++ b/go-backend/internal/web/views/dashboard_components.templ @@ -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) { @@ -8,12 +12,13 @@ templ DashboardPage(activePath string, content templ.Component) { XTablo +
@DashboardSidebar(activePath) - @DashboardMainContent(content) + @DashboardMainContentWithClass(mainClass, content)
@@ -24,13 +29,21 @@ templ DashboardNotFoundPage(displayName string, email string) { } templ DashboardMainContent(content templ.Component) { -
+ @DashboardMainContentWithClass("dashboard-main flex-1 overflow-auto", content) +} + +templ DashboardMainContentWithClass(mainClass string, content templ.Component) { +
@content
} 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,8 +64,9 @@ templ DashboardSidebar(activePath string) { @@ -103,11 +119,11 @@ templ SidebarOrganization() { } -templ OverviewMainContent(displayName string, email string) { +templ OverviewMainContent(displayName string, email string, tablos []TabloCardView, showAllProjects bool) {
@OverviewHeader(displayName) @OverviewActions(overviewQuickActions()) - @OverviewProjects(overviewProjects()) + @OverviewProjectsSection(tablos, showAllProjects) @OverviewTasks(overviewTasks())
} @@ -190,25 +206,37 @@ templ OverviewActions(actions []quickAction) { } -templ OverviewProjects(projects []dashboardProject) { -
+templ OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) { +

Mes Projets

- for _, project := range projects { - @ProjectCard(project) + for _, project := range visibleOverviewProjects(projects, showAllProjects) { + @TabloGridCard(project) }
+ @SeeMoreProjects(hiddenOverviewProjectsCount(projects, showAllProjects)) +
+} + +templ SeeMoreProjects(hiddenCount int) { + if hiddenCount > 0 {
-
-
+ } } templ OverviewTasks(tasks []dashboardTask) { @@ -243,36 +271,6 @@ templ QuickActionCard(action quickAction) { } -templ ProjectCard(project dashboardProject) { -
-
- { project.Status } - -
-
-
- { project.Initial } -
-

{ project.Title }

-
-
- @ActionIcon("calendar") - { project.Date } -
-
-
- Progression: - { progressPercentLabel(project.Progress) } -
-
-
-
-
-
-} - templ TaskRow(task dashboardTask) {
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -237,7 +342,7 @@ func DashboardSidebar(activePath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -261,9 +366,9 @@ func DashboardNavOOB(activePath string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var6 := templ.GetChildren(ctx) - if templ_7745c5c3_Var6 == nil { - templ_7745c5c3_Var6 = templ.NopComponent + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, item := range sidebarPrimaryNavItems(activePath) { @@ -298,12 +403,12 @@ func SidebarOrganization() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -311,7 +416,7 @@ func SidebarOrganization() templ.Component { }) } -func OverviewMainContent(displayName string, email string) templ.Component { +func OverviewMainContent(displayName string, email string, tablos []TabloCardView, showAllProjects bool) 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 { @@ -327,12 +432,12 @@ func OverviewMainContent(displayName string, email string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -344,7 +449,7 @@ func OverviewMainContent(displayName string, email string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = OverviewProjects(overviewProjects()).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = OverviewProjectsSection(tablos, showAllProjects).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -352,7 +457,7 @@ func OverviewMainContent(displayName string, email string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -376,9 +481,9 @@ func TasksMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = AppSectionMainContent("Tâches", "Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.").Render(ctx, templ_7745c5c3_Buffer) @@ -405,9 +510,9 @@ func TablosMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = AppSectionMainContent("Projets", "Gardez une vue claire sur vos tablos, leur état d'avancement et les prochaines décisions à prendre.").Render(ctx, templ_7745c5c3_Buffer) @@ -434,9 +539,9 @@ func PlanningMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = AppSectionMainContent("Planning", "Visualisez le rythme de l'équipe, les jalons à venir et les arbitrages de charge.").Render(ctx, templ_7745c5c3_Buffer) @@ -463,9 +568,9 @@ func ChatMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent + templ_7745c5c3_Var17 := templ.GetChildren(ctx) + if templ_7745c5c3_Var17 == nil { + templ_7745c5c3_Var17 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = AppSectionMainContent("Discussions", "Retrouvez les conversations importantes, les décisions récentes et les échanges à relancer.").Render(ctx, templ_7745c5c3_Buffer) @@ -492,9 +597,9 @@ func FilesMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var13 := templ.GetChildren(ctx) - if templ_7745c5c3_Var13 == nil { - templ_7745c5c3_Var13 = templ.NopComponent + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = AppSectionMainContent("Fichiers", "Centralisez les documents utiles, les pièces partagées et les ressources de travail.").Render(ctx, templ_7745c5c3_Buffer) @@ -521,9 +626,9 @@ func FeedbackMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent + templ_7745c5c3_Var19 := templ.GetChildren(ctx) + if templ_7745c5c3_Var19 == nil { + templ_7745c5c3_Var19 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = AppSectionMainContent("Feedback", "Collectez les retours produit, priorisez les signaux et transformez-les en actions concrètes.").Render(ctx, templ_7745c5c3_Buffer) @@ -550,38 +655,38 @@ func AppSectionMainContent(title string, description string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var15 := templ.GetChildren(ctx) - if templ_7745c5c3_Var15 == nil { - templ_7745c5c3_Var15 = templ.NopComponent + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Espace de travail

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
Espace de travail

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(title) + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 143, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 159, Col: 14} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(description) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 144, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 160, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -605,25 +710,25 @@ func NotFoundContent(displayName string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = templ.NopComponent + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
Erreur de navigation
404

Page introuvable

Cette page n'existe pas ou n'est plus disponible.

Retour à l'aperçu
Connecté en tant que ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
Erreur de navigation
404

Page introuvable

Cette page n'existe pas ou n'est plus disponible.

Retour à l'aperçu
Connecté en tant que ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(dashboardGreetingName(displayName)) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(dashboardGreetingName(displayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 164, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 180, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, 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, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -647,38 +752,38 @@ func OverviewHeader(displayName string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var20 := templ.GetChildren(ctx) - if templ_7745c5c3_Var20 == nil { - templ_7745c5c3_Var20 = templ.NopComponent + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(dashboardTodayLabel()) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(dashboardTodayLabel()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 172, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 188, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, 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, 26, "

Bonjour, ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Bonjour, ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(dashboardGreetingName(displayName)) + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(dashboardGreetingName(displayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 174, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 190, Col: 84} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, 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, 27, "!

Founder

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "!
Founder
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -702,12 +807,12 @@ func OverviewActions(actions []quickAction) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var23 := templ.GetChildren(ctx) - if templ_7745c5c3_Var23 == nil { - templ_7745c5c3_Var23 = templ.NopComponent + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -717,7 +822,7 @@ func OverviewActions(actions []quickAction) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -725,7 +830,7 @@ func OverviewActions(actions []quickAction) templ.Component { }) } -func OverviewProjects(projects []dashboardProject) templ.Component { +func OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) 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 { @@ -741,25 +846,77 @@ func OverviewProjects(projects []dashboardProject) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var24 := templ.GetChildren(ctx) - if templ_7745c5c3_Var24 == nil { - templ_7745c5c3_Var24 = templ.NopComponent + templ_7745c5c3_Var29 := templ.GetChildren(ctx) + if templ_7745c5c3_Var29 == nil { + templ_7745c5c3_Var29 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Mes Projets

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

Mes Projets

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, project := range projects { - templ_7745c5c3_Err = ProjectCard(project).Render(ctx, templ_7745c5c3_Buffer) + for _, project := range visibleOverviewProjects(projects, showAllProjects) { + templ_7745c5c3_Err = TabloGridCard(project).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = SeeMoreProjects(hiddenOverviewProjectsCount(projects, showAllProjects)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SeeMoreProjects(hiddenCount int) 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_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if hiddenCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } return nil }) } @@ -780,12 +937,12 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

Mes Tâches

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Mes Tâches

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -795,7 +952,7 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -819,12 +976,12 @@ func QuickActionCard(action quickAction) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var26 := templ.GetChildren(ctx) - if templ_7745c5c3_Var26 == nil { - templ_7745c5c3_Var26 = templ.NopComponent + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func ProjectCard(project dashboardProject) 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_Var29 := templ.GetChildren(ctx) - if templ_7745c5c3_Var29 == nil { - templ_7745c5c3_Var29 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var30 = []any{"project-status " + toneClass(project.StatusTone)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(project.Status) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 249, Col: 85} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var33 = []any{"project-avatar " + projectAccentClass(project.Accent)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var33).String()) + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(action.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 268, Col: 49} } _, 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, 44, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(project.Initial) + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(action.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 256, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 269, 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, 45, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(project.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 258, Col: 22} - } - _, 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, 46, "

") - 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, 47, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(project.Date) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 262, Col: 23} - } - _, 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, 48, "
Progression: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(progressPercentLabel(project.Progress)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 267, Col: 52} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var39 = []any{"project-progress-bar " + projectAccentClass(project.Accent)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1071,52 +1039,52 @@ func TaskRow(task dashboardTask) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var42 := templ.GetChildren(ctx) - if templ_7745c5c3_Var42 == nil { - templ_7745c5c3_Var42 = templ.NopComponent + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var43 = []any{taskRowClass(task.Completed)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var43...) + var templ_7745c5c3_Var37 = []any{taskRowClass(task.Completed)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var45 = []any{taskCheckClass(task.Completed)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var45...) + var templ_7745c5c3_Var39 = []any{taskCheckClass(task.Completed)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(task.Title) + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(task.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 284, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 282, Col: 18} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var48 = []any{"task-project-badge " + projectAccentClass(task.ProjectHue)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var48...) + var templ_7745c5c3_Var42 = []any{"task-project-badge " + projectAccentClass(task.ProjectHue)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(task.ProjectKey) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 285, Col: 28} + } + _, 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, 50, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(task.Project) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 287, Col: 50} + } + _, 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, 51, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(task.Date) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 288, Col: 39} + } + _, 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, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 = []any{"task-status " + toneClass(task.StatusTone)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var48).String()) + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(task.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 291, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(task.ProjectKey) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 287, Col: 28} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(task.Project) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 289, Col: 50} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(task.Date) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 290, Col: 39} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var53 = []any{"task-status " + toneClass(task.StatusTone)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var53...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(task.Status) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 293, Col: 75} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1259,69 +1227,69 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var56 := templ.GetChildren(ctx) - if templ_7745c5c3_Var56 == nil { - templ_7745c5c3_Var56 = templ.NopComponent + templ_7745c5c3_Var50 := templ.GetChildren(ctx) + if templ_7745c5c3_Var50 == nil { + templ_7745c5c3_Var50 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var57 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var57...) + var templ_7745c5c3_Var51 = []any{sidebarNavItemClass(item.Active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1366,69 +1334,69 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var63 := templ.GetChildren(ctx) - if templ_7745c5c3_Var63 == nil { - templ_7745c5c3_Var63 = templ.NopComponent + templ_7745c5c3_Var57 := templ.GetChildren(ctx) + if templ_7745c5c3_Var57 == nil { + templ_7745c5c3_Var57 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var64 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var64...) + var templ_7745c5c3_Var58 = []any{sidebarNavItemClass(item.Active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var58...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1473,25 +1441,25 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var70 := templ.GetChildren(ctx) - if templ_7745c5c3_Var70 == nil { - templ_7745c5c3_Var70 = templ.NopComponent + templ_7745c5c3_Var64 := templ.GetChildren(ctx) + if templ_7745c5c3_Var64 == nil { + templ_7745c5c3_Var64 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1499,20 +1467,20 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 328, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 326, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/views/home.go b/go-backend/internal/web/views/home.go index e05f6ca..cd6983f 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -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]) +} diff --git a/go-backend/internal/web/views/icons.templ b/go-backend/internal/web/views/icons.templ index d7e4a44..19a2373 100644 --- a/go-backend/internal/web/views/icons.templ +++ b/go-backend/internal/web/views/icons.templ @@ -2,6 +2,11 @@ package views templ ActionIcon(kind string) { switch kind { + case "plus": + case "folder-plus": + case "grid3x3": + + case "list": + + case "search": + + case "filter": + case "check-circle": @@ -15,6 +15,7 @@ templ AuthPage(content templ.Component) { XTablo + @@ -64,7 +65,3 @@ templ LoginPage() { templ SignupPage() { @AuthPage(SignupScreen()) } - -templ HomePage(displayName string, email string) { - @DashboardPage("/", OverviewMainContent(displayName, email)) -} diff --git a/go-backend/internal/web/views/pages_templ.go b/go-backend/internal/web/views/pages_templ.go index de124ea..8c301a9 100644 --- a/go-backend/internal/web/views/pages_templ.go +++ b/go-backend/internal/web/views/pages_templ.go @@ -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, "XTablo
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "XTablo
") 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 diff --git a/go-backend/internal/web/views/tablos.templ b/go-backend/internal/web/views/tablos.templ new file mode 100644 index 0000000..13d6c32 --- /dev/null +++ b/go-backend/internal/web/views/tablos.templ @@ -0,0 +1,289 @@ +package views + +import "xtablo-backend/internal/web/ui" + +templ TablosPageContent(vm TablosPageViewModel) { +
+
+

Mes Projets

+ @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", + }, + }) +
+ +
+
+ + + + @ActionIcon("search") + + +
+
+ @StatusPill(vm, "all", "Tous") + @StatusPill(vm, "todo", "Pas commencé") + @StatusPill(vm, "in_progress", "En cours") + @StatusPill(vm, "done", "Terminé") +
+
+ if vm.HasTablos() { + if vm.IsGridView() { +
+ for _, tablo := range vm.Tablos { + @TabloGridCard(tablo) + } +
+ } else { +
+ @ui.Table(ui.TableProps{ + Head: TabloListHead(), + Body: TabloListBody(vm.Tablos), + }) +
+ } + } 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) + } +
+} + +templ StatusPill(vm TablosPageViewModel, status string, label string) { + + if status == "all" { + + @ActionIcon("filter") + + } + { label } + +} + +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) { +
+
+ @ui.Badge(ui.BadgeProps{ + Label: tablo.StatusLabel, + Variant: badgeVariantForTone(tablo.StatusTone), + }) + @BorderlessDeleteButton(tablo.DeleteRequestURL) +
+
+
+ { tablo.Initial } +
+

{ tablo.Name }

+
+
+ @ActionIcon("calendar") + { tablo.CardDateLabel } +
+
+
+ Progression: + { tablo.ProgressLabel } +
+
+
+
+
+
+} + +templ TabloListRow(tablo TabloCardView) { + + +
+
svg]:w-4 [&>svg]:h-4 " + tablo.IconBgClass + " " + tablo.IconFgClass }> + @ActionIcon(tablo.IconKind) +
+ { tablo.Name } +
+ + + @ui.Badge(ui.BadgeProps{ + Label: tablo.StatusLabel, + Variant: badgeVariantForTone(tablo.StatusTone), + }) + + +
+ @ActionIcon("calendar") + { tablo.CreatedAtLabel } +
+ + +
+
+
+
+ { tablo.ProgressLabel } +
+ + + @BorderlessDeleteButton(tablo.DeleteRequestURL) + + +} + +templ CreateTabloModal(vm TablosPageViewModel) { + @ui.Modal(ui.ModalProps{ + Title: "Nouveau projet", + Body: CreateTabloModalBody(vm), + }) +} + +templ TabloListHead() { + + Projet + Statut + Créé le + Progression + + +} + +templ TabloListBody(tablos []TabloCardView) { + for _, tablo := range tablos { + @TabloListRow(tablo) + } +} + +templ CreateTabloModalBody(vm TablosPageViewModel) { +
+ + + + + if vm.ErrorMessage != "" { +
{ vm.ErrorMessage }
+ } + @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, + }) +
+ + Annuler + + @ui.Button(ui.ButtonProps{ + Label: "Créer le projet", + Variant: ui.ButtonVariantPrimary, + Size: ui.SizeMD, + Type: "submit", + }) +
+
+} diff --git a/go-backend/internal/web/views/tablos_templ.go b/go-backend/internal/web/views/tablos_templ.go new file mode 100644 index 0000000..f88b800 --- /dev/null +++ b/go-backend/internal/web/views/tablos_templ.go @@ -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, "

Mes Projets

") + 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, "
") + 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, "") + 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, " Vue en grille ") + 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, "") + 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, " Vue en liste
") + 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, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.HasTablos() { + if vm.IsGridView() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + 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, "
") + 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, "
") + 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if status == "all" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + 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, " ") + 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, "") + 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, "
") + 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, "
") + 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, "
") + 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, "

") + 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, "

") + 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, "") + 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, "
Progression: ") + 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, "
") + 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, "
") + 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, "
") + 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, "
") + 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, "
") + 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, "
") + 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, "
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, "
") + 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, "
") + 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, "") + 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, "ProjetStatutCréé leProgression") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.ErrorMessage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") + 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, "
") + 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, "
Annuler") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/views/tablos_view.go b/go-backend/internal/web/views/tablos_view.go new file mode 100644 index 0000000..111dce8 --- /dev/null +++ b/go-backend/internal/web/views/tablos_view.go @@ -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 + } +} diff --git a/go-backend/justfile b/go-backend/justfile index 97bc6ea..2c0c7ec 100644 --- a/go-backend/justfile +++ b/go-backend/justfile @@ -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 . diff --git a/go-backend/package.json b/go-backend/package.json new file mode 100644 index 0000000..bab9545 --- /dev/null +++ b/go-backend/package.json @@ -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" + } +} diff --git a/go-backend/router.go b/go-backend/router.go index 999b974..95c5d10 100644 --- a/go-backend/router.go +++ b/go-backend/router.go @@ -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()) diff --git a/go-backend/router_test.go b/go-backend/router_test.go index f720c68..20a5eac 100644 --- a/go-backend/router_test.go +++ b/go-backend/router_test.go @@ -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") diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css index 38689b5..0ced644 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -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; diff --git a/go-backend/static/tailwind.css b/go-backend/static/tailwind.css new file mode 100644 index 0000000..6314adb --- /dev/null +++ b/go-backend/static/tailwind.css @@ -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: ""; + 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: ""; + 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: ""; + 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: ""; + 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; + } + } +} diff --git a/go-backend/tailwind.input.css b/go-backend/tailwind.input.css new file mode 100644 index 0000000..00077a6 --- /dev/null +++ b/go-backend/tailwind.input.css @@ -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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b8c7a5..dcfb60d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f8f575a..41169ed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - 'apps/*' + - 'go-backend' - 'packages/*' -