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.

Tokens

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

Buttons

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

Badges

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

Icon Buttons

Compact icon-only actions for destructive and neutral controls.

Inputs

Shared single-line and multiline text controls.

Form Fields

Labeled controls with optional hint and error messaging.

Modals

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

Tables

Shared table shell for server-rendered list views.

Empty States

Centered fallback messaging with optional icon and action.

Cards

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

+ + 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.

+
+
+ for _, page := range pages { + +

{ page.Title }

+

{ page.Description }

+ +
+ } +
+
+} 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) {
") 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/*' -