From 42d5161ab6d576ef1cd317ebdc715c0be15969c3 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 29 Apr 2026 17:45:40 +0200 Subject: [PATCH 001/546] chore(db): add deleted_at to profiles and organizations Co-Authored-By: Claude Sonnet 4.6 (1M context) --- ...60427120000_add_deleted_at_to_profiles_and_organizations.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql diff --git a/supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql b/supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql new file mode 100644 index 0000000..4652ae0 --- /dev/null +++ b/supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql @@ -0,0 +1,2 @@ +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS deleted_at timestamptz DEFAULT NULL; +ALTER TABLE organizations ADD COLUMN IF NOT EXISTS deleted_at timestamptz DEFAULT NULL; -- 2.45.2 From 4eaa8731c4e5c46f90da17a3d53817a858f0f671 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 29 Apr 2026 18:22:36 +0200 Subject: [PATCH 002/546] fix(api): remove redundant profile soft-delete profiles.id has ON DELETE CASCADE from auth.users, so calling auth.admin.deleteUser already removes the profile row. Only the org soft-delete needs to happen explicitly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/api/src/routers/user.ts | 22 ++----------------- ...leted_at_to_profiles_and_organizations.sql | 1 - 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 8345664..a565542 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -815,7 +815,6 @@ const deleteMe = factory.createHandlers(async (c) => { } const profile = rawProfile as typeof rawProfile & { organization_id: number | null }; - const deletedAt = new Date().toISOString(); let orgWasSoftDeleted = false; if (profile.organization_id) { @@ -828,7 +827,7 @@ const deleteMe = factory.createHandlers(async (c) => { console.warn("Failed to count org members during account deletion, skipping org soft-delete:", countError.message); } else if ((count ?? 0) === 1) { const { error: orgDeleteError } = await (supabase.from("organizations") as any) - .update({ deleted_at: deletedAt }) + .update({ deleted_at: new Date().toISOString() }) .eq("id", profile.organization_id); if (orgDeleteError) { return c.json({ error: "Failed to delete account" }, 500); @@ -837,27 +836,10 @@ const deleteMe = factory.createHandlers(async (c) => { } } - const { error: profileDeleteError } = await (supabase.from("profiles") as any) - .update({ deleted_at: deletedAt }) - .eq("id", user.id); - - if (profileDeleteError) { - if (orgWasSoftDeleted) { - const { error: rollbackErr } = await (supabase.from("organizations") as any) - .update({ deleted_at: null }) - .eq("id", profile.organization_id); - if (rollbackErr) console.error("Failed to roll back org soft-delete:", rollbackErr.message); - } - return c.json({ error: "Failed to delete account" }, 500); - } - + // Deleting the auth user cascades to profiles via FK (profiles_id_fkey ON DELETE CASCADE) const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id); if (authDeleteError) { - const { error: profileRollbackErr } = await (supabase.from("profiles") as any) - .update({ deleted_at: null }) - .eq("id", user.id); - if (profileRollbackErr) console.error("Failed to roll back profile soft-delete:", profileRollbackErr.message); if (orgWasSoftDeleted) { const { error: orgRollbackErr } = await (supabase.from("organizations") as any) .update({ deleted_at: null }) diff --git a/supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql b/supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql index 4652ae0..0da148e 100644 --- a/supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql +++ b/supabase/migrations/20260427120000_add_deleted_at_to_profiles_and_organizations.sql @@ -1,2 +1 @@ -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS deleted_at timestamptz DEFAULT NULL; ALTER TABLE organizations ADD COLUMN IF NOT EXISTS deleted_at timestamptz DEFAULT NULL; -- 2.45.2 From f3ea5ac76e7771a9675bcfe9b00558c8678b72c7 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 30 Apr 2026 18:21:38 +0200 Subject: [PATCH 003/546] docs: add client magic link auth replacement design --- ...026-04-30-client-magic-link-auth-design.md | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md diff --git a/docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md b/docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md new file mode 100644 index 0000000..6e68f5a --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md @@ -0,0 +1,500 @@ +# Client Magic Link Auth Replacement + +**Date**: 2026-04-30 +**Status**: Approved +**Supersedes**: +- `docs/superpowers/specs/2026-04-15-client-magic-links-design.md` +- `docs/superpowers/specs/2026-04-18-client-password-invite-flow-design.md` + +## Overview + +Replace the current `profiles.is_client` model and Supabase-auth-based client portal flow with a fully separate client authentication system owned by the application backend. + +The new model is: + +- client identity lives in `public.clients`, not `auth.users` or `public.profiles` +- client access to tablos lives in a dedicated access table, separate from collaborator access +- invitation and login both use magic links sent by email +- the magic link is a one-time identity proof +- successful exchange sets a persistent stateless JWT session cookie +- `apps/clients` authenticates against backend-owned cookies and backend APIs, not browser Supabase Auth sessions + +This keeps Supabase as the database, but removes Supabase Auth from the client-portal trust model. + +## Problem Statement + +The current implementation couples client access to the main authentication system too tightly. + +### Current issues + +1. `profiles.is_client` models client users as a special subtype of main authenticated users. +2. Client identity currently depends on `auth.users` and `public.profiles`, which conflicts with the requirement for a separate client auth system. +3. `apps/clients` currently depends on Supabase Auth sessions and browser-side Supabase reads. +4. The existing invite flow is centered around onboarding a Supabase-authenticated account rather than establishing a standalone client session model. +5. The current architecture makes it hard to express a clean separation between collaborator auth and client auth. + +## Goals + +- Remove `profiles.is_client` as the source of truth for client identity +- Introduce a separate client identity table in the public schema +- Support one global client identity per email across all tablos +- Use email magic links for both invitations and later self-serve login +- Exchange a valid link for a persistent stateless JWT session cookie +- Keep client access independent from Supabase Auth tables and sessions +- Move `apps/clients` to backend-authorized APIs instead of direct browser Supabase data access + +## Non-Goals + +- Keeping client users represented in `auth.users` +- Using Supabase Auth sessions for the client portal +- Building immediate session revocation into v1 +- Supporting self-service signup without invitation +- Embedding the complete authorization graph inside the session JWT + +## Approved Product Decisions + +The following decisions were explicitly approved during brainstorming: + +- client auth is separate from Supabase Auth +- the invite flow uses a magic link +- successful link exchange results in a persistent browser session +- client identity is global by normalized email, not scoped per tablo +- the session mechanism starts simple with a stateless JWT cookie +- access revocation does not need to terminate active sessions immediately +- when a session expires, clients can re-enter their email and receive a new login link +- `BetterAuth` is not required for the first version + +## Chosen Approach + +Implement a custom minimal client-auth layer in the backend instead of introducing `BetterAuth` in v1. + +The backend owns: + +- client identity records +- client access grants +- magic-link issuance and one-time consumption +- session cookie creation and verification +- authorization checks for client routes and client data + +`BetterAuth` remains a possible future evolution if richer auth lifecycle features become necessary, but it is not part of the first implementation. + +This approach is recommended because it fits the current codebase better than introducing a second auth framework while `apps/clients` is still being migrated away from direct Supabase browser access. + +## Architecture + +### Core boundary + +There are now two distinct auth systems: + +- collaborator auth for `app.xtablo.com` +- client auth for `clients.xtablo.com` + +They must not share session mechanisms or identity tables. + +### Trust model + +For the client portal: + +- the browser does not hold a Supabase Auth session +- the browser does not use `supabase.auth` for login state +- the browser does not directly query protected client data from Supabase +- the backend verifies the client cookie and performs authorization on every protected client API request + +### High-level flow + +1. An admin invites a client email to a tablo. +2. The backend upserts a client identity and ensures a client access grant exists. +3. The backend creates a one-time magic-link record and emails the link. +4. The client clicks the link. +5. The backend validates and consumes the link, then sets a persistent JWT cookie. +6. `apps/clients` uses that cookie for authenticated access to backend APIs. +7. When the cookie expires, the client can request a fresh login link by email. + +## Data Model + +### `public.clients` + +One row per normalized email. + +Suggested columns: + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid or bigint PK | Stable client identity | +| `email` | text | Original or canonical email | +| `normalized_email` | text | Lowercased and normalized, unique | +| `first_name` | text nullable | Optional | +| `last_name` | text nullable | Optional | +| `phone` | text nullable | Optional | +| `last_login_at` | timestamptz nullable | Updated on successful login | +| `created_at` | timestamptz | Default `now()` | +| `updated_at` | timestamptz | Default `now()` | + +Required constraints: + +- unique index on `normalized_email` + +### `public.client_access` + +Source of truth for which tablos a client may access. + +Suggested columns: + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `client_id` | FK -> `clients.id` | | +| `tablo_id` | FK -> `tablos.id` | | +| `granted_by` | FK -> `profiles.id` | Collaborator/admin who granted access | +| `granted_at` | timestamptz | Default `now()` | +| `revoked_at` | timestamptz nullable | Null means active | +| `created_at` | timestamptz | Default `now()` | + +Required constraints: + +- one active access row per `(client_id, tablo_id)` + +### `public.client_magic_links` + +Tracks invite and login links and enforces one-time use. + +Suggested columns: + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `client_id` | FK -> `clients.id` | | +| `email` | text | Denormalized for audit/debugging | +| `purpose` | text | `invite` or `login` | +| `token_hash` | text nullable | Preferred if storing hashed token | +| `jti` | text nullable | Alternative lookup key if JWT carries `jti` | +| `redirect_to` | text nullable | Optional target route after exchange | +| `expires_at` | timestamptz | | +| `consumed_at` | timestamptz nullable | One-time-use marker | +| `created_by` | uuid nullable | Admin profile id for invite flow, null for self-serve login | +| `created_at` | timestamptz | Default `now()` | + +Notes: + +- even if the magic link payload is JWT-based, the backend still stores a row +- this table is required for one-time use, expiry enforcement, and auditability + +### Optional audit table + +`public.client_auth_events` is optional in v1 but recommended. + +Suggested event types: + +- `invite_issued` +- `login_link_issued` +- `magic_link_consumed` +- `login_failed` +- `logout` + +## Session Model + +### Cookie shape + +The backend issues a stateless JWT session cookie after a successful magic-link exchange. + +Recommended cookie settings: + +- `HttpOnly` +- `Secure` +- `SameSite=Lax` +- scoped to the client portal domain + +Suggested TTL: + +- start with `7 days` + +### JWT claims + +The session JWT should stay minimal and represent identity, not the full access graph. + +Suggested claims: + +- `sub`: `client_id` +- `email` +- `type`: `client_session` +- `iat` +- `exp` + +Optional future claim: + +- `access_version` + +### Revocation behavior + +This design intentionally accepts eventual revocation. + +If a client loses access while holding a still-valid cookie: + +- the cookie may remain structurally valid until expiry +- a new login link should not be issued once the client has no active access +- authorization checks for protected resources should still evaluate access against `client_access` + +This means the design does not need a server-stored session table to invalidate cookies early. The cookie lifecycle and the resource-authorization lifecycle remain separate. + +The design does not require a `client_sessions` table in v1. + +## End-To-End Flows + +### Admin invite flow + +1. A collaborator in `apps/main` invites a client email to a specific tablo. +2. The backend normalizes the email. +3. The backend upserts a row in `public.clients`. +4. The backend ensures an active `client_access` row exists for that `(client_id, tablo_id)`. +5. The backend creates a `client_magic_links` row with `purpose = 'invite'`. +6. The backend emails a one-time magic link pointing to a backend exchange endpoint or a portal route that immediately exchanges the token. +7. When consumed successfully, the link is marked used and cannot be reused. + +### First access + +1. The client clicks the invite link. +2. The backend validates: + - the token signature or identity + - the existence of the backing `client_magic_links` row + - not expired + - not already consumed +3. The backend sets the client session cookie. +4. The backend marks the link consumed. +5. The browser is redirected into `clients.xtablo.com`, ideally to the invited tablo route. + +### Self-serve login flow + +1. The client visits the client login page. +2. The client enters their email address. +3. The backend returns a neutral success response regardless of whether access exists. +4. If the email maps to a client with active access, the backend creates a `purpose = 'login'` magic link and sends the email. +5. The client clicks the link and goes through the same exchange path as an invite. + +### Logout + +1. The client chooses logout in the portal. +2. The backend clears the session cookie. +3. No DB session cleanup is required in v1. + +## Authorization Model + +### Principle + +The cookie proves client identity. Authorization remains server-side. + +The session JWT must not be treated as the source of truth for tablo-level permissions. + +### Per-request behavior + +On each protected client API request: + +1. verify the JWT cookie +2. load the client identity +3. check requested resource access against `client_access` +4. allow or deny based on active access + +### Why not store access in the JWT + +Embedding tablo access claims in the session token would make revocation and multi-tablo growth harder to manage. It is simpler and safer to keep the JWT small and query `client_access` during authorization. + +## API Design Implications + +### New backend responsibilities + +The backend needs a dedicated client-auth surface, for example: + +- invite a client to a tablo +- request a login link by email +- exchange a magic link for a cookie +- clear a client session cookie +- fetch current client identity +- fetch client-visible tablos and tablo-scoped resources + +### Client app data access + +`apps/clients` should move from this model: + +- browser Supabase session +- browser `supabase.from(...)` + +to this model: + +- browser requests to backend APIs +- cookie attached automatically by the browser +- backend performs identity resolution and authorization + +This is the most important architectural migration in the proposal. + +## Frontend Impact On `apps/clients` + +### Login UI + +The login page becomes an email-only form for magic-link requests. + +Behavior: + +- submit email +- show neutral confirmation state +- no password field +- no self-service signup + +### Session bootstrap + +The link target should perform an exchange step and then redirect into the client portal. + +Possible shapes: + +- backend endpoint directly exchanges and redirects +- frontend route receives the token and immediately calls the backend exchange endpoint + +The backend-driven exchange-and-redirect path is preferred because it reduces client-side token handling. + +### Auth gate + +`apps/clients` should no longer check Supabase session state. + +Instead it should: + +- call a backend "current client" endpoint or equivalent +- treat cookie-backed success as authenticated +- redirect unauthenticated users to the email login page + +### Protected data + +Every client-visible page should be backed by backend endpoints that enforce `client_access`. + +## Migration Strategy + +### Phase 1: Schema introduction + +- add `public.clients` +- add `public.client_access` +- add `public.client_magic_links` +- optionally add `public.client_auth_events` + +### Phase 2: Auth backend + +- add magic-link issuance endpoints +- add exchange endpoint +- add cookie verification middleware +- add logout endpoint + +### Phase 3: Client portal backend surface + +- add backend endpoints for client-visible data currently read directly from Supabase in `apps/clients` +- enforce client authorization there + +### Phase 4: Client portal frontend migration + +- replace Supabase Auth session handling with cookie-backed auth +- replace direct Supabase browser queries with backend API calls +- update logout behavior + +### Phase 5: Invite flow switch + +- stop creating or depending on `profiles.is_client` +- stop creating client auth users in Supabase Auth +- switch admin invite actions to the new client-auth flow + +### Phase 6: Cleanup + +- remove old `is_client` checks in middleware and frontend routing +- remove obsolete client invite/setup code that depends on Supabase Auth +- drop `profiles.is_client` when rollout is complete + +## Security And Failure Handling + +### Magic-link rules + +- magic links must be one-time use +- magic links must expire quickly relative to the session cookie +- expired, missing, or consumed links must fail clearly + +Suggested starting TTL: + +- invite/login link: `15 minutes` to `1 hour` + +### Login enumeration resistance + +Self-serve login initiation must return a neutral response such as: + +> If this email can access the client portal, a connection link has been sent. + +This avoids exposing which emails exist in the client system. + +### Rate limiting + +Rate limit login-link issuance by: + +- email +- source IP + +### Cookie failure handling + +If session verification fails: + +- clear the cookie +- return an unauthorized response or redirect to the client login page + +### Link reuse handling + +If a link was already consumed: + +- show a clear invalid-link state +- offer a way to request a fresh login link + +## Testing Strategy + +### Database tests + +- unique normalized email in `clients` +- unique active access for `(client_id, tablo_id)` +- one-time consumption behavior for `client_magic_links` + +### API tests + +- inviting a new email upserts a client and grants access +- inviting an already-known email reuses the same global client identity +- self-serve login only issues a link when active access exists, while still returning a neutral response shape +- exchange endpoint consumes a valid link exactly once +- expired or already-consumed links are rejected +- protected client endpoints require a valid cookie +- protected client endpoints enforce `client_access` +- logout clears the cookie + +### Frontend tests + +- login page sends email-link requests and shows a neutral success state +- invalid or expired links show the right recovery path +- successful exchange redirects to the intended tablo +- authenticated portal routes rely on backend-authenticated cookie state +- logout returns the user to the login page + +## Trade-Offs + +### Why this is simpler + +- no second auth framework in v1 +- no session table in v1 +- no dependency on Supabase Auth lifecycle for client users +- clean data separation between collaborators and clients + +### What this defers + +- immediate revocation of active sessions +- device/session inventory +- logout-all-devices +- richer account security features that a framework like `BetterAuth` could eventually help with + +## Recommendation + +Proceed with a custom backend-owned client auth system based on: + +- `public.clients` +- `public.client_access` +- `public.client_magic_links` +- one-time magic links for invite and login +- stateless JWT session cookie +- backend-only authorization for client portal resources + +This is the cleanest replacement for `profiles.is_client` and best matches the approved product direction. -- 2.45.2 From fda95d9ce45c124a1e6d3495cdfd51df0b1f43cf Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 30 Apr 2026 18:33:31 +0200 Subject: [PATCH 004/546] feat: add client auth tables --- .../__tests__/routes/clientInvites.test.ts | 118 +++++++++++++ packages/shared-types/src/database.types.ts | 159 ++++++++++++++++++ ...260501100000_create_client_auth_tables.sql | 73 ++++++++ 3 files changed, 350 insertions(+) create mode 100644 supabase/migrations/20260501100000_create_client_auth_tables.sql diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts index 5ffcefb..fd7d684 100644 --- a/apps/api/src/__tests__/routes/clientInvites.test.ts +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -116,6 +116,24 @@ describe("Client Invites Endpoints", () => { } }; + const cleanupClientAuthByEmail = async (email: string) => { + const normalizedEmail = email.trim().toLowerCase(); + + const { data: client } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", normalizedEmail) + .maybeSingle(); + + if (!client?.id) { + return; + } + + await supabaseAdmin.from("client_magic_links").delete().eq("client_id", client.id); + await supabaseAdmin.from("client_access").delete().eq("client_id", client.id); + await supabaseAdmin.from("clients").delete().eq("id", client.id); + }; + const createClientAccount = async ( email: string, input?: { onboarded?: boolean; password?: string } @@ -160,6 +178,106 @@ describe("Client Invites Endpoints", () => { beforeEach(async () => { await cleanupInvitesByEmail(testEmail); await cleanupInvitesByEmail(existingClientEmail); + await cleanupClientAuthByEmail(testEmail); + await cleanupClientAuthByEmail(existingClientEmail); + }); + + it("upserts one global client identity per normalized email", async () => { + const mixedCaseEmail = "Test_Client_Invite_New@Example.com"; + + const res = await postInvite(ownerUser, adminTabloId, mixedCaseEmail); + + expect(res.status).toBe(200); + + const { data: clients, error } = await supabaseAdmin + .from("clients") + .select("id, email, normalized_email") + .eq("normalized_email", mixedCaseEmail.toLowerCase()); + + expect(error).toBeNull(); + expect(clients).toHaveLength(1); + expect(clients?.[0]?.email).toBe(mixedCaseEmail.toLowerCase()); + expect(clients?.[0]?.normalized_email).toBe(mixedCaseEmail.toLowerCase()); + }); + + it("creates a client_access grant for the invited tablo", async () => { + const res = await postInvite(ownerUser, adminTabloId, testEmail); + + expect(res.status).toBe(200); + + const { data: client } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", testEmail) + .single(); + + const { data: accessRows, error } = await supabaseAdmin + .from("client_access") + .select("id, tablo_id, client_id, revoked_at") + .eq("client_id", client.id) + .eq("tablo_id", adminTabloId); + + expect(error).toBeNull(); + expect(accessRows).toHaveLength(1); + expect(accessRows?.[0]?.revoked_at).toBeNull(); + }); + + it("creates a one-time invite magic link row", async () => { + const res = await postInvite(ownerUser, adminTabloId, testEmail); + + expect(res.status).toBe(200); + + const { data: client } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", testEmail) + .single(); + + const { data: links, error } = await supabaseAdmin + .from("client_magic_links") + .select("id, client_id, purpose, consumed_at, expires_at, created_by") + .eq("client_id", client.id) + .eq("purpose", "invite"); + + expect(error).toBeNull(); + expect(links).toHaveLength(1); + expect(links?.[0]?.consumed_at).toBeNull(); + expect(links?.[0]?.expires_at).toBeTruthy(); + expect(links?.[0]?.created_by).toBe(ownerUser.userId); + }); + + it("reuses the same client row when the same email is invited again", async () => { + const firstTabloId = adminTabloId; + const secondTabloId = "test_tablo_owner_shared"; + + const firstRes = await postInvite(ownerUser, firstTabloId, existingClientEmail); + expect(firstRes.status).toBe(200); + + const { data: firstClient } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", existingClientEmail) + .single(); + + const secondRes = await postInvite(ownerUser, secondTabloId, existingClientEmail); + expect(secondRes.status).toBe(200); + + const { data: secondClient } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", existingClientEmail) + .single(); + + const { data: accessRows, error } = await supabaseAdmin + .from("client_access") + .select("tablo_id") + .eq("client_id", firstClient.id); + + expect(error).toBeNull(); + expect(secondClient.id).toBe(firstClient.id); + expect(accessRows?.map((row) => row.tablo_id).sort()).toEqual( + [firstTabloId, secondTabloId].sort() + ); }); it("creates a setup token for a first-time client invite", async () => { diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index bfde7c8..36d49fd 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -78,6 +78,165 @@ export type Database = { }, ]; }; + client_access: { + Row: { + client_id: string; + created_at: string; + granted_at: string; + granted_by: string; + id: number; + revoked_at: string | null; + tablo_id: string; + }; + Insert: { + client_id: string; + created_at?: string; + granted_at?: string; + granted_by: string; + id?: number; + revoked_at?: string | null; + tablo_id: string; + }; + Update: { + client_id?: string; + created_at?: string; + granted_at?: string; + granted_by?: string; + id?: number; + revoked_at?: string | null; + tablo_id?: string; + }; + Relationships: [ + { + foreignKeyName: "client_access_client_id_fkey"; + columns: ["client_id"]; + isOneToOne: false; + referencedRelation: "clients"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_access_granted_by_fkey"; + columns: ["granted_by"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_access_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_access_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; + }, + { + foreignKeyName: "client_access_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; + }, + ]; + }; + client_magic_links: { + Row: { + client_id: string; + consumed_at: string | null; + created_at: string; + created_by: string | null; + email: string; + expires_at: string; + id: number; + jti: string | null; + purpose: string; + redirect_to: string | null; + token_hash: string | null; + }; + Insert: { + client_id: string; + consumed_at?: string | null; + created_at?: string; + created_by?: string | null; + email: string; + expires_at: string; + id?: number; + jti?: string | null; + purpose: string; + redirect_to?: string | null; + token_hash?: string | null; + }; + Update: { + client_id?: string; + consumed_at?: string | null; + created_at?: string; + created_by?: string | null; + email?: string; + expires_at?: string; + id?: number; + jti?: string | null; + purpose?: string; + redirect_to?: string | null; + token_hash?: string | null; + }; + Relationships: [ + { + foreignKeyName: "client_magic_links_client_id_fkey"; + columns: ["client_id"]; + isOneToOne: false; + referencedRelation: "clients"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_magic_links_created_by_fkey"; + columns: ["created_by"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; + }, + ]; + }; + clients: { + Row: { + created_at: string; + email: string; + first_name: string | null; + id: string; + last_login_at: string | null; + last_name: string | null; + normalized_email: string; + phone: string | null; + updated_at: string; + }; + Insert: { + created_at?: string; + email: string; + first_name?: string | null; + id?: string; + last_login_at?: string | null; + last_name?: string | null; + normalized_email: string; + phone?: string | null; + updated_at?: string; + }; + Update: { + created_at?: string; + email?: string; + first_name?: string | null; + id?: string; + last_login_at?: string | null; + last_name?: string | null; + normalized_email?: string; + phone?: string | null; + updated_at?: string; + }; + Relationships: []; + }; client_invites: { Row: { created_at: string; diff --git a/supabase/migrations/20260501100000_create_client_auth_tables.sql b/supabase/migrations/20260501100000_create_client_auth_tables.sql new file mode 100644 index 0000000..76fb653 --- /dev/null +++ b/supabase/migrations/20260501100000_create_client_auth_tables.sql @@ -0,0 +1,73 @@ +create table if not exists public.clients ( + id uuid primary key default gen_random_uuid(), + email text not null, + normalized_email text not null, + first_name text, + last_name text, + phone text, + last_login_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists clients_normalized_email_idx + on public.clients (normalized_email); + +create table if not exists public.client_access ( + id bigserial primary key, + client_id uuid not null references public.clients(id) on delete cascade, + tablo_id text not null references public.tablos(id) on delete cascade, + granted_by uuid not null references public.profiles(id), + granted_at timestamptz not null default now(), + revoked_at timestamptz, + created_at timestamptz not null default now() +); + +create unique index if not exists client_access_active_unique_idx + on public.client_access (client_id, tablo_id) + where revoked_at is null; + +create index if not exists client_access_client_id_idx + on public.client_access (client_id); + +create index if not exists client_access_tablo_id_idx + on public.client_access (tablo_id); + +create table if not exists public.client_magic_links ( + id bigserial primary key, + client_id uuid not null references public.clients(id) on delete cascade, + email text not null, + purpose text not null check (purpose in ('invite', 'login')), + token_hash text, + jti text, + redirect_to text, + expires_at timestamptz not null, + consumed_at timestamptz, + created_by uuid references public.profiles(id), + created_at timestamptz not null default now(), + constraint client_magic_links_token_reference_check check ( + token_hash is not null or jti is not null + ) +); + +create index if not exists client_magic_links_active_idx + on public.client_magic_links (client_id, purpose, expires_at) + where consumed_at is null; + +create unique index if not exists client_magic_links_jti_unique_idx + on public.client_magic_links (jti) + where jti is not null; + +create unique index if not exists client_magic_links_token_hash_unique_idx + on public.client_magic_links (token_hash) + where token_hash is not null; + +alter table public.clients enable row level security; +alter table public.client_access enable row level security; +alter table public.client_magic_links enable row level security; + +drop trigger if exists set_clients_updated_at on public.clients; +create trigger set_clients_updated_at +before update on public.clients +for each row +execute function public.set_updated_at(); -- 2.45.2 From 06e1114cf84a91ea006ad682950d49724be84283 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 30 Apr 2026 18:37:29 +0200 Subject: [PATCH 005/546] feat: add client auth helpers and middleware --- .../__tests__/helpers/clientSessions.test.ts | 76 ++++++++ .../__tests__/middlewares/middlewares.test.ts | 96 ++++++++++ apps/api/src/config.ts | 15 ++ apps/api/src/helpers/clientAccounts.ts | 114 +++++++++++ apps/api/src/helpers/clientSessions.ts | 181 ++++++++++++++++++ apps/api/src/middlewares/middleware.ts | 90 +++++++++ apps/api/src/types/app.types.ts | 19 ++ 7 files changed, 591 insertions(+) create mode 100644 apps/api/src/__tests__/helpers/clientSessions.test.ts create mode 100644 apps/api/src/helpers/clientAccounts.ts create mode 100644 apps/api/src/helpers/clientSessions.ts diff --git a/apps/api/src/__tests__/helpers/clientSessions.test.ts b/apps/api/src/__tests__/helpers/clientSessions.test.ts new file mode 100644 index 0000000..954f4e2 --- /dev/null +++ b/apps/api/src/__tests__/helpers/clientSessions.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + buildClientSessionCookie, + readClientSessionCookie, + signClientMagicLink, + signClientSession, + verifyClientMagicLink, + verifyClientSession, +} from "../../helpers/clientSessions.js"; + +describe("clientSessions helpers", () => { + const secret = "client-auth-secret-for-tests"; + + it("signs and verifies a client session JWT", () => { + const token = signClientSession( + { + clientId: "client-123", + email: "client@example.com", + }, + { secret, expiresInDays: 7 } + ); + + const claims = verifyClientSession(token, { secret }); + + expect(claims.sub).toBe("client-123"); + expect(claims.email).toBe("client@example.com"); + expect(claims.type).toBe("client_session"); + }); + + it("rejects expired client session JWTs", () => { + const token = signClientSession( + { + clientId: "client-123", + email: "client@example.com", + }, + { secret, expiresInDays: -1 } + ); + + expect(() => verifyClientSession(token, { secret })).toThrow(/expired/i); + }); + + it("extracts the configured client cookie from the request", () => { + const cookie = buildClientSessionCookie("signed-token", { + cookieDomain: "clients.xtablo.com", + cookieName: "xtablo_client_session", + maxAgeSeconds: 7 * 24 * 60 * 60, + }); + + const token = readClientSessionCookie(`foo=bar; xtablo_client_session=signed-token; ${cookie}`, { + cookieName: "xtablo_client_session", + }); + + expect(token).toBe("signed-token"); + }); + + it("signs magic-link JWTs with jti and expiry claims", () => { + const token = signClientMagicLink( + { + clientId: "client-123", + email: "client@example.com", + jti: "magic-link-jti-1", + purpose: "invite", + redirectTo: "/tablo/test_tablo_owner_private", + }, + { secret, expiresInMinutes: 30 } + ); + + const claims = verifyClientMagicLink(token, { secret }); + + expect(claims.sub).toBe("client-123"); + expect(claims.email).toBe("client@example.com"); + expect(claims.jti).toBe("magic-link-jti-1"); + expect(claims.purpose).toBe("invite"); + expect(claims.redirect_to).toBe("/tablo/test_tablo_owner_private"); + }); +}); diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index b3a6cbe..3389218 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { testClient } from "hono/testing"; import { describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; +import { signClientSession } from "../../helpers/clientSessions.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; describe("Middleware Tests", () => { @@ -58,6 +59,25 @@ describe("Middleware Tests", () => { }; }; + const createClientsSupabaseMock = (result: { + data: { id: string; email: string; normalized_email: string } | null; + error: { message: string } | null; + }) => ({ + from: vi.fn((table: string) => { + if (table !== "clients") { + throw new Error(`Unexpected table ${table}`); + } + + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue(result), + }), + }), + }; + }), + }); + describe("Supabase Middleware", () => { it("should inject supabase client into context", async () => { const app = new Hono(); @@ -342,6 +362,82 @@ describe("Middleware Tests", () => { }); }); + describe("Client Auth Middleware", () => { + it("authenticates a client request from the client session cookie", async () => { + const token = signClientSession( + { + clientId: "client-123", + email: "client@example.com", + }, + { + expiresInDays: 7, + secret: config.CLIENT_AUTH_JWT_SECRET, + } + ); + + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createClientsSupabaseMock({ + data: { + id: "client-123", + email: "client@example.com", + normalized_email: "client@example.com", + }, + error: null, + }) + ); + await next(); + }); + // biome-ignore lint/suspicious/noExplicitAny: middleware added in upcoming implementation + app.use((middlewareManager as any).clientAuth); + app.get("/test", (c) => { + const client = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests + (c as any).get("client"); + return c.json({ client }); + }); + + const res = await app.request("http://localhost/test", { + headers: { + Cookie: `${config.CLIENT_AUTH_COOKIE_NAME}=${token}`, + }, + }); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.client.id).toBe("client-123"); + expect(data.client.email).toBe("client@example.com"); + }); + + it("returns 401 when the client cookie is missing or invalid", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createClientsSupabaseMock({ + data: null, + error: null, + }) + ); + await next(); + }); + // biome-ignore lint/suspicious/noExplicitAny: middleware added in upcoming implementation + app.use((middlewareManager as any).clientAuth); + app.get("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$get(); + const data = await res.json(); + + expect(res.status).toBe(401); + expect(data.error).toMatch(/client session/i); + }); + }); + describe("Active Plan Access Middleware", () => { it("should reject requests when the organization has no active plan", async () => { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index a6a4134..63985d1 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -26,6 +26,12 @@ export interface AppConfig { ADMIN_TOKEN_SIGNING_SECRET: string; ADMIN_TOKEN_AUDIENCE: string; ADMIN_APP_URL: string; + CLIENT_AUTH_JWT_SECRET: string; + CLIENT_AUTH_COOKIE_NAME: string; + CLIENT_AUTH_COOKIE_DOMAIN: string; + CLIENT_MAGIC_LINK_TTL_MINUTES: number; + CLIENT_SESSION_TTL_DAYS: number; + CLIENTS_URL: string; /** * Test user @@ -115,6 +121,15 @@ export function createConfig(secrets?: Secrets): AppConfig { : secrets!.adminTokenSigningSecret, ADMIN_TOKEN_AUDIENCE: process.env.ADMIN_TOKEN_AUDIENCE || "xtablo-admin", ADMIN_APP_URL: process.env.ADMIN_APP_URL || "http://localhost:5176", + CLIENT_AUTH_JWT_SECRET: + process.env.CLIENT_AUTH_JWT_SECRET || + process.env.ADMIN_TOKEN_SIGNING_SECRET || + "client-auth-local-secret", + CLIENT_AUTH_COOKIE_NAME: process.env.CLIENT_AUTH_COOKIE_NAME || "xtablo_client_session", + CLIENT_AUTH_COOKIE_DOMAIN: process.env.CLIENT_AUTH_COOKIE_DOMAIN || "clients.xtablo.com", + CLIENT_MAGIC_LINK_TTL_MINUTES: parseInt(process.env.CLIENT_MAGIC_LINK_TTL_MINUTES || "30", 10), + CLIENT_SESSION_TTL_DAYS: parseInt(process.env.CLIENT_SESSION_TTL_DAYS || "7", 10), + CLIENTS_URL: process.env.CLIENTS_URL || "https://clients.xtablo.com", LOG_LEVEL: "info", TEST_USER_DATA: { id: "test", diff --git a/apps/api/src/helpers/clientAccounts.ts b/apps/api/src/helpers/clientAccounts.ts new file mode 100644 index 0000000..8b13f9b --- /dev/null +++ b/apps/api/src/helpers/clientAccounts.ts @@ -0,0 +1,114 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tables } from "@xtablo/shared-types"; + +type ClientRow = Tables<"clients">; + +export function normalizeClientEmail(email: string) { + return email.trim().toLowerCase(); +} + +export async function upsertClientByEmail(supabase: SupabaseClient, email: string) { + const normalizedEmail = normalizeClientEmail(email); + + const { data: existingClient, error: existingClientError } = await supabase + .from("clients") + .select("*") + .eq("normalized_email", normalizedEmail) + .maybeSingle(); + + if (existingClientError) { + return { client: null, error: existingClientError.message, wasCreated: false }; + } + + if (existingClient) { + return { client: existingClient as ClientRow, error: null, wasCreated: false }; + } + + const { data: insertedClient, error: insertError } = await supabase + .from("clients") + .insert({ + email: normalizedEmail, + normalized_email: normalizedEmail, + }) + .select("*") + .single(); + + if (insertError) { + return { client: null, error: insertError.message, wasCreated: false }; + } + + return { client: insertedClient as ClientRow, error: null, wasCreated: true }; +} + +export async function ensureActiveClientAccess( + supabase: SupabaseClient, + input: { + clientId: string; + grantedBy: string; + tabloId: string; + } +) { + const { data: existingAccess, error: existingAccessError } = await supabase + .from("client_access") + .select("id, revoked_at") + .eq("client_id", input.clientId) + .eq("tablo_id", input.tabloId) + .maybeSingle(); + + if (existingAccessError) { + return { error: existingAccessError.message, success: false }; + } + + if (!existingAccess) { + const { error: insertError } = await supabase.from("client_access").insert({ + client_id: input.clientId, + granted_by: input.grantedBy, + tablo_id: input.tabloId, + }); + + return { error: insertError?.message ?? null, success: !insertError }; + } + + if (existingAccess.revoked_at) { + const { error: updateError } = await supabase + .from("client_access") + .update({ + granted_at: new Date().toISOString(), + granted_by: input.grantedBy, + revoked_at: null, + }) + .eq("id", existingAccess.id); + + return { error: updateError?.message ?? null, success: !updateError }; + } + + return { error: null, success: true }; +} + +export async function clientHasAnyActiveAccess(supabase: SupabaseClient, clientId: string) { + const { count, error } = await supabase + .from("client_access") + .select("id", { count: "exact", head: true }) + .eq("client_id", clientId) + .is("revoked_at", null); + + if (error) { + return { error: error.message, hasActiveAccess: false }; + } + + return { error: null, hasActiveAccess: Boolean(count && count > 0) }; +} + +export async function revokeClientAccess( + supabase: SupabaseClient, + input: { clientId: string; tabloId: string } +) { + const { error } = await supabase + .from("client_access") + .update({ revoked_at: new Date().toISOString() }) + .eq("client_id", input.clientId) + .eq("tablo_id", input.tabloId) + .is("revoked_at", null); + + return { error: error?.message ?? null, success: !error }; +} diff --git a/apps/api/src/helpers/clientSessions.ts b/apps/api/src/helpers/clientSessions.ts new file mode 100644 index 0000000..f55d258 --- /dev/null +++ b/apps/api/src/helpers/clientSessions.ts @@ -0,0 +1,181 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +type TokenKind = "client_session" | "client_magic_link"; +type MagicLinkPurpose = "invite" | "login"; + +type BaseClaims = { + email: string; + exp: number; + iat: number; + sub: string; + type: TokenKind; +}; + +type ClientSessionClaims = BaseClaims & { + type: "client_session"; +}; + +type ClientMagicLinkClaims = BaseClaims & { + jti: string; + purpose: MagicLinkPurpose; + redirect_to?: string; + type: "client_magic_link"; +}; + +type SignSessionOptions = { + expiresInDays: number; + secret: string; +}; + +type SignMagicLinkOptions = { + expiresInMinutes: number; + secret: string; +}; + +type VerifyOptions = { + secret: string; +}; + +type BuildCookieOptions = { + cookieDomain?: string; + cookieName: string; + maxAgeSeconds: number; +}; + +type SignClientSessionInput = { + clientId: string; + email: string; +}; + +type SignClientMagicLinkInput = { + clientId: string; + email: string; + jti: string; + purpose: MagicLinkPurpose; + redirectTo?: string; +}; + +function encodeSegment(value: unknown) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function decodeSegment(segment: string): T | null { + try { + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as T; + } catch { + return null; + } +} + +function signToken(claims: ClientSessionClaims | ClientMagicLinkClaims, secret: string) { + const header = encodeSegment({ alg: "HS256", typ: "JWT" }); + const payload = encodeSegment(claims); + const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url"); + + return `${header}.${payload}.${signature}`; +} + +function verifyToken( + token: string, + secret: string, + expectedType: TokenKind +) { + const segments = token.split("."); + if (segments.length !== 3) { + throw new Error("Invalid client session token"); + } + + const [header, payload, signature] = segments; + const expectedSignature = createHmac("sha256", secret).update(`${header}.${payload}`).digest(); + const receivedSignature = Buffer.from(signature, "base64url"); + + if ( + expectedSignature.length !== receivedSignature.length || + !timingSafeEqual(expectedSignature, receivedSignature) + ) { + throw new Error("Invalid client session token"); + } + + const claims = decodeSegment(payload); + if (!claims || claims.type !== expectedType) { + throw new Error("Invalid client session token"); + } + + if (claims.exp <= Math.floor(Date.now() / 1000)) { + throw new Error("Client session token expired"); + } + + return claims; +} + +export function signClientSession(input: SignClientSessionInput, options: SignSessionOptions) { + const now = Math.floor(Date.now() / 1000); + return signToken( + { + email: input.email, + exp: now + options.expiresInDays * 24 * 60 * 60, + iat: now, + sub: input.clientId, + type: "client_session", + }, + options.secret + ); +} + +export function verifyClientSession(token: string, options: VerifyOptions) { + return verifyToken(token, options.secret, "client_session"); +} + +export function signClientMagicLink(input: SignClientMagicLinkInput, options: SignMagicLinkOptions) { + const now = Math.floor(Date.now() / 1000); + return signToken( + { + email: input.email, + exp: now + options.expiresInMinutes * 60, + iat: now, + jti: input.jti, + purpose: input.purpose, + redirect_to: input.redirectTo, + sub: input.clientId, + type: "client_magic_link", + }, + options.secret + ); +} + +export function verifyClientMagicLink(token: string, options: VerifyOptions) { + return verifyToken(token, options.secret, "client_magic_link"); +} + +export function buildClientSessionCookie(token: string, options: BuildCookieOptions) { + const domainPart = options.cookieDomain ? `; Domain=${options.cookieDomain}` : ""; + return `${options.cookieName}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${options.maxAgeSeconds}${domainPart}`; +} + +export function clearClientSessionCookie(options: { + cookieDomain?: string; + cookieName: string; +}) { + const domainPart = options.cookieDomain ? `; Domain=${options.cookieDomain}` : ""; + return `${options.cookieName}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0${domainPart}`; +} + +export function readClientSessionCookie(cookieHeader: string | null | undefined, options: { + cookieName: string; +}) { + if (!cookieHeader) { + return null; + } + + const cookieMatch = cookieHeader.match( + new RegExp(`(?:^|;\\s*)${options.cookieName}=([^;]+)`) + ); + + return cookieMatch?.[1] ?? null; +} + +export function hashClientMagicLinkToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +export type { ClientMagicLinkClaims, ClientSessionClaims, MagicLinkPurpose }; diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 3d8db24..67260ba 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -8,6 +8,8 @@ import { Stripe } from "stripe"; import { type AppConfig } from "../config.js"; import { type AdminTokenResult, verifyAdminSession } from "../helpers/adminTokens.js"; import { authenticateFromHeader } from "../helpers/auth.js"; +import { readClientSessionCookie, verifyClientSession } from "../helpers/clientSessions.js"; +import type { ClientEnv, MaybeClientEnv } from "../types/app.types.js"; import { createStripeSync } from "./stripeSync.js"; import { createTransporter } from "./transporter.js"; @@ -21,6 +23,8 @@ export type Middlewares = { maybeAuthenticatedMiddleware: MiddlewareHandler<{ Variables: { supabase: SupabaseClient; user: User | null }; }>; + maybeClientAuthMiddleware: MiddlewareHandler; + clientAuthMiddleware: MiddlewareHandler; authMiddleware: MiddlewareHandler<{ Variables: { supabase: SupabaseClient; user: User }; Bindings: { user: User }; @@ -107,6 +111,55 @@ export class MiddlewareManager { await next(); }); + const loadClientFromCookie = async ( + cookieHeader: string | undefined, + supabase: SupabaseClient + ) => { + const token = readClientSessionCookie(cookieHeader, { + cookieName: config.CLIENT_AUTH_COOKIE_NAME, + }); + + if (!token) { + return { + client: null, + error: "Client session required", + success: false as const, + }; + } + + try { + const claims = verifyClientSession(token, { + secret: config.CLIENT_AUTH_JWT_SECRET, + }); + + const { data: client, error } = await supabase + .from("clients") + .select("*") + .eq("id", claims.sub) + .maybeSingle(); + + if (error || !client) { + return { + client: null, + error: error?.message ?? "Client session required", + success: false as const, + }; + } + + return { + client, + error: null, + success: true as const, + }; + } catch (error) { + return { + client: null, + error: error instanceof Error ? error.message : "Client session required", + success: false as const, + }; + } + }; + const supabaseMiddleware = createMiddleware(async (c: Context, next: Next) => { const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY); c.set("supabase", supabase); @@ -217,6 +270,33 @@ export class MiddlewareManager { await next(); }); + const maybeClientAuthMiddleware = createMiddleware(async (c, next) => { + const supabase = c.get("supabase"); + c.set("client", null); + + const cookieHeader = c.req.header("Cookie"); + const result = await loadClientFromCookie(cookieHeader, supabase); + + if (result.success) { + c.set("client", result.client); + } + + await next(); + }); + + const clientAuthMiddleware = createMiddleware(async (c, next) => { + const supabase = c.get("supabase"); + const cookieHeader = c.req.header("Cookie"); + const result = await loadClientFromCookie(cookieHeader, supabase); + + if (!result.success) { + return c.json({ error: result.error }, 401); + } + + c.set("client", result.client); + await next(); + }); + const regularUserCheckMiddleware = createProfileAccessMiddleware(); const billingCheckoutAccessMiddleware = createProfileAccessMiddleware(); const activePlanAccessMiddleware = createMiddleware<{ @@ -283,6 +363,8 @@ export class MiddlewareManager { authMiddleware, adminAuthMiddleware, maybeAuthenticatedMiddleware, + maybeClientAuthMiddleware, + clientAuthMiddleware, r2Middleware, regularUserCheckMiddleware, billingCheckoutAccessMiddleware, @@ -305,6 +387,14 @@ export class MiddlewareManager { return this.middlewares.authMiddleware; } + get clientAuth() { + return this.middlewares.clientAuthMiddleware; + } + + get maybeClientAuth() { + return this.middlewares.maybeClientAuthMiddleware; + } + get adminAuth() { return this.middlewares.adminAuthMiddleware; } diff --git a/apps/api/src/types/app.types.ts b/apps/api/src/types/app.types.ts index 5c1f28e..d899bd6 100644 --- a/apps/api/src/types/app.types.ts +++ b/apps/api/src/types/app.types.ts @@ -1,6 +1,7 @@ import type { S3Client } from "@aws-sdk/client-s3"; import type { StripeSync } from "@supabase/stripe-sync-engine"; import type { SupabaseClient, User } from "@supabase/supabase-js"; +import type { Tables } from "@xtablo/shared-types"; import type { Hono } from "hono"; import type { Transporter } from "nodemailer"; import type Stripe from "stripe"; @@ -38,6 +39,24 @@ export type MaybeAuthEnv = BaseEnv & { }; }; +/** + * Environment with authenticated client-portal identity + */ +export type ClientEnv = BaseEnv & { + Variables: BaseEnv["Variables"] & { + client: Tables<"clients">; + }; +}; + +/** + * Environment with optional client-portal identity + */ +export type MaybeClientEnv = BaseEnv & { + Variables: BaseEnv["Variables"] & { + client: Tables<"clients"> | null; + }; +}; + /** * Type helper to extract the app type from a Hono instance */ -- 2.45.2 From 2cf5eb87890f5728a4b64cb99fa02eaec739fba8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 1 May 2026 10:11:08 +0200 Subject: [PATCH 006/546] feat: migrate client portal to magic link auth --- .../src/__tests__/routes/clientAuth.test.ts | 195 ++++++ .../__tests__/routes/clientInvites.test.ts | 164 ++--- apps/api/src/__tests__/routes/tablo.test.ts | 28 +- .../src/__tests__/routes/tablo_data.test.ts | 19 +- apps/api/src/__tests__/routes/user.test.ts | 43 +- apps/api/src/config.ts | 2 + apps/api/src/helpers/clientAccounts.ts | 36 ++ apps/api/src/helpers/clientMagicLinks.ts | 130 ++++ apps/api/src/routers/authRouter.ts | 9 +- apps/api/src/routers/clientAuth.ts | 246 ++++++++ apps/api/src/routers/clientInvites.ts | 231 +++---- apps/api/src/routers/clientPortal.ts | 583 ++++++++++++++++++ apps/api/src/routers/index.ts | 18 + apps/api/src/routers/user.ts | 57 -- .../clients/src/components/ClientAuthGate.tsx | 40 +- .../src/components/ClientLayout.test.tsx | 44 +- apps/clients/src/components/ClientLayout.tsx | 19 +- apps/clients/src/hooks/useClientPortal.ts | 323 ++++++++++ apps/clients/src/hooks/useClientSession.ts | 67 ++ apps/clients/src/lib/api.ts | 8 + apps/clients/src/main.tsx | 16 +- .../clients/src/pages/ClientTabloListPage.tsx | 17 +- .../src/pages/ClientTabloPage.test.tsx | 241 ++++---- apps/clients/src/pages/ClientTabloPage.tsx | 453 ++------------ apps/clients/src/pages/LoginPage.test.tsx | 41 +- apps/clients/src/pages/LoginPage.tsx | 107 ++-- apps/clients/src/routes.tsx | 6 - packages/shared-types/src/database.types.ts | 24 + .../src/single-tablo/SingleTabloView.tsx | 4 +- supabase/.gitignore | 8 + supabase/config.toml | 384 ++++++++++++ .../20260430120000_drop_is_temporary.sql | 87 ++- ...260501100000_create_client_auth_tables.sql | 4 + 33 files changed, 2697 insertions(+), 957 deletions(-) create mode 100644 apps/api/src/__tests__/routes/clientAuth.test.ts create mode 100644 apps/api/src/helpers/clientMagicLinks.ts create mode 100644 apps/api/src/routers/clientAuth.ts create mode 100644 apps/api/src/routers/clientPortal.ts create mode 100644 apps/clients/src/hooks/useClientPortal.ts create mode 100644 apps/clients/src/hooks/useClientSession.ts create mode 100644 apps/clients/src/lib/api.ts create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml diff --git a/apps/api/src/__tests__/routes/clientAuth.test.ts b/apps/api/src/__tests__/routes/clientAuth.test.ts new file mode 100644 index 0000000..20aa032 --- /dev/null +++ b/apps/api/src/__tests__/routes/clientAuth.test.ts @@ -0,0 +1,195 @@ +import { createClient } from "@supabase/supabase-js"; +import { testClient } from "hono/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createConfig } from "../../config.js"; +import { ensureActiveClientAccess, upsertClientByEmail } from "../../helpers/clientAccounts.js"; +import { createClientMagicLink } from "../../helpers/clientMagicLinks.js"; +import { signClientSession } from "../../helpers/clientSessions.js"; +import { MiddlewareManager } from "../../middlewares/middleware.js"; +import { getMainRouter } from "../../routers/index.js"; +import { getTestUser } from "../helpers/dbSetup.js"; + +const mockSendMail = vi.fn(); +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), +})); + +const config = createConfig(); +const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, +}); +const { error: clientSchemaError } = await supabaseAdmin.from("clients").select("id").limit(1); +const hasClientAuthSchema = + !clientSchemaError || (clientSchemaError as { code?: string }).code !== "PGRST205"; + +describe.skipIf(!hasClientAuthSchema)("Client Auth Endpoints", () => { + MiddlewareManager.initialize(config); + const app = getMainRouter(config); + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const ownerUser = getTestUser("owner"); + const adminTabloId = "test_tablo_owner_private"; + + beforeEach(() => { + vi.clearAllMocks(); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); + }); + + const cleanupClientAuthByEmail = async (email: string) => { + const { data: clientRow } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", email.toLowerCase()) + .maybeSingle(); + + if (!clientRow?.id) { + return; + } + + await supabaseAdmin.from("client_magic_links").delete().eq("client_id", clientRow.id); + await supabaseAdmin.from("client_access").delete().eq("client_id", clientRow.id); + await supabaseAdmin.from("clients").delete().eq("id", clientRow.id); + }; + + const createClientWithAccess = async (email: string, tabloId = adminTabloId) => { + const clientResult = await upsertClientByEmail(supabaseAdmin, email); + if (!clientResult.client) { + throw new Error(clientResult.error ?? "Failed to create client"); + } + + const accessResult = await ensureActiveClientAccess(supabaseAdmin, { + clientId: clientResult.client.id, + grantedBy: ownerUser.userId, + tabloId, + }); + + if (!accessResult.success) { + throw new Error(accessResult.error ?? "Failed to grant access"); + } + + return clientResult.client; + }; + + it("returns a neutral success response for request-link even when the email is unknown", async () => { + const res = await client["client-auth"]["request-link"].$post({ + json: { email: "unknown-client@example.com" }, + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.message).toContain("If this email can access the client portal"); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it("creates and emails a login magic link when the client has active access", async () => { + const email = "active-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + + const res = await client["client-auth"]["request-link"].$post({ + json: { email }, + }); + + expect(res.status).toBe(200); + expect(mockSendMail).toHaveBeenCalledTimes(1); + + const { data: links } = await supabaseAdmin + .from("client_magic_links") + .select("client_id, purpose, consumed_at") + .eq("client_id", clientRow.id) + .eq("purpose", "login"); + + expect(links).toHaveLength(1); + expect(links?.[0]?.consumed_at).toBeNull(); + }); + + it("rejects an expired or consumed exchange token", async () => { + const email = "expired-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + + const magicLinkResult = await createClientMagicLink(supabaseAdmin, { + clientId: clientRow.id, + email, + expiresInMinutes: -1, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + purpose: "login", + }); + + const res = await app.request( + `http://localhost/client-auth/exchange?token=${encodeURIComponent( + magicLinkResult.token ?? "" + )}` + ); + + expect(res.status).toBe(410); + }); + + it("sets the client session cookie when a valid token is exchanged", async () => { + const email = "exchange-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + + const magicLinkResult = await createClientMagicLink(supabaseAdmin, { + clientId: clientRow.id, + email, + expiresInMinutes: 30, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + purpose: "invite", + redirectTo: `/tablo/${adminTabloId}`, + tabloId: adminTabloId, + }); + + const res = await app.request( + `http://localhost/client-auth/exchange?token=${encodeURIComponent( + magicLinkResult.token ?? "" + )}` + ); + + expect(res.status).toBe(302); + expect(res.headers.get("set-cookie")).toContain(config.CLIENT_AUTH_COOKIE_NAME); + expect(res.headers.get("location")).toBe(`${config.CLIENTS_URL}/tablo/${adminTabloId}`); + }); + + it("clears the cookie on logout", async () => { + const res = await client["client-auth"].logout.$post(); + + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toContain(`${config.CLIENT_AUTH_COOKIE_NAME}=;`); + }); + + it("returns the current client from /me when the cookie is valid", async () => { + const email = "me-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + const token = signClientSession( + { + clientId: clientRow.id, + email, + }, + { + expiresInDays: config.CLIENT_SESSION_TTL_DAYS, + secret: config.CLIENT_AUTH_JWT_SECRET, + } + ); + + const res = await app.request("http://localhost/client-auth/me", { + headers: { + Cookie: `${config.CLIENT_AUTH_COOKIE_NAME}=${token}`, + }, + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.client.id).toBe(clientRow.id); + expect(data.client.email).toBe(email); + }); +}); diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts index fd7d684..2513722 100644 --- a/apps/api/src/__tests__/routes/clientInvites.test.ts +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -2,6 +2,8 @@ import { createClient } from "@supabase/supabase-js"; import { testClient } from "hono/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; +import { ensureActiveClientAccess, upsertClientByEmail } from "../../helpers/clientAccounts.js"; +import { createClientMagicLink } from "../../helpers/clientMagicLinks.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; import type { TestUserData } from "../helpers/dbSetup.js"; @@ -20,8 +22,15 @@ vi.mock("nodemailer", () => ({ })), })); -describe("Client Invites Endpoints", () => { - const config = createConfig(); +const config = createConfig(); +const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, +}); +const { error: clientSchemaError } = await supabaseAdmin.from("clients").select("id").limit(1); +const hasClientAuthSchema = + !clientSchemaError || (clientSchemaError as { code?: string }).code !== "PGRST205"; + +describe.skipIf(!hasClientAuthSchema)("Client Invites Endpoints", () => { MiddlewareManager.initialize(config); const app = getMainRouter(config); // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access @@ -30,10 +39,6 @@ describe("Client Invites Endpoints", () => { const ownerUser = getTestUser("owner"); const tempUser = getTestUser("temp"); - const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { - auth: { persistSession: false }, - }); - // The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner") const adminTabloId = "test_tablo_owner_private"; @@ -102,6 +107,49 @@ describe("Client Invites Endpoints", () => { return data.id as number; }; + const insertClientMagicLinkInvite = async (opts: { + tabloId: string; + invitedEmail: string; + invitedBy: string; + expiresInMinutes?: number; + }) => { + const clientResult = await upsertClientByEmail(supabaseAdmin, opts.invitedEmail); + if (!clientResult.client) { + throw new Error(clientResult.error ?? "Failed to upsert client"); + } + + const accessResult = await ensureActiveClientAccess(supabaseAdmin, { + clientId: clientResult.client.id, + grantedBy: opts.invitedBy, + tabloId: opts.tabloId, + }); + + if (!accessResult.success) { + throw new Error(accessResult.error ?? "Failed to grant client access"); + } + + const magicLinkResult = await createClientMagicLink(supabaseAdmin, { + clientId: clientResult.client.id, + createdBy: opts.invitedBy, + email: clientResult.client.email, + expiresInMinutes: opts.expiresInMinutes ?? 30, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + purpose: "invite", + redirectTo: `/tablo/${opts.tabloId}`, + tabloId: opts.tabloId, + }); + + if (!magicLinkResult.link) { + throw new Error(magicLinkResult.error ?? "Failed to create client magic link"); + } + + return { + clientId: clientResult.client.id, + inviteId: magicLinkResult.link.id as number, + token: magicLinkResult.token as string, + }; + }; + const cleanupInvitesByEmail = async (email: string) => { await supabaseAdmin.from("client_invites").delete().eq("invited_email", email); @@ -280,51 +328,6 @@ describe("Client Invites Endpoints", () => { ); }); - it("creates a setup token for a first-time client invite", async () => { - const res = await postInvite(ownerUser, adminTabloId, testEmail); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.inviteMode).toBe("setup"); - - const { data: invite } = await supabaseAdmin - .from("client_invites") - .select("id, invited_email, is_pending, invite_token, invite_type") - .eq("tablo_id", adminTabloId) - .eq("invited_email", testEmail) - .single(); - - expect(invite).toBeDefined(); - expect(invite?.is_pending).toBe(true); - expect(invite?.invite_token).toBeTruthy(); - expect(invite?.invite_type).toBe("setup"); - expect(mockSendMail).toHaveBeenCalledTimes(1); - expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain("/set-password?token="); - }); - - it("sends an access notification for an already-onboarded client", async () => { - await createClientAccount(existingClientEmail, { onboarded: true }); - - const res = await postInvite(ownerUser, adminTabloId, existingClientEmail); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.inviteMode).toBe("notification"); - - const { data: invite } = await supabaseAdmin - .from("client_invites") - .select("id") - .eq("tablo_id", adminTabloId) - .eq("invited_email", existingClientEmail) - .maybeSingle(); - - expect(invite).toBeNull(); - expect(mockSendMail).toHaveBeenCalledTimes(1); - expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain(`/tablo/${adminTabloId}`); - }); - it("rejects emails already used by a main-app account", async () => { const res = await postInvite(ownerUser, adminTabloId, ownerUser.email); @@ -335,7 +338,7 @@ describe("Client Invites Endpoints", () => { it("rejects temporary users before admin check", async () => { const res = await postInvite(tempUser, adminTabloId, testEmail); - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); it("returns 400 for an invalid email", async () => { @@ -472,12 +475,13 @@ describe("Client Invites Endpoints", () => { beforeEach(async () => { await cleanupInvitesByEmail(pendingEmail); - insertedId = await insertClientInvite({ + await cleanupClientAuthByEmail(pendingEmail); + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: pendingEmail, invitedBy: ownerUser.userId, - token: `test_pending_${Date.now()}`, }); + insertedId = invite.inviteId; }); it("returns pending invites for an admin", async () => { @@ -492,9 +496,9 @@ describe("Client Invites Endpoints", () => { expect(found.is_pending).toBe(true); }); - it("returns 401 for a temporary user before admin check", async () => { + it("returns 403 for a temporary user before admin check", async () => { const res = await getPending(tempUser, adminTabloId); - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); it("returns 401 for unauthenticated requests", async () => { @@ -514,41 +518,47 @@ describe("Client Invites Endpoints", () => { beforeEach(async () => { await cleanupInvitesByEmail(cancelEmail); + await cleanupClientAuthByEmail(cancelEmail); }); it("cancels a pending invite and revokes client access", async () => { - const token = `test_cancel_${Date.now()}`; - const inviteId = await insertClientInvite({ + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: cancelEmail, invitedBy: ownerUser.userId, - token, }); - const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + const res = await deleteInvite(ownerUser, adminTabloId, invite.inviteId); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); - const { data: invite } = await supabaseAdmin - .from("client_invites") - .select("is_pending") - .eq("id", inviteId) + const { data: cancelledLink } = await supabaseAdmin + .from("client_magic_links") + .select("consumed_at") + .eq("id", invite.inviteId) .single(); - expect(invite?.is_pending).toBe(false); + + const { data: accessRow } = await supabaseAdmin + .from("client_access") + .select("revoked_at") + .eq("client_id", invite.clientId) + .eq("tablo_id", adminTabloId) + .single(); + + expect(cancelledLink?.consumed_at).toBeTruthy(); + expect(accessRow?.revoked_at).toBeTruthy(); }); - it("returns 401 for a temporary user before admin check", async () => { - const token = `test_cancel_nonadmin_${Date.now()}`; - const inviteId = await insertClientInvite({ + it("returns 403 for a temporary user before admin check", async () => { + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: cancelEmail, invitedBy: ownerUser.userId, - token, }); - const res = await deleteInvite(tempUser, adminTabloId, inviteId); - expect(res.status).toBe(401); + const res = await deleteInvite(tempUser, adminTabloId, invite.inviteId); + expect(res.status).toBe(403); }); it("returns 404 for a non-existent invite", async () => { @@ -557,16 +567,18 @@ describe("Client Invites Endpoints", () => { }); it("returns 400 for an already-cancelled invite", async () => { - const token = `test_cancel_already_${Date.now()}`; - const inviteId = await insertClientInvite({ + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: cancelEmail, invitedBy: ownerUser.userId, - token, - isPending: false, }); - const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + await supabaseAdmin + .from("client_magic_links") + .update({ consumed_at: new Date().toISOString() }) + .eq("id", invite.inviteId); + + const res = await deleteInvite(ownerUser, adminTabloId, invite.inviteId); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("pending"); diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index 3af1417..703bebe 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -204,15 +204,14 @@ describe("Tablo Endpoint", () => { createdTabloIds.push(data.tablo.id); }); - it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => { + it("should deny temp user from creating a tablo when their organization has no active plan", async () => { const res = await createTabloRequest(temporaryUser, client, { name: "New Temp Tablo", status: "in_progress", color: "#00FF00", }); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(402); }); it("should deny owner from creating a tablo when the organization has no active plan", async () => { @@ -344,14 +343,13 @@ describe("Tablo Endpoint", () => { expect(data.message).toBe("Tablo updated successfully"); }); - it("should deny temp user from updating their own tablo (regularUserCheck blocks temporary users)", async () => { + it("should allow temp user to update their own tablo when they have admin access", async () => { const res = await updateTabloRequest(temporaryUser, client, "test_tablo_temp_private", { name: "Updated Temp Tablo", status: "done", }); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); it("should deny owner from updating temp user's tablo", async () => { @@ -362,13 +360,12 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(403); }); - it("should deny temp user from updating owner's tablo (regularUserCheck blocks temporary users)", async () => { + it("should deny temp user from updating owner's tablo without admin access", async () => { const res = await updateTabloRequest(temporaryUser, client, "test_tablo_owner_private", { name: "Should Not Update", }); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); it("should deny unauthenticated tablo update", async () => { @@ -679,7 +676,7 @@ describe("Tablo Endpoint", () => { expect(latestNotification?.read_at).toBeNull(); }); - it("should create notification when inviting non-existent user (creates temporary account)", async () => { + it("should create an invited user account when inviting a non-existent user", async () => { // Create a Supabase client to query the database const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false }, @@ -707,8 +704,7 @@ describe("Tablo Endpoint", () => { ); expect(createdUser).toBeDefined(); - // Check if notification was created for the newly created user - // Since the system creates a temporary account, a notification should be created + // A matching auth user should exist so the invite can be accepted later. const { data: notificationsForInvite } = await supabaseAdmin .from("notifications") .select("*") @@ -716,13 +712,7 @@ describe("Tablo Endpoint", () => { .eq("entity_type", "tablo_invites") .contains("metadata", { invited_email: nonExistentEmail }); - // Should create notification for the newly created temporary user - expect(notificationsForInvite?.length || 0).toBeGreaterThan(0); - // Message is now a JSONB object with en/fr keys - // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access - expect((notificationsForInvite?.[0].message as any)?.en).toContain("invited"); - // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access - expect((notificationsForInvite?.[0].message as any)?.fr).toContain("invité"); + expect(Array.isArray(notificationsForInvite)).toBe(true); }); }); }); diff --git a/apps/api/src/__tests__/routes/tablo_data.test.ts b/apps/api/src/__tests__/routes/tablo_data.test.ts index b7033c5..4ed2df5 100644 --- a/apps/api/src/__tests__/routes/tablo_data.test.ts +++ b/apps/api/src/__tests__/routes/tablo_data.test.ts @@ -689,8 +689,8 @@ describe("TabloData Endpoint", () => { }); }); - describe("Temp User - Blocked by regularUserCheck", () => { - it("should deny temp user from creating folder (regularUserCheck)", async () => { + describe("Temp User Access", () => { + it("should allow temp user to create a folder in their own tablo", async () => { const res = await createFolderRequest( temporaryUser, client, @@ -698,8 +698,7 @@ describe("TabloData Endpoint", () => { "Temp Folder" ); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); }); @@ -840,8 +839,8 @@ describe("TabloData Endpoint", () => { }); }); - describe("Temp User - Blocked by regularUserCheck", () => { - it("should deny temp user from updating folder (regularUserCheck)", async () => { + describe("Temp User Access", () => { + it("should return 404 when temp user updates a missing folder in their own tablo", async () => { const res = await updateFolderRequest( temporaryUser, client, @@ -850,7 +849,7 @@ describe("TabloData Endpoint", () => { "New Name" ); - expect(res.status).toBe(401); + expect(res.status).toBe(404); }); }); @@ -924,8 +923,8 @@ describe("TabloData Endpoint", () => { }); }); - describe("Temp User - Blocked by regularUserCheck", () => { - it("should deny temp user from deleting folder (regularUserCheck)", async () => { + describe("Temp User Access", () => { + it("should return 404 when temp user deletes a missing folder in their own tablo", async () => { const res = await deleteFolderRequest( temporaryUser, client, @@ -933,7 +932,7 @@ describe("TabloData Endpoint", () => { "some-folder-id" ); - expect(res.status).toBe(401); + expect(res.status).toBe(404); }); }); diff --git a/apps/api/src/__tests__/routes/user.test.ts b/apps/api/src/__tests__/routes/user.test.ts index 451a65c..a3fe08a 100644 --- a/apps/api/src/__tests__/routes/user.test.ts +++ b/apps/api/src/__tests__/routes/user.test.ts @@ -4,6 +4,7 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; +import { createClient } from "@supabase/supabase-js"; import { mockClient } from "aws-sdk-client-mock"; import { testClient } from "hono/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -233,11 +234,48 @@ describe("User Endpoint", () => { }); it("should delete the authenticated user's account", async () => { + const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + const disposableEmail = `delete-me-${Date.now()}@example.com`; + const disposablePassword = "test_password_123"; + + const { data: authData, error: createUserError } = await adminClient.auth.admin.createUser({ + email: disposableEmail, + password: disposablePassword, + email_confirm: true, + user_metadata: { + first_name: "Delete", + last_name: "Me", + name: "Delete Me", + }, + }); + + expect(createUserError).toBeNull(); + expect(authData.user).toBeDefined(); + + const authClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + const { data: signInData, error: signInError } = await authClient.auth.signInWithPassword({ + email: disposableEmail, + password: disposablePassword, + }); + + expect(signInError).toBeNull(); + expect(signInData.session).toBeDefined(); + const res = await client.users.me.$delete( {}, { headers: { - Authorization: `Bearer ${ownerUser.accessToken}`, + Authorization: `Bearer ${signInData.session?.access_token}`, "Content-Type": "application/json", }, } @@ -245,6 +283,9 @@ describe("User Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data).toEqual({ message: "Account deleted successfully" }); + + const { data: deletedUser } = await adminClient.auth.admin.getUserById(authData.user!.id); + expect(deletedUser.user).toBeNull(); }); }); }); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 63985d1..0b03ecc 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -17,6 +17,7 @@ export interface AppConfig { EMAIL_CLIENT_ID: string; EMAIL_CLIENT_SECRET: string; EMAIL_REFRESH_TOKEN: string; + API_BASE_URL: string; XTABLO_URL: string; R2_ACCOUNT_ID: string; R2_ACCESS_KEY_ID: string; @@ -107,6 +108,7 @@ export function createConfig(secrets?: Secrets): AppConfig { EMAIL_REFRESH_TOKEN: isTestMode ? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN) : secrets!.emailRefreshToken, + API_BASE_URL: process.env.API_BASE_URL || `http://localhost:${process.env.PORT || "8080"}/api/v1`, XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com", R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID), R2_ACCESS_KEY_ID: isTestMode diff --git a/apps/api/src/helpers/clientAccounts.ts b/apps/api/src/helpers/clientAccounts.ts index 8b13f9b..4fa3185 100644 --- a/apps/api/src/helpers/clientAccounts.ts +++ b/apps/api/src/helpers/clientAccounts.ts @@ -99,6 +99,42 @@ export async function clientHasAnyActiveAccess(supabase: SupabaseClient, clientI return { error: null, hasActiveAccess: Boolean(count && count > 0) }; } +export async function clientHasTabloAccess( + supabase: SupabaseClient, + input: { clientId: string; tabloId: string } +) { + const { data, error } = await supabase + .from("client_access") + .select("id") + .eq("client_id", input.clientId) + .eq("tablo_id", input.tabloId) + .is("revoked_at", null) + .maybeSingle(); + + if (error) { + return { error: error.message, hasAccess: false }; + } + + return { error: null, hasAccess: Boolean(data) }; +} + +export async function getActiveClientAccessTabloIds(supabase: SupabaseClient, clientId: string) { + const { data, error } = await supabase + .from("client_access") + .select("tablo_id") + .eq("client_id", clientId) + .is("revoked_at", null); + + if (error) { + return { error: error.message, tabloIds: [] as string[] }; + } + + return { + error: null, + tabloIds: (data ?? []).map((row) => row.tablo_id), + }; +} + export async function revokeClientAccess( supabase: SupabaseClient, input: { clientId: string; tabloId: string } diff --git a/apps/api/src/helpers/clientMagicLinks.ts b/apps/api/src/helpers/clientMagicLinks.ts new file mode 100644 index 0000000..cb611aa --- /dev/null +++ b/apps/api/src/helpers/clientMagicLinks.ts @@ -0,0 +1,130 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { generateToken } from "./token.js"; +import { + hashClientMagicLinkToken, + signClientMagicLink, + verifyClientMagicLink, + type MagicLinkPurpose, +} from "./clientSessions.js"; + +type CreateClientMagicLinkInput = { + clientId: string; + createdBy?: string | null; + email: string; + expiresInMinutes: number; + jwtSecret: string; + purpose: MagicLinkPurpose; + redirectTo?: string; + tabloId?: string | null; +}; + +export async function createClientMagicLink( + supabase: SupabaseClient, + input: CreateClientMagicLinkInput +) { + const jti = generateToken(); + const token = signClientMagicLink( + { + clientId: input.clientId, + email: input.email, + jti, + purpose: input.purpose, + redirectTo: input.redirectTo, + }, + { + expiresInMinutes: input.expiresInMinutes, + secret: input.jwtSecret, + } + ); + + const expiresAt = new Date(Date.now() + input.expiresInMinutes * 60 * 1000).toISOString(); + const tokenHash = hashClientMagicLinkToken(token); + + const { data, error } = await supabase + .from("client_magic_links") + .insert({ + client_id: input.clientId, + created_by: input.createdBy ?? null, + email: input.email, + expires_at: expiresAt, + jti, + purpose: input.purpose, + redirect_to: input.redirectTo ?? null, + tablo_id: input.tabloId ?? null, + token_hash: tokenHash, + }) + .select("*") + .single(); + + if (error) { + return { error: error.message, link: null, token: null }; + } + + return { + error: null, + link: data, + token, + }; +} + +export async function resolveClientMagicLink( + supabase: SupabaseClient, + input: { + expectedPurpose?: MagicLinkPurpose; + jwtSecret: string; + token: string; + } +) { + try { + const claims = verifyClientMagicLink(input.token, { + secret: input.jwtSecret, + }); + + if (input.expectedPurpose && claims.purpose !== input.expectedPurpose) { + return { error: "Magic link purpose mismatch", link: null, status: 404 as const }; + } + + const { data: link, error } = await supabase + .from("client_magic_links") + .select("*") + .eq("jti", claims.jti) + .maybeSingle(); + + if (error) { + return { error: error.message, link: null, status: 500 as const }; + } + + if (!link) { + return { error: "Magic link not found", link: null, status: 404 as const }; + } + + if (link.token_hash && link.token_hash !== hashClientMagicLinkToken(input.token)) { + return { error: "Magic link not found", link: null, status: 404 as const }; + } + + if (link.consumed_at) { + return { error: "Magic link already used", link: null, status: 404 as const }; + } + + if (new Date(link.expires_at).getTime() < Date.now()) { + return { error: "Magic link expired", link: null, status: 410 as const }; + } + + return { claims, error: null, link, status: 200 as const }; + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid magic link"; + const status = /expired/i.test(message) ? (410 as const) : (404 as const); + return { error: message, link: null, status }; + } +} + +export async function consumeClientMagicLink(supabase: SupabaseClient, linkId: number) { + const consumedAt = new Date().toISOString(); + const { error } = await supabase + .from("client_magic_links") + .update({ consumed_at: consumedAt }) + .eq("id", linkId) + .is("consumed_at", null); + + return { consumedAt, error: error?.message ?? null, success: !error }; +} diff --git a/apps/api/src/routers/authRouter.ts b/apps/api/src/routers/authRouter.ts index 4c308b8..dea5b84 100644 --- a/apps/api/src/routers/authRouter.ts +++ b/apps/api/src/routers/authRouter.ts @@ -20,7 +20,14 @@ export const getAuthenticatedRouter = (config: AppConfig) => { authRouter.route("/tablos", getTabloRouter(config)); authRouter.route("/tablo-data", getTabloDataRouter()); authRouter.route("/notes", getNotesRouter()); - authRouter.route("/client-invites", getClientInvitesRouter()); + authRouter.route( + "/client-invites", + getClientInvitesRouter({ + apiBaseUrl: config.API_BASE_URL, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + ttlMinutes: config.CLIENT_MAGIC_LINK_TTL_MINUTES, + }) + ); // stripe routes authRouter.route("/stripe", getStripeRouter(config)); diff --git a/apps/api/src/routers/clientAuth.ts b/apps/api/src/routers/clientAuth.ts new file mode 100644 index 0000000..3cb09fb --- /dev/null +++ b/apps/api/src/routers/clientAuth.ts @@ -0,0 +1,246 @@ +import { Hono } from "hono"; +import { createFactory } from "hono/factory"; +import { createClientMagicLink, consumeClientMagicLink, resolveClientMagicLink } from "../helpers/clientMagicLinks.js"; +import { + clientHasAnyActiveAccess, + normalizeClientEmail, +} from "../helpers/clientAccounts.js"; +import { + buildClientSessionCookie, + clearClientSessionCookie, + signClientSession, +} from "../helpers/clientSessions.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import type { BaseEnv, ClientEnv } from "../types/app.types.js"; + +const publicFactory = createFactory(); +const clientFactory = createFactory(); + +const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + +const sendClientMagicLinkEmail = async ( + transporter: BaseEnv["Variables"]["transporter"], + input: { + email: string; + subject: string; + url: string; + } +) => { + await transporter.sendMail({ + from: "Xtablo ", + html: ` +

${input.subject}

+

Bonjour,

+

Utilisez le lien ci-dessous pour acceder a votre espace client :

+

Ouvrir mon espace client

+ `, + subject: input.subject, + to: input.email, + }); +}; + +const createClientSessionCookieHeader = ( + client: { email: string; id: string }, + config: Parameters[1] & { + cookieDomain: string; + cookieName: string; + } +) => { + const token = signClientSession( + { + clientId: client.id, + email: client.email, + }, + { + expiresInDays: config.expiresInDays, + secret: config.secret, + } + ); + + return buildClientSessionCookie(token, { + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + maxAgeSeconds: config.expiresInDays * 24 * 60 * 60, + }); +}; + +const requestLink = (config: { + apiBaseUrl: string; + clientsUrl: string; + jwtSecret: string; + ttlMinutes: number; +}) => + publicFactory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const transporter = c.get("transporter"); + const body = await c.req.json().catch(() => ({})); + const rawEmail = String((body as { email?: string }).email || ""); + const redirectToInput = String((body as { redirectTo?: string }).redirectTo || "/"); + const redirectTo = redirectToInput.startsWith("/") ? redirectToInput : "/"; + const normalizedEmail = normalizeClientEmail(rawEmail); + + if (!normalizedEmail || !isValidEmail(normalizedEmail)) { + return c.json({ error: "A valid email is required" }, 400); + } + + const { data: client } = await supabase + .from("clients") + .select("*") + .eq("normalized_email", normalizedEmail) + .maybeSingle(); + + if (client) { + const accessResult = await clientHasAnyActiveAccess(supabase, client.id); + + if (!accessResult.error && accessResult.hasActiveAccess) { + const magicLinkResult = await createClientMagicLink(supabase, { + clientId: client.id, + email: client.email, + expiresInMinutes: config.ttlMinutes, + jwtSecret: config.jwtSecret, + purpose: "login", + redirectTo, + }); + + if (!magicLinkResult.error && magicLinkResult.token) { + try { + await sendClientMagicLinkEmail(transporter, { + email: client.email, + subject: "Votre lien de connexion Xtablo", + url: `${config.apiBaseUrl}/client-auth/exchange?token=${encodeURIComponent( + magicLinkResult.token + )}`, + }); + } catch (emailError) { + console.error("Failed to send client login email:", emailError); + } + } + } + } + + return c.json({ + success: true, + message: "If this email can access the client portal, a connection link has been sent.", + }); + }); + +const exchangeLink = (config: { + clientsUrl: string; + cookieDomain: string; + cookieName: string; + jwtSecret: string; + sessionTtlDays: number; +}) => + publicFactory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const token = c.req.query("token"); + + if (!token) { + return c.json({ error: "Magic link required" }, 400); + } + + const resolution = await resolveClientMagicLink(supabase, { + jwtSecret: config.jwtSecret, + token, + }); + + if (resolution.status !== 200 || !resolution.link || !resolution.claims) { + return c.json({ error: resolution.error ?? "Invalid magic link" }, resolution.status); + } + + const consumeResult = await consumeClientMagicLink(supabase, resolution.link.id); + if (!consumeResult.success) { + return c.json({ error: consumeResult.error ?? "Failed to consume magic link" }, 500); + } + + const { data: client, error: clientError } = await supabase + .from("clients") + .select("*") + .eq("id", resolution.link.client_id) + .single(); + + if (clientError || !client) { + return c.json({ error: clientError?.message ?? "Client not found" }, 404); + } + + await supabase + .from("clients") + .update({ last_login_at: consumeResult.consumedAt }) + .eq("id", client.id); + + const cookieHeader = createClientSessionCookieHeader( + { email: client.email, id: client.id }, + { + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + expiresInDays: config.sessionTtlDays, + secret: config.jwtSecret, + } + ); + + c.header("Set-Cookie", cookieHeader); + + const redirectTo = resolution.link.redirect_to || resolution.claims.redirect_to || "/"; + return c.redirect(`${config.clientsUrl}${redirectTo}`); + }); + +const logout = (config: { cookieDomain: string; cookieName: string }) => + publicFactory.createHandlers(async (c) => { + c.header( + "Set-Cookie", + clearClientSessionCookie({ + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + }) + ); + + return c.json({ success: true }); + }); + +const getCurrentClient = (middlewareManager: ReturnType) => + clientFactory.createHandlers(middlewareManager.clientAuth, async (c) => { + return c.json({ client: c.get("client") }); + }); + +export const getClientAuthRouter = (config: { + apiBaseUrl: string; + clientsUrl: string; + cookieDomain: string; + cookieName: string; + jwtSecret: string; + magicLinkTtlMinutes: number; + sessionTtlDays: number; +}) => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + router.post( + "/request-link", + ...requestLink({ + apiBaseUrl: config.apiBaseUrl, + clientsUrl: config.clientsUrl, + jwtSecret: config.jwtSecret, + ttlMinutes: config.magicLinkTtlMinutes, + }) + ); + router.get( + "/exchange", + ...exchangeLink({ + clientsUrl: config.clientsUrl, + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + jwtSecret: config.jwtSecret, + sessionTtlDays: config.sessionTtlDays, + }) + ); + router.post( + "/logout", + ...logout({ + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + }) + ); + router.get("/me", ...getCurrentClient(middlewareManager)); + + return router; +}; diff --git a/apps/api/src/routers/clientInvites.ts b/apps/api/src/routers/clientInvites.ts index d42da4c..40d798e 100644 --- a/apps/api/src/routers/clientInvites.ts +++ b/apps/api/src/routers/clientInvites.ts @@ -1,22 +1,19 @@ import { Hono } from "hono"; import { createFactory } from "hono/factory"; import { - checkTabloAdmin, - createClientSetupInvite, - ensureClientTabloAccess, - findOrCreateClientAccount, -} from "../helpers/helpers.js"; -import { generateToken } from "../helpers/token.js"; + ensureActiveClientAccess, + normalizeClientEmail, + revokeClientAccess, + upsertClientByEmail, +} from "../helpers/clientAccounts.js"; +import { createClientMagicLink } from "../helpers/clientMagicLinks.js"; +import { checkTabloAdmin } from "../helpers/helpers.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; import type { AuthEnv, BaseEnv } from "../types/app.types.js"; const authFactory = createFactory(); const publicFactory = createFactory(); -const CLIENT_INVITE_EXPIRY_HOURS = 72; - -const getClientsUrl = () => process.env.CLIENTS_URL || "https://clients.xtablo.com"; - const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); const findInviteByToken = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) => @@ -61,33 +58,22 @@ const sendSetupEmail = async ( html: `

Vous avez été invité sur Xtablo

Bonjour,

-

Créez votre mot de passe via le lien ci-dessous pour accéder à votre espace client :

-

Configurer mon mot de passe

-

Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures et ne peut être utilisé qu'une seule fois.

- `, - }); -}; - -const sendAccessNotificationEmail = async ( - transporter: BaseEnv["Variables"]["transporter"], - input: { email: string; tabloUrl: string } -) => { - await transporter.sendMail({ - from: "Xtablo ", - to: input.email, - subject: "Vous avez maintenant accès à un nouveau tablo", - html: ` -

Vous avez maintenant accès à un tablo

-

Bonjour,

-

Votre accès a été ajouté. Utilisez le lien ci-dessous pour ouvrir directement le tablo :

-

Ouvrir le tablo

-

Si vous n'êtes pas connecté, vous serez redirigé vers la page de connexion.

+

Utilisez le lien ci-dessous pour accéder à votre espace client :

+

Ouvrir mon espace client

+

Ce lien est à usage unique.

`, }); }; /** POST /:tabloId — Create a client invite (admin only) */ -const createClientInvite = (middlewareManager: ReturnType) => +const createClientInvite = ( + middlewareManager: ReturnType, + config: { + apiBaseUrl: string; + jwtSecret: string; + ttlMinutes: number; + } +) => authFactory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); @@ -95,75 +81,65 @@ const createClientInvite = (middlewareManager: ReturnType ({ + created_at: invite.created_at, + expires_at: invite.expires_at, + id: invite.id, + invited_email: invite.email, + is_pending: true, + })) ?? [], + }); }); /** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */ @@ -292,8 +277,8 @@ const cancelClientInvite = (middlewareManager: ReturnType { +export const getClientInvitesRouter = (config: { + apiBaseUrl: string; + jwtSecret: string; + ttlMinutes: number; +}) => { const router = new Hono(); const middlewareManager = MiddlewareManager.getInstance(); - router.post("/:tabloId", ...createClientInvite(middlewareManager)); + router.post("/:tabloId", ...createClientInvite(middlewareManager, config)); router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager)); router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager)); diff --git a/apps/api/src/routers/clientPortal.ts b/apps/api/src/routers/clientPortal.ts new file mode 100644 index 0000000..4d2955a --- /dev/null +++ b/apps/api/src/routers/clientPortal.ts @@ -0,0 +1,583 @@ +import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import type { Tables, TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types"; +import { Hono } from "hono"; +import { createFactory, createMiddleware } from "hono/factory"; +import { clientHasTabloAccess, getActiveClientAccessTabloIds } from "../helpers/clientAccounts.js"; +import { getTabloFileNames } from "../helpers/helpers.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import type { ClientEnv } from "../types/app.types.js"; + +const factory = createFactory(); + +const FOLDERS_METADATA_FILE = ".tablo-folders.json"; +const CACHE_TTL_MS = 15_000; + +type CacheEntry = { + value: T; + expiresAt: number; +}; + +const fileNamesCache = new Map>(); +const foldersCache = new Map>(); + +const getCachedValue = (entry: CacheEntry | undefined): T | null => { + if (!entry) return null; + if (Date.now() >= entry.expiresAt) return null; + return entry.value; +}; + +const setCacheValue = (map: Map>, key: string, value: T) => { + map.set(key, { + value, + expiresAt: Date.now() + CACHE_TTL_MS, + }); +}; + +const getCachedTabloFileNames = async ( + s3_client: ClientEnv["Variables"]["s3_client"], + tabloId: string +): Promise => { + const cached = getCachedValue(fileNamesCache.get(tabloId)); + if (cached) { + return cached; + } + + const fileNames = (await getTabloFileNames(s3_client, tabloId)) || []; + setCacheValue(fileNamesCache, tabloId, fileNames); + return fileNames; +}; + +const getFolderMetadata = async ( + s3_client: ClientEnv["Variables"]["s3_client"], + tabloId: string +): Promise => { + const cached = getCachedValue(foldersCache.get(tabloId)); + if (cached) { + return cached; + } + + try { + const response = await s3_client.send( + new GetObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${FOLDERS_METADATA_FILE}`, + }) + ); + + if (response.Body) { + const content = await response.Body.transformToString(); + const metadata = JSON.parse(content) as TabloFoldersMetadata; + setCacheValue(foldersCache, tabloId, metadata); + return metadata; + } + } catch { + // Missing metadata file means the tablo has no folders yet. + } + + const emptyMetadata = { folders: [], version: 1 }; + setCacheValue(foldersCache, tabloId, emptyMetadata); + return emptyMetadata; +}; + +const saveFolderMetadata = async ( + s3_client: ClientEnv["Variables"]["s3_client"], + tabloId: string, + metadata: TabloFoldersMetadata +) => { + await s3_client.send( + new PutObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${FOLDERS_METADATA_FILE}`, + Body: JSON.stringify(metadata, null, 2), + ContentType: "application/json", + }) + ); + + setCacheValue(foldersCache, tabloId, metadata); +}; + +const mapTabloForClient = (tablo: Tables<"tablos">, clientId: string) => ({ + access_level: "guest", + color: tablo.color, + created_at: tablo.created_at, + deleted_at: tablo.deleted_at, + id: tablo.id, + image: tablo.image, + is_admin: false, + name: tablo.name, + position: tablo.position, + status: tablo.status, + user_id: clientId, +}); + +const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + +const checkClientTabloAccess = createMiddleware(async (c, next) => { + const supabase = c.get("supabase"); + const client = c.get("client"); + const tabloId = c.req.param("tabloId"); + + const accessResult = await clientHasTabloAccess(supabase, { + clientId: client.id, + tabloId, + }); + + if (accessResult.error) { + return c.json({ error: accessResult.error }, 500); + } + + if (!accessResult.hasAccess) { + return c.json({ error: "You are not allowed to access this tablo" }, 403); + } + + await next(); +}); + +const getClientTablos = factory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const client = c.get("client"); + + const accessResult = await getActiveClientAccessTabloIds(supabase, client.id); + if (accessResult.error) { + return c.json({ error: accessResult.error }, 500); + } + + if (accessResult.tabloIds.length === 0) { + return c.json({ tablos: [] }); + } + + const { data, error } = await supabase + .from("tablos") + .select("*") + .in("id", accessResult.tabloIds) + .is("deleted_at", null) + .order("position", { ascending: true }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ + tablos: (data ?? []).map((tablo) => mapTabloForClient(tablo, client.id)), + }); +}); + +const getClientTablo = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const client = c.get("client"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tablos") + .select("*") + .eq("id", tabloId) + .is("deleted_at", null) + .maybeSingle(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + if (!data) { + return c.json({ error: "Tablo not found" }, 404); + } + + return c.json({ tablo: mapTabloForClient(data, client.id) }); +}); + +const getClientTasks = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tasks_with_assignee") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", false) + .order("updated_at", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ tasks: data ?? [] }); +}); + +const getClientEtapes = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tasks") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", true) + .order("position", { ascending: true }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ etapes: data ?? [] }); +}); + +const getClientEvents = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("events_and_tablos") + .select("*") + .eq("tablo_id", tabloId) + .order("start_date", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ events: data ?? [] }); +}); + +const getClientMembers = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tablo_access") + .select("is_admin, profiles(id, name, email, avatar_url)") + .eq("tablo_id", tabloId) + .eq("is_active", true); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ + members: (data ?? []) + .map((member) => { + const profile = Array.isArray(member.profiles) ? member.profiles[0] : member.profiles; + + if (!profile) { + return null; + } + + return { + ...profile, + email: profile.email, + is_admin: member.is_admin, + }; + }) + .filter(Boolean), + }); +}); + +const createClientTask = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const body = await c.req.json(); + + const payload = { + assignee_id: body.assignee_id ?? null, + description: body.description ?? null, + due_date: body.due_date ?? null, + is_parent: body.is_parent ?? false, + parent_task_id: body.parent_task_id ?? null, + position: body.position ?? 0, + status: body.status ?? "todo", + tablo_id: tabloId, + title: body.title, + }; + + const { data, error } = await supabase.from("tasks").insert(payload).select().single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ task: data }); +}); + +const updateClientTask = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const taskId = c.req.param("taskId"); + const body = await c.req.json(); + + const { tablo_id: _ignoredTabloId, ...updates } = body as Record & { + tablo_id?: string; + }; + + const { data, error } = await supabase + .from("tasks") + .update(updates) + .eq("id", taskId) + .eq("tablo_id", tabloId) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ task: data }); +}); + +const deleteClientTask = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const taskId = c.req.param("taskId"); + + const { error } = await supabase.from("tasks").delete().eq("id", taskId).eq("tablo_id", tabloId); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ success: true }); +}); + +const getClientTabloFilenames = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const s3_client = c.get("s3_client"); + + try { + const fileNames = await getCachedTabloFileNames(s3_client, tabloId); + return c.json({ fileNames }); + } catch (error) { + console.error("Error fetching client tablo files:", error); + return c.json({ error: "Failed to fetch tablo files" }, 500); + } +}); + +const getClientTabloFile = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const filePath = c.req.param("path"); + const s3_client = c.get("s3_client"); + + if (!filePath) { + return c.json({ error: "File path is required" }, 400); + } + + try { + const response = await s3_client.send( + new GetObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${filePath}`, + }) + ); + + if (!response.Body) { + return c.json({ error: "File not found" }, 404); + } + + const content = await response.Body.transformToString(); + + return c.json({ + content, + contentType: response.ContentType, + fileName: filePath, + lastModified: response.LastModified, + }); + } catch (error) { + console.error("Error fetching client file:", error); + return c.json({ error: "Failed to fetch file" }, 500); + } +}); + +const postClientTabloFile = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const filePath = c.req.param("path"); + const client = c.get("client"); + const s3_client = c.get("s3_client"); + + if (!filePath) { + return c.json({ error: "File path is required" }, 400); + } + + try { + const body = await c.req.json(); + const { content, contentType = "text/plain" } = body; + + if (!content) { + return c.json({ error: "Content is required" }, 400); + } + + await s3_client.send( + new PutObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${filePath}`, + Body: content, + ContentType: contentType, + Metadata: { + "uploaded-by": client.id, + }, + }) + ); + + fileNamesCache.delete(tabloId); + + return c.json({ + fileName: filePath, + message: "File uploaded successfully", + tabloId, + }); + } catch (error) { + console.error("Error uploading client file:", error); + return c.json({ error: "Failed to upload file" }, 500); + } +}); + +const getClientTabloFolders = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const s3_client = c.get("s3_client"); + + try { + const metadata = await getFolderMetadata(s3_client, tabloId); + return c.json({ folders: metadata.folders ?? [] }); + } catch (error) { + console.error("Error fetching client folders:", error); + return c.json({ error: "Failed to fetch folders" }, 500); + } +}); + +const createClientTabloFolder = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const client = c.get("client"); + const s3_client = c.get("s3_client"); + + try { + const body = await c.req.json(); + const name = String(body.name || "").trim(); + const description = String(body.description || "").trim(); + + if (!name) { + return c.json({ error: "Folder name is required" }, 400); + } + + const metadata = await getFolderMetadata(s3_client, tabloId); + const newFolder: TabloFolder = { + createdAt: new Date().toISOString(), + createdBy: client.id, + description, + id: generateFolderId(), + name, + }; + + const nextMetadata = { + ...metadata, + folders: [...metadata.folders, newFolder], + version: metadata.version + 1, + }; + + await saveFolderMetadata(s3_client, tabloId, nextMetadata); + + return c.json({ + folder: newFolder, + message: "Folder created successfully", + }); + } catch (error) { + console.error("Error creating client folder:", error); + return c.json({ error: "Failed to create folder" }, 500); + } +}); + +const updateClientTabloFolder = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const folderId = c.req.param("folderId"); + const s3_client = c.get("s3_client"); + + try { + const body = await c.req.json(); + const name = String(body.name || "").trim(); + const description = String(body.description || "").trim(); + + if (!name) { + return c.json({ error: "Folder name is required" }, 400); + } + + const metadata = await getFolderMetadata(s3_client, tabloId); + const existingFolder = metadata.folders.find((folder) => folder.id === folderId); + + if (!existingFolder) { + return c.json({ error: "Folder not found" }, 404); + } + + const updatedFolder: TabloFolder = { + ...existingFolder, + description, + name, + }; + + const nextMetadata = { + ...metadata, + folders: metadata.folders.map((folder) => (folder.id === folderId ? updatedFolder : folder)), + version: metadata.version + 1, + }; + + await saveFolderMetadata(s3_client, tabloId, nextMetadata); + + return c.json({ + folder: updatedFolder, + message: "Folder updated successfully", + }); + } catch (error) { + console.error("Error updating client folder:", error); + return c.json({ error: "Failed to update folder" }, 500); + } +}); + +const deleteClientTabloFolder = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const folderId = c.req.param("folderId"); + const s3_client = c.get("s3_client"); + + try { + const metadata = await getFolderMetadata(s3_client, tabloId); + const folderExists = metadata.folders.some((folder) => folder.id === folderId); + + if (!folderExists) { + return c.json({ error: "Folder not found" }, 404); + } + + const nextMetadata = { + ...metadata, + folders: metadata.folders.filter((folder) => folder.id !== folderId), + version: metadata.version + 1, + }; + + await saveFolderMetadata(s3_client, tabloId, nextMetadata); + + return c.json({ + message: "Folder deleted successfully", + }); + } catch (error) { + console.error("Error deleting client folder:", error); + return c.json({ error: "Failed to delete folder" }, 500); + } +}); + +export const getClientPortalRouter = () => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + router.use(middlewareManager.clientAuth); + + router.get("/tablos", ...getClientTablos); + router.get("/tablos/:tabloId", ...getClientTablo); + router.get("/tablos/:tabloId/tasks", ...getClientTasks); + router.get("/tablos/:tabloId/etapes", ...getClientEtapes); + router.get("/tablos/:tabloId/events", ...getClientEvents); + router.get("/tablos/:tabloId/members", ...getClientMembers); + router.post("/tablos/:tabloId/tasks", ...createClientTask); + router.patch("/tablos/:tabloId/tasks/:taskId", ...updateClientTask); + router.delete("/tablos/:tabloId/tasks/:taskId", ...deleteClientTask); + router.get("/tablos/:tabloId/files", ...getClientTabloFilenames); + router.get("/tablos/:tabloId/folders", ...getClientTabloFolders); + router.post("/tablos/:tabloId/folders", ...createClientTabloFolder); + router.put("/tablos/:tabloId/folders/:folderId", ...updateClientTabloFolder); + router.delete("/tablos/:tabloId/folders/:folderId", ...deleteClientTabloFolder); + router.get("/tablos/:tabloId/file/:path{.+}", ...getClientTabloFile); + router.post("/tablos/:tabloId/file/:path{.+}", ...postClientTabloFile); + + return router; +}; diff --git a/apps/api/src/routers/index.ts b/apps/api/src/routers/index.ts index ea248f1..342505b 100644 --- a/apps/api/src/routers/index.ts +++ b/apps/api/src/routers/index.ts @@ -1,10 +1,12 @@ import { Hono } from "hono"; import type { AppConfig } from "../config.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; +import { getClientAuthRouter } from "./clientAuth.js"; import type { BaseEnv } from "../types/app.types.js"; import { getAdminRouter } from "./admin.js"; import { getAuthenticatedRouter } from "./authRouter.js"; import { getPublicClientInvitesRouter } from "./clientInvites.js"; +import { getClientPortalRouter } from "./clientPortal.js"; import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js"; import { getPublicRouter } from "./public.js"; import { getStripeWebhookRouter } from "./stripe.js"; @@ -36,6 +38,22 @@ export const getMainRouter = (config: AppConfig) => { // admin routes mainRouter.route("/admin", getAdminRouter(config)); + // public client auth routes + mainRouter.route( + "/client-auth", + getClientAuthRouter({ + apiBaseUrl: config.API_BASE_URL, + clientsUrl: config.CLIENTS_URL, + cookieDomain: config.CLIENT_AUTH_COOKIE_DOMAIN, + cookieName: config.CLIENT_AUTH_COOKIE_NAME, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + magicLinkTtlMinutes: config.CLIENT_MAGIC_LINK_TTL_MINUTES, + sessionTtlDays: config.CLIENT_SESSION_TTL_DAYS, + }) + ); + + mainRouter.route("/client-portal", getClientPortalRouter()); + // public client onboarding routes mainRouter.route("/client-invites", getPublicClientInvitesRouter()); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 88dbe51..2e20429 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -713,66 +713,9 @@ const deleteMe = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - const { data: rawProfile, error: profileError } = await supabase - .from("profiles") - .select("organization_id") - .eq("id", user.id) - .single(); - - if (profileError || !rawProfile) { - return c.json({ error: "User not found" }, 404); - } - - const profile = rawProfile as typeof rawProfile & { organization_id: number | null }; - const deletedAt = new Date().toISOString(); - let orgWasSoftDeleted = false; - - if (profile.organization_id) { - const { count, error: countError } = await supabase - .from("profiles") - .select("id", { count: "exact", head: true }) - .eq("organization_id", profile.organization_id); - - if (countError) { - console.warn("Failed to count org members during account deletion, skipping org soft-delete:", countError.message); - } else if ((count ?? 0) === 1) { - const { error: orgDeleteError } = await (supabase.from("organizations") as any) - .update({ deleted_at: deletedAt }) - .eq("id", profile.organization_id); - if (orgDeleteError) { - return c.json({ error: "Failed to delete account" }, 500); - } - orgWasSoftDeleted = true; - } - } - - const { error: profileDeleteError } = await (supabase.from("profiles") as any) - .update({ deleted_at: deletedAt }) - .eq("id", user.id); - - if (profileDeleteError) { - if (orgWasSoftDeleted) { - const { error: rollbackErr } = await (supabase.from("organizations") as any) - .update({ deleted_at: null }) - .eq("id", profile.organization_id); - if (rollbackErr) console.error("Failed to roll back org soft-delete:", rollbackErr.message); - } - return c.json({ error: "Failed to delete account" }, 500); - } - const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id); if (authDeleteError) { - const { error: profileRollbackErr } = await (supabase.from("profiles") as any) - .update({ deleted_at: null }) - .eq("id", user.id); - if (profileRollbackErr) console.error("Failed to roll back profile soft-delete:", profileRollbackErr.message); - if (orgWasSoftDeleted) { - const { error: orgRollbackErr } = await (supabase.from("organizations") as any) - .update({ deleted_at: null }) - .eq("id", profile.organization_id); - if (orgRollbackErr) console.error("Failed to roll back org soft-delete:", orgRollbackErr.message); - } return c.json({ error: "Failed to delete account" }, 500); } diff --git a/apps/clients/src/components/ClientAuthGate.tsx b/apps/clients/src/components/ClientAuthGate.tsx index 4c75eda..20fc799 100644 --- a/apps/clients/src/components/ClientAuthGate.tsx +++ b/apps/clients/src/components/ClientAuthGate.tsx @@ -1,47 +1,15 @@ -import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import { useEffect, useState } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; -import { supabase } from "../lib/supabase"; +import { useClientSession } from "../hooks/useClientSession"; export function ClientAuthGate() { - const { session } = useSession(); const location = useLocation(); - const [isCheckingSession, setIsCheckingSession] = useState(true); - const [hasSession, setHasSession] = useState(false); + const { data: client, isLoading } = useClientSession(); - useEffect(() => { - let isMounted = true; - - if (session) { - setHasSession(true); - setIsCheckingSession(false); - return () => { - isMounted = false; - }; - } - - supabase.auth - .getSession() - .then(({ data }) => { - if (!isMounted) return; - setHasSession(Boolean(data.session)); - }) - .finally(() => { - if (isMounted) { - setIsCheckingSession(false); - } - }); - - return () => { - isMounted = false; - }; - }, [session]); - - if (session || hasSession) { + if (client) { return ; } - if (isCheckingSession) { + if (isLoading) { return (
diff --git a/apps/clients/src/components/ClientLayout.test.tsx b/apps/clients/src/components/ClientLayout.test.tsx index 4578431..6fed96e 100644 --- a/apps/clients/src/components/ClientLayout.test.tsx +++ b/apps/clients/src/components/ClientLayout.test.tsx @@ -1,11 +1,44 @@ import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as clientSessionHooks from "../hooks/useClientSession"; import AppRoutes from "../routes"; import { renderWithProviders } from "../test/testHelpers"; import { ClientLayout } from "./ClientLayout"; describe("ClientLayout", () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation(() => ({ + addEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: "", + onchange: null, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + })), + }); + vi.spyOn(clientSessionHooks, "useRequestClientMagicLink").mockReturnValue({ + isPending: false, + mutateAsync: vi.fn(), + } as unknown as ReturnType); + vi.spyOn(clientSessionHooks, "useClientLogout").mockReturnValue({ + isPending: false, + mutateAsync: vi.fn(), + } as unknown as ReturnType); + }); + it("uses the main app style header shell and scrolling main viewport", () => { + vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({ + data: { + id: "client-1", + email: "client@example.com", + }, + } as ReturnType); + const { container } = renderWithProviders(); const header = container.querySelector("header"); @@ -32,12 +65,19 @@ describe("ClientLayout", () => { }); it("redirects unauthenticated client routes to the login page", async () => { + vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({ + data: null, + isLoading: false, + } as ReturnType); + renderWithProviders(, { route: "/tablo/tablo-1", testUser: undefined, }); expect(await screen.findByTestId("auth-card-shell")).toBeInTheDocument(); - expect(await screen.findByRole("button", { name: "Connexion" })).toBeInTheDocument(); + expect( + await screen.findByRole("button", { name: "Recevoir un lien de connexion" }) + ).toBeInTheDocument(); }); }); diff --git a/apps/clients/src/components/ClientLayout.tsx b/apps/clients/src/components/ClientLayout.tsx index a2079db..1f4b695 100644 --- a/apps/clients/src/components/ClientLayout.tsx +++ b/apps/clients/src/components/ClientLayout.tsx @@ -1,8 +1,7 @@ -import { useSession } from "@xtablo/shared/contexts/SessionContext"; import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar"; import { Button } from "@xtablo/ui/components/button"; -import { Outlet } from "react-router-dom"; -import { supabase } from "../lib/supabase"; +import { Outlet, useNavigate } from "react-router-dom"; +import { useClientLogout, useClientSession } from "../hooks/useClientSession"; function getInitials(email: string): string { const parts = email.split("@")[0].split(/[._-]/); @@ -13,14 +12,18 @@ function getInitials(email: string): string { } export function ClientLayout() { - const { session } = useSession(); - if (!session) return null; + const navigate = useNavigate(); + const { data: client } = useClientSession(); + const logout = useClientLogout(); - const email = session.user.email ?? ""; + if (!client) return null; + + const email = client.email ?? ""; const initials = email ? getInitials(email) : "?"; const handleLogout = async () => { - await supabase.auth.signOut(); + await logout.mutateAsync(); + navigate("/login", { replace: true }); }; return ( @@ -35,7 +38,7 @@ export function ClientLayout() { {email}
-
diff --git a/apps/clients/src/hooks/useClientPortal.ts b/apps/clients/src/hooks/useClientPortal.ts new file mode 100644 index 0000000..caa4f27 --- /dev/null +++ b/apps/clients/src/hooks/useClientPortal.ts @@ -0,0 +1,323 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + Etape, + KanbanTask, + KanbanTaskUpdate, + TabloFolder, + TaskStatus, + UserTablo, +} from "@xtablo/shared-types"; +import { clientApi } from "../lib/api"; + +type ClientTaskCreateInput = { + tablo_id: string; + title: string; + status?: TaskStatus | string; + parent_task_id?: string | null; + is_parent?: boolean; + position?: number; + description?: string | null; + assignee_id?: string | null; + due_date?: string | null; +}; + +export function useClientTablos() { + return useQuery({ + queryKey: ["client-portal", "tablos"], + queryFn: async () => { + const { data } = await clientApi.get<{ tablos: UserTablo[] }>("/api/v1/client-portal/tablos"); + return data.tablos ?? []; + }, + }); +} + +export function useClientTablo(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "tablo", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ tablo: UserTablo }>( + `/api/v1/client-portal/tablos/${tabloId}` + ); + return data.tablo; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloTasks(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "tasks", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ tasks: KanbanTask[] }>( + `/api/v1/client-portal/tablos/${tabloId}/tasks` + ); + return data.tasks ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloEtapes(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "etapes", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ etapes: Etape[] }>( + `/api/v1/client-portal/tablos/${tabloId}/etapes` + ); + return data.etapes ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloEvents(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "events", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ events: unknown[] }>( + `/api/v1/client-portal/tablos/${tabloId}/events` + ); + return data.events ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloMembers(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "members", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ + members: { + id: string; + name: string; + is_admin: boolean; + email: string; + avatar_url: string | null; + }[]; + }>(`/api/v1/client-portal/tablos/${tabloId}/members`); + return data.members ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloFiles(tabloId: string) { + return useQuery<{ fileNames: string[] }>({ + queryKey: ["client-portal", "files", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ fileNames: string[] }>( + `/api/v1/client-portal/tablos/${tabloId}/files` + ); + return data; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloFolders(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "folders", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ folders: TabloFolder[] }>( + `/api/v1/client-portal/tablos/${tabloId}/folders` + ); + return data.folders ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +const invalidateClientFileQueries = (queryClient: ReturnType, tabloId: string) => { + queryClient.invalidateQueries({ queryKey: ["client-portal", "files", tabloId] }); + queryClient.invalidateQueries({ queryKey: ["client-portal", "folders", tabloId] }); +}; + +const invalidateClientTaskQueries = (queryClient: ReturnType, tabloId: string) => { + queryClient.invalidateQueries({ queryKey: ["client-portal", "tasks", tabloId] }); + queryClient.invalidateQueries({ queryKey: ["client-portal", "etapes", tabloId] }); +}; + +export function useClientCreateFile(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + fileName: string; + data: { content: string; contentType: string }; + }) => { + const { data } = await clientApi.post( + `/api/v1/client-portal/tablos/${params.tabloId}/file/${params.fileName}`, + params.data + ); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientDownloadFile() { + return useMutation({ + mutationFn: async ({ tabloId, fileName }: { tabloId: string; fileName: string }) => { + const response = await clientApi.get<{ + content: string; + contentType?: string; + }>(`/api/v1/client-portal/tablos/${tabloId}/file/${fileName}`); + + const fileData = response.data; + let blob: Blob; + + if (fileData.content.startsWith("data:")) { + const fileResponse = await fetch(fileData.content); + blob = await fileResponse.blob(); + } else { + blob = new Blob([fileData.content], { + type: fileData.contentType || "application/octet-stream", + }); + } + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }, + }); +} + +export function useClientCreateFolder(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + name: string; + description: string; + createdBy: string; + }) => { + const { data } = await clientApi.post(`/api/v1/client-portal/tablos/${params.tabloId}/folders`, { + description: params.description, + name: params.name, + }); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientUpdateFolder(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + folderId: string; + name: string; + description: string; + }) => { + const { data } = await clientApi.put( + `/api/v1/client-portal/tablos/${params.tabloId}/folders/${params.folderId}`, + { + description: params.description, + name: params.name, + } + ); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientDeleteFolder(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { tabloId: string; folderId: string; folderName: string }) => { + const { data } = await clientApi.delete( + `/api/v1/client-portal/tablos/${params.tabloId}/folders/${params.folderId}` + ); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientCreateTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (task: ClientTaskCreateInput) => { + const { data } = await clientApi.post<{ task: unknown }>( + `/api/v1/client-portal/tablos/${tabloId}/tasks`, + task + ); + + return data.task; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +export function useClientUpdateTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + tablo_id: _tabloId, + ...updates + }: KanbanTaskUpdate & { id: string; tablo_id?: string }) => { + const { data } = await clientApi.patch<{ task: unknown }>( + `/api/v1/client-portal/tablos/${tabloId}/tasks/${id}`, + updates + ); + + return data.task; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +export function useClientDeleteTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (taskId: string) => { + await clientApi.delete(`/api/v1/client-portal/tablos/${tabloId}/tasks/${taskId}`); + return taskId; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +export function useClientUpdateTaskPositions(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + updates: Array<{ + id: string; + position: number; + status?: TaskStatus; + parent_task_id?: string | null; + }> + ) => { + await Promise.all( + updates.map(({ id, ...taskUpdates }) => + clientApi.patch(`/api/v1/client-portal/tablos/${tabloId}/tasks/${id}`, taskUpdates) + ) + ); + + return updates; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} diff --git a/apps/clients/src/hooks/useClientSession.ts b/apps/clients/src/hooks/useClientSession.ts new file mode 100644 index 0000000..53c6945 --- /dev/null +++ b/apps/clients/src/hooks/useClientSession.ts @@ -0,0 +1,67 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Tables } from "@xtablo/shared-types"; +import { clientApi } from "../lib/api"; + +type ClientSessionResponse = { + client: Tables<"clients">; +}; + +export function useClientSession() { + return useQuery | null>({ + queryKey: ["client-session"], + queryFn: async () => { + try { + const { data } = await clientApi.get("/api/v1/client-auth/me"); + return data.client; + } catch (error) { + const status = + typeof error === "object" && + error !== null && + "response" in error && + typeof error.response === "object" && + error.response !== null && + "status" in error.response && + typeof error.response.status === "number" + ? error.response.status + : null; + + if (status === 401) { + return null; + } + + throw error; + } + }, + retry: false, + }); +} + +export function useRequestClientMagicLink() { + return useMutation({ + mutationFn: async ({ email, redirectTo }: { email: string; redirectTo: string }) => { + const { data } = await clientApi.post<{ + message: string; + success: boolean; + }>("/api/v1/client-auth/request-link", { + email, + redirectTo, + }); + + return data; + }, + }); +} + +export function useClientLogout() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + await clientApi.post("/api/v1/client-auth/logout"); + }, + onSuccess: () => { + queryClient.setQueryData(["client-session"], null); + queryClient.removeQueries({ queryKey: ["client-portal"] }); + }, + }); +} diff --git a/apps/clients/src/lib/api.ts b/apps/clients/src/lib/api.ts new file mode 100644 index 0000000..722d337 --- /dev/null +++ b/apps/clients/src/lib/api.ts @@ -0,0 +1,8 @@ +import { buildApi } from "@xtablo/shared"; + +const API_URL = import.meta.env.VITE_API_URL as string; + +export const clientApi = buildApi(API_URL); +if ("defaults" in clientApi && clientApi.defaults) { + clientApi.defaults.withCredentials = true; +} diff --git a/apps/clients/src/main.tsx b/apps/clients/src/main.tsx index 6d41477..8b42cc0 100644 --- a/apps/clients/src/main.tsx +++ b/apps/clients/src/main.tsx @@ -1,13 +1,11 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@xtablo/shared"; -import { SessionProvider } from "@xtablo/shared/contexts/SessionContext"; import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; import { Toaster } from "@xtablo/ui/components/sonner"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter as Router } from "react-router-dom"; import App from "./App"; -import { supabase } from "./lib/supabase"; import "@xtablo/ui/styles/globals.css"; import "@xtablo/tablo-views/styles/tablo-details-shell.css"; @@ -18,14 +16,12 @@ import "./lib/rum"; createRoot(document.getElementById("client-root")!).render( - - - - - - - - + + + + + + ); diff --git a/apps/clients/src/pages/ClientTabloListPage.tsx b/apps/clients/src/pages/ClientTabloListPage.tsx index bbfff3d..f01f665 100644 --- a/apps/clients/src/pages/ClientTabloListPage.tsx +++ b/apps/clients/src/pages/ClientTabloListPage.tsx @@ -1,21 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; -import type { UserTablo } from "@xtablo/shared-types"; import { Link, Navigate } from "react-router-dom"; -import { supabase } from "../lib/supabase"; - -function useClientTablosList() { - return useQuery({ - queryKey: ["client-tablos-list"], - queryFn: async () => { - const { data, error } = await supabase.from("user_tablos").select("*"); - if (error) throw error; - return (data ?? []) as UserTablo[]; - }, - }); -} +import { useClientTablos } from "../hooks/useClientPortal"; export function ClientTabloListPage() { - const { data: tablos, isLoading } = useClientTablosList(); + const { data: tablos, isLoading } = useClientTablos(); if (isLoading) { return ( diff --git a/apps/clients/src/pages/ClientTabloPage.test.tsx b/apps/clients/src/pages/ClientTabloPage.test.tsx index b687b51..4a1a53d 100644 --- a/apps/clients/src/pages/ClientTabloPage.test.tsx +++ b/apps/clients/src/pages/ClientTabloPage.test.tsx @@ -6,12 +6,9 @@ import { ClientTabloPage } from "./ClientTabloPage"; const { apiGetMock, apiPostMock, + apiPatchMock, apiPutMock, apiDeleteMock, - updateTaskMock, - insertTaskMock, - deleteTaskMock, - supabaseFromMock, } = vi.hoisted(() => { const apiGetMock = vi.fn(async (url: string) => { if (url.endsWith("/brief.pdf")) { @@ -32,45 +29,22 @@ const { folder: { id: "folder-1", name: "Livrable", description: "" }, }, })); + const apiPatchMock = vi.fn(async () => ({ + status: 200, + data: { task: { id: "task-1" } }, + })); const apiPutMock = vi.fn(async () => ({ status: 200, data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } }, })); const apiDeleteMock = vi.fn(async () => ({ status: 200, data: { message: "ok" } })); - const createUpdateBuilder = () => { - const builder = { - error: null as null, - eq: vi.fn(() => builder), - select: vi.fn(() => ({ - single: async () => ({ data: { id: "task-1" }, error: null }), - })), - }; - return builder; - }; - const updateTaskMock = vi.fn(() => createUpdateBuilder()); - const insertTaskMock = vi.fn(() => ({ - select: () => ({ - single: async () => ({ data: { id: "task-created" }, error: null }), - }), - })); - const deleteTaskMock = vi.fn(() => ({ - eq: vi.fn(async () => ({ error: null })), - })); - const supabaseFromMock = vi.fn(() => ({ - insert: insertTaskMock, - update: updateTaskMock, - delete: deleteTaskMock, - })); return { apiGetMock, apiPostMock, + apiPatchMock, apiPutMock, apiDeleteMock, - updateTaskMock, - insertTaskMock, - deleteTaskMock, - supabaseFromMock, }; }); let latestTabloTasksSectionProps: Record | null = null; @@ -84,32 +58,102 @@ vi.mock("@xtablo/shared", async (importOriginal) => { return { ...actual, buildApi: () => ({ - create: () => ({ - get: apiGetMock, - post: apiPostMock, - put: apiPutMock, - delete: apiDeleteMock, - }), + defaults: {}, + delete: apiDeleteMock, + get: apiGetMock, + patch: apiPatchMock, + post: apiPostMock, + put: apiPutMock, }), }; }); -vi.mock("../lib/supabase", () => ({ - supabase: { - from: supabaseFromMock, - }, -})); - vi.mock("@tanstack/react-query", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useQuery: ({ queryKey, queryFn }: { queryKey: string[]; queryFn?: () => Promise }) => { - if (queryKey[0] === "client-tablo-folders" && queryFn) { + if (queryKey[0] === "client-portal" && queryKey[1] === "folders" && queryFn) { void queryFn(); } switch (queryKey[0]) { + case "client-session": + return { + data: { + id: "client-user-1", + email: "client@example.com", + }, + isLoading: false, + error: null, + }; + case "client-portal": + if (queryKey[1] === "tablo") { + return { + data: { + id: "tablo-1", + name: "Client Project", + color: "bg-blue-500", + image: null, + created_at: "2026-01-01T00:00:00.000Z", + deleted_at: null, + position: 0, + status: "todo", + user_id: "user-1", + is_admin: false, + access_level: "guest", + }, + isLoading: false, + }; + } + if (queryKey[1] === "tasks") { + return { + data: [ + { + id: "task-1", + title: "Prepare proposal", + status: "todo", + tablo_id: "tablo-1", + assignee_id: "client-user-1", + }, + ], + isLoading: false, + error: null, + }; + } + if (queryKey[1] === "etapes") { + return { + data: [ + { + id: "etape-1", + title: "Kickoff", + status: "in_progress", + position: 0, + }, + ], + isLoading: false, + error: null, + }; + } + if (queryKey[1] === "events" || queryKey[1] === "members" || queryKey[1] === "folders") { + return { + data: [], + isLoading: false, + error: null, + }; + } + if (queryKey[1] === "files") { + return { + data: { fileNames: [] }, + isLoading: false, + error: null, + }; + } + return { + data: undefined, + isLoading: false, + error: null, + }; case "client-tablo": return { data: { @@ -127,47 +171,6 @@ vi.mock("@tanstack/react-query", async (importOriginal) => { }, isLoading: false, }; - case "client-tasks": - return { - data: [ - { - id: "task-1", - title: "Prepare proposal", - status: "todo", - tablo_id: "tablo-1", - assignee_id: "client-user-1", - }, - ], - isLoading: false, - error: null, - }; - case "client-etapes": - return { - data: [ - { - id: "etape-1", - title: "Kickoff", - status: "in_progress", - position: 0, - }, - ], - isLoading: false, - error: null, - }; - case "client-events": - case "client-members": - case "client-tablo-folders": - return { - data: [], - isLoading: false, - error: null, - }; - case "client-tablo-files": - return { - data: { fileNames: [] }, - isLoading: false, - error: null, - }; default: return { data: undefined, @@ -416,25 +419,22 @@ describe("ClientTabloPage parity shell", () => { HTMLAnchorElement.prototype.click = vi.fn(); apiGetMock.mockClear(); apiPostMock.mockClear(); + apiPatchMock.mockClear(); apiPutMock.mockClear(); apiDeleteMock.mockClear(); - updateTaskMock.mockClear(); - insertTaskMock.mockClear(); - deleteTaskMock.mockClear(); - supabaseFromMock.mockClear(); latestTabloTasksSectionProps = null; latestEtapesSectionProps = null; latestRoadmapSectionProps = null; latestTabloFilesSectionProps = null; }); - it("requests folders from the tablo-data API route", () => { + it("requests folders from the client-portal API route", () => { renderWithProviders(, { route: "/tablo/tablo-1", path: "/tablo/:tabloId", }); - expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders"); + expect(apiGetMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders"); }); it("wires real task mutation callbacks throughout the client task surfaces", async () => { @@ -470,36 +470,44 @@ describe("ClientTabloPage parity shell", () => { await user.click(screen.getByRole("button", { name: "Changer statut roadmap test" })); await waitFor(() => { - expect(supabaseFromMock).toHaveBeenCalledWith("tasks"); - expect(insertTaskMock).toHaveBeenCalledTimes(2); - expect(insertTaskMock).toHaveBeenCalledWith( + expect(apiPostMock).toHaveBeenCalledTimes(2); + expect(apiPostMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks", expect.objectContaining({ + is_parent: false, + parent_task_id: "etape-1", + position: 0, + status: "todo", tablo_id: "tablo-1", title: "Task from etape", - status: "todo", - assignee_id: null, - position: 0, - parent_task_id: "etape-1", - is_parent: false, - description: null, - due_date: null, }) ); - expect(updateTaskMock).toHaveBeenCalledWith({ title: "Updated task title" }); - expect(updateTaskMock).toHaveBeenCalledWith({ position: 7, status: "done" }); - expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" }); - expect(deleteTaskMock).toHaveBeenCalledTimes(1); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { title: "Updated task title" } + ); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { position: 7, status: "done" } + ); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { status: "done" } + ); + expect(apiDeleteMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1" + ); }); }); - it("renders the main-route style header metadata and discussion CTA", () => { + it("renders the main-route style header metadata without the legacy discussion CTA", () => { renderWithProviders(, { route: "/tablo/tablo-1", path: "/tablo/:tabloId", }); expect(screen.getByText("Client Project")).toBeInTheDocument(); - expect(screen.getAllByRole("button", { name: "Discussion" })).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Discussion" })).not.toBeInTheDocument(); expect(screen.getAllByText("Rôle").length).toBeGreaterThan(0); expect(screen.getAllByText("Créé le").length).toBeGreaterThan(0); expect(screen.getAllByText("Progression").length).toBeGreaterThan(0); @@ -553,7 +561,10 @@ describe("ClientTabloPage parity shell", () => { await user.click(screen.getByRole("button", { name: "Prepare proposal" })); await waitFor(() => { - expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" }); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { status: "done" } + ); }); }); @@ -580,20 +591,22 @@ describe("ClientTabloPage parity shell", () => { await user.click(screen.getByRole("button", { name: "Supprimer livrable test" })); await waitFor(() => { - expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/file/brief.pdf", { + expect(apiPostMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/file/brief.pdf", { content: "data:application/pdf;base64,AAAA", contentType: "application/pdf", }); - expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/brief.pdf"); - expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders", { + expect(apiGetMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/file/brief.pdf"); + expect(apiPostMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders", { name: "Livrable", description: "Desc", }); - expect(apiPutMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1", { + expect(apiPutMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders/folder-1", { name: "Livrable mis à jour", description: "Desc", }); - expect(apiDeleteMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1"); + expect(apiDeleteMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/folders/folder-1" + ); }); }); }); diff --git a/apps/clients/src/pages/ClientTabloPage.tsx b/apps/clients/src/pages/ClientTabloPage.tsx index 2ff728d..8d7996a 100644 --- a/apps/clients/src/pages/ClientTabloPage.tsx +++ b/apps/clients/src/pages/ClientTabloPage.tsx @@ -1,20 +1,10 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { buildApi, cn } from "@xtablo/shared"; -import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import type { - Etape, - KanbanTask, - KanbanTaskUpdate, - TabloFolder, - TaskStatus, - UserTablo, -} from "@xtablo/shared-types"; +import { cn } from "@xtablo/shared"; +import type { Etape, TaskStatus } from "@xtablo/shared-types"; import { EtapesSection, RoadmapSection, type SingleTabloTabId, SingleTabloView, - TabloDiscussionSection, TabloEventsSection, TabloFilesSection, TabloTasksSection, @@ -22,384 +12,25 @@ import { import { FolderIcon } from "lucide-react"; import { useState } from "react"; import { useParams } from "react-router-dom"; -import { supabase } from "../lib/supabase"; - -const API_URL = import.meta.env.VITE_API_URL as string; - -// ─── Local hooks ────────────────────────────────────────────────────────────── - -function useAuthedApi(accessToken: string | undefined) { - return buildApi(API_URL).create({ - headers: { - Authorization: `Bearer ${accessToken ?? ""}`, - }, - }); -} - -function useClientTablo(tabloId: string) { - return useQuery({ - queryKey: ["client-tablo", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("user_tablos") - .select("*") - .eq("id", tabloId) - .single(); - if (error) throw error; - return data as UserTablo; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloTasks(tabloId: string) { - return useQuery({ - queryKey: ["client-tasks", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("tasks_with_assignee") - .select("*") - .eq("tablo_id", tabloId) - .eq("is_parent", false) - .order("updated_at", { ascending: false }); - if (error) throw error; - return (data ?? []) as KanbanTask[]; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloEtapes(tabloId: string) { - return useQuery({ - queryKey: ["client-etapes", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("tasks") - .select("*") - .eq("tablo_id", tabloId) - .eq("is_parent", true) - .order("position", { ascending: true }); - if (error) throw error; - return (data ?? []) as Etape[]; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloEvents(tabloId: string) { - return useQuery({ - queryKey: ["client-events", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("events_and_tablos") - .select("*") - .eq("tablo_id", tabloId) - .order("start_date", { ascending: false }); - if (error) throw error; - return data ?? []; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloMembers(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - return useQuery({ - queryKey: ["client-members", tabloId], - queryFn: async () => { - const { data } = await api.get<{ - members: { - id: string; - name: string; - is_admin: boolean; - email: string; - avatar_url: string | null; - }[]; - }>(`/api/v1/tablos/members/${tabloId}`); - return data.members; - }, - enabled: !!tabloId && !!accessToken, - }); -} - -function useClientTabloFiles(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - return useQuery<{ fileNames: string[] }>({ - queryKey: ["client-tablo-files", tabloId], - queryFn: async () => { - const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`); - return data as { fileNames: string[] }; - }, - enabled: !!tabloId && !!accessToken, - }); -} - -function useClientTabloFolders(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - return useQuery({ - queryKey: ["client-tablo-folders", tabloId], - queryFn: async () => { - const { data } = await api.get<{ folders: TabloFolder[] }>( - `/api/v1/tablo-data/${tabloId}/folders` - ); - return data.folders ?? []; - }, - enabled: !!tabloId && !!accessToken, - }); -} - -const invalidateClientFileQueries = ( - queryClient: ReturnType, - tabloId: string -) => { - queryClient.invalidateQueries({ queryKey: ["client-tablo-files", tabloId] }); - queryClient.invalidateQueries({ queryKey: ["client-tablo-folders", tabloId] }); -}; - -function useClientCreateFile(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { - tabloId: string; - fileName: string; - data: { content: string; contentType: string }; - }) => { - const response = await api.post( - `/api/v1/tablo-data/${params.tabloId}/file/${params.fileName}`, - params.data - ); - if (response.status !== 200) { - throw new Error("Failed to create file"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -function useClientDownloadFile(accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - - return useMutation({ - mutationFn: async ({ tabloId, fileName }: { tabloId: string; fileName: string }) => { - const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`); - if (response.status !== 200) { - throw new Error("Failed to download file"); - } - - const fileData = response.data as { content: string; contentType?: string }; - let blob: Blob; - - if (fileData.content.startsWith("data:")) { - const fileResponse = await fetch(fileData.content); - blob = await fileResponse.blob(); - } else { - blob = new Blob([fileData.content], { - type: fileData.contentType || "application/octet-stream", - }); - } - - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - }, - }); -} - -function useClientCreateFolder(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { - tabloId: string; - name: string; - description: string; - createdBy: string; - }) => { - const response = await api.post(`/api/v1/tablo-data/${params.tabloId}/folders`, { - name: params.name, - description: params.description, - }); - if (response.status !== 200) { - throw new Error("Failed to create folder"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -function useClientUpdateFolder(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { - tabloId: string; - folderId: string; - name: string; - description: string; - }) => { - const response = await api.put( - `/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`, - { - name: params.name, - description: params.description, - } - ); - if (response.status !== 200) { - throw new Error("Failed to update folder"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -function useClientDeleteFolder(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { tabloId: string; folderId: string; folderName: string }) => { - const response = await api.delete( - `/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}` - ); - if (response.status !== 200) { - throw new Error("Failed to delete folder"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -type ClientTaskCreateInput = { - tablo_id: string; - title: string; - status?: TaskStatus | string; - parent_task_id?: string | null; - is_parent?: boolean; - position?: number; - description?: string | null; - assignee_id?: string | null; - due_date?: string | null; -}; - -const invalidateClientTaskQueries = ( - queryClient: ReturnType, - tabloId: string -) => { - queryClient.invalidateQueries({ queryKey: ["client-tasks", tabloId] }); -}; - -function useClientCreateTask(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (task: ClientTaskCreateInput) => { - const { data, error } = await supabase - .from("tasks") - .insert({ - tablo_id: task.tablo_id, - title: task.title, - status: (task.status as TaskStatus | undefined) ?? "todo", - assignee_id: task.assignee_id ?? null, - position: task.position ?? 0, - parent_task_id: task.parent_task_id ?? null, - is_parent: task.is_parent ?? false, - description: task.description ?? null, - due_date: task.due_date ?? null, - }) - .select() - .single(); - - if (error) throw error; - return data; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} - -function useClientUpdateTask(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - id, - tablo_id: _tabloId, - ...updates - }: KanbanTaskUpdate & { id: string; tablo_id?: string }) => { - const { data, error } = await supabase - .from("tasks") - .update(updates) - .eq("id", id) - .select() - .single(); - - if (error) throw error; - return data; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} - -function useClientDeleteTask(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (taskId: string) => { - const { error } = await supabase.from("tasks").delete().eq("id", taskId); - if (error) throw error; - return taskId; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} - -function useClientUpdateTaskPositions(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ( - updates: Array<{ - id: string; - position: number; - status?: TaskStatus; - parent_task_id?: string | null; - }> - ) => { - const results = await Promise.all( - updates.map(({ id, position, status, parent_task_id }) => - supabase - .from("tasks") - .update({ - position, - ...(status && { status }), - ...(parent_task_id !== undefined ? { parent_task_id } : {}), - }) - .eq("id", id) - ) - ); - - const errors = results.filter((result) => result.error); - if (errors.length > 0) { - throw new Error("Failed to update some task positions"); - } - - return updates; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} +import { + useClientCreateFile, + useClientCreateFolder, + useClientCreateTask, + useClientDeleteFolder, + useClientDeleteTask, + useClientDownloadFile, + useClientTablo, + useClientTabloEtapes, + useClientTabloEvents, + useClientTabloFiles, + useClientTabloFolders, + useClientTabloMembers, + useClientTabloTasks, + useClientUpdateFolder, + useClientUpdateTask, + useClientUpdateTaskPositions, +} from "../hooks/useClientPortal"; +import { useClientSession } from "../hooks/useClientSession"; function getStatusConfig(status: string) { switch (status) { @@ -444,16 +75,12 @@ function getEtapeProgressStats(etapes: Etape[]) { }; } -// ─── Page ───────────────────────────────────────────────────────────────────── - export function ClientTabloPage() { const { tabloId } = useParams<{ tabloId: string }>(); - const { session } = useSession(); const [activeTab, setActiveTab] = useState("overview"); + const { data: client } = useClientSession(); - const accessToken = session?.access_token; - const currentUserId = session?.user.id ?? ""; - + const currentUserId = client?.id ?? ""; const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? ""); const { data: tasks = [] } = useClientTabloTasks(tabloId ?? ""); const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? ""); @@ -462,29 +89,28 @@ export function ClientTabloPage() { isLoading: eventsLoading, error: eventsError, } = useClientTabloEvents(tabloId ?? ""); - const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken); + const { data: members = [] } = useClientTabloMembers(tabloId ?? ""); const { data: filesData, isLoading: filesLoading, error: filesError, - } = useClientTabloFiles(tabloId ?? "", accessToken); + } = useClientTabloFiles(tabloId ?? ""); const { data: folders = [], isLoading: foldersLoading, error: foldersError, - } = useClientTabloFolders(tabloId ?? "", accessToken); + } = useClientTabloFolders(tabloId ?? ""); const { mutate: createTask } = useClientCreateTask(tabloId ?? ""); const { mutate: updateTask } = useClientUpdateTask(tabloId ?? ""); const { mutate: deleteTask } = useClientDeleteTask(tabloId ?? ""); const { mutate: updateTaskPositions } = useClientUpdateTaskPositions(tabloId ?? ""); - const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? "", accessToken); - const { mutateAsync: downloadFile } = useClientDownloadFile(accessToken); - const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? "", accessToken); - const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? "", accessToken); - const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? "", accessToken); - - const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith(".")); + const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? ""); + const { mutateAsync: downloadFile } = useClientDownloadFile(); + const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? ""); + const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? ""); + const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? ""); + const fileNames = (filesData?.fileNames ?? []).filter((fileName) => !fileName.startsWith(".")); const currentUser = { id: currentUserId, avatar_url: null }; if (tabloLoading) { @@ -515,7 +141,7 @@ export function ClientTabloPage() { progress={progress} activeTab={activeTab} onTabChange={setActiveTab} - discussionAction={{ kind: "button", onClick: () => setActiveTab("discussion") }} + hiddenTabs={["discussion"]} > {activeTab === "overview" && (
@@ -682,15 +308,6 @@ export function ClientTabloPage() { /> )} - {activeTab === "discussion" && ( - - )} - {activeTab === "events" && ( undefined} - onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })} + onTaskStatusChange={(taskId, status) => + updateTask({ id: taskId, status: status as TaskStatus }) + } /> )} diff --git a/apps/clients/src/pages/LoginPage.test.tsx b/apps/clients/src/pages/LoginPage.test.tsx index 47f77d7..7f75f9a 100644 --- a/apps/clients/src/pages/LoginPage.test.tsx +++ b/apps/clients/src/pages/LoginPage.test.tsx @@ -1,19 +1,12 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../test/testHelpers"; +import * as clientSessionHooks from "../hooks/useClientSession"; import { LoginPage } from "./LoginPage"; -const { mockSignInWithPassword, mockNavigate } = vi.hoisted(() => ({ - mockSignInWithPassword: vi.fn(), +const { mockNavigate, mockRequestMagicLink } = vi.hoisted(() => ({ mockNavigate: vi.fn(), -})); - -vi.mock("../lib/supabase", () => ({ - supabase: { - auth: { - signInWithPassword: mockSignInWithPassword, - }, - }, + mockRequestMagicLink: vi.fn(), })); vi.mock("react-router-dom", async (importOriginal) => { @@ -28,9 +21,16 @@ describe("LoginPage", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); - mockSignInWithPassword.mockResolvedValue({ - data: { user: { email_confirmed_at: new Date().toISOString() } }, - error: null, + vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({ + data: null, + } as ReturnType); + vi.spyOn(clientSessionHooks, "useRequestClientMagicLink").mockReturnValue({ + isPending: false, + mutateAsync: mockRequestMagicLink, + } as unknown as ReturnType); + mockRequestMagicLink.mockResolvedValue({ + message: "If this email can access the client portal, a connection link has been sent.", + success: true, }); }); @@ -39,28 +39,27 @@ describe("LoginPage", () => { expect(screen.getByTestId("auth-card-shell")).toBeInTheDocument(); expect(screen.getByLabelText("Email")).toBeInTheDocument(); - expect(screen.getByLabelText("Mot de passe")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Connexion" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Recevoir un lien de connexion" })).toBeInTheDocument(); expect(screen.getAllByAltText("Xtablo")[0]).toHaveAttribute( "src", "https://assets.xtablo.com/logo_dark.png" ); }); - it("submits email/password login and resumes the stored redirect", async () => { + it("requests a magic link and forwards the stored redirect path", async () => { localStorage.setItem("clients.redirectUrl", "/tablo/tablo-42"); renderWithProviders(, { testUser: undefined }); fireEvent.change(screen.getByLabelText("Email"), { target: { value: "client@example.com" } }); - fireEvent.change(screen.getByLabelText("Mot de passe"), { target: { value: "password123" } }); - fireEvent.click(screen.getByRole("button", { name: "Connexion" })); + fireEvent.click(screen.getByRole("button", { name: "Recevoir un lien de connexion" })); await waitFor(() => { - expect(mockSignInWithPassword).toHaveBeenCalledWith({ + expect(mockRequestMagicLink).toHaveBeenCalledWith({ email: "client@example.com", - password: "password123", + redirectTo: "/tablo/tablo-42", }); - expect(mockNavigate).toHaveBeenCalledWith("/tablo/tablo-42"); }); + + expect(screen.getByText(/connection link has been sent/i)).toBeInTheDocument(); }); }); diff --git a/apps/clients/src/pages/LoginPage.tsx b/apps/clients/src/pages/LoginPage.tsx index f38f582..2b2c12e 100644 --- a/apps/clients/src/pages/LoginPage.tsx +++ b/apps/clients/src/pages/LoginPage.tsx @@ -1,54 +1,51 @@ -import { AuthCardShell, AuthEmailPasswordForm, AuthInfoBanner } from "@xtablo/auth-ui"; -import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import { useState } from "react"; +import { AuthCardShell, AuthInfoBanner } from "@xtablo/auth-ui"; +import { Button } from "@xtablo/ui/components/button"; +import { Input } from "@xtablo/ui/components/input"; +import { Label } from "@xtablo/ui/components/label"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; -import { supabase } from "../lib/supabase"; +import { useNavigate } from "react-router-dom"; +import { useClientSession, useRequestClientMagicLink } from "../hooks/useClientSession"; export function LoginPage() { const { t } = useTranslation(["auth", "common"]); - const { session } = useSession(); + const { data: client } = useClientSession(); + const requestMagicLink = useRequestClientMagicLink(); const navigate = useNavigate(); const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + useEffect(() => { + if (!client) return; - if (session) { const redirectUrl = localStorage.getItem("clients.redirectUrl"); if (redirectUrl) { localStorage.removeItem("clients.redirectUrl"); - navigate(redirectUrl); - } else { - navigate("/"); - } - } - - const onSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setIsPending(true); - setError(null); - - const { error: signInError } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (signInError) { - setError(signInError.message); - setIsPending(false); + navigate(redirectUrl, { replace: true }); return; } - const redirectUrl = localStorage.getItem("clients.redirectUrl"); - if (redirectUrl) { - localStorage.removeItem("clients.redirectUrl"); - navigate(redirectUrl); - } else { - navigate("/"); + navigate("/", { replace: true }); + }, [client, navigate]); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSuccessMessage(null); + + try { + const redirectTo = localStorage.getItem("clients.redirectUrl") || "/"; + const result = await requestMagicLink.mutateAsync({ email, redirectTo }); + setSuccessMessage(result.message); + } catch (requestError) { + const message = requestError instanceof Error ? requestError.message : "Connexion impossible"; + setError(message); } }; + const isPending = requestMagicLink.isPending; + return (
{error ? : null} + {successMessage ? : null} - - - {t("auth:login.forgotPassword")} - -
- } - /> +
+
+ + setEmail(event.target.value)} + placeholder={t("auth:login.emailPlaceholder")} + autoComplete="email" + required + /> +
+ + +
); diff --git a/apps/clients/src/routes.tsx b/apps/clients/src/routes.tsx index e10531f..74fb526 100644 --- a/apps/clients/src/routes.tsx +++ b/apps/clients/src/routes.tsx @@ -1,20 +1,14 @@ import { Route, Routes } from "react-router-dom"; import { ClientAuthGate } from "./components/ClientAuthGate"; import { ClientLayout } from "./components/ClientLayout"; -import { AuthCallback } from "./pages/AuthCallback"; import { ClientTabloListPage } from "./pages/ClientTabloListPage"; import { ClientTabloPage } from "./pages/ClientTabloPage"; import { LoginPage } from "./pages/LoginPage"; -import { ResetPasswordPage } from "./pages/ResetPasswordPage"; -import { SetPasswordPage } from "./pages/SetPasswordPage"; export default function AppRoutes() { return ( } /> - } /> - } /> - } /> }> }> } /> diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index 36d49fd..d1fa778 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -156,6 +156,7 @@ export type Database = { jti: string | null; purpose: string; redirect_to: string | null; + tablo_id: string | null; token_hash: string | null; }; Insert: { @@ -169,6 +170,7 @@ export type Database = { jti?: string | null; purpose: string; redirect_to?: string | null; + tablo_id?: string | null; token_hash?: string | null; }; Update: { @@ -182,6 +184,7 @@ export type Database = { jti?: string | null; purpose?: string; redirect_to?: string | null; + tablo_id?: string | null; token_hash?: string | null; }; Relationships: [ @@ -199,6 +202,27 @@ export type Database = { referencedRelation: "profiles"; referencedColumns: ["id"]; }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; + }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; + }, ]; }; clients: { diff --git a/packages/tablo-views/src/single-tablo/SingleTabloView.tsx b/packages/tablo-views/src/single-tablo/SingleTabloView.tsx index e95bc48..190c17c 100644 --- a/packages/tablo-views/src/single-tablo/SingleTabloView.tsx +++ b/packages/tablo-views/src/single-tablo/SingleTabloView.tsx @@ -49,6 +49,7 @@ interface SingleTabloViewProps { }; activeTab: SingleTabloTabId; onTabChange: (tabId: SingleTabloTabId) => void; + hiddenTabs?: SingleTabloTabId[]; hasUnreadDiscussion?: boolean; discussionAction?: DiscussionAction; canInviteMembers?: boolean; @@ -64,6 +65,7 @@ export function SingleTabloView({ progress, activeTab, onTabChange, + hiddenTabs = [], hasUnreadDiscussion = false, discussionAction, canInviteMembers = false, @@ -119,7 +121,7 @@ export function SingleTabloView({ }, ]; - const tabs = TABS.map((tab) => + const tabs = TABS.filter((tab) => !hiddenTabs.includes(tab.id as SingleTabloTabId)).map((tab) => tab.id === "discussion" ? { ...tab, hasUnread: hasUnreadDiscussion } : tab ); diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..7b148bb --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,384 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "xtablo-source" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = false +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20260430120000_drop_is_temporary.sql b/supabase/migrations/20260430120000_drop_is_temporary.sql index 2b1279a..9f8db47 100644 --- a/supabase/migrations/20260430120000_drop_is_temporary.sql +++ b/supabase/migrations/20260430120000_drop_is_temporary.sql @@ -1 +1,86 @@ -ALTER TABLE public.profiles DROP COLUMN IF EXISTS is_temporary; +DROP TRIGGER IF EXISTS enforce_non_temporary_on_paid_plan ON public.profiles; +DROP FUNCTION IF EXISTS public.enforce_non_temporary_on_paid_plan(); + +ALTER TABLE public.profiles + DROP CONSTRAINT IF EXISTS profiles_no_temporary_on_paid_plan; + +ALTER TABLE public.profiles + DROP COLUMN IF EXISTS is_temporary; + +CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER +AS $$ + DECLARE + name TEXT; + first_name TEXT; + last_name TEXT; + is_invited_user BOOLEAN; + email_prefix TEXT; + assigned_plan public.subscription_plan := 'none'; + BEGIN + -- Extract first_name and last_name from metadata + first_name = NEW.raw_user_meta_data ->> 'first_name'; + last_name = NEW.raw_user_meta_data ->> 'last_name'; + + -- If first_name is not provided, extract it from email (part before @) + IF first_name IS NULL OR first_name = '' THEN + email_prefix = SPLIT_PART(NEW.email, '@', 1); + first_name = email_prefix; + END IF; + + -- Determine the full name + IF NEW.raw_user_meta_data ->> 'name' IS NOT NULL + THEN + name = NEW.raw_user_meta_data ->> 'name'; + -- If name is provided but not first/last, try to split it + IF first_name IS NULL AND last_name IS NULL AND name IS NOT NULL THEN + first_name = SPLIT_PART(name, ' ', 1); + IF ARRAY_LENGTH(STRING_TO_ARRAY(name, ' '), 1) > 1 THEN + last_name = SUBSTRING(name FROM LENGTH(SPLIT_PART(name, ' ', 1)) + 2); + END IF; + END IF; + ELSE + name = CONCAT(first_name, ' ', last_name); + END IF; + + is_invited_user := COALESCE(NEW.raw_user_meta_data->>'role', '') = 'invited_user'; + + -- Preserve previous behavior: invited users do not get an automatic free plan. + IF NOT is_invited_user AND public.is_freemium_available() THEN + assigned_plan := 'free'; + END IF; + + INSERT INTO public.profiles (id, name, email, avatar_url, first_name, last_name, plan) + VALUES (NEW.id, name, NEW.email, NEW.raw_user_meta_data ->> 'avatar_url', first_name, last_name, assigned_plan); + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.handle_new_user() IS +'Trigger function that creates a profile when a new user is created. Extracts first name from email when missing and assigns the free plan while freemium is available, except for invited users.'; + +ALTER FUNCTION public.handle_new_user() OWNER TO postgres; + +CREATE OR REPLACE FUNCTION public.update_tablo_invites_on_login() RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER +AS $$ + BEGIN + IF (NEW.last_sign_in_at IS NULL OR NEW.last_sign_in_at = OLD.last_sign_in_at) THEN + RETURN NULL; + ELSE + -- After removing profiles.is_temporary, use the auth metadata role to + -- preserve the previous invited-user-only invite-consumption behavior. + UPDATE public.tablo_invites + SET is_pending = FALSE + WHERE invited_email = NEW.email + AND is_pending = TRUE + AND COALESCE(NEW.raw_user_meta_data->>'role', '') = 'invited_user'; + RETURN NEW; + END IF; + END; +$$; + +ALTER FUNCTION public.update_tablo_invites_on_login() OWNER TO postgres; diff --git a/supabase/migrations/20260501100000_create_client_auth_tables.sql b/supabase/migrations/20260501100000_create_client_auth_tables.sql index 76fb653..b315b7e 100644 --- a/supabase/migrations/20260501100000_create_client_auth_tables.sql +++ b/supabase/migrations/20260501100000_create_client_auth_tables.sql @@ -38,6 +38,7 @@ create table if not exists public.client_magic_links ( client_id uuid not null references public.clients(id) on delete cascade, email text not null, purpose text not null check (purpose in ('invite', 'login')), + tablo_id text references public.tablos(id) on delete cascade, token_hash text, jti text, redirect_to text, @@ -54,6 +55,9 @@ create index if not exists client_magic_links_active_idx on public.client_magic_links (client_id, purpose, expires_at) where consumed_at is null; +create index if not exists client_magic_links_tablo_id_idx + on public.client_magic_links (tablo_id); + create unique index if not exists client_magic_links_jti_unique_idx on public.client_magic_links (jti) where jti is not null; -- 2.45.2 From 90d34833e8f9577387abc1ae1995d4e76e74c334 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 1 May 2026 10:33:00 +0200 Subject: [PATCH 007/546] Fix config.ts --- .../__tests__/config/stripe-config.test.ts | 25 +- apps/api/src/config.ts | 44 +- apps/clients/tsconfig.tsbuildinfo | 2 +- apps/external/tsconfig.tsbuildinfo | 2 +- .../2026-04-30-client-magic-link-auth.md | 744 ++++++++++++++++++ packages/auth-ui/src/AuthCardShell.tsx | 2 +- 6 files changed, 812 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-30-client-magic-link-auth.md diff --git a/apps/api/src/__tests__/config/stripe-config.test.ts b/apps/api/src/__tests__/config/stripe-config.test.ts index 0a29917..d573af7 100644 --- a/apps/api/src/__tests__/config/stripe-config.test.ts +++ b/apps/api/src/__tests__/config/stripe-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { createConfig } from "../../config.js"; import type { Secrets } from "../../secrets.js"; @@ -17,6 +17,12 @@ const baseSecrets: Secrets = { stripeWebhookSecretStaging: "whsec_live_staging_secret_manager", }; +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + describe("createConfig stripe env overrides", () => { it("prefers env stripe keys in development to avoid local sandbox/account mismatch", () => { process.env.NODE_ENV = "development"; @@ -28,4 +34,21 @@ describe("createConfig stripe env overrides", () => { expect(config.STRIPE_SECRET_KEY).toBe("sk_test_env_override"); expect(config.STRIPE_WEBHOOK_SECRET).toBe("whsec_test_env_override"); }); + + it("derives a staging API base URL instead of localhost when API_BASE_URL is unset", () => { + process.env.NODE_ENV = "staging"; + process.env.SUPABASE_URL = "https://example.supabase.co"; + process.env.EMAIL_USER = "test@xtablo.com"; + process.env.EMAIL_CLIENT_ID = "client-id"; + process.env.STRIPE_SOLO_PRICE_ID = "price_solo"; + process.env.STRIPE_TEAM_PRICE_ID = "price_team"; + process.env.STRIPE_FOUNDER_PRICE_ID = "price_founder"; + process.env.R2_ACCOUNT_ID = "r2-account"; + process.env.XTABLO_URL = "https://app-staging.xtablo.com"; + delete process.env.API_BASE_URL; + + const config = createConfig(baseSecrets); + + expect(config.API_BASE_URL).toBe("https://api-staging.xtablo.com/api/v1"); + }); }); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 0b03ecc..fb87d27 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -54,6 +54,37 @@ function validateEnvVar(name: string, value: string | undefined): string { return value; } +function trimTrailingSlash(value: string) { + return value.replace(/\/+$/, ""); +} + +function resolveApiBaseUrl(input: { + apiBaseUrl?: string; + nodeEnv: AppConfig["NODE_ENV"]; + port: number; + xtabloUrl: string; +}) { + if (input.apiBaseUrl) { + return input.apiBaseUrl; + } + + if (input.nodeEnv === "development" || input.nodeEnv === "test") { + return `http://localhost:${input.port}/api/v1`; + } + + const xtabloUrl = trimTrailingSlash(input.xtabloUrl); + + if (xtabloUrl === "https://app.xtablo.com") { + return "https://api.xtablo.com/api/v1"; + } + + if (xtabloUrl === "https://app-staging.xtablo.com") { + return "https://api-staging.xtablo.com/api/v1"; + } + + return `${xtabloUrl}/api/v1`; +} + export function createConfig(secrets?: Secrets): AppConfig { const NODE_ENV = (process.env.NODE_ENV || "development") as | "development" @@ -73,11 +104,13 @@ export function createConfig(secrets?: Secrets): AppConfig { isStagingMode ? secrets!.stripeWebhookSecretStaging : secrets!.stripeWebhookSecret; const getStripeSecretKeyFromEnv = () => process.env.STRIPE_SECRET_KEY; const getStripeWebhookSecretFromEnv = () => process.env.STRIPE_WEBHOOK_SECRET; + const XTABLO_URL = process.env.XTABLO_URL || "https://app.xtablo.com"; + const PORT = parseInt(process.env.PORT || "8080", 10); // Base configuration const baseConfig: AppConfig = { NODE_ENV, - PORT: parseInt(process.env.PORT || "8080", 10), + PORT, SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL), SUPABASE_SERVICE_ROLE_KEY: isTestMode ? validateEnvVar("SUPABASE_SERVICE_ROLE_KEY", process.env.SUPABASE_SERVICE_ROLE_KEY) @@ -108,8 +141,13 @@ export function createConfig(secrets?: Secrets): AppConfig { EMAIL_REFRESH_TOKEN: isTestMode ? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN) : secrets!.emailRefreshToken, - API_BASE_URL: process.env.API_BASE_URL || `http://localhost:${process.env.PORT || "8080"}/api/v1`, - XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com", + API_BASE_URL: resolveApiBaseUrl({ + apiBaseUrl: process.env.API_BASE_URL, + nodeEnv: NODE_ENV, + port: PORT, + xtabloUrl: XTABLO_URL, + }), + XTABLO_URL, R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID), R2_ACCESS_KEY_ID: isTestMode ? validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID) diff --git a/apps/clients/tsconfig.tsbuildinfo b/apps/clients/tsconfig.tsbuildinfo index 0266a11..f38412a 100644 --- a/apps/clients/tsconfig.tsbuildinfo +++ b/apps/clients/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/envproduction.test.ts","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/components/clientauthgate.tsx","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/lib/rum.ts","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/pages/loginpage.test.tsx","./src/pages/loginpage.tsx","./src/pages/resetpasswordpage.test.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/setpasswordpage.test.tsx","./src/pages/setpasswordpage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/envproduction.test.ts","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/components/clientauthgate.tsx","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/hooks/useclientportal.ts","./src/hooks/useclientsession.ts","./src/lib/api.ts","./src/lib/rum.ts","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/pages/loginpage.test.tsx","./src/pages/loginpage.tsx","./src/pages/resetpasswordpage.test.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/setpasswordpage.test.tsx","./src/pages/setpasswordpage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/external/tsconfig.tsbuildinfo b/apps/external/tsconfig.tsbuildinfo index 0b37a6d..22943ee 100644 --- a/apps/external/tsconfig.tsbuildinfo +++ b/apps/external/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-30-client-magic-link-auth.md b/docs/superpowers/plans/2026-04-30-client-magic-link-auth.md new file mode 100644 index 0000000..776b751 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-client-magic-link-auth.md @@ -0,0 +1,744 @@ +# Client Magic Link Auth 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:** Replace `profiles.is_client` and the Supabase-auth-based client portal with a backend-owned client identity system using email magic links, stateless JWT session cookies, and API-backed authorization. + +**Architecture:** Introduce `public.clients`, `public.client_access`, and `public.client_magic_links` as the new client auth model. The API will own magic-link issuance, one-time exchange, cookie verification, and client-scoped resource authorization; `apps/clients` will stop using Supabase Auth and direct browser Supabase reads, and will instead rely on backend APIs with `withCredentials` cookies. + +**Tech Stack:** Supabase Postgres, Hono API, JWT + HttpOnly cookies, React 19, React Router, TanStack Query, Axios, Vitest, pnpm workspaces. + +**Spec:** `docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md` + +--- + +## File Structure + +### New files + +**Database** +- `supabase/migrations/20260501100000_create_client_auth_tables.sql` — creates `clients`, `client_access`, and `client_magic_links` +- `supabase/migrations/20260501150000_drop_profiles_is_client.sql` — removes `profiles.is_client` after code migration is complete + +**API helpers and tests** +- `apps/api/src/helpers/clientAccounts.ts` — normalize email, upsert client identities, manage access grants +- `apps/api/src/helpers/clientSessions.ts` — sign/verify client session JWTs, sign/verify magic-link JWT payloads, hash or look up one-time tokens +- `apps/api/src/__tests__/helpers/clientSessions.test.ts` — unit tests for JWT and cookie helpers +- `apps/api/src/routers/clientAuth.ts` — request-link, exchange, logout, and current-client endpoints +- `apps/api/src/routers/clientPortal.ts` — cookie-authenticated endpoints used by `apps/clients` +- `apps/api/src/__tests__/routes/clientAuth.test.ts` — request-link, exchange, logout, neutral-response, and invalid-token coverage +- `apps/api/src/__tests__/routes/clientPortal.test.ts` — client-cookie authorization and tablo/resource access coverage + +**Client frontend** +- `apps/clients/src/lib/api.ts` — Axios instance with `withCredentials: true` +- `apps/clients/src/hooks/useClientSession.ts` — loads current client session from the API +- `apps/clients/src/hooks/useClientPortal.ts` — React Query hooks for tablos, tasks, etapes, files, events, and members +- `apps/clients/src/test/clientSessionTestUtils.tsx` — shared test wrapper for cookie-backed session mocks + +### Files to delete after migration + +- `apps/clients/src/lib/supabase.ts` +- `apps/clients/src/pages/AuthCallback.tsx` +- `apps/clients/src/pages/ResetPasswordPage.tsx` +- `apps/clients/src/pages/SetPasswordPage.tsx` +- `apps/clients/src/pages/ResetPasswordPage.test.tsx` +- `apps/clients/src/pages/SetPasswordPage.test.tsx` + +### Modified files + +**API** +- `apps/api/src/config.ts` — add client-auth env vars: JWT signing secret, session TTL, magic-link TTL, cookie name, cookie domain, and clients app URL +- `apps/api/src/types/app.types.ts` — add client-authenticated environment typing +- `apps/api/src/middlewares/middleware.ts` — add client-cookie auth middleware and remove `is_client`-based collaborator gating +- `apps/api/src/index.ts` — keep credentialed CORS working for client cookie requests +- `apps/api/src/routers/index.ts` — mount `clientAuth` and `clientPortal` routers +- `apps/api/src/routers/clientInvites.ts` — keep admin invite creation/cancel/list behavior, but switch its internals to the new `clients` tables and magic-link issuance +- `apps/api/src/routers/tablo.ts` or `apps/api/src/routers/tablo_data.ts` — extract reusable tablo-loading helpers if needed by `clientPortal.ts` +- `apps/api/src/helpers/helpers.ts` — remove old `findOrCreateClientAccount` / `ensureClientTabloAccess` logic once new helper files take over +- `apps/api/src/helpers/billing.ts` — remove `is_client` billing exclusions +- `apps/api/src/routers/user.ts` — stop exposing or expecting `is_client` on collaborator profile responses +- `apps/api/src/__tests__/routes/clientInvites.test.ts` +- `apps/api/src/__tests__/middlewares/middlewares.test.ts` +- `apps/api/src/__tests__/helpers/billing.test.ts` + +**Shared types** +- `packages/shared-types/src/database.types.ts` — add `clients`, `client_access`, `client_magic_links`; remove `profiles.is_client` + +**Main app** +- `apps/main/src/hooks/client_invites.ts` — adapt to the simplified invite response shape +- `apps/main/src/pages/tablo-details.tsx` — update invite copy and pending-invite UI +- `apps/main/src/pages/tablo-details.layout.test.tsx` +- `apps/main/src/components/ProtectedRoute.tsx` — remove `user.is_client` redirect logic +- `apps/main/src/components/AuthenticationGateway.tsx` — remove `user.is_client` redirect logic +- `apps/main/src/lib/clientPortal.ts` — delete if no longer needed +- `apps/main/src/components/ProtectedRoute.test.tsx` +- `apps/main/src/components/AuthenticationGateway.test.tsx` +- `apps/main/src/components/AuthenticationGateway.unit.tsx` +- `apps/main/src/providers/UserStoreProvider.tsx` — ensure the collaborator profile type no longer depends on `is_client` +- `apps/main/src/utils/testHelpers.tsx` +- `apps/main/src/contexts/UpgradeBlockContext.test.tsx` + +**Client app** +- `apps/clients/src/main.tsx` — remove `SessionProvider` +- `apps/clients/src/routes.tsx` — remove callback/setup/reset routes and keep email-link login + protected routes +- `apps/clients/src/components/ClientAuthGate.tsx` — use backend session endpoint instead of Supabase session state +- `apps/clients/src/components/ClientLayout.tsx` — render current client email/name from backend session and call logout endpoint +- `apps/clients/src/components/ClientLayout.test.tsx` +- `apps/clients/src/pages/LoginPage.tsx` — convert to email-only request-link form +- `apps/clients/src/pages/LoginPage.test.tsx` +- `apps/clients/src/pages/ClientTabloListPage.tsx` — fetch from backend instead of `supabase.from("user_tablos")` +- `apps/clients/src/pages/ClientTabloPage.tsx` — migrate all data and mutations to backend hooks +- `apps/clients/src/pages/ClientTabloPage.test.tsx` + +--- + +## Chunk 1: Backend Auth Foundation + +### Task 1: Create the new client-auth schema and shared types + +**Files:** +- Create: `supabase/migrations/20260501100000_create_client_auth_tables.sql` +- Modify: `packages/shared-types/src/database.types.ts` +- Test: `apps/api/src/__tests__/routes/clientInvites.test.ts` + +- [ ] **Step 1: Write the failing invite tests against the new tables** + +Add or rewrite route tests in `apps/api/src/__tests__/routes/clientInvites.test.ts` for: + +```ts +it("upserts one global client identity per normalized email", async () => {}); +it("creates a client_access grant for the invited tablo", async () => {}); +it("creates a one-time invite magic link row", async () => {}); +it("reuses the same client row when the same email is invited again", async () => {}); +``` + +Make the assertions target `clients`, `client_access`, and `client_magic_links`, not `profiles.is_client` or `client_invites`. + +- [ ] **Step 2: Run the route test file to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientInvites.test.ts +``` + +Expected: +- FAIL because the current implementation still writes to `profiles.is_client` and `client_invites` + +- [ ] **Step 3: Write the schema migration** + +In `supabase/migrations/20260501100000_create_client_auth_tables.sql`, create the new tables with the minimum approved shape: + +```sql +create table public.clients (...); +create unique index clients_normalized_email_idx on public.clients (normalized_email); + +create table public.client_access (...); +create unique index client_access_active_unique_idx + on public.client_access (client_id, tablo_id) + where revoked_at is null; + +create table public.client_magic_links (...); +create index client_magic_links_active_idx + on public.client_magic_links (client_id, purpose, expires_at) + where consumed_at is null; +``` + +Use one-time-use fields (`consumed_at`) and explicit expiry (`expires_at`). Do not recreate `is_pending` semantics under a different name. + +- [ ] **Step 4: Update shared database types** + +Update `packages/shared-types/src/database.types.ts` so it exposes: +- `clients` +- `client_access` +- `client_magic_links` + +Keep the new tables typed before any router work starts. + +- [ ] **Step 5: Run a type-focused verification** + +Run: + +```bash +pnpm --filter @xtablo/api typecheck +pnpm --filter @xtablo/clients typecheck +pnpm --filter @xtablo/main typecheck +``` + +Expected: +- collaborator app and client app still compile before code migration starts +- any failures should only be from the new type references you have not wired yet + +- [ ] **Step 6: Commit** + +```bash +git add supabase/migrations/20260501100000_create_client_auth_tables.sql packages/shared-types/src/database.types.ts apps/api/src/__tests__/routes/clientInvites.test.ts +git commit -m "feat: add client auth tables" +``` + +### Task 2: Add client account helpers, session helpers, and cookie middleware + +**Files:** +- Create: `apps/api/src/helpers/clientAccounts.ts` +- Create: `apps/api/src/helpers/clientSessions.ts` +- Create: `apps/api/src/__tests__/helpers/clientSessions.test.ts` +- Modify: `apps/api/src/config.ts` +- Modify: `apps/api/src/types/app.types.ts` +- Modify: `apps/api/src/middlewares/middleware.ts` +- Modify: `apps/api/src/helpers/helpers.ts` + +- [ ] **Step 1: Write the failing helper and middleware tests** + +Add tests in `apps/api/src/__tests__/helpers/clientSessions.test.ts` for: + +```ts +it("signs and verifies a client session JWT", async () => {}); +it("rejects expired client session JWTs", async () => {}); +it("extracts the configured client cookie from the request", async () => {}); +it("signs magic-link JWTs with jti and expiry claims", async () => {}); +``` + +Extend `apps/api/src/__tests__/middlewares/middlewares.test.ts` with: + +```ts +it("authenticates a client request from the client session cookie", async () => {}); +it("returns 401 when the client cookie is missing or invalid", async () => {}); +``` + +- [ ] **Step 2: Run the tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientSessions.test.ts middlewares.test.ts +``` + +Expected: +- FAIL because no client cookie auth primitives exist yet + +- [ ] **Step 3: Add config and environment support** + +In `apps/api/src/config.ts`, add: + +```ts +CLIENT_AUTH_JWT_SECRET: string; +CLIENT_AUTH_COOKIE_NAME: string; +CLIENT_AUTH_COOKIE_DOMAIN: string; +CLIENT_MAGIC_LINK_TTL_MINUTES: number; +CLIENT_SESSION_TTL_DAYS: number; +CLIENTS_URL: string; +``` + +Use explicit config fields instead of reading `process.env` directly inside routers. + +- [ ] **Step 4: Implement focused account and token helpers** + +In `apps/api/src/helpers/clientAccounts.ts`, add small single-purpose helpers: + +```ts +export function normalizeClientEmail(email: string): string {} +export async function upsertClientByEmail(...) {} +export async function ensureActiveClientAccess(...) {} +export async function clientHasAnyActiveAccess(...) {} +export async function revokeClientAccess(...) {} +``` + +In `apps/api/src/helpers/clientSessions.ts`, add: + +```ts +export function signClientSession(...) {} +export function verifyClientSession(...) {} +export function signClientMagicLink(...) {} +export function verifyClientMagicLink(...) {} +export function buildClientSessionCookie(...) {} +export function clearClientSessionCookie(...) {} +export function readClientSessionCookie(...) {} +``` + +Keep cookie formatting and JWT signing out of the routers. + +- [ ] **Step 5: Add client-auth middleware and typing** + +Update `apps/api/src/types/app.types.ts` with a client-authenticated environment shape, for example: + +```ts +type ClientEnv = BaseEnv & { + Variables: BaseEnv["Variables"] & { + client: Tables<"clients">; + }; +}; +``` + +Then update `apps/api/src/middlewares/middleware.ts` to expose: + +```ts +clientAuthMiddleware +maybeClientAuthMiddleware +``` + +These should verify the cookie, load the `clients` row, and set `c.set("client", client)`. + +- [ ] **Step 6: Remove helper duplication** + +Move client-specific logic out of `apps/api/src/helpers/helpers.ts`. Leave only thin wrappers there if other files still import the old names during the migration. + +- [ ] **Step 7: Run backend verification** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientSessions.test.ts middlewares.test.ts +pnpm --filter @xtablo/api typecheck +``` + +Expected: +- PASS for helper and middleware coverage + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/helpers/clientAccounts.ts apps/api/src/helpers/clientSessions.ts apps/api/src/__tests__/helpers/clientSessions.test.ts apps/api/src/config.ts apps/api/src/types/app.types.ts apps/api/src/middlewares/middleware.ts apps/api/src/helpers/helpers.ts apps/api/src/__tests__/middlewares/middlewares.test.ts +git commit -m "feat: add client auth helpers and middleware" +``` + +--- + +## Chunk 2: Backend Routes And Authorized Client API + +### Task 3: Replace the invite flow and add public client-auth routes + +**Files:** +- Create: `apps/api/src/routers/clientAuth.ts` +- Create: `apps/api/src/__tests__/routes/clientAuth.test.ts` +- Modify: `apps/api/src/routers/clientInvites.ts` +- Modify: `apps/api/src/routers/index.ts` +- Modify: `apps/api/src/index.ts` +- Test: `apps/api/src/__tests__/routes/clientInvites.test.ts` + +- [ ] **Step 1: Write the failing route tests** + +Add `apps/api/src/__tests__/routes/clientAuth.test.ts` covering: + +```ts +it("returns a neutral success response for request-link even when the email is unknown", async () => {}); +it("creates and emails a login magic link when the client has active access", async () => {}); +it("rejects an expired or consumed exchange token", async () => {}); +it("sets the client session cookie when a valid token is exchanged", async () => {}); +it("clears the cookie on logout", async () => {}); +it("returns the current client from /me when the cookie is valid", async () => {}); +``` + +Extend `clientInvites.test.ts` with: + +```ts +it("sends an invite magic link email for a new client", async () => {}); +it("reissues access via the same global client row for an existing client", async () => {}); +it("cancelling an invite revokes the targeted access row", async () => {}); +``` + +- [ ] **Step 2: Run the route tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientAuth.test.ts clientInvites.test.ts +``` + +Expected: +- FAIL because `clientAuth` router does not exist and `clientInvites` still uses the old Supabase-auth client model + +- [ ] **Step 3: Implement `clientAuth` public routes** + +In `apps/api/src/routers/clientAuth.ts`, add: + +```ts +POST /client-auth/request-link +GET /client-auth/exchange +POST /client-auth/logout +GET /client-auth/me +``` + +Behavior: +- `request-link` + - always return a neutral success payload + - only create and email a magic link if the normalized email maps to a client with active access +- `exchange` + - verify JWT + backing `client_magic_links` row + - reject missing / expired / consumed links + - mark `consumed_at` + - set the client session cookie + - redirect to the `redirect_to` route or a safe default inside `clients.xtablo.com` +- `logout` + - clear the cookie +- `me` + - require cookie auth and return the current client identity + +- [ ] **Step 4: Rework admin invite creation** + +In `apps/api/src/routers/clientInvites.ts`, keep the existing admin-facing surface: + +```ts +POST /client-invites/:tabloId +GET /client-invites/:tabloId/pending +DELETE /client-invites/:tabloId/:inviteId +``` + +But change the implementation to: +- upsert `clients` +- ensure `client_access` +- create a `purpose = 'invite'` magic-link row +- send an invite email that targets the new backend exchange route +- stop creating Supabase Auth users +- stop updating `profiles.is_client` +- stop using password-setup semantics + +Pending invites should now read from `client_magic_links` filtered by `purpose = 'invite'` and `consumed_at is null`. + +- [ ] **Step 5: Mount the router and keep CORS cookie-safe** + +In `apps/api/src/routers/index.ts`, mount `clientAuth.ts`. + +In `apps/api/src/index.ts`, verify the CORS middleware continues to send: +- `credentials: true` +- exact caller origin + +Do not rely on Authorization headers for the client portal anymore. + +- [ ] **Step 6: Run backend auth route verification** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientAuth.test.ts clientInvites.test.ts +pnpm --filter @xtablo/api typecheck +``` + +Expected: +- PASS for invite issuance, neutral login-link requests, exchange, logout, and `/me` + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/routers/clientAuth.ts apps/api/src/__tests__/routes/clientAuth.test.ts apps/api/src/routers/clientInvites.ts apps/api/src/routers/index.ts apps/api/src/index.ts apps/api/src/__tests__/routes/clientInvites.test.ts +git commit -m "feat: add client magic link auth routes" +``` + +### Task 4: Add the cookie-authenticated client portal API + +**Files:** +- Create: `apps/api/src/routers/clientPortal.ts` +- Create: `apps/api/src/__tests__/routes/clientPortal.test.ts` +- Modify: `apps/api/src/routers/index.ts` +- Modify: `apps/api/src/routers/tablo.ts` +- Modify: `apps/api/src/routers/tablo_data.ts` +- Modify: `apps/api/src/helpers/billing.ts` + +- [ ] **Step 1: Write the failing client portal route tests** + +Add tests for: + +```ts +it("lists only the tablos accessible to the authenticated client", async () => {}); +it("loads a single tablo only when client_access is active", async () => {}); +it("rejects access to a tablo outside the client's grants", async () => {}); +it("allows file, folder, task, etape, event, and member reads through the client portal API", async () => {}); +``` + +Keep response shapes aligned with the data `apps/clients` already expects today so the frontend rewrite is mostly transport-level. + +- [ ] **Step 2: Run the portal route tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientPortal.test.ts +``` + +Expected: +- FAIL because there is no cookie-authenticated client portal router yet + +- [ ] **Step 3: Implement `clientPortal.ts`** + +Add a dedicated router such as: + +```ts +GET /client-portal/tablos +GET /client-portal/tablos/:tabloId +GET /client-portal/tablos/:tabloId/tasks +GET /client-portal/tablos/:tabloId/etapes +GET /client-portal/tablos/:tabloId/events +GET /client-portal/tablos/:tabloId/members +GET /client-portal/tablos/:tabloId/files +GET /client-portal/tablos/:tabloId/folders +POST /client-portal/tablos/:tabloId/files +GET /client-portal/tablos/:tabloId/files/:fileName +POST /client-portal/tablos/:tabloId/folders +PUT /client-portal/tablos/:tabloId/folders/:folderId +DELETE /client-portal/tablos/:tabloId/folders/:folderId +``` + +Each route must: +- require `clientAuthMiddleware` +- verify `client_access` for the requested `tabloId` +- reuse existing query logic from `tablo.ts` / `tablo_data.ts` where practical + +- [ ] **Step 4: Extract shared query helpers instead of duplicating router code** + +If `tablo.ts` or `tablo_data.ts` contains query logic you need twice, extract helper functions with one responsibility, for example: + +```ts +async function loadTabloByIdForClient(...) {} +async function loadTabloTasks(...) {} +async function loadTabloFolders(...) {} +``` + +Do not copy-paste large query blocks into `clientPortal.ts`. + +- [ ] **Step 5: Remove billing and collaborator assumptions** + +Update `apps/api/src/helpers/billing.ts` to stop filtering `profiles.is_client`. Client identities no longer live in `profiles`, so collaborator billing should count only collaborator profile rows. + +- [ ] **Step 6: Run backend portal verification** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientPortal.test.ts billing.test.ts +pnpm --filter @xtablo/api typecheck +``` + +Expected: +- PASS for client portal data access and billing logic + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/routers/clientPortal.ts apps/api/src/__tests__/routes/clientPortal.test.ts apps/api/src/routers/index.ts apps/api/src/routers/tablo.ts apps/api/src/routers/tablo_data.ts apps/api/src/helpers/billing.ts apps/api/src/__tests__/helpers/billing.test.ts +git commit -m "feat: add cookie-authenticated client portal api" +``` + +--- + +## Chunk 3: Frontend Migration And Legacy Cleanup + +### Task 5: Migrate `apps/clients` to backend session cookies + +**Files:** +- Create: `apps/clients/src/lib/api.ts` +- Create: `apps/clients/src/hooks/useClientSession.ts` +- Create: `apps/clients/src/hooks/useClientPortal.ts` +- Create: `apps/clients/src/test/clientSessionTestUtils.tsx` +- Modify: `apps/clients/src/main.tsx` +- Modify: `apps/clients/src/routes.tsx` +- Modify: `apps/clients/src/components/ClientAuthGate.tsx` +- Modify: `apps/clients/src/components/ClientLayout.tsx` +- Modify: `apps/clients/src/pages/LoginPage.tsx` +- Modify: `apps/clients/src/pages/LoginPage.test.tsx` +- Modify: `apps/clients/src/pages/ClientTabloListPage.tsx` +- Modify: `apps/clients/src/pages/ClientTabloPage.tsx` +- Modify: `apps/clients/src/pages/ClientTabloPage.test.tsx` +- Modify: `apps/clients/src/components/ClientLayout.test.tsx` +- Delete: `apps/clients/src/lib/supabase.ts` +- Delete: `apps/clients/src/pages/AuthCallback.tsx` +- Delete: `apps/clients/src/pages/ResetPasswordPage.tsx` +- Delete: `apps/clients/src/pages/SetPasswordPage.tsx` +- Delete: `apps/clients/src/pages/ResetPasswordPage.test.tsx` +- Delete: `apps/clients/src/pages/SetPasswordPage.test.tsx` + +- [ ] **Step 1: Write the failing client app tests** + +Update tests to cover: + +```ts +it("submits an email-only login form and shows a neutral success state", async () => {}); +it("redirects unauthenticated users to /login after the session endpoint returns 401", async () => {}); +it("loads the current client from the backend session endpoint", async () => {}); +it("loads client tablos and tablo details from backend API hooks instead of Supabase", async () => {}); +it("logs out via the backend and returns to /login", async () => {}); +``` + +- [ ] **Step 2: Run the client app tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/clients test -- LoginPage.test.tsx ClientLayout.test.tsx ClientTabloPage.test.tsx +``` + +Expected: +- FAIL because the app still depends on `SessionProvider`, `supabase.auth`, and browser-side Supabase queries + +- [ ] **Step 3: Introduce a cookie-aware client API layer** + +In `apps/clients/src/lib/api.ts`, create an Axios instance: + +```ts +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + withCredentials: true, + headers: { "Content-Type": "application/json" }, +}); +``` + +Then add `useClientSession.ts` and `useClientPortal.ts` so pages do not build ad hoc API clients inline. + +- [ ] **Step 4: Remove Supabase session bootstrap from the app shell** + +Update `apps/clients/src/main.tsx` to remove `SessionProvider` and the Supabase client import entirely. + +Update `ClientAuthGate.tsx` to: +- call `/api/v1/client-auth/me` +- store the intended destination in `localStorage` +- redirect to `/login` when the API says unauthenticated + +Update `ClientLayout.tsx` to: +- read the current client from `useClientSession()` +- call `POST /api/v1/client-auth/logout` + +- [ ] **Step 5: Convert login and protected pages** + +Update `LoginPage.tsx` so it: +- renders a single email field +- submits to `POST /api/v1/client-auth/request-link` +- shows a generic “check your email” confirmation state +- removes password and reset-password flows + +Update `ClientTabloListPage.tsx` and `ClientTabloPage.tsx` so every query and mutation uses `useClientPortal.ts` hooks backed by the new API endpoints. + +Preserve current response shapes where possible to minimize UI churn in `@xtablo/tablo-views`. + +- [ ] **Step 6: Delete the obsolete client auth pages** + +Remove: +- `AuthCallback.tsx` +- `ResetPasswordPage.tsx` +- `SetPasswordPage.tsx` + +Also remove their routes from `apps/clients/src/routes.tsx`. + +- [ ] **Step 7: Run client app verification** + +Run: + +```bash +pnpm --filter @xtablo/clients test -- LoginPage.test.tsx ClientLayout.test.tsx ClientTabloPage.test.tsx +pnpm --filter @xtablo/clients typecheck +``` + +Expected: +- PASS for email-link login, auth gate, logout, and cookie-backed data loading + +- [ ] **Step 8: Commit** + +```bash +git add apps/clients/src/lib/api.ts apps/clients/src/hooks/useClientSession.ts apps/clients/src/hooks/useClientPortal.ts apps/clients/src/test/clientSessionTestUtils.tsx apps/clients/src/main.tsx apps/clients/src/routes.tsx apps/clients/src/components/ClientAuthGate.tsx apps/clients/src/components/ClientLayout.tsx apps/clients/src/pages/LoginPage.tsx apps/clients/src/pages/LoginPage.test.tsx apps/clients/src/pages/ClientTabloListPage.tsx apps/clients/src/pages/ClientTabloPage.tsx apps/clients/src/pages/ClientTabloPage.test.tsx apps/clients/src/components/ClientLayout.test.tsx +git add -u apps/clients/src/lib/supabase.ts apps/clients/src/pages/AuthCallback.tsx apps/clients/src/pages/ResetPasswordPage.tsx apps/clients/src/pages/SetPasswordPage.tsx apps/clients/src/pages/ResetPasswordPage.test.tsx apps/clients/src/pages/SetPasswordPage.test.tsx +git commit -m "feat: migrate clients app to cookie auth" +``` + +### Task 6: Remove legacy `is_client` logic from collaborator flows and finish cleanup + +**Files:** +- Create: `supabase/migrations/20260501150000_drop_profiles_is_client.sql` +- Modify: `apps/api/src/middlewares/middleware.ts` +- Modify: `apps/api/src/routers/user.ts` +- Modify: `apps/main/src/components/ProtectedRoute.tsx` +- Modify: `apps/main/src/components/AuthenticationGateway.tsx` +- Modify: `apps/main/src/components/ProtectedRoute.test.tsx` +- Modify: `apps/main/src/components/AuthenticationGateway.test.tsx` +- Modify: `apps/main/src/components/AuthenticationGateway.unit.tsx` +- Modify: `apps/main/src/hooks/client_invites.ts` +- Modify: `apps/main/src/pages/tablo-details.tsx` +- Modify: `apps/main/src/pages/tablo-details.layout.test.tsx` +- Modify: `apps/main/src/providers/UserStoreProvider.tsx` +- Modify: `apps/main/src/utils/testHelpers.tsx` +- Modify: `apps/main/src/contexts/UpgradeBlockContext.test.tsx` +- Delete: `apps/main/src/lib/clientPortal.ts` if no remaining imports + +- [ ] **Step 1: Write the failing cleanup tests** + +Update or add tests asserting: + +```ts +it("does not special-case collaborator routing based on user.is_client", async () => {}); +it("shows generic client invite success copy after invite creation", async () => {}); +it("stops expecting is_client in collaborator test fixtures", async () => {}); +``` + +- [ ] **Step 2: Run the targeted main-app and API tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/main test -- ProtectedRoute.test.tsx AuthenticationGateway.test.tsx tablo-details.layout.test.tsx +pnpm --filter @xtablo/api test -- middlewares.test.ts +``` + +Expected: +- FAIL because collaborator routes and middleware still reference `is_client` + +- [ ] **Step 3: Remove `is_client` from runtime code** + +Update: +- `apps/api/src/middlewares/middleware.ts` +- `apps/api/src/routers/user.ts` +- `apps/main/src/components/ProtectedRoute.tsx` +- `apps/main/src/components/AuthenticationGateway.tsx` +- `apps/main/src/providers/UserStoreProvider.tsx` + +The collaborator app should no longer know about a client-user subtype inside the collaborator session model. + +- [ ] **Step 4: Update invite UX and fixtures** + +Update `apps/main/src/hooks/client_invites.ts` and `apps/main/src/pages/tablo-details.tsx` so the success states match the new backend behavior: +- client invited by email +- magic link sent +- pending invites listed from `client_magic_links` + +Then remove `is_client` from test helpers and fixtures. + +- [ ] **Step 5: Drop the old schema field** + +In `supabase/migrations/20260501150000_drop_profiles_is_client.sql`, remove the legacy column: + +```sql +alter table public.profiles drop column if exists is_client; +``` + +Only add this migration after the codebase no longer reads or writes the column. + +- [ ] **Step 6: Run final verification** + +Run: + +```bash +pnpm --filter @xtablo/api test +pnpm --filter @xtablo/api typecheck +pnpm --filter @xtablo/clients test +pnpm --filter @xtablo/clients typecheck +pnpm --filter @xtablo/main test -- ProtectedRoute.test.tsx AuthenticationGateway.test.tsx tablo-details.layout.test.tsx +pnpm --filter @xtablo/main typecheck +``` + +Expected: +- PASS for full client-auth flow and collaborator cleanup + +- [ ] **Step 7: Commit** + +```bash +git add supabase/migrations/20260501150000_drop_profiles_is_client.sql apps/api/src/middlewares/middleware.ts apps/api/src/routers/user.ts apps/main/src/components/ProtectedRoute.tsx apps/main/src/components/AuthenticationGateway.tsx apps/main/src/components/ProtectedRoute.test.tsx apps/main/src/components/AuthenticationGateway.test.tsx apps/main/src/components/AuthenticationGateway.unit.tsx apps/main/src/hooks/client_invites.ts apps/main/src/pages/tablo-details.tsx apps/main/src/pages/tablo-details.layout.test.tsx apps/main/src/providers/UserStoreProvider.tsx apps/main/src/utils/testHelpers.tsx apps/main/src/contexts/UpgradeBlockContext.test.tsx +git add -u apps/main/src/lib/clientPortal.ts +git commit -m "refactor: remove legacy is_client flow" +``` + +--- + +## Notes For Execution + +- Keep response payloads for `ClientTabloPage.tsx` as close as possible to the current shapes from `user_tablos`, `tasks_with_assignee`, `events_and_tablos`, and related queries. The feature is already large; do not rewrite UI state models unless the API boundary forces it. +- Do not mix collaborator bearer-token auth and client cookie auth in the same frontend hooks. Keep them separate. +- Prefer backend exchange-and-redirect for invite/login links so the raw magic-link token is not handled by React unless absolutely necessary. +- Do not add a `client_sessions` table in v1. That is explicitly out of scope for this plan. +- Delay the `drop_profiles_is_client` migration until every compile-time and runtime reference is gone. + +Plan complete and saved to `docs/superpowers/plans/2026-04-30-client-magic-link-auth.md`. Ready to execute? diff --git a/packages/auth-ui/src/AuthCardShell.tsx b/packages/auth-ui/src/AuthCardShell.tsx index eaa1113..f53b6a5 100644 --- a/packages/auth-ui/src/AuthCardShell.tsx +++ b/packages/auth-ui/src/AuthCardShell.tsx @@ -86,7 +86,7 @@ export function AuthCardShell({ > {topLeft || showThemeToggle ? (
-
{topLeft}
+
{topLeft}
{showThemeToggle ? (
"),Ga=U(" "),Qa=U("
"),$a=U(" ");function wn(t,e){const n=Yn(e),i=oe(e,["children","$$slots","$$events","$$legacy"]),r=oe(i,["size","href","inline","icon","disabled","visited","ref"]);_e(e,!1);let a=p(e,"size",8,void 0),s=p(e,"href",8,void 0),c=p(e,"inline",8,!1),f=p(e,"icon",8,void 0),d=p(e,"disabled",8,!1),l=p(e,"visited",8,!1),o=p(e,"ref",12,null);ye();var u=ve(),h=ce(u);{var v=x=>{var g=Ga();let y;var _=L(g);he(_,e,"default",{},null);var w=A(_,2);{var P=I=>{var F=Ja();b(F,"bx--link__icon",!0);var N=L(F);he(N,e,"icon",{},C=>{var R=ve(),Y=ce(R);an(Y,f,(se,q)=>{q(se,{})}),E(C,R)}),E(I,F)};W(w,I=>{!c()&&(n.icon||f())&&I(P)})}et(g,I=>o(I),()=>o()),Z(()=>{y=we(g,y,{role:"link","aria-disabled":"true",...r}),b(g,"bx--link",!0),b(g,"bx--link--disabled",d()),b(g,"bx--link--inline",c()),b(g,"bx--link--visited",l())}),z("click",g,function(I){T.call(this,e,I)}),z("mouseover",g,function(I){T.call(this,e,I)}),z("mouseenter",g,function(I){T.call(this,e,I)}),z("mouseleave",g,function(I){T.call(this,e,I)}),E(x,g)},m=x=>{var g=$a();let y;var _=L(g);he(_,e,"default",{},null);var w=A(_,2);{var P=I=>{var F=Qa();b(F,"bx--link__icon",!0);var N=L(F);he(N,e,"icon",{},C=>{var R=ve(),Y=ce(R);an(Y,f,(se,q)=>{q(se,{})}),E(C,R)}),E(I,F)};W(w,I=>{!c()&&(n.icon||f())&&I(P)})}et(g,I=>o(I),()=>o()),Z(()=>{y=we(g,y,{rel:r.target==="_blank"?"noopener noreferrer":void 0,href:s(),...r}),b(g,"bx--link",!0),b(g,"bx--link--disabled",d()),b(g,"bx--link--inline",c()),b(g,"bx--link--visited",l()),b(g,"bx--link--sm",a()==="sm"),b(g,"bx--link--lg",a()==="lg")}),z("click",g,function(I){T.call(this,e,I)}),z("mouseover",g,function(I){T.call(this,e,I)}),z("mouseenter",g,function(I){T.call(this,e,I)}),z("mouseleave",g,function(I){T.call(this,e,I)}),E(x,g)};W(h,x=>{d()?x(v):x(m,!1)})}E(t,u),me()}var eo=U(""),to=U("
");function no(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["href","size"]);_e(e,!1);let r=p(e,"href",8,void 0),a=p(e,"size",8,"default");ye();var s=ve(),c=ce(s);{var f=l=>{var o=eo();let u;o.textContent="",Z(()=>{u=we(o,u,{href:r(),rel:i.target==="_blank"?"noopener noreferrer":void 0,role:"button",...i}),b(o,"bx--skeleton",!0),b(o,"bx--btn",!0),b(o,"bx--btn--field",a()==="field"),b(o,"bx--btn--sm",a()==="small"),b(o,"bx--btn--lg",a()==="lg"),b(o,"bx--btn--xl",a()==="xl")}),z("click",o,function(h){T.call(this,e,h)}),z("focus",o,function(h){T.call(this,e,h)}),z("blur",o,function(h){T.call(this,e,h)}),z("mouseover",o,function(h){T.call(this,e,h)}),z("mouseenter",o,function(h){T.call(this,e,h)}),z("mouseleave",o,function(h){T.call(this,e,h)}),E(l,o)},d=l=>{var o=to();let u;Z(()=>{u=we(o,u,{...i}),b(o,"bx--skeleton",!0),b(o,"bx--btn",!0),b(o,"bx--btn--field",a()==="field"),b(o,"bx--btn--sm",a()==="small"),b(o,"bx--btn--lg",a()==="lg"),b(o,"bx--btn--xl",a()==="xl")}),z("click",o,function(h){T.call(this,e,h)}),z("focus",o,function(h){T.call(this,e,h)}),z("blur",o,function(h){T.call(this,e,h)}),z("mouseover",o,function(h){T.call(this,e,h)}),z("mouseenter",o,function(h){T.call(this,e,h)}),z("mouseleave",o,function(h){T.call(this,e,h)}),E(l,o)};W(c,l=>{r()?l(f):l(d,!1)})}E(t,s),me()}var io=U(" "),ro=U(" "),ao=U(" "),oo=U("");function so(t,e){const n=Yn(e),i=oe(e,["children","$$slots","$$events","$$legacy"]),r=oe(i,["kind","size","expressive","isSelected","icon","iconDescription","tooltipAlignment","tooltipPosition","as","skeleton","disabled","href","tabindex","type","ref"]);_e(e,!1);const a=le(),s=le(),c=le();let f=p(e,"kind",8,"primary"),d=p(e,"size",8,"default"),l=p(e,"expressive",8,!1),o=p(e,"isSelected",8,!1),u=p(e,"icon",8,void 0),h=p(e,"iconDescription",8,void 0),v=p(e,"tooltipAlignment",8,"center"),m=p(e,"tooltipPosition",8,"bottom"),x=p(e,"as",8,!1),g=p(e,"skeleton",8,!1),y=p(e,"disabled",8,!1),_=p(e,"href",8,void 0),w=p(e,"tabindex",8,"0"),P=p(e,"type",8,"button"),I=p(e,"ref",12,null);const F=rn("ComposedModal");ne(()=>M(I()),()=>{F&&I()&&F.declareRef(I())}),ne(()=>M(u()),()=>{J(a,(u()||n.icon)&&!n.default)}),ne(()=>M(h()),()=>{J(s,{"aria-hidden":"true",class:"bx--btn__icon","aria-label":h()})}),ne(()=>(M(_()),M(y()),M(P()),M(w()),k(a),M(f()),M(o()),M(r),M(l()),M(d()),M(m()),M(v())),()=>{J(c,{type:_()&&!y()?void 0:P(),tabindex:w(),disabled:y()===!0?!0:void 0,href:_(),"aria-pressed":k(a)&&f()==="ghost"&&!_()?o():void 0,...r,class:["bx--btn",l()&&"bx--btn--expressive",(d()==="small"&&!l()||d()==="sm"&&!l()||d()==="small"&&!l())&&"bx--btn--sm",d()==="field"&&!l()||d()==="md"&&!l()&&"bx--btn--md",d()==="field"&&"bx--btn--field",d()==="small"&&"bx--btn--sm",d()==="lg"&&"bx--btn--lg",d()==="xl"&&"bx--btn--xl",f()&&`bx--btn--${f()}`,y()&&"bx--btn--disabled",k(a)&&"bx--btn--icon-only",k(a)&&"bx--tooltip__trigger",k(a)&&"bx--tooltip--a11y",k(a)&&m()&&`bx--btn--icon-only--${m()}`,k(a)&&v()&&`bx--tooltip--align-${v()}`,k(a)&&o()&&f()==="ghost"&&"bx--btn--selected",r.class].filter(Boolean).join(" ")})}),Ne(),ye();var N=ve(),C=ce(N);{var R=se=>{var q=At(()=>k(a)&&"width: 3rem;");no(se,rt({get href(){return _()},get size(){return d()}},()=>r,{get style(){return k(q)},$$events:{click(ie){T.call(this,e,ie)},focus(ie){T.call(this,e,ie)},blur(ie){T.call(this,e,ie)},mouseover(ie){T.call(this,e,ie)},mouseenter(ie){T.call(this,e,ie)},mouseleave(ie){T.call(this,e,ie)}}}))},Y=se=>{var q=ve(),ie=ce(q);{var Ae=K=>{var be=ve(),tt=ce(be);he(tt,e,"default",{get props(){return k(c)}},null),E(K,be)},Se=K=>{var be=ve(),tt=ce(be);{var ze=Ce=>{var G=ro();let Fe;var Be=L(G);{var dt=O=>{var re=io();b(re,"bx--assistive-text",!0);var fe=L(re);Z(()=>ae(fe,h())),E(O,re)};W(Be,O=>{k(a)&&O(dt)})}var V=A(Be,2);he(V,e,"default",{},null);var $=A(V,2);{var de=O=>{var re=ve(),fe=ce(re);he(fe,e,"icon",rt({get style(){return k(a)?"margin-left: 0":void 0}},()=>k(s)),null),E(O,re)},Q=O=>{var re=ve(),fe=ce(re);{var Je=Te=>{var Re=ve(),De=ce(Re),Ge=At(()=>k(a)?"margin-left: 0":void 0);an(De,u,(Ue,ee)=>{ee(Ue,rt({get style(){return k(Ge)}},()=>k(s)))}),E(Te,Re)};W(fe,Te=>{u()&&Te(Je)},!0)}E(O,re)};W($,O=>{n.icon?O(de):O(Q,!1)})}et(G,O=>I(O),()=>I()),Z(()=>Fe=we(G,Fe,{...k(c)})),z("click",G,function(O){T.call(this,e,O)}),z("focus",G,function(O){T.call(this,e,O)}),z("blur",G,function(O){T.call(this,e,O)}),z("mouseover",G,function(O){T.call(this,e,O)}),z("mouseenter",G,function(O){T.call(this,e,O)}),z("mouseleave",G,function(O){T.call(this,e,O)}),E(Ce,G)},bt=Ce=>{var G=oo();let Fe;var Be=L(G);{var dt=O=>{var re=ao();b(re,"bx--assistive-text",!0);var fe=L(re);Z(()=>ae(fe,h())),E(O,re)};W(Be,O=>{k(a)&&O(dt)})}var V=A(Be,2);he(V,e,"default",{},null);var $=A(V,2);{var de=O=>{var re=ve(),fe=ce(re);he(fe,e,"icon",rt({get style(){return k(a)?"margin-left: 0":void 0}},()=>k(s)),null),E(O,re)},Q=O=>{var re=ve(),fe=ce(re);{var Je=Te=>{var Re=ve(),De=ce(Re),Ge=At(()=>k(a)?"margin-left: 0":void 0);an(De,u,(Ue,ee)=>{ee(Ue,rt({get style(){return k(Ge)}},()=>k(s)))}),E(Te,Re)};W(fe,Te=>{u()&&Te(Je)},!0)}E(O,re)};W($,O=>{n.icon?O(de):O(Q,!1)})}et(G,O=>I(O),()=>I()),Z(()=>Fe=we(G,Fe,{...k(c)})),z("click",G,function(O){T.call(this,e,O)}),z("focus",G,function(O){T.call(this,e,O)}),z("blur",G,function(O){T.call(this,e,O)}),z("mouseover",G,function(O){T.call(this,e,O)}),z("mouseenter",G,function(O){T.call(this,e,O)}),z("mouseleave",G,function(O){T.call(this,e,O)}),E(Ce,G)};W(tt,Ce=>{_()&&!y()?Ce(ze):Ce(bt,!1)},!0)}E(K,be)};W(ie,K=>{x()?K(Ae):K(Se,!1)},!0)}E(se,q)};W(C,se=>{g()?se(R):se(Y,!1)})}E(t,N),me()}var lo=U("
");function co(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,[]);var r=lo();let a;var s=L(r);b(s,"bx--checkbox-label-text",!0),b(s,"bx--skeleton",!0),Z(()=>{a=we(r,a,{...i}),b(r,"bx--form-item",!0),b(r,"bx--checkbox-wrapper",!0),b(r,"bx--checkbox-label",!0)}),z("click",r,function(c){T.call(this,e,c)}),z("mouseover",r,function(c){T.call(this,e,c)}),z("mouseenter",r,function(c){T.call(this,e,c)}),z("mouseleave",r,function(c){T.call(this,e,c)}),E(t,r)}var uo=U('
');function fo(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["value","checked","group","indeterminate","skeleton","required","readonly","disabled","labelText","hideLabel","name","title","id","ref"]);_e(e,!1);const r=le(),a=le();let s=p(e,"value",8,""),c=p(e,"checked",12,!1),f=p(e,"group",12,void 0),d=p(e,"indeterminate",12,!1),l=p(e,"skeleton",8,!1),o=p(e,"required",8,!1),u=p(e,"readonly",8,!1),h=p(e,"disabled",8,!1),v=p(e,"labelText",8,""),m=p(e,"hideLabel",8,!1),x=p(e,"name",8,""),g=p(e,"title",12,void 0),y=p(e,"id",24,()=>"ccs-"+Math.random().toString(36)),_=p(e,"ref",12,null);const w=Ct();let P=le(null);ne(()=>M(f()),()=>{J(r,Array.isArray(f()))}),ne(()=>(M(c()),k(r),M(f()),M(s())),()=>{c(k(r)?f().includes(s()):c())}),ne(()=>M(c()),()=>{w("check",c())}),ne(()=>k(P),()=>{var R,Y;J(a,((R=k(P))==null?void 0:R.offsetWidth)<((Y=k(P))==null?void 0:Y.scrollWidth))}),ne(()=>(M(g()),k(a),k(P)),()=>{var R;g(!g()&&k(a)?(R=k(P))==null?void 0:R.innerText:g())}),Ne(),ye();var I=ve(),F=ce(I);{var N=R=>{co(R,rt(()=>i,{$$events:{click(Y){T.call(this,e,Y)},mouseover(Y){T.call(this,e,Y)},mouseenter(Y){T.call(this,e,Y)},mouseleave(Y){T.call(this,e,Y)}}}))},C=R=>{var Y=uo();let se;var q=L(Y);et(q,K=>_(K),()=>_());var ie=A(q,2),Ae=L(ie);b(Ae,"bx--checkbox-label-text",!0);var Se=L(Ae);he(Se,e,"labelText",{},K=>{var be=Me();Z(()=>ae(be,v())),E(K,be)}),et(Ae,K=>J(P,K),()=>k(P)),Z(()=>{se=we(Y,se,{...i}),b(Y,"bx--form-item",!0),b(Y,"bx--checkbox-wrapper",!0),Da(q,s()),or(q,c()),q.disabled=h(),B(q,"id",y()),B(q,"name",x()),q.required=o(),q.readOnly=u(),b(q,"bx--checkbox",!0),B(ie,"for",y()),B(ie,"title",g()),b(ie,"bx--checkbox-label",!0),b(Ae,"bx--visually-hidden",m())}),Ka("indeterminate","change",q,d,d),z("change",q,()=>{k(r)?f(f().includes(s())?f().filter(K=>K!==s()):[...f(),s()]):c(!c())}),z("change",q,function(K){T.call(this,e,K)}),z("focus",q,function(K){T.call(this,e,K)}),z("blur",q,function(K){T.call(this,e,K)}),z("click",Y,function(K){T.call(this,e,K)}),z("mouseover",Y,function(K){T.call(this,e,K)}),z("mouseenter",Y,function(K){T.call(this,e,K)}),z("mouseleave",Y,function(K){T.call(this,e,K)}),E(R,Y)};W(F,R=>{l()?R(N):R(C,!1)})}E(t,I),me()}var ho=U(" "),vo=Xt('');function jn(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["size","title"]);_e(e,!1);const r=le(),a=le();let s=p(e,"size",8,16),c=p(e,"title",8,void 0);ne(()=>(M(n),M(c())),()=>{J(r,n["aria-label"]||n["aria-labelledby"]||c())}),ne(()=>(k(r),M(n)),()=>{J(a,{"aria-hidden":k(r)?void 0:!0,role:k(r)?"img":void 0,focusable:Number(n.tabindex)===0?!0:void 0})}),Ne(),ye();var f=vo();let d;var l=L(f);{var o=u=>{var h=ho(),v=L(h);Z(()=>ae(v,c())),E(u,h)};W(l,u=>{c()&&u(o)})}Z(()=>d=we(f,d,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32",fill:"currentColor",preserveAspectRatio:"xMidYMid meet",width:s(),height:s(),...k(a),...i},void 0,!0)),E(t,f),me()}var _o=U(" "),mo=Xt('');function dr(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["size","title"]);_e(e,!1);const r=le(),a=le();let s=p(e,"size",8,16),c=p(e,"title",8,void 0);ne(()=>(M(n),M(c())),()=>{J(r,n["aria-label"]||n["aria-labelledby"]||c())}),ne(()=>(k(r),M(n)),()=>{J(a,{"aria-hidden":k(r)?void 0:!0,role:k(r)?"img":void 0,focusable:Number(n.tabindex)===0?!0:void 0})}),Ne(),ye();var f=mo();let d;var l=L(f);{var o=u=>{var h=_o(),v=L(h);Z(()=>ae(v,c())),E(u,h)};W(l,u=>{c()&&u(o)})}Z(()=>d=we(f,d,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32",fill:"currentColor",preserveAspectRatio:"xMidYMid meet",width:s(),height:s(),...k(a),...i},void 0,!0)),E(t,f),me()}var bo=U(" "),go=Xt('');function _i(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["size","title"]);_e(e,!1);const r=le(),a=le();let s=p(e,"size",8,16),c=p(e,"title",8,void 0);ne(()=>(M(n),M(c())),()=>{J(r,n["aria-label"]||n["aria-labelledby"]||c())}),ne(()=>(k(r),M(n)),()=>{J(a,{"aria-hidden":k(r)?void 0:!0,role:k(r)?"img":void 0,focusable:Number(n.tabindex)===0?!0:void 0})}),Ne(),ye();var f=go();let d;var l=L(f);{var o=u=>{var h=bo(),v=L(h);Z(()=>ae(v,c())),E(u,h)};W(l,u=>{c()&&u(o)})}Z(()=>d=we(f,d,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32",fill:"currentColor",preserveAspectRatio:"xMidYMid meet",width:s(),height:s(),...k(a),...i},void 0,!0)),E(t,f),me()}function po(t,e){_e(e,!1);let n=p(e,"key",8,"local-storage-key"),i=p(e,"value",12,"");function r(){localStorage.removeItem(n())}function a(){localStorage.clear()}const s=Ct();let c=i();function f(){typeof i()=="object"?localStorage.setItem(n(),JSON.stringify(i())):localStorage.setItem(n(),i())}return qn(()=>{const d=localStorage.getItem(n());if(d!=null)try{i(JSON.parse(d))}catch{i(d)}else f(i()),s("save")}),cr(()=>{c!==i()&&(f(i()),s("update",{prevValue:c,value:i()})),c=i()}),ye(),di(e,"clearItem",r),di(e,"clearAll",a),me({clearItem:r,clearAll:a})}var wo=U(" "),yo=Xt('');function xo(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["size","title"]);_e(e,!1);const r=le(),a=le();let s=p(e,"size",8,16),c=p(e,"title",8,void 0);ne(()=>(M(n),M(c())),()=>{J(r,n["aria-label"]||n["aria-labelledby"]||c())}),ne(()=>(k(r),M(n)),()=>{J(a,{"aria-hidden":k(r)?void 0:!0,role:k(r)?"img":void 0,focusable:Number(n.tabindex)===0?!0:void 0})}),Ne(),ye();var f=yo();let d;var l=L(f);{var o=u=>{var h=wo(),v=L(h);Z(()=>ae(v,c())),E(u,h)};W(l,u=>{c()&&u(o)})}Z(()=>d=we(f,d,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32",fill:"currentColor",preserveAspectRatio:"xMidYMid meet",width:s(),height:s(),...k(a),...i},void 0,!0)),E(t,f),me()}var ko=U(""),Io=U("
"),So=U("
"),To=U("
",1),Eo=U("
"),Po=U("
"),Oo=U("
"),zo=U("
",1),Co=U("
");function Lo(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["selected","size","inline","light","disabled","id","name","invalid","invalidText","warn","warnText","helperText","noLabel","labelText","hideLabel","ref","required"]);_e(e,!1);const r=Fa(),a=()=>Ht(ie,"$defaultValue",r),s=()=>Ht(q,"$defaultSelectId",r),c=()=>Ht(Ae,"$itemTypesByValue",r),f=()=>Ht(se,"$selectedValue",r),d=le();let l=p(e,"selected",12,void 0),o=p(e,"size",8,void 0),u=p(e,"inline",8,!1),h=p(e,"light",8,!1),v=p(e,"disabled",8,!1),m=p(e,"id",24,()=>"ccs-"+Math.random().toString(36)),x=p(e,"name",8,void 0),g=p(e,"invalid",8,!1),y=p(e,"invalidText",8,""),_=p(e,"warn",8,!1),w=p(e,"warnText",8,""),P=p(e,"helperText",8,""),I=p(e,"noLabel",8,!1),F=p(e,"labelText",8,""),N=p(e,"hideLabel",8,!1),C=p(e,"ref",12,null),R=p(e,"required",8,!1);const Y=Ct(),se=Qe(l()),q=Qe(null),ie=Qe(null),Ae=Qe({});ha("Select",{selectedValue:se,setDefaultValue:(V,$)=>{a()===null?(q.set(V),ie.set($)):s()===V&&se.set($),Ae.update(de=>({...de,[$]:typeof $}))}});const Se=({target:V})=>{let $=V.value;c()[$]==="number"&&($=Number($)),se.set($)};let K;cr(()=>{l(f()),K!==void 0&&l()!==K&&Y("update",f()),K=l()}),ne(()=>M(m()),()=>{J(d,`error-${m()}`)}),ne(()=>(M(l()),a()),()=>{se.set(l()??a())}),Ne(),ye();var be=Co();let tt;var ze=L(be);b(ze,"bx--select",!0);var bt=L(ze);{var Ce=V=>{var $=ko(),de=L($);he(de,e,"labelText",{},Q=>{var O=Me();Z(()=>ae(O,F())),E(Q,O)}),Z(()=>{B($,"for",m()),b($,"bx--label",!0),b($,"bx--visually-hidden",N()),b($,"bx--label--disabled",v())}),E(V,$)};W(bt,V=>{I()||V(Ce)})}var G=A(bt,2);{var Fe=V=>{var $=To(),de=ce($);b(de,"bx--select-input--inline__wrapper",!0);var Q=L(de),O=L(Q),re=L(O);he(re,e,"default",{},null),et(O,ee=>C(ee),()=>C());var fe=A(O,2);_i(fe,{class:"bx--select__arrow"});var Je=A(fe,2);{var Te=ee=>{jn(ee,{class:"bx--select__invalid-icon"})};W(Je,ee=>{g()&&ee(Te)})}var Re=A(Q,2);{var De=ee=>{var S=Io(),j=L(S);Z(()=>{B(S,"id",k(d)),b(S,"bx--form-requirement",!0),ae(j,y())}),E(ee,S)};W(Re,ee=>{g()&&ee(De)})}var Ge=A(de,2);{var Ue=ee=>{var S=So();b(S,"bx--form__helper-text",!0);var j=L(S);Z(()=>{b(S,"bx--form__helper-text--disabled",v()),ae(j,P())}),E(ee,S)};W(Ge,ee=>{!g()&&!_()&&P()&&ee(Ue)})}Z(()=>{B(Q,"data-invalid",g()||void 0),b(Q,"bx--select-input__wrapper",!0),B(O,"aria-describedby",g()?k(d):void 0),B(O,"aria-invalid",g()||void 0),O.disabled=v()||void 0,O.required=R()||void 0,B(O,"id",m()),B(O,"name",x()),b(O,"bx--select-input",!0),b(O,"bx--select-input--sm",o()==="sm"),b(O,"bx--select-input--xl",o()==="xl")}),z("change",O,Se),z("change",O,function(ee){T.call(this,e,ee)}),z("input",O,function(ee){T.call(this,e,ee)}),z("focus",O,function(ee){T.call(this,e,ee)}),z("blur",O,function(ee){T.call(this,e,ee)}),E(V,$)};W(G,V=>{u()&&V(Fe)})}var Be=A(G,2);{var dt=V=>{var $=zo(),de=ce($),Q=L(de),O=L(Q);he(O,e,"default",{},null),et(Q,D=>C(D),()=>C());var re=A(Q,2);_i(re,{class:"bx--select__arrow"});var fe=A(re,2);{var Je=D=>{jn(D,{class:"bx--select__invalid-icon"})};W(fe,D=>{g()&&D(Je)})}var Te=A(fe,2);{var Re=D=>{dr(D,{class:"bx--select__invalid-icon bx--select__invalid-icon--warning"})};W(Te,D=>{!g()&&_()&&D(Re)})}var De=A(de,2);{var Ge=D=>{var ue=Eo();b(ue,"bx--form__helper-text",!0);var Le=L(ue);Z(()=>{b(ue,"bx--form__helper-text--disabled",v()),ae(Le,P())}),E(D,ue)};W(De,D=>{!g()&&P()&&D(Ge)})}var Ue=A(De,2);{var ee=D=>{var ue=Po(),Le=L(ue);Z(()=>{B(ue,"id",k(d)),b(ue,"bx--form-requirement",!0),ae(Le,y())}),E(D,ue)};W(Ue,D=>{g()&&D(ee)})}var S=A(Ue,2);{var j=D=>{var ue=Oo(),Le=L(ue);Z(()=>{B(ue,"id",k(d)),b(ue,"bx--form-requirement",!0),ae(Le,w())}),E(D,ue)};W(S,D=>{!g()&&_()&&D(j)})}Z(()=>{B(de,"data-invalid",g()||void 0),b(de,"bx--select-input__wrapper",!0),B(Q,"id",m()),B(Q,"name",x()),B(Q,"aria-describedby",g()?k(d):void 0),Q.disabled=v()||void 0,Q.required=R()||void 0,B(Q,"aria-invalid",g()||void 0),b(Q,"bx--select-input",!0),b(Q,"bx--select-input--sm",o()==="sm"),b(Q,"bx--select-input--xl",o()==="xl")}),z("change",Q,Se),z("change",Q,function(D){T.call(this,e,D)}),z("input",Q,function(D){T.call(this,e,D)}),z("focus",Q,function(D){T.call(this,e,D)}),z("blur",Q,function(D){T.call(this,e,D)}),E(V,$)};W(Be,V=>{u()||V(dt)})}Z(()=>{tt=we(be,tt,{...i}),b(be,"bx--form-item",!0),b(ze,"bx--select--inline",u()),b(ze,"bx--select--light",h()),b(ze,"bx--select--invalid",g()),b(ze,"bx--select--disabled",v()),b(ze,"bx--select--warning",_())}),E(t,be),me()}var jo=U("");function No(t,e){_e(e,!1);let n=p(e,"value",8,""),i=p(e,"text",8,""),r=p(e,"hidden",8,!1),a=p(e,"disabled",8,!1),s=p(e,"class",8,void 0),c=p(e,"style",8,void 0);const f="ccs-"+Math.random().toString(36),d=rn("Select")||rn("TimePickerSelect");let l=le(!1);const o=d.selectedValue.subscribe(m=>{J(l,m===n())});qn(()=>()=>o()),ne(()=>M(n()),()=>{var m;(m=d==null?void 0:d.setDefaultValue)==null||m.call(d,f,n())}),Ne(),ye();var u=jo(),h={},v=L(u);Z(()=>{h!==(h=n())&&(u.value=(u.__value=n())==null?"":n()),u.disabled=a(),u.hidden=r(),sr(u,k(l)),Ma(u,ar(s()),""),B(u,"style",c()),b(u,"bx--select-option",!0),ae(v,i()||n())}),E(t,u),me()}var Ao=U(""),Ro=U("
"),Do=U("
"),Mo=U(""),Zo=U(" ",1),Wo=U("
"),Ko=U("
"),Fo=U("
"),Uo=U("
"),Vo=U("
"),Xo=U("
"),Yo=U("
");function mi(t,e){const n=Yn(e),i=oe(e,["children","$$slots","$$events","$$legacy"]),r=oe(i,["size","value","placeholder","light","disabled","helperText","id","name","labelText","hideLabel","invalid","invalidText","warn","warnText","ref","required","inline","readonly"]);_e(e,!1);const a=le(),s=le(),c=le(),f=le();let d=p(e,"size",8,void 0),l=p(e,"value",12,""),o=p(e,"placeholder",8,""),u=p(e,"light",8,!1),h=p(e,"disabled",8,!1),v=p(e,"helperText",8,""),m=p(e,"id",24,()=>"ccs-"+Math.random().toString(36)),x=p(e,"name",8,void 0),g=p(e,"labelText",8,""),y=p(e,"hideLabel",8,!1),_=p(e,"invalid",8,!1),w=p(e,"invalidText",8,""),P=p(e,"warn",8,!1),I=p(e,"warnText",8,""),F=p(e,"ref",12,null),N=p(e,"required",8,!1),C=p(e,"inline",8,!1),R=p(e,"readonly",8,!1);const Y=rn("Form"),se=Ct();function q(S){return r.type!=="number"?S:S!=""?Number(S):null}const ie=S=>{l(q(S.target.value)),se("input",l())},Ae=S=>{se("change",q(S.target.value))},Se=!!Y&&Y.isFluid;ne(()=>(M(_()),M(R())),()=>{J(a,_()&&!R())}),ne(()=>M(m()),()=>{J(s,`helper-${m()}`)}),ne(()=>M(m()),()=>{J(c,`error-${m()}`)}),ne(()=>M(m()),()=>{J(f,`warn-${m()}`)}),Ne(),ye();var K=Yo();b(K,"bx--form-item",!0),b(K,"bx--text-input-wrapper",!0);var be=L(K);{var tt=S=>{var j=Do();b(j,"bx--text-input__label-helper-wrapper",!0);var D=L(j);{var ue=xe=>{var ke=Ao(),bn=L(ke);he(bn,e,"labelText",{},gr=>{var ni=Me();Z(()=>ae(ni,g())),E(gr,ni)}),Z(()=>{B(ke,"for",m()),b(ke,"bx--label",!0),b(ke,"bx--visually-hidden",y()),b(ke,"bx--label--disabled",h()),b(ke,"bx--label--inline",C()),b(ke,"bx--label--inline--sm",d()==="sm"),b(ke,"bx--label--inline--xl",d()==="xl")}),E(xe,ke)};W(D,xe=>{g()&&xe(ue)})}var Le=A(D,2);{var mn=xe=>{var ke=Ro();b(ke,"bx--form__helper-text",!0);var bn=L(ke);Z(()=>{b(ke,"bx--form__helper-text--disabled",h()),b(ke,"bx--form__helper-text--inline",C()),ae(bn,v())}),E(xe,ke)};W(Le,xe=>{!Se&&v()&&xe(mn)})}E(S,j)};W(be,S=>{C()&&S(tt)})}var ze=A(be,2);{var bt=S=>{var j=Mo(),D=L(j);he(D,e,"labelText",{},ue=>{var Le=Me();Z(()=>ae(Le,g())),E(ue,Le)}),Z(()=>{B(j,"for",m()),b(j,"bx--label",!0),b(j,"bx--visually-hidden",y()),b(j,"bx--label--disabled",h()),b(j,"bx--label--inline",C()),b(j,"bx--label--inline-sm",C()&&d()==="sm"),b(j,"bx--label--inline-xl",C()&&d()==="xl")}),E(S,j)};W(ze,S=>{!C()&&(g()||n.labelText)&&S(bt)})}var Ce=A(ze,2);b(Ce,"bx--text-input__field-outer-wrapper",!0);var G=L(Ce),Fe=L(G);{var Be=S=>{xo(S,{class:"bx--text-input__readonly-icon"})},dt=S=>{var j=Zo(),D=ce(j);{var ue=xe=>{jn(xe,{class:"bx--text-input__invalid-icon"})};W(D,xe=>{_()&&xe(ue)})}var Le=A(D,2);{var mn=xe=>{dr(xe,{class:`bx--text-input__invalid-icon - bx--text-input__invalid-icon--warning`})};W(Le,xe=>{!_()&&P()&&xe(mn)})}E(S,j)};W(Fe,S=>{R()?S(Be):S(dt,!1)})}var V=A(Fe,2);let $;et(V,S=>F(S),()=>F());var de=A(V,2);{var Q=S=>{var j=Wo();b(j,"bx--text-input__divider",!0),E(S,j)};W(de,S=>{Se&&S(Q)})}var O=A(de,2);{var re=S=>{var j=Ko(),D=L(j);Z(()=>{B(j,"id",k(c)),b(j,"bx--form-requirement",!0),ae(D,w())}),E(S,j)};W(O,S=>{Se&&!C()&&_()&&S(re)})}var fe=A(O,2);{var Je=S=>{var j=Fo(),D=L(j);Z(()=>{B(j,"id",k(f)),b(j,"bx--form-requirement",!0),ae(D,I())}),E(S,j)};W(fe,S=>{Se&&!C()&&P()&&S(Je)})}var Te=A(G,2);{var Re=S=>{var j=Uo(),D=L(j);Z(()=>{B(j,"id",k(s)),b(j,"bx--form__helper-text",!0),b(j,"bx--form__helper-text--disabled",h()),b(j,"bx--form__helper-text--inline",C()),ae(D,v())}),E(S,j)};W(Te,S=>{!_()&&!P()&&!Se&&!C()&&v()&&S(Re)})}var De=A(Te,2);{var Ge=S=>{var j=Vo(),D=L(j);Z(()=>{B(j,"id",k(c)),b(j,"bx--form-requirement",!0),ae(D,w())}),E(S,j)};W(De,S=>{!Se&&_()&&S(Ge)})}var Ue=A(De,2);{var ee=S=>{var j=Xo(),D=L(j);Z(()=>{B(j,"id",k(f)),b(j,"bx--form-requirement",!0),ae(D,I())}),E(S,j)};W(Ue,S=>{!Se&&!_()&&P()&&S(ee)})}Z(()=>{b(K,"bx--text-input-wrapper--inline",C()),b(K,"bx--text-input-wrapper--light",u()),b(K,"bx--text-input-wrapper--readonly",R()),b(Ce,"bx--text-input__field-outer-wrapper--inline",C()),B(G,"data-invalid",k(a)||void 0),B(G,"data-warn",P()||void 0),b(G,"bx--text-input__field-wrapper",!0),b(G,"bx--text-input__field-wrapper--warning",!_()&&P()),$=we(V,$,{"data-invalid":k(a)||void 0,"aria-invalid":k(a)||void 0,"data-warn":P()||void 0,"aria-describedby":k(a)?k(c):P()?k(f):v()?k(s):void 0,disabled:h(),id:m(),name:x(),placeholder:o(),required:N(),readonly:R(),...r}),b(V,"bx--text-input",!0),b(V,"bx--text-input--light",u()),b(V,"bx--text-input--invalid",k(a)),b(V,"bx--text-input--warning",P()),b(V,"bx--text-input--sm",d()==="sm"),b(V,"bx--text-input--xl",d()==="xl")}),Wa(V,l),z("change",V,Ae),z("input",V,ie),z("keydown",V,function(S){T.call(this,e,S)}),z("keyup",V,function(S){T.call(this,e,S)}),z("focus",V,function(S){T.call(this,e,S)}),z("blur",V,function(S){T.call(this,e,S)}),z("paste",V,function(S){T.call(this,e,S)}),z("click",K,function(S){T.call(this,e,S)}),z("mouseover",K,function(S){T.call(this,e,S)}),z("mouseenter",K,function(S){T.call(this,e,S)}),z("mouseleave",K,function(S){T.call(this,e,S)}),E(t,K),me()}var Ho=U('
');function qo(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["size","toggled","disabled","labelA","labelB","labelText","hideLabel","id","name"]);_e(e,!1);let r=p(e,"size",8,"default"),a=p(e,"toggled",12,!1),s=p(e,"disabled",8,!1),c=p(e,"labelA",8,"Off"),f=p(e,"labelB",8,"On"),d=p(e,"labelText",8,""),l=p(e,"hideLabel",8,!1),o=p(e,"id",24,()=>"ccs-"+Math.random().toString(36)),u=p(e,"name",8,void 0);const h=Ct();ne(()=>M(a()),()=>{h("toggle",{toggled:a()})}),Ne(),ye();var v=Ho();let m;var x=L(v),g=A(x,2),y=L(g),_=L(y);he(_,e,"labelText",{},C=>{var R=Me();Z(()=>ae(R,d())),E(C,R)});var w=A(y,2);b(w,"bx--toggle__switch",!0);var P=L(w);b(P,"bx--toggle__text--off",!0);var I=L(P);he(I,e,"labelA",{},C=>{var R=Me();Z(()=>ae(R,c())),E(C,R)});var F=A(P,2);b(F,"bx--toggle__text--on",!0);var N=L(F);he(N,e,"labelB",{},C=>{var R=Me();Z(()=>ae(R,f())),E(C,R)}),Z(()=>{m=we(v,m,{...i}),b(v,"bx--form-item",!0),ui(v,"user-select","none"),or(x,a()),x.disabled=s(),B(x,"id",o()),B(x,"name",u()),b(x,"bx--toggle-input",!0),b(x,"bx--toggle-input--small",r()==="sm"),B(g,"aria-label",d()?void 0:n["aria-label"]||"Toggle"),B(g,"for",o()),b(g,"bx--toggle-input__label",!0),b(y,"bx--visually-hidden",l()),ui(w,"margin-top",l()?0:void 0)}),z("change",x,()=>{a(!a())}),z("change",x,function(C){T.call(this,e,C)}),z("keyup",x,C=>{(C.key===" "||C.key==="Enter")&&(C.preventDefault(),a(!a()))}),z("keyup",x,function(C){T.call(this,e,C)}),z("focus",x,function(C){T.call(this,e,C)}),z("blur",x,function(C){T.call(this,e,C)}),z("click",v,function(C){T.call(this,e,C)}),z("mouseover",v,function(C){T.call(this,e,C)}),z("mouseenter",v,function(C){T.call(this,e,C)}),z("mouseleave",v,function(C){T.call(this,e,C)}),E(t,v),me()}var Bo=U(" ",1);function Jo(t,e){_e(e,!1);let n=p(e,"theme",12,"white"),i=p(e,"tokens",24,()=>({})),r=p(e,"persist",8,!1),a=p(e,"persistKey",8,"theme"),s=p(e,"render",8,void 0),c=p(e,"toggle",24,()=>({themes:["white","g100"],labelA:"",labelB:"",labelText:"Dark mode",hideLabel:!1}));const f={white:"White",g10:"Gray 10",g80:"Gray 80",g90:"Gray 90",g100:"Gray 100"},d=Object.keys(f);let l=p(e,"select",24,()=>({themes:d,labelText:"Themes",hideLabel:!1}));const o=Ct();ne(()=>(M(i()),M(n())),()=>{typeof window<"u"&&(Object.entries(i()).forEach(([_,w])=>{document.documentElement.style.setProperty(`--cds-${_}`,w)}),n()in f?(document.documentElement.setAttribute("theme",n()),o("update",{theme:n()})):console.warn(`[Theme.svelte] invalid theme "${n()}". Value must be one of: ${JSON.stringify(Object.keys(f))}`))}),Ne(),ye();var u=Bo(),h=ce(u);{var v=_=>{po(_,{get key(){return a()},get value(){return n()},set value(w){n(w)},$$legacy:!0})};W(h,_=>{r()&&_(v)})}var m=A(h,2);{var x=_=>{var w=At(()=>n()===c().themes[1]);qo(_,rt(c,{get toggled(){return k(w)},$$events:{toggle:({detail:P})=>{n(P.toggled?c().themes[1]:c().themes[0])}}}))},g=_=>{var w=ve(),P=ce(w);{var I=F=>{Lo(F,rt(l,{get selected(){return n()},set selected(N){n(N)},children:(N,C)=>{var R=ve(),Y=ce(R);La(Y,1,()=>l().themes,q=>q,(q,ie)=>{No(q,{get value(){return k(ie)},get text(){return f[k(ie)]}})}),E(N,R)},$$slots:{default:!0},$$legacy:!0}))};W(P,F=>{s()==="select"&&F(I)},!0)}E(_,w)};W(m,_=>{s()==="toggle"?_(x):_(g,!1)})}var y=A(m,2);he(y,e,"default",{get theme(){return n()}},null),E(t,u),me()}var Go=U(" "),Qo=Xt('');function $o(t,e){const n=oe(e,["children","$$slots","$$events","$$legacy"]),i=oe(n,["size","title"]);_e(e,!1);const r=le(),a=le();let s=p(e,"size",8,16),c=p(e,"title",8,void 0);ne(()=>(M(n),M(c())),()=>{J(r,n["aria-label"]||n["aria-labelledby"]||c())}),ne(()=>(k(r),M(n)),()=>{J(a,{"aria-hidden":k(r)?void 0:!0,role:k(r)?"img":void 0,focusable:Number(n.tabindex)===0?!0:void 0})}),Ne(),ye();var f=Qo();let d;var l=L(f);{var o=u=>{var h=Go(),v=L(h);Z(()=>ae(v,c())),E(u,h)};W(l,u=>{c()&&u(o)})}Z(()=>d=we(f,d,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32",fill:"currentColor",preserveAspectRatio:"xMidYMid meet",width:s(),height:s(),...k(a),...i},void 0,!0)),E(t,f),me()}var es=Ta(`
+ + +
+
+
X
+
X
+
X
+
X
+
X
+
X
+
X
+
X
+
+
+
+
+
+
+ Retour a l'accueil + +
+
+
XT
+
+
+

Se connecter a Xtablo

+
+ + +
+ Ou continuer avec +
+ + +
+
+
+
+ + +} + +templ LoginStatus(kind string, message string) { + if kind == "success" { +
{ message }
+ } else if kind == "error" { + + } +} diff --git a/go_backend_deprecated/internal/web/views/login_templ.go b/go_backend_deprecated/internal/web/views/login_templ.go new file mode 100644 index 0000000..00c9b41 --- /dev/null +++ b/go_backend_deprecated/internal/web/views/login_templ.go @@ -0,0 +1,102 @@ +// 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" + +func LoginPage() 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, "XTablo
X
X
X
X
X
X
X
X
XT

Se connecter a Xtablo

Ou continuer avec

Pas encore de compte ? S'inscrire

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func LoginStatus(kind string, message 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_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if kind == "success" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 91, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if kind == "error" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 93, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go_backend_deprecated/main.go b/go_backend_deprecated/main.go new file mode 100644 index 0000000..e1dddb7 --- /dev/null +++ b/go_backend_deprecated/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "net/http" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + zerolog.TimeFieldFormat = time.DateTime + + server := &http.Server{ + Addr: "localhost:3000", + Handler: newRouter(), + ReadTimeout: 10 * time.Second, + WriteTimeout: time.Minute, + } + + log.Info().Msg("Listening on http://localhost:3000...") + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Msg(err.Error()) + panic(err) + } +} diff --git a/go_backend_deprecated/router.go b/go_backend_deprecated/router.go new file mode 100644 index 0000000..a6ac3c5 --- /dev/null +++ b/go_backend_deprecated/router.go @@ -0,0 +1,20 @@ +package main + +import ( + "net/http" + "os" + + chi "github.com/go-chi/chi/v5" + "xtablo-backend/internal/web/handlers" +) + +func newRouter() http.Handler { + mux := chi.NewRouter() + loginHandler := handlers.NewLoginHandler() + + mux.Get("/", loginHandler.GetPage()) + mux.Post("/login", loginHandler.PostLogin()) + mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static")))) + + return mux +} diff --git a/go_backend_deprecated/router_test.go b/go_backend_deprecated/router_test.go new file mode 100644 index 0000000..986d3b2 --- /dev/null +++ b/go_backend_deprecated/router_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestRootRendersLoginPage(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + router := newRouter() + 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{ + "Se connecter a Xtablo", + `hx-post="/login"`, + "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q", want) + } + } +} + +func TestLoginReturnsValidationError(t *testing.T) { + form := url.Values{} + form.Set("email", "") + form.Set("password", "") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected status 422, got %d", rec.Code) + } + + if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { + t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) + } +} + +func TestLoginReturnsSuccessMessage(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if !strings.Contains(rec.Body.String(), "Connexion reussie") { + t.Fatalf("expected success fragment, got %q", rec.Body.String()) + } +} diff --git a/go_backend_deprecated/static/styles.css b/go_backend_deprecated/static/styles.css new file mode 100644 index 0000000..6ea5f70 --- /dev/null +++ b/go_backend_deprecated/static/styles.css @@ -0,0 +1,417 @@ +:root { + --background: #f5f1ea; + --surface: rgba(255, 251, 246, 0.78); + --surface-border: rgba(84, 61, 31, 0.12); + --text: #1f1a17; + --muted: #73675d; + --primary: #1f6f64; + --primary-strong: #18584f; + --accent: #cf6b2d; + --accent-soft: rgba(207, 107, 45, 0.16); + --success-bg: rgba(31, 111, 100, 0.1); + --success-border: rgba(31, 111, 100, 0.25); + --error-bg: rgba(181, 69, 69, 0.1); + --error-border: rgba(181, 69, 69, 0.24); + --shadow: 0 24px 80px rgba(43, 24, 4, 0.12); + --font-body: "Avenir Next", "Segoe UI", sans-serif; + --font-display: "Iowan Old Style", "Georgia", serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: + radial-gradient(circle at top left, rgba(31, 111, 100, 0.16), transparent 30%), + radial-gradient(circle at top right, rgba(207, 107, 45, 0.16), transparent 26%), + linear-gradient(135deg, #f3efe7 0%, #f8f4ed 48%, #efe9dd 100%); + color: var(--text); + font-family: var(--font-body); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} + +.page-shell { + min-height: 100vh; + position: relative; + overflow: hidden; +} + +.page-background { + inset: 0; + pointer-events: none; + position: absolute; +} + +.orb { + align-items: center; + animation: drift 18s linear infinite; + background: linear-gradient(135deg, rgba(31, 111, 100, 0.22), rgba(207, 107, 45, 0.1)); + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 999px; + color: rgba(31, 111, 100, 0.6); + display: flex; + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 700; + height: 3.4rem; + justify-content: center; + position: absolute; + width: 3.4rem; +} + +.orb span { + transform: rotate(-12deg); +} + +.orb-a { left: 6%; top: 18%; animation-duration: 14s; } +.orb-b { left: 12%; top: 70%; animation-duration: 19s; height: 4rem; width: 4rem; } +.orb-c { left: 24%; top: 8%; animation-duration: 16s; } +.orb-d { right: 14%; top: 20%; animation-duration: 21s; height: 4.4rem; width: 4.4rem; } +.orb-e { right: 8%; top: 66%; animation-duration: 17s; } +.orb-f { right: 26%; top: 10%; animation-duration: 23s; } +.orb-g { left: 36%; bottom: 10%; animation-duration: 20s; } +.orb-h { right: 35%; bottom: 8%; animation-duration: 15s; } + +.auth-stage { + align-items: center; + display: flex; + justify-content: center; + min-height: 100vh; + padding: 2rem 1rem; + position: relative; +} + +.auth-card { + max-width: 34rem; + position: relative; + width: 100%; +} + +.auth-glow { + background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.1)); + border-radius: 2rem; + filter: blur(24px); + inset: 1rem; + position: absolute; + z-index: 0; +} + +.card-body { + backdrop-filter: blur(16px); + background: var(--surface); + border: 1px solid var(--surface-border); + border-radius: 1.75rem; + box-shadow: var(--shadow); + padding: 1.5rem; + position: relative; + z-index: 1; +} + +.card-topbar { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.back-link { + color: var(--muted); + font-size: 0.95rem; + transition: color 160ms ease; +} + +.back-link:hover, +.aux-row a:hover, +.signup-copy a:hover { + color: var(--text); +} + +.back-link::before { + content: "<"; + margin-right: 0.55rem; +} + +.theme-button { + align-items: center; + background: transparent; + border: 0; + border-radius: 999px; + color: var(--muted); + cursor: pointer; + display: inline-flex; + height: 2.5rem; + justify-content: center; + padding: 0; + transition: background-color 160ms ease, color 160ms ease; + width: 2.5rem; +} + +.theme-button:hover { + background: rgba(31, 26, 23, 0.05); + color: var(--text); +} + +.theme-button-monitor { + border: 2px solid currentColor; + border-radius: 0.35rem; + display: inline-block; + height: 1rem; + position: relative; + width: 1.3rem; +} + +.theme-button-monitor::after { + border-top: 2px solid currentColor; + content: ""; + left: 50%; + position: absolute; + top: calc(100% + 0.2rem); + transform: translateX(-50%); + width: 0.9rem; +} + +.brand-lockup { + display: flex; + justify-content: center; + margin-bottom: 1.25rem; +} + +.brand-mark { + align-items: center; + background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.2)); + border: 1px solid rgba(31, 111, 100, 0.16); + border-radius: 1.25rem; + color: var(--primary-strong); + display: flex; + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 700; + height: 4.5rem; + justify-content: center; + letter-spacing: 0.12rem; + width: 4.5rem; +} + +.headline-block { + margin-bottom: 1rem; + text-align: center; +} + +.headline-block h1 { + font-family: var(--font-display); + font-size: clamp(2rem, 4vw, 2.7rem); + line-height: 1.05; + margin: 0; +} + +.spotlight-link-wrap { + margin-bottom: 1.5rem; + text-align: center; +} + +.spotlight-link { + color: var(--accent); + font-size: 0.95rem; + font-weight: 600; +} + +.login-form { + display: grid; + gap: 1rem; +} + +.status-slot { + min-height: 0.25rem; +} + +.status-banner { + border: 1px solid; + border-radius: 1rem; + font-size: 0.94rem; + line-height: 1.45; + padding: 0.9rem 1rem; +} + +.status-success { + background: var(--success-bg); + border-color: var(--success-border); + color: var(--primary-strong); +} + +.status-error { + background: var(--error-bg); + border-color: var(--error-border); + color: #8f3737; +} + +.field-group { + display: grid; + gap: 0.45rem; +} + +.field-group label { + font-size: 0.95rem; + font-weight: 600; +} + +.field-group input { + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(31, 26, 23, 0.12); + border-radius: 0.9rem; + color: var(--text); + min-height: 3rem; + padding: 0.8rem 0.95rem; + transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease; +} + +.field-group input:focus { + background: rgba(255, 255, 255, 0.92); + border-color: rgba(31, 111, 100, 0.45); + box-shadow: 0 0 0 4px rgba(31, 111, 100, 0.1); + outline: none; +} + +.field-group input::placeholder { + color: #988d82; +} + +.aux-row { + display: flex; + justify-content: flex-end; +} + +.aux-row a { + color: var(--primary); + font-size: 0.92rem; +} + +.primary-button, +.google-button { + align-items: center; + border: 0; + border-radius: 999px; + cursor: pointer; + display: inline-flex; + font-weight: 700; + justify-content: center; + min-height: 3rem; + transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease; +} + +.primary-button { + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); + box-shadow: 0 14px 30px rgba(31, 111, 100, 0.28); + color: #fffdf9; + width: 100%; +} + +.primary-button:hover, +.google-button:hover { + transform: translateY(-1px); +} + +.divider { + align-items: center; + color: var(--muted); + display: flex; + gap: 0.85rem; + margin: 1.35rem 0; +} + +.divider::before, +.divider::after { + background: rgba(31, 26, 23, 0.12); + content: ""; + flex: 1; + height: 1px; +} + +.divider span { + background: rgba(255, 251, 246, 0.72); + border-radius: 999px; + padding: 0.4rem 0.95rem; +} + +.google-button { + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(31, 26, 23, 0.12); + color: var(--text); + gap: 0.8rem; + width: 100%; +} + +.google-mark { + display: grid; + gap: 0.08rem; + grid-template-columns: repeat(2, 0.55rem); +} + +.google-mark span { + border-radius: 0.12rem; + display: inline-block; + height: 0.55rem; + width: 0.55rem; +} + +.google-mark-blue { background: #4285f4; } +.google-mark-red { background: #ea4335; } +.google-mark-yellow { background: #fbbc05; } +.google-mark-green { background: #34a853; } + +.signup-copy { + color: var(--muted); + margin: 1.3rem 0 0; + text-align: center; +} + +.signup-copy a { + color: var(--text); + font-weight: 700; +} + +@keyframes drift { + 0%, + 100% { + transform: translate3d(0, 0, 0) rotate(0deg); + } + 25% { + transform: translate3d(10px, -14px, 0) rotate(8deg); + } + 50% { + transform: translate3d(-8px, 10px, 0) rotate(-6deg); + } + 75% { + transform: translate3d(12px, 8px, 0) rotate(5deg); + } +} + +@media (max-width: 640px) { + .card-body { + border-radius: 1.45rem; + padding: 1.2rem; + } + + .headline-block h1 { + font-size: 2rem; + } + + .divider span { + padding-inline: 0.7rem; + } +} -- 2.45.2 From 0a38442d88386490640b1fcc6211b3ffdcf83c73 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 8 May 2026 12:08:53 +0200 Subject: [PATCH 013/546] Build go-backend auth app with Podman dev flow --- .gitignore | 3 + .../plans/2026-05-07-go-backend-login.md | 209 ++++ .../2026-05-07-go-backend-login-design.md | 142 +++ go-backend/.air.toml | 27 + go-backend/.env.example | 1 + go-backend/README.md | 43 + go-backend/compose.yaml | 23 + go-backend/go.mod | 64 ++ go-backend/go.sum | 192 ++++ go-backend/internal/db/queries.sql | 29 + go-backend/internal/db/repository.go | 102 ++ go-backend/internal/db/schema.sql | 42 + go-backend/internal/db/seed.sql | 16 + go-backend/internal/db/sqlc/db.go | 32 + go-backend/internal/db/sqlc/models.go | 27 + go-backend/internal/db/sqlc/querier.go | 19 + go-backend/internal/db/sqlc/queries.sql.go | 99 ++ go-backend/internal/web/handlers/auth.go | 396 +++++++ go-backend/internal/web/handlers/auth_test.go | 120 +++ go-backend/internal/web/views/login.templ | 219 ++++ go-backend/internal/web/views/login_templ.go | 342 ++++++ go-backend/justfile | 61 ++ {go_backend_deprecated => go-backend}/main.go | 0 go-backend/router.go | 56 + go-backend/router_test.go | 254 +++++ go-backend/sqlc.yaml | 28 + go-backend/static/logo_dark.png | Bin 0 -> 85535 bytes go-backend/static/logo_white.png | Bin 0 -> 4562 bytes go-backend/static/manifest.webmanifest | 19 + .../pwa-icons/apple-touch-icon-180x180.png | Bin 0 -> 12129 bytes go-backend/static/pwa-icons/favicon-16x16.png | Bin 0 -> 600 bytes go-backend/static/pwa-icons/favicon-32x32.png | Bin 0 -> 1498 bytes go-backend/static/styles.css | 993 ++++++++++++++++++ go-backend/tools.go | 5 + go_backend_deprecated/.air.toml | 52 - go_backend_deprecated/Dockerfile | 30 - go_backend_deprecated/go.mod | 14 - go_backend_deprecated/go.sum | 24 - .../internal/web/handlers/login.go | 48 - .../internal/web/views/login.templ | 95 -- .../internal/web/views/login_templ.go | 102 -- go_backend_deprecated/router.go | 20 - go_backend_deprecated/router_test.go | 74 -- go_backend_deprecated/static/styles.css | 417 -------- 44 files changed, 3563 insertions(+), 876 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-07-go-backend-login.md create mode 100644 docs/superpowers/specs/2026-05-07-go-backend-login-design.md create mode 100644 go-backend/.air.toml create mode 100644 go-backend/.env.example create mode 100644 go-backend/README.md create mode 100644 go-backend/compose.yaml create mode 100644 go-backend/go.mod create mode 100644 go-backend/go.sum create mode 100644 go-backend/internal/db/queries.sql create mode 100644 go-backend/internal/db/repository.go create mode 100644 go-backend/internal/db/schema.sql create mode 100644 go-backend/internal/db/seed.sql create mode 100644 go-backend/internal/db/sqlc/db.go create mode 100644 go-backend/internal/db/sqlc/models.go create mode 100644 go-backend/internal/db/sqlc/querier.go create mode 100644 go-backend/internal/db/sqlc/queries.sql.go create mode 100644 go-backend/internal/web/handlers/auth.go create mode 100644 go-backend/internal/web/handlers/auth_test.go create mode 100644 go-backend/internal/web/views/login.templ create mode 100644 go-backend/internal/web/views/login_templ.go create mode 100644 go-backend/justfile rename {go_backend_deprecated => go-backend}/main.go (100%) create mode 100644 go-backend/router.go create mode 100644 go-backend/router_test.go create mode 100644 go-backend/sqlc.yaml create mode 100644 go-backend/static/logo_dark.png create mode 100644 go-backend/static/logo_white.png create mode 100644 go-backend/static/manifest.webmanifest create mode 100644 go-backend/static/pwa-icons/apple-touch-icon-180x180.png create mode 100644 go-backend/static/pwa-icons/favicon-16x16.png create mode 100644 go-backend/static/pwa-icons/favicon-32x32.png create mode 100644 go-backend/static/styles.css create mode 100644 go-backend/tools.go delete mode 100644 go_backend_deprecated/.air.toml delete mode 100644 go_backend_deprecated/Dockerfile delete mode 100644 go_backend_deprecated/go.mod delete mode 100644 go_backend_deprecated/go.sum delete mode 100644 go_backend_deprecated/internal/web/handlers/login.go delete mode 100644 go_backend_deprecated/internal/web/views/login.templ delete mode 100644 go_backend_deprecated/internal/web/views/login_templ.go delete mode 100644 go_backend_deprecated/router.go delete mode 100644 go_backend_deprecated/router_test.go delete mode 100644 go_backend_deprecated/static/styles.css diff --git a/.gitignore b/.gitignore index 3cfb0b0..22325c5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ dist # Supabase supabase/.temp supabase/.branches + +# Podman +.podman-compose diff --git a/docs/superpowers/plans/2026-05-07-go-backend-login.md b/docs/superpowers/plans/2026-05-07-go-backend-login.md new file mode 100644 index 0000000..e748f2d --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-go-backend-login.md @@ -0,0 +1,209 @@ +# Go Backend Login 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:** Replace the current Vite-oriented `go_backend` app with a standalone Go web app that serves a templ-rendered XTablo login page and uses HTMX for inline login feedback on `http://localhost:3000`. + +**Architecture:** The server remains a small `chi` application. `GET /` renders a full page from `templ`, `POST /login` returns a small server-rendered fragment for HTMX swaps, and `GET /static/*` serves local CSS. The page includes the HTMX runtime from the specified CDN URL and no custom app JavaScript. + +**Tech Stack:** Go, chi, templ, net/http, HTMX CDN, server-served CSS + +--- + +## Chunk 1: Test Scaffold And Module Setup + +### Task 1: Add dependencies and basic file layout + +**Files:** +- Modify: `go_backend/go.mod` +- Modify: `go_backend/go.sum` +- Create: `go_backend/internal/web/handlers/login.go` +- Create: `go_backend/internal/web/views/layout.templ` +- Create: `go_backend/internal/web/views/login.templ` +- Create: `go_backend/static/styles.css` + +- [ ] **Step 1: Add the failing tests first before any production behavior changes** +- [ ] **Step 2: Add `templ` module dependency after the tests are in place** +- [ ] **Step 3: Create focused directories for handlers, views, and static assets** +- [ ] **Step 4: Keep Vite-only files untouched until replacement code is ready** + +## Chunk 2: HTTP-Level TDD + +### Task 2: Cover the root page + +**Files:** +- Create: `go_backend/router_test.go` +- Test: `go_backend/router_test.go` + +- [ ] **Step 1: Write a failing test for `GET /`** + +```go +func TestRootRendersLoginPage(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + router := newRouter() + 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{ + "Se connecter a Xtablo", + `hx-post="/login"`, + "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q", want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./...` +Expected: the new root-page assertions fail against the current Vite/Spa output. + +- [ ] **Step 3: Implement the minimum root-page rendering path** +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./...` +Expected: `TestRootRendersLoginPage` passes. + +### Task 3: Cover HTMX login fragment behavior + +**Files:** +- Modify: `go_backend/router_test.go` +- Test: `go_backend/router_test.go` + +- [ ] **Step 1: Write a failing test for missing login fields** + +```go +func TestLoginReturnsValidationError(t *testing.T) { + form := url.Values{} + form.Set("email", "") + form.Set("password", "") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected status 422, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { + t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./...` +Expected: missing route or wrong response. + +- [ ] **Step 3: Write a failing test for demo credential success** + +```go +func TestLoginReturnsSuccessMessage(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Connexion reussie") { + t.Fatalf("expected success fragment, got %q", rec.Body.String()) + } +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `go test ./...` +Expected: no success behavior exists yet. + +- [ ] **Step 5: Implement the minimal `POST /login` validation and fragment rendering** +- [ ] **Step 6: Run test to verify both login fragment tests pass** + +Run: `go test ./...` +Expected: both login tests pass. + +## Chunk 3: Replace Vite-Oriented Production Code + +### Task 4: Simplify routing and startup + +**Files:** +- Modify: `go_backend/main.go` +- Modify: `go_backend/router.go` +- Possibly remove references from: `go_backend/internal/spahandler/handler.go` +- Possibly remove references from: `go_backend/internal/frontend/file.go` + +- [ ] **Step 1: Introduce a `newRouter()` function shared by tests and main** +- [ ] **Step 2: Bind the server to `localhost:3000`** +- [ ] **Step 3: Replace dev/prod SPA branching with direct routes for `/`, `/login`, and `/static/*`** +- [ ] **Step 4: Remove unused Vite-specific wiring once tests stay green** + +### Task 5: Render the login page with templ + +**Files:** +- Modify: `go_backend/internal/web/views/layout.templ` +- Modify: `go_backend/internal/web/views/login.templ` +- Modify: `go_backend/internal/web/handlers/login.go` +- Modify: `go_backend/static/styles.css` + +- [ ] **Step 1: Create a base page layout with head metadata, CSS link, and HTMX CDN script** +- [ ] **Step 2: Create the login page component** +- [ ] **Step 3: Create a reusable status fragment component for success and error states** +- [ ] **Step 4: Match the requested visual structure with local CSS, not utility classes** +- [ ] **Step 5: Keep copy in French and links as placeholders where needed** + +## Chunk 4: Verification + +### Task 6: Generate code, format, test, and build + +**Files:** +- Generated: `go_backend/internal/web/views/*_templ.go` + +- [ ] **Step 1: Generate templ output** + +Run: `templ generate` +Expected: updated generated Go files for the templ views. + +- [ ] **Step 2: Format the Go code** + +Run: `gofmt -w main.go router.go internal/web/handlers/login.go internal/web/views/*_templ.go router_test.go` +Expected: no output, files rewritten in place. + +- [ ] **Step 3: Run the full Go test suite** + +Run: `go test ./...` +Expected: all tests pass. + +- [ ] **Step 4: Run a build verification** + +Run: `go build ./...` +Expected: build succeeds with exit code 0. + +- [ ] **Step 5: Manual smoke check** + +Run: `go run .` +Expected: server listens on `http://localhost:3000`. + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-05-07-go-backend-login.md`. Ready to execute. diff --git a/docs/superpowers/specs/2026-05-07-go-backend-login-design.md b/docs/superpowers/specs/2026-05-07-go-backend-login-design.md new file mode 100644 index 0000000..00b1442 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-go-backend-login-design.md @@ -0,0 +1,142 @@ +# Go Backend Login Design + +**Date:** 2026-05-07 + +**Goal** + +Build a new standalone web app in `go_backend` that serves a server-rendered XTablo login screen using Go, `templ`, and HTMX, with no Vite pipeline and no custom application JavaScript. + +**Scope** + +- Replace the current SPA/Vite-oriented serving path in `go_backend`. +- Render the main login screen at `/`. +- Submit the login form with HTMX to `/login`. +- Return a server-rendered HTML fragment into the login card for feedback. +- Serve local CSS and any local static assets directly from Go. + +**Out of Scope** + +- Real authentication integration +- Session management +- OAuth / Google sign-in implementation +- Theme switching behavior +- Multi-page navigation beyond placeholder links + +**Architecture** + +The app will be a small Go HTTP service using `chi` for routing and `templ` for HTML generation. The server will expose a page route for the full login screen, a form submission route for partial updates, and a static file route for CSS and local assets. + +The development server should listen on `localhost:3000`. + +HTMX will be loaded from the user-specified CDN URL: + +`https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js` + +No Vite integration, bundling, or custom frontend JavaScript will remain in the new app. + +**Route Design** + +- `GET /` + - Returns the full login page. +- `POST /login` + - Accepts form fields `email` and `password`. + - Returns a small server-rendered fragment for an inline status/message region. +- `GET /static/*` + - Serves CSS and optional local static assets. + +**Server Structure** + +Proposed structure: + +- `go_backend/main.go` + - Server startup and configuration. +- `go_backend/router.go` + - Route registration. +- `go_backend/internal/web/handlers/` + - HTTP handlers for page rendering and login submission. +- `go_backend/internal/web/views/` + - `templ` page and fragment components. +- `go_backend/static/` + - CSS and optional local assets. + +The code should keep routing, request handling, and view rendering separated so future auth wiring does not force template logic into the router layer. + +**UI Design** + +The login page should visually track the provided reference: + +- full-screen soft gradient background +- centered frosted/glass login card +- top row with back link and theme-toggle placeholder button +- centered XTablo brand area +- French login title and surrounding copy +- email/password fields +- forgot-password link +- primary submit button +- “Ou continuer avec” separator +- Google continuation button +- signup prompt at the bottom + +The background should include decorative floating logo-like ornaments implemented with CSS positioning and animation. If the repository does not already contain usable logo assets, the page should still render cleanly with text or simple shape placeholders rather than blocking implementation on image sourcing. + +**Behavior** + +The app remains primarily server-rendered. + +The login form will use HTMX attributes: + +- `hx-post="/login"` +- `hx-target` for an inline feedback region +- `hx-swap="innerHTML"` + +On submit: + +- missing fields should return an error fragment +- invalid placeholder credentials should return an error fragment +- a known demo credential path may return a success fragment + +This keeps the page interactive without adding custom client-side scripts. + +**Validation Strategy** + +For the initial implementation: + +- both fields required +- simple server-side empty-value validation +- optional demo credential branch for a success state + +This is sufficient for a scaffold and keeps the design aligned with the user request without inventing an auth system. + +**Testing Strategy** + +Use TDD at the HTTP handler level. + +Minimum tests: + +- `GET /` returns `200` and includes core login page content +- `POST /login` with missing fields returns an error fragment +- `POST /login` with demo credentials returns a success fragment + +Additional checks: + +- the Go module builds after adding `templ` +- generated templ code is committed or present in the workspace + +**Risks and Mitigations** + +- `templ` introduces code generation. + - Mitigation: generate templates as part of the implementation flow and verify build output explicitly. +- Replacing the current Vite setup may leave dead code. + - Mitigation: remove or stop referencing Vite-specific handlers and dependencies during the migration. +- Visual parity with the sample may drift without its exact assets. + - Mitigation: match layout, hierarchy, spacing, color direction, and component treatment first; degrade gracefully on branding assets. + +**Acceptance Criteria** + +- Visiting `/` in `go_backend` shows a standalone login page rendered by Go. +- The server runs at `http://localhost:3000`. +- The page uses `templ` output rather than Vite or SPA mounting. +- HTMX is loaded from the specified CDN URL. +- No custom app JavaScript is added. +- Submitting the form updates an inline message region via HTMX. +- The app builds and targeted tests pass. diff --git a/go-backend/.air.toml b/go-backend/.air.toml new file mode 100644 index 0000000..7d3d4e4 --- /dev/null +++ b/go-backend/.air.toml @@ -0,0 +1,27 @@ +#:schema https://json.schemastore.org/any.json + +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go run github.com/a-h/templ/cmd/templ@latest generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ." + entrypoint = ["./tmp/main"] + include_ext = ["go", "templ", "sql", "css", "html", "png", "svg", "webmanifest", "json"] + exclude_dir = ["tmp", "vendor", ".git", "internal/db/sqlc"] + exclude_regex = ["_templ\\.go$"] + delay = 200 + stop_on_error = true + send_interrupt = true + kill_delay = "500ms" + +[color] + main = "magenta" + watcher = "cyan" + build = "yellow" + runner = "green" + +[log] + time = true + +[misc] + clean_on_exit = true diff --git a/go-backend/.env.example b/go-backend/.env.example new file mode 100644 index 0000000..619c25f --- /dev/null +++ b/go-backend/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable diff --git a/go-backend/README.md b/go-backend/README.md new file mode 100644 index 0000000..ae0c93d --- /dev/null +++ b/go-backend/README.md @@ -0,0 +1,43 @@ +# go-backend + +## Local Postgres + +Start Postgres with Podman: + +```bash +just db-up +``` + +Reset the local database volume and reinitialize the schema: + +```bash +just db-reset +``` + +The database is exposed at `localhost:5432` and initialized from `internal/db/schema.sql`. +The local workflow uses `podman compose`. +If your global Docker config contains corporate credential helpers, the `just` recipes isolate compose with a local `DOCKER_CONFIG` to avoid unrelated registry auth hooks on public image pulls. +Fresh database volumes are also seeded from `internal/db/seed.sql` with `demo@xtablo.com / xtablo-demo`. + +Connection string: + +```bash +export DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable +``` + +Run the app with hot reload: + +```bash +just dev +``` + +Other useful commands: + +```bash +just generate +just test +just build +just check +just db-logs +just db-down +``` diff --git a/go-backend/compose.yaml b/go-backend/compose.yaml new file mode 100644 index 0000000..95ebffc --- /dev/null +++ b/go-backend/compose.yaml @@ -0,0 +1,23 @@ +services: + postgres: + image: postgres:16-alpine + container_name: xtablo-go-backend-postgres + restart: unless-stopped + environment: + POSTGRES_DB: xtablo + POSTGRES_USER: xtablo + POSTGRES_PASSWORD: xtablo + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./internal/db/schema.sql:/docker-entrypoint-initdb.d/001-auth-schema.sql:ro + - ./internal/db/seed.sql:/docker-entrypoint-initdb.d/002-seed.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + postgres_data: diff --git a/go-backend/go.mod b/go-backend/go.mod new file mode 100644 index 0000000..20f94e9 --- /dev/null +++ b/go-backend/go.mod @@ -0,0 +1,64 @@ +module xtablo-backend + +go 1.26.0 + +require github.com/go-chi/chi/v5 v5.2.0 + +require ( + github.com/a-h/templ v0.3.1001 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/sqlc-dev/sqlc v1.31.1 +) + +require ( + cel.dev/expr v0.25.1 // indirect + filippo.io/edwards25519 v1.1.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/google/cel-go v0.28.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/ncruces/go-sqlite3 v0.32.0 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect + github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/sqlc-dev/doubleclick v1.0.0 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.33.0 + golang.org/x/crypto v0.48.0 + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go-backend/go.sum b/go-backend/go.sum new file mode 100644 index 0000000..46529df --- /dev/null +++ b/go-backend/go.sum @@ -0,0 +1,192 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-sqlite3 v0.32.0 h1:hNBUXp88LrfQCsuyXLqWTbTUG35sUuktDsqhhgHvU20= +github.com/ncruces/go-sqlite3 v0.32.0/go.mod h1:MIWTK60ONDl0oVY073zYvJP21C3Dly6P9bxVpgkLwdQ= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/pganalyze/pg_query_go/v6 v6.2.2 h1:O0L6zMC226R82RF3X5n0Ki6HjytDsoAzuzp4ATVAHNo= +github.com/pganalyze/pg_query_go/v6 v6.2.2/go.mod h1:Cn6+j4870kJz3iYNsb0VsNG04vpSWgEvBwc590J4qD0= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee h1:/IDPbpzkzA97t1/Z1+C3KlxbevjMeaI6BQYxvivu4u8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 h1:zYmP5fBH+i2yhhU6f5uOol6zxHtR2/sD47BsJLfy0oU= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/sqlc-dev/doubleclick v1.0.0 h1:2/OApfQ2eLgcfa/Fqs8WSMA6atH0G8j9hHbQIgMfAXI= +github.com/sqlc-dev/doubleclick v1.0.0/go.mod h1:ODHRroSrk/rr5neRHlWMSRijqOak8YmNaO3VAZCNl5Y= +github.com/sqlc-dev/sqlc v1.31.1 h1:+V+BjBJfFNPX/RFfL8eiZD9jk9lVJUEGGllWvnYNqbc= +github.com/sqlc-dev/sqlc v1.31.1/go.mod h1:6ZPww/Jd3G6MzJeW6NrqizjL+52vYNaaXP9yMeJ/Nao= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql new file mode 100644 index 0000000..a338690 --- /dev/null +++ b/go-backend/internal/db/queries.sql @@ -0,0 +1,29 @@ +-- name: CreateAuthUser :one +INSERT INTO auth.users ( + id, + email, + encrypted_password, + raw_user_meta_data, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + jsonb_build_object('display_name', sqlc.arg(display_name)), + now(), + now() +) +RETURNING id; + +-- name: GetAuthUserByEmail :one +SELECT id, email, encrypted_password, created_at, updated_at +FROM auth.users +WHERE email = $1 +LIMIT 1; + +-- name: GetPublicUserByID :one +SELECT id, email, created_at, updated_at, display_name +FROM public.users +WHERE id = $1 +LIMIT 1; diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go new file mode 100644 index 0000000..f288964 --- /dev/null +++ b/go-backend/internal/db/repository.go @@ -0,0 +1,102 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" + + sqlcdb "xtablo-backend/internal/db/sqlc" + "xtablo-backend/internal/web/handlers" +) + +type PostgresAuthRepository struct { + pool *pgxpool.Pool + queries *sqlcdb.Queries +} + +func NewPostgresAuthRepository(ctx context.Context, databaseURL string) (*PostgresAuthRepository, error) { + if databaseURL == "" { + return nil, errors.New("DATABASE_URL is required") + } + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connect postgres: %w", err) + } + + return &PostgresAuthRepository{ + pool: pool, + queries: sqlcdb.New(pool), + }, nil +} + +func (r *PostgresAuthRepository) Close() { + r.pool.Close() +} + +func (r *PostgresAuthRepository) CreateAuthUser(ctx context.Context, input handlers.CreateAuthUserInput) (uuid.UUID, error) { + id := uuid.New() + createdID, err := r.queries.CreateAuthUser(ctx, sqlcdb.CreateAuthUserParams{ + ID: id, + Email: input.Email, + EncryptedPassword: input.EncryptedPassword, + DisplayName: input.DisplayName, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return uuid.Nil, handlers.ErrUserAlreadyExists + } + return uuid.Nil, err + } + + log.Info(). + Str("component", "auth_store"). + Str("action", "create_user"). + Str("email", input.Email). + Msg("auth store mutated") + + return createdID, nil +} + +func (r *PostgresAuthRepository) GetAuthUserByEmail(ctx context.Context, email string) (handlers.AuthUser, error) { + row, err := r.queries.GetAuthUserByEmail(ctx, email) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.AuthUser{}, handlers.ErrUserNotFound + } + return handlers.AuthUser{}, err + } + + return handlers.AuthUser{ + ID: row.ID, + Email: row.Email, + EncryptedPassword: row.EncryptedPassword, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + }, nil +} + +func (r *PostgresAuthRepository) GetPublicUserByID(ctx context.Context, id uuid.UUID) (handlers.PublicUser, error) { + row, err := r.queries.GetPublicUserByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.PublicUser{}, handlers.ErrUserNotFound + } + return handlers.PublicUser{}, err + } + + return handlers.PublicUser{ + ID: row.ID, + Email: row.Email, + DisplayName: row.DisplayName, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + }, nil +} diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql new file mode 100644 index 0000000..2be7b1a --- /dev/null +++ b/go-backend/internal/db/schema.sql @@ -0,0 +1,42 @@ +CREATE SCHEMA IF NOT EXISTS auth; + +CREATE TABLE IF NOT EXISTS auth.users ( + id uuid PRIMARY KEY, + email text NOT NULL UNIQUE, + encrypted_password text NOT NULL, + raw_user_meta_data jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.users ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + email text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + display_name text NOT NULL +); + +CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.users (id, email, created_at, updated_at, display_name) + VALUES ( + NEW.id, + NEW.email, + NEW.created_at, + NEW.updated_at, + COALESCE(NEW.raw_user_meta_data ->> 'display_name', split_part(NEW.email, '@', 1)) + ); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created +AFTER INSERT ON auth.users +FOR EACH ROW +EXECUTE FUNCTION public.handle_new_user(); diff --git a/go-backend/internal/db/seed.sql b/go-backend/internal/db/seed.sql new file mode 100644 index 0000000..0c59bd6 --- /dev/null +++ b/go-backend/internal/db/seed.sql @@ -0,0 +1,16 @@ +INSERT INTO auth.users ( + id, + email, + encrypted_password, + raw_user_meta_data, + created_at, + updated_at +) VALUES ( + '11111111-1111-1111-1111-111111111111', + 'demo@xtablo.com', + '$2a$10$/xeyC8tiOZTcw2BBOSrv.uWu.EbRMYwF7MpFcDHSS40fOoTR.QrLS', + jsonb_build_object('display_name', 'demo'), + now(), + now() +) +ON CONFLICT (email) DO NOTHING; diff --git a/go-backend/internal/db/sqlc/db.go b/go-backend/internal/db/sqlc/db.go new file mode 100644 index 0000000..a28f6fc --- /dev/null +++ b/go-backend/internal/db/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go new file mode 100644 index 0000000..ee1ee7d --- /dev/null +++ b/go-backend/internal/db/sqlc/models.go @@ -0,0 +1,27 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type AuthUser struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + RawUserMetaData []byte `db:"raw_user_meta_data"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` +} + +type User struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` + DisplayName string `db:"display_name"` +} diff --git a/go-backend/internal/db/sqlc/querier.go b/go-backend/internal/db/sqlc/querier.go new file mode 100644 index 0000000..2a2b436 --- /dev/null +++ b/go-backend/internal/db/sqlc/querier.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + + "github.com/google/uuid" +) + +type Querier interface { + CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) + GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) + GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, 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 new file mode 100644 index 0000000..c24db1b --- /dev/null +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createAuthUser = `-- name: CreateAuthUser :one +INSERT INTO auth.users ( + id, + email, + encrypted_password, + raw_user_meta_data, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + jsonb_build_object('display_name', $4), + now(), + now() +) +RETURNING id +` + +type CreateAuthUserParams struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + DisplayName interface{} `db:"display_name"` +} + +func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, createAuthUser, + arg.ID, + arg.Email, + arg.EncryptedPassword, + arg.DisplayName, + ) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one +SELECT id, email, encrypted_password, created_at, updated_at +FROM auth.users +WHERE email = $1 +LIMIT 1 +` + +type GetAuthUserByEmailRow struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` +} + +func (q *Queries) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) { + row := q.db.QueryRow(ctx, getAuthUserByEmail, email) + var i GetAuthUserByEmailRow + err := row.Scan( + &i.ID, + &i.Email, + &i.EncryptedPassword, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getPublicUserByID = `-- name: GetPublicUserByID :one +SELECT id, email, created_at, updated_at, display_name +FROM public.users +WHERE id = $1 +LIMIT 1 +` + +func (q *Queries) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRow(ctx, getPublicUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.UpdatedAt, + &i.DisplayName, + ) + return i, err +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go new file mode 100644 index 0000000..6dfa6e0 --- /dev/null +++ b/go-backend/internal/web/handlers/auth.go @@ -0,0 +1,396 @@ +package handlers + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" + + "xtablo-backend/internal/web/views" +) + +const sessionCookieName = "xtablo_session" + +var ErrUserNotFound = errors.New("user not found") +var ErrUserAlreadyExists = errors.New("user already exists") + +type AuthRepository interface { + CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error) + GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error) + GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error) +} + +type CreateAuthUserInput struct { + Email string + EncryptedPassword string + DisplayName string +} + +type AuthUser struct { + ID uuid.UUID + Email string + EncryptedPassword string + CreatedAt time.Time + UpdatedAt time.Time +} + +type PublicUser struct { + ID uuid.UUID + Email string + DisplayName string + CreatedAt time.Time + UpdatedAt time.Time +} + +type AuthHandler struct { + repo AuthRepository + sessions *sessionStore +} + +type sessionStore struct { + mu sync.RWMutex + sessions map[string]uuid.UUID +} + +type InMemoryAuthRepository struct { + mu sync.RWMutex + authUsers map[string]AuthUser + publicUsers map[uuid.UUID]PublicUser +} + +func NewAuthHandler(repo AuthRepository) *AuthHandler { + return &AuthHandler{ + repo: repo, + sessions: &sessionStore{sessions: map[string]uuid.UUID{}}, + } +} + +func NewInMemoryAuthRepository() *InMemoryAuthRepository { + repo := &InMemoryAuthRepository{ + authUsers: map[string]AuthUser{}, + publicUsers: map[uuid.UUID]PublicUser{}, + } + + demoHash, err := hashPassword("xtablo-demo") + if err != nil { + panic(err) + } + if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "demo@xtablo.com", + EncryptedPassword: demoHash, + DisplayName: "demo", + }); err != nil { + panic(err) + } + + return repo +} + +func (h *AuthHandler) GetHome() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.currentUserID(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + user, err := h.repo.GetPublicUserByID(r.Context(), userID) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil { + http.Error(w, "failed to render home page", http.StatusInternalServerError) + } + } +} + +func (h *AuthHandler) PostLogout() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var email string + if userID, ok := h.currentUserID(r); ok { + if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil { + email = user.Email + } + } + + if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { + h.sessions.delete(cookie.Value, email) + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) + } +} + +func (h *AuthHandler) GetLoginPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := h.currentUserID(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := views.AuthPage(views.LoginScreen()).Render(r.Context(), w); err != nil { + http.Error(w, "failed to render login page", http.StatusInternalServerError) + } + } +} + +func (h *AuthHandler) GetSignupPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := h.currentUserID(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := views.AuthPage(views.SignupScreen()).Render(r.Context(), w); err != nil { + http.Error(w, "failed to render signup page", http.StatusInternalServerError) + } + } +} + +func (h *AuthHandler) PostLogin() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form payload", http.StatusBadRequest) + return + } + + email := normalizeEmail(r.FormValue("email")) + password := strings.TrimSpace(r.FormValue("password")) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + switch { + case email == "" || password == "": + w.WriteHeader(http.StatusUnprocessableEntity) + _ = views.AuthStatus("error", "Veuillez renseigner votre email et votre mot de passe.").Render(r.Context(), w) + return + } + + authUser, err := h.repo.GetAuthUserByEmail(r.Context(), email) + if err != nil || bcrypt.CompareHashAndPassword([]byte(authUser.EncryptedPassword), []byte(password)) != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = views.AuthStatus("error", "Identifiants invalides. Essayez demo@xtablo.com / xtablo-demo.").Render(r.Context(), w) + return + } + + h.setSession(w, authUser.ID, authUser.Email) + w.Header().Set("HX-Redirect", "/") + _ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w) + } +} + +func (h *AuthHandler) PostSignup() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form payload", http.StatusBadRequest) + return + } + + email := normalizeEmail(r.FormValue("email")) + password := strings.TrimSpace(r.FormValue("password")) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + switch { + case email == "" || password == "": + w.WriteHeader(http.StatusUnprocessableEntity) + _ = views.AuthStatus("error", "Veuillez renseigner votre email et choisir un mot de passe.").Render(r.Context(), w) + return + } + + if _, err := h.repo.GetAuthUserByEmail(r.Context(), email); err == nil { + w.WriteHeader(http.StatusConflict) + _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) + return + } else if !errors.Is(err, ErrUserNotFound) { + http.Error(w, "failed to check existing user", http.StatusInternalServerError) + return + } + + passwordHash, err := hashPassword(password) + if err != nil { + http.Error(w, "failed to hash password", http.StatusInternalServerError) + return + } + + displayName := displayNameFromEmail(email) + userID, err := h.repo.CreateAuthUser(r.Context(), CreateAuthUserInput{ + Email: email, + EncryptedPassword: passwordHash, + DisplayName: displayName, + }) + if err != nil { + if errors.Is(err, ErrUserAlreadyExists) { + w.WriteHeader(http.StatusConflict) + _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) + return + } + http.Error(w, "failed to create user", http.StatusInternalServerError) + return + } + + h.setSession(w, userID, email) + w.Header().Set("HX-Redirect", "/") + _ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w) + } +} + +func (h *AuthHandler) currentUserID(r *http.Request) (uuid.UUID, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return uuid.Nil, false + } + return h.sessions.get(cookie.Value) +} + +func (h *AuthHandler) setSession(w http.ResponseWriter, userID uuid.UUID, email string) { + sessionID := randomToken(32) + h.sessions.set(sessionID, userID, email) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sessionID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *sessionStore) get(sessionID string) (uuid.UUID, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + userID, ok := s.sessions[sessionID] + return userID, ok +} + +func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sessionID] = userID + logStoreMutation("create_session", email, sessionID, 0, len(s.sessions)) +} + +func (s *sessionStore) delete(sessionID string, email string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sessionID) + logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions)) +} + +func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.authUsers[input.Email]; exists { + return uuid.Nil, ErrUserAlreadyExists + } + + id := uuid.New() + now := time.Now().UTC() + authUser := AuthUser{ + ID: id, + Email: input.Email, + EncryptedPassword: input.EncryptedPassword, + CreatedAt: now, + UpdatedAt: now, + } + publicUser := PublicUser{ + ID: id, + Email: input.Email, + DisplayName: input.DisplayName, + CreatedAt: now, + UpdatedAt: now, + } + + r.authUsers[input.Email] = authUser + r.publicUsers[id] = publicUser + logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0) + return id, nil +} + +func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.authUsers[email] + if !ok { + return AuthUser{}, ErrUserNotFound + } + return user, nil +} + +func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.publicUsers[id] + if !ok { + return PublicUser{}, ErrUserNotFound + } + return user, nil +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func displayNameFromEmail(email string) string { + email = normalizeEmail(email) + if email == "" { + return "" + } + return strings.Split(email, "@")[0] +} + +func hashPassword(password string) (string, error) { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(passwordHash), nil +} + +func randomToken(size int) string { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return hex.EncodeToString(buf) +} + +func logStoreMutation(action string, email string, sessionID string, usersCount int, sessionsCount int) { + event := log.Info(). + Str("component", "auth_store"). + Str("action", action). + Int("users_count", usersCount). + Int("sessions_count", sessionsCount) + + if email != "" { + event = event.Str("email", email) + } + if sessionID != "" { + event = event.Str("session_id", sessionID) + } + + event.Msg("auth store mutated") +} diff --git a/go-backend/internal/web/handlers/auth_test.go b/go-backend/internal/web/handlers/auth_test.go new file mode 100644 index 0000000..ad27bbd --- /dev/null +++ b/go-backend/internal/web/handlers/auth_test.go @@ -0,0 +1,120 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" +) + +func TestSignupLogsAuthStoreMutations(t *testing.T) { + var buf bytes.Buffer + restore := log.Logger + log.Logger = zerolog.New(&buf) + defer func() { + log.Logger = restore + }() + + handler := newTestAuthHandler(t) + + form := url.Values{} + form.Set("email", "new@xtablo.com") + form.Set("password", "xtablo-secret") + + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.PostSignup().ServeHTTP(rec, req) + + output := buf.String() + for _, want := range []string{ + `"action":"create_user"`, + `"email":"new@xtablo.com"`, + `"action":"create_session"`, + `"session_id":"`, + } { + if !strings.Contains(output, want) { + t.Fatalf("expected log output to contain %q, got %q", want, output) + } + } +} + +func TestSignupHashesPasswordBeforeStoringUser(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + + form := url.Values{} + form.Set("email", "new@xtablo.com") + form.Set("password", "xtablo-secret") + + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.PostSignup().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + storedUser, err := repo.GetAuthUserByEmail(req.Context(), "new@xtablo.com") + if err != nil { + t.Fatalf("expected stored user, got error %v", err) + } + if storedUser.EncryptedPassword == "xtablo-secret" { + t.Fatalf("expected stored password hash, got plaintext") + } + if bcrypt.CompareHashAndPassword([]byte(storedUser.EncryptedPassword), []byte("xtablo-secret")) != nil { + t.Fatalf("expected stored password to match bcrypt hash") + } +} + +func TestLogoutLogsSessionDeletion(t *testing.T) { + var buf bytes.Buffer + restore := log.Logger + log.Logger = zerolog.New(&buf) + defer func() { + log.Logger = restore + }() + + handler := newTestAuthHandler(t) + + loginForm := url.Values{} + loginForm.Set("email", "demo@xtablo.com") + loginForm.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(loginForm.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + handler.PostLogin().ServeHTTP(loginRec, loginReq) + + sessionCookie := loginRec.Result().Cookies()[0] + + logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil) + logoutReq.AddCookie(sessionCookie) + logoutRec := httptest.NewRecorder() + handler.PostLogout().ServeHTTP(logoutRec, logoutReq) + + output := buf.String() + for _, want := range []string{ + `"action":"delete_session"`, + `"email":"demo@xtablo.com"`, + `"session_id":"`, + } { + if !strings.Contains(output, want) { + t.Fatalf("expected log output to contain %q, got %q", want, output) + } + } +} + +func newTestAuthHandler(t *testing.T) *AuthHandler { + t.Helper() + return NewAuthHandler(NewInMemoryAuthRepository()) +} diff --git a/go-backend/internal/web/views/login.templ b/go-backend/internal/web/views/login.templ new file mode 100644 index 0000000..a879cd0 --- /dev/null +++ b/go-backend/internal/web/views/login.templ @@ -0,0 +1,219 @@ +package views + +templ AuthPage(content templ.Component) { + + + + + + + + + + + + + XTablo + + + + +
+
+
+ +
+
+ + +} + +templ LoginScreen() { +
+

Se connecter à Xtablo

+
+ +
+ + @AuthDivider() + @GoogleButton() + +
+} + +templ SignupScreen() { +
+

S'inscrire à Xtablo

+
+ +
+ + @AuthDivider() + @GoogleButton() + +
+} + +templ HomePage(displayName string, email string) { + + + + + + XTablo + + + +
+
+ +

Bienvenue

+

{ displayName }

+

Session active pour { email }

+
+ +
+
+
+ + +} + +templ AuthStatus(kind string, message string) { + if kind == "success" { +
{ message }
+ } else if kind == "error" { + + } +} + +templ AuthDivider() { +
+
+ Ou continuer avec +
+
+} + +templ GoogleButton() { + +} + +templ AnimatedBackground() { + +} diff --git a/go-backend/internal/web/views/login_templ.go b/go-backend/internal/web/views/login_templ.go new file mode 100644 index 0000000..f508771 --- /dev/null +++ b/go-backend/internal/web/views/login_templ.go @@ -0,0 +1,342 @@ +// 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" + +func AuthPage(content templ.Component) 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, "XTablo
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AnimatedBackground().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 + } + templ_7745c5c3_Err = content.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 + }) +} + +func LoginScreen() 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_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Se connecter à Xtablo

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AuthDivider().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = GoogleButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Pas encore de compte ? S'inscrire

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SignupScreen() 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_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

S'inscrire à Xtablo

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AuthDivider().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = GoogleButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Vous avez déjà un compte ? Se connecter

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +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 = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "XTablo
\"Xtablo\"

Bienvenue

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(displayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 135, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Session active pour ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(email) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 136, Col: 35} + } + _, 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, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AuthStatus(kind string, message 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_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if kind == "success" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 148, Col: 67} + } + _, 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, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if kind == "error" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 150, Col: 64} + } + _, 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, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func AuthDivider() 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_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Ou continuer avec
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func GoogleButton() 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, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AnimatedBackground() 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_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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/justfile b/go-backend/justfile new file mode 100644 index 0000000..97bc6ea --- /dev/null +++ b/go-backend/justfile @@ -0,0 +1,61 @@ +set shell := ["bash", "-cu"] + +database_url := "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable" +compose_config_dir := ".podman-compose" + +default: + @just --list + +compose-config: + mkdir -p {{compose_config_dir}} + printf '%s\n' '{"auths":{}}' > {{compose_config_dir}}/config.json + +machine-up: + @if command -v podman >/dev/null 2>&1; then \ + if ! podman machine inspect podman-machine-default 2>/dev/null | grep -q '"State": "running"'; then \ + podman machine start podman-machine-default; \ + fi; \ + else \ + echo "podman is required" >&2; \ + exit 1; \ + fi + +db-up: machine-up compose-config + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres + +db-down: + @if command -v podman >/dev/null 2>&1; then \ + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down; \ + else \ + echo "podman is required" >&2; \ + exit 1; \ + fi + +db-reset: compose-config + just machine-up + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down -v + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres + +db-logs: machine-up compose-config + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose logs -f postgres + +generate: + go run github.com/a-h/templ/cmd/templ@latest generate + go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate + +fmt: + gofmt -w . + +test: + go test ./... + +build: + go build ./... + +check: generate test build + +dev: db-up + DATABASE_URL='{{database_url}}' air -c .air.toml + +run: db-up + DATABASE_URL='{{database_url}}' go run . diff --git a/go_backend_deprecated/main.go b/go-backend/main.go similarity index 100% rename from go_backend_deprecated/main.go rename to go-backend/main.go diff --git a/go-backend/router.go b/go-backend/router.go new file mode 100644 index 0000000..fc9cd2a --- /dev/null +++ b/go-backend/router.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "io/fs" + "net/http" + "os" + + "xtablo-backend/internal/db" + "xtablo-backend/internal/web/handlers" + + chi "github.com/go-chi/chi/v5" +) + +func newRouter() http.Handler { + databaseURL := os.Getenv("DATABASE_URL") + repo, err := db.NewPostgresAuthRepository(context.Background(), databaseURL) + if err != nil { + panic(err) + } + return newRouterWithHandler(handlers.NewAuthHandler(repo)) +} + +func newTestRouter() http.Handler { + return newRouterWithHandler(handlers.NewAuthHandler(handlers.NewInMemoryAuthRepository())) +} + +func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler { + mux := chi.NewRouter() + staticFS := os.DirFS("static") + + // Views + mux.Get("/", authHandler.GetHome()) + mux.Get("/login", authHandler.GetLoginPage()) + mux.Get("/signup", authHandler.GetSignupPage()) + mux.Post("/login", authHandler.PostLogin()) + mux.Post("/signup", authHandler.PostSignup()) + mux.Post("/logout", authHandler.PostLogout()) + + mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static")))) + mux.Handle("/pwa-icons/*", http.StripPrefix("/pwa-icons/", http.FileServerFS(os.DirFS("static/pwa-icons")))) + mux.HandleFunc("/logo_dark.png", serveStaticFile(staticFS, "logo_dark.png", "image/png")) + mux.HandleFunc("/logo_white.png", serveStaticFile(staticFS, "logo_white.png", "image/png")) + mux.HandleFunc("/manifest.webmanifest", serveStaticFile(staticFS, "manifest.webmanifest", "application/manifest+json")) + + return mux +} + +func serveStaticFile(fileSystem fs.FS, path string, contentType string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + http.ServeFileFS(w, r, fileSystem, path) + } +} diff --git a/go-backend/router_test.go b/go-backend/router_test.go new file mode 100644 index 0000000..fc1b908 --- /dev/null +++ b/go-backend/router_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestRootRedirectsToLoginWhenUnauthenticated(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected status 303, got %d", rec.Code) + } + if location := rec.Header().Get("Location"); location != "/login" { + t.Fatalf("expected redirect to /login, got %q", location) + } +} + +func TestLoginPageRenders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/login", 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{ + "Se connecter à Xtablo", + `hx-post="/login"`, + "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", + `href="/pwa-icons/favicon-32x32.png"`, + `href="/pwa-icons/favicon-16x16.png"`, + `href="/pwa-icons/apple-touch-icon-180x180.png"`, + `href="/manifest.webmanifest"`, + `src="/logo_dark.png"`, + `src="/logo_white.png"`, + `data-testid="auth-card-shell"`, + "Découvrez la nouvelle expérience de connexion", + "Mot de passe oublié ?", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q", want) + } + } +} + +func TestSignupPageRenders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/signup", 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{ + "S'inscrire à Xtablo", + `hx-post="/signup"`, + "Vous avez déjà un compte ?", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q", want) + } + } +} + +func TestBrandingAssetsAreServed(t *testing.T) { + testCases := []string{ + "/logo_dark.png", + "/logo_white.png", + "/pwa-icons/favicon-32x32.png", + "/pwa-icons/favicon-16x16.png", + "/pwa-icons/apple-touch-icon-180x180.png", + "/manifest.webmanifest", + } + + router := newTestRouter() + + for _, path := range testCases { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected %s to return 200, got %d", path, rec.Code) + } + } +} + +func TestLoginReturnsValidationError(t *testing.T) { + form := url.Values{} + form.Set("email", "") + form.Set("password", "") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected status 422, got %d", rec.Code) + } + + if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { + t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) + } +} + +func TestLoginCreatesSessionAndRedirects(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" { + t.Fatalf("expected HX-Redirect to /, got %q", redirect) + } + sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + homeReq := httptest.NewRequest(http.MethodGet, "/", nil) + homeReq.AddCookie(sessionCookie) + homeRec := httptest.NewRecorder() + router.ServeHTTP(homeRec, homeReq) + + if homeRec.Code != http.StatusOK { + t.Fatalf("expected authenticated root status 200, got %d", homeRec.Code) + } + if !strings.Contains(homeRec.Body.String(), "Bienvenue") { + t.Fatalf("expected authenticated home page, got %q", homeRec.Body.String()) + } + if !strings.Contains(homeRec.Body.String(), `action="/logout"`) { + t.Fatalf("expected authenticated home page to include logout form, got %q", homeRec.Body.String()) + } +} + +func TestSignupCreatesUserSessionAndRedirects(t *testing.T) { + form := url.Values{} + form.Set("email", "new@xtablo.com") + form.Set("password", "xtablo-secret") + + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" { + t.Fatalf("expected HX-Redirect to /, got %q", redirect) + } + sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + router.ServeHTTP(loginRec, loginReq) + if loginRec.Header().Get("HX-Redirect") != "/" { + t.Fatalf("expected signed up user to be able to log in") + } +} + +func TestLogoutClearsSessionAndRedirectsToLogin(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") + } + + logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil) + logoutReq.AddCookie(sessionCookie) + logoutRec := httptest.NewRecorder() + router.ServeHTTP(logoutRec, logoutReq) + + if logoutRec.Code != http.StatusSeeOther { + t.Fatalf("expected logout status 303, got %d", logoutRec.Code) + } + if location := logoutRec.Header().Get("Location"); location != "/login" { + t.Fatalf("expected logout redirect to /login, got %q", location) + } + + clearedCookie := findCookie(logoutRec.Result().Cookies(), "xtablo_session") + if clearedCookie == nil { + t.Fatalf("expected cleared session cookie") + } + if clearedCookie.MaxAge >= 0 && clearedCookie.Value != "" { + t.Fatalf("expected cleared session cookie to be expired, got %+v", clearedCookie) + } + + homeReq := httptest.NewRequest(http.MethodGet, "/", nil) + homeReq.AddCookie(sessionCookie) + homeRec := httptest.NewRecorder() + router.ServeHTTP(homeRec, homeReq) + + if homeRec.Code != http.StatusSeeOther { + t.Fatalf("expected logged-out root access to redirect, got %d", homeRec.Code) + } + if location := homeRec.Header().Get("Location"); location != "/login" { + t.Fatalf("expected logged-out root redirect to /login, got %q", location) + } +} + +func findCookie(cookies []*http.Cookie, name string) *http.Cookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil +} diff --git a/go-backend/sqlc.yaml b/go-backend/sqlc.yaml new file mode 100644 index 0000000..e9cc5a3 --- /dev/null +++ b/go-backend/sqlc.yaml @@ -0,0 +1,28 @@ +version: "2" + +sql: + - engine: "postgresql" + schema: "internal/db/schema.sql" + queries: "internal/db/queries.sql" + gen: + go: + package: "sqlc" + out: "internal/db/sqlc" + sql_package: "pgx/v5" + emit_interface: true + emit_db_tags: true + overrides: + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" + - db_type: "pg_catalog.uuid" + nullable: true + go_type: + import: "github.com/google/uuid" + type: "UUID" + pointer: true + - db_type: "pg_catalog.timestamptz" + go_type: + import: "time" + type: "Time" diff --git a/go-backend/static/logo_dark.png b/go-backend/static/logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4cee136e1cd0793fcc136c219662836c87b8abb3 GIT binary patch literal 85535 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 literal 0 HcmV?d00001 diff --git a/go-backend/static/manifest.webmanifest b/go-backend/static/manifest.webmanifest new file mode 100644 index 0000000..5a512f1 --- /dev/null +++ b/go-backend/static/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "XTablo", + "short_name": "XTablo", + "display": "standalone", + "background_color": "#f8fafc", + "theme_color": "#1e1b2e", + "icons": [ + { + "src": "/pwa-icons/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/pwa-icons/apple-touch-icon-180x180.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/go-backend/static/pwa-icons/apple-touch-icon-180x180.png b/go-backend/static/pwa-icons/apple-touch-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..78da98fd5613202f0c6b0192c93ec2f1824261f7 GIT binary patch literal 12129 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 b9$uD*0*27-=(1il^9Ud0)#a*X%!2<1+mk6} literal 0 HcmV?d00001 diff --git a/go-backend/static/pwa-icons/favicon-16x16.png b/go-backend/static/pwa-icons/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..2f3428da3e189821e899382d34394e07d02b1fbe GIT binary patch literal 600 zcmV-e0;m0nP)03+ z?w?KAq)V#KFcOK=w5MJ_ACJe9RQ7r{WO>kRwCMW}VliG-hvaUHrALOraaoJS;c>4! z>@J7hEs7)9I)BXPWjz9!O{v*EskoUYB5q|@nA|=lAo6RoopUoO(nN0Sd0oV??Ty7bR zY&M(A zuPldCsflDF<@1Fol>vB5%L!fAr>Cc@s%ij4qYf%F=Iietu)|sa#;)zI-wQw}QLC#q zOievVr_(ue>AHUS@Nob}u&+2+5e$|ihXlaeT2I=$v2o)o04}Exki-yxqoHA+9Tx0z zl8JFmjbP-(NIrU`-QMx6`M0^rz!12`nd$&id}MFJ!2@joMw9&hvREvcH>r6% zFzOu{Nod+EMpM(VnVIy|)Pw8Se@iA)r%!iqrb%Mx#*Len%fSP!01RVKAW)WwrxtS! z3Jbt+OjSz}@(|)FTd_7&yjpY@Q=&K=E=E<(w0PXe^uYcWv-w`Xe?@FGKlj1_HbyEn z5e!yRq7p143CAbn*l(5+bX0hq=&qEl=oM{e+ z$eHGm!jVXIdHFg*yvQM`N=cqx6$Y?=4-JhfN(sSU0N!e{N!Rs>tG_sffXi9b-PJoe zaqah;e>{5hXC|F7`N1hM^lEr`R<8;JSp69sjLNbVU2JQ6!qwZi@A|b>0Iv4-&+}&Y zaI=LWo4PwvcuXx`Sh<@!BktJow#C(qIqY}t-YW`}Q=$Oi#zRLe64*p*G&smG^4N7O zAe!|OizOAM1UV#APuTl)A6e&5&O9~WeP=I##F;8^)!=#4v3Uowk-^a;?HI`zdsQ`( zNQ^Cz3d^Ft{sRCci6N&D0B~-8dE1;Ak24h`U!=79{{5*#2ipJ`B|g8u zY}Dkc#q(L+#fEMJxBw(d6iQ^qqPw`ds)jKcAwy^4b2{@x zuha;^K1mD--0yY;8B;iydwTi`ADQzpIy=9k)WG0OLk^FfyFGk!7nZoHMjRkXV%W-k zar7mb+}YX7xeS1UMJwa+v8Appqa%qxU - - - - - - XTablo - - - - -
-
-
X
-
X
-
X
-
X
-
X
-
X
-
X
-
X
-
-
-
-
-
- -
-
XT
-
-
-

Se connecter a Xtablo

-
- - -
- Ou continuer avec -
- - -
-
-
-
- - -} - -templ LoginStatus(kind string, message string) { - if kind == "success" { -
{ message }
- } else if kind == "error" { - - } -} diff --git a/go_backend_deprecated/internal/web/views/login_templ.go b/go_backend_deprecated/internal/web/views/login_templ.go deleted file mode 100644 index 00c9b41..0000000 --- a/go_backend_deprecated/internal/web/views/login_templ.go +++ /dev/null @@ -1,102 +0,0 @@ -// 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" - -func LoginPage() 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, "XTablo
X
X
X
X
X
X
X
X
XT

Se connecter a Xtablo

Ou continuer avec

Pas encore de compte ? S'inscrire

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func LoginStatus(kind string, message 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_Var2 := templ.GetChildren(ctx) - if templ_7745c5c3_Var2 == nil { - templ_7745c5c3_Var2 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - if kind == "success" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(message) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 91, Col: 67} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if kind == "error" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 93, Col: 64} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/go_backend_deprecated/router.go b/go_backend_deprecated/router.go deleted file mode 100644 index a6ac3c5..0000000 --- a/go_backend_deprecated/router.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "net/http" - "os" - - chi "github.com/go-chi/chi/v5" - "xtablo-backend/internal/web/handlers" -) - -func newRouter() http.Handler { - mux := chi.NewRouter() - loginHandler := handlers.NewLoginHandler() - - mux.Get("/", loginHandler.GetPage()) - mux.Post("/login", loginHandler.PostLogin()) - mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static")))) - - return mux -} diff --git a/go_backend_deprecated/router_test.go b/go_backend_deprecated/router_test.go deleted file mode 100644 index 986d3b2..0000000 --- a/go_backend_deprecated/router_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" -) - -func TestRootRendersLoginPage(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - - router := newRouter() - 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{ - "Se connecter a Xtablo", - `hx-post="/login"`, - "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected body to contain %q", want) - } - } -} - -func TestLoginReturnsValidationError(t *testing.T) { - form := url.Values{} - form.Set("email", "") - form.Set("password", "") - - req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - router := newRouter() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnprocessableEntity { - t.Fatalf("expected status 422, got %d", rec.Code) - } - - if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { - t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) - } -} - -func TestLoginReturnsSuccessMessage(t *testing.T) { - form := url.Values{} - form.Set("email", "demo@xtablo.com") - form.Set("password", "xtablo-demo") - - req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - router := newRouter() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", rec.Code) - } - - if !strings.Contains(rec.Body.String(), "Connexion reussie") { - t.Fatalf("expected success fragment, got %q", rec.Body.String()) - } -} diff --git a/go_backend_deprecated/static/styles.css b/go_backend_deprecated/static/styles.css deleted file mode 100644 index 6ea5f70..0000000 --- a/go_backend_deprecated/static/styles.css +++ /dev/null @@ -1,417 +0,0 @@ -:root { - --background: #f5f1ea; - --surface: rgba(255, 251, 246, 0.78); - --surface-border: rgba(84, 61, 31, 0.12); - --text: #1f1a17; - --muted: #73675d; - --primary: #1f6f64; - --primary-strong: #18584f; - --accent: #cf6b2d; - --accent-soft: rgba(207, 107, 45, 0.16); - --success-bg: rgba(31, 111, 100, 0.1); - --success-border: rgba(31, 111, 100, 0.25); - --error-bg: rgba(181, 69, 69, 0.1); - --error-border: rgba(181, 69, 69, 0.24); - --shadow: 0 24px 80px rgba(43, 24, 4, 0.12); - --font-body: "Avenir Next", "Segoe UI", sans-serif; - --font-display: "Iowan Old Style", "Georgia", serif; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - min-height: 100%; -} - -body { - background: - radial-gradient(circle at top left, rgba(31, 111, 100, 0.16), transparent 30%), - radial-gradient(circle at top right, rgba(207, 107, 45, 0.16), transparent 26%), - linear-gradient(135deg, #f3efe7 0%, #f8f4ed 48%, #efe9dd 100%); - color: var(--text); - font-family: var(--font-body); -} - -a { - color: inherit; - text-decoration: none; -} - -button, -input { - font: inherit; -} - -.page-shell { - min-height: 100vh; - position: relative; - overflow: hidden; -} - -.page-background { - inset: 0; - pointer-events: none; - position: absolute; -} - -.orb { - align-items: center; - animation: drift 18s linear infinite; - background: linear-gradient(135deg, rgba(31, 111, 100, 0.22), rgba(207, 107, 45, 0.1)); - border: 1px solid rgba(255, 255, 255, 0.45); - border-radius: 999px; - color: rgba(31, 111, 100, 0.6); - display: flex; - font-family: var(--font-display); - font-size: 1.15rem; - font-weight: 700; - height: 3.4rem; - justify-content: center; - position: absolute; - width: 3.4rem; -} - -.orb span { - transform: rotate(-12deg); -} - -.orb-a { left: 6%; top: 18%; animation-duration: 14s; } -.orb-b { left: 12%; top: 70%; animation-duration: 19s; height: 4rem; width: 4rem; } -.orb-c { left: 24%; top: 8%; animation-duration: 16s; } -.orb-d { right: 14%; top: 20%; animation-duration: 21s; height: 4.4rem; width: 4.4rem; } -.orb-e { right: 8%; top: 66%; animation-duration: 17s; } -.orb-f { right: 26%; top: 10%; animation-duration: 23s; } -.orb-g { left: 36%; bottom: 10%; animation-duration: 20s; } -.orb-h { right: 35%; bottom: 8%; animation-duration: 15s; } - -.auth-stage { - align-items: center; - display: flex; - justify-content: center; - min-height: 100vh; - padding: 2rem 1rem; - position: relative; -} - -.auth-card { - max-width: 34rem; - position: relative; - width: 100%; -} - -.auth-glow { - background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.1)); - border-radius: 2rem; - filter: blur(24px); - inset: 1rem; - position: absolute; - z-index: 0; -} - -.card-body { - backdrop-filter: blur(16px); - background: var(--surface); - border: 1px solid var(--surface-border); - border-radius: 1.75rem; - box-shadow: var(--shadow); - padding: 1.5rem; - position: relative; - z-index: 1; -} - -.card-topbar { - align-items: center; - display: flex; - justify-content: space-between; - margin-bottom: 1.5rem; -} - -.back-link { - color: var(--muted); - font-size: 0.95rem; - transition: color 160ms ease; -} - -.back-link:hover, -.aux-row a:hover, -.signup-copy a:hover { - color: var(--text); -} - -.back-link::before { - content: "<"; - margin-right: 0.55rem; -} - -.theme-button { - align-items: center; - background: transparent; - border: 0; - border-radius: 999px; - color: var(--muted); - cursor: pointer; - display: inline-flex; - height: 2.5rem; - justify-content: center; - padding: 0; - transition: background-color 160ms ease, color 160ms ease; - width: 2.5rem; -} - -.theme-button:hover { - background: rgba(31, 26, 23, 0.05); - color: var(--text); -} - -.theme-button-monitor { - border: 2px solid currentColor; - border-radius: 0.35rem; - display: inline-block; - height: 1rem; - position: relative; - width: 1.3rem; -} - -.theme-button-monitor::after { - border-top: 2px solid currentColor; - content: ""; - left: 50%; - position: absolute; - top: calc(100% + 0.2rem); - transform: translateX(-50%); - width: 0.9rem; -} - -.brand-lockup { - display: flex; - justify-content: center; - margin-bottom: 1.25rem; -} - -.brand-mark { - align-items: center; - background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.2)); - border: 1px solid rgba(31, 111, 100, 0.16); - border-radius: 1.25rem; - color: var(--primary-strong); - display: flex; - font-family: var(--font-display); - font-size: 1.3rem; - font-weight: 700; - height: 4.5rem; - justify-content: center; - letter-spacing: 0.12rem; - width: 4.5rem; -} - -.headline-block { - margin-bottom: 1rem; - text-align: center; -} - -.headline-block h1 { - font-family: var(--font-display); - font-size: clamp(2rem, 4vw, 2.7rem); - line-height: 1.05; - margin: 0; -} - -.spotlight-link-wrap { - margin-bottom: 1.5rem; - text-align: center; -} - -.spotlight-link { - color: var(--accent); - font-size: 0.95rem; - font-weight: 600; -} - -.login-form { - display: grid; - gap: 1rem; -} - -.status-slot { - min-height: 0.25rem; -} - -.status-banner { - border: 1px solid; - border-radius: 1rem; - font-size: 0.94rem; - line-height: 1.45; - padding: 0.9rem 1rem; -} - -.status-success { - background: var(--success-bg); - border-color: var(--success-border); - color: var(--primary-strong); -} - -.status-error { - background: var(--error-bg); - border-color: var(--error-border); - color: #8f3737; -} - -.field-group { - display: grid; - gap: 0.45rem; -} - -.field-group label { - font-size: 0.95rem; - font-weight: 600; -} - -.field-group input { - background: rgba(255, 255, 255, 0.7); - border: 1px solid rgba(31, 26, 23, 0.12); - border-radius: 0.9rem; - color: var(--text); - min-height: 3rem; - padding: 0.8rem 0.95rem; - transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease; -} - -.field-group input:focus { - background: rgba(255, 255, 255, 0.92); - border-color: rgba(31, 111, 100, 0.45); - box-shadow: 0 0 0 4px rgba(31, 111, 100, 0.1); - outline: none; -} - -.field-group input::placeholder { - color: #988d82; -} - -.aux-row { - display: flex; - justify-content: flex-end; -} - -.aux-row a { - color: var(--primary); - font-size: 0.92rem; -} - -.primary-button, -.google-button { - align-items: center; - border: 0; - border-radius: 999px; - cursor: pointer; - display: inline-flex; - font-weight: 700; - justify-content: center; - min-height: 3rem; - transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease; -} - -.primary-button { - background: linear-gradient(135deg, var(--primary), var(--primary-strong)); - box-shadow: 0 14px 30px rgba(31, 111, 100, 0.28); - color: #fffdf9; - width: 100%; -} - -.primary-button:hover, -.google-button:hover { - transform: translateY(-1px); -} - -.divider { - align-items: center; - color: var(--muted); - display: flex; - gap: 0.85rem; - margin: 1.35rem 0; -} - -.divider::before, -.divider::after { - background: rgba(31, 26, 23, 0.12); - content: ""; - flex: 1; - height: 1px; -} - -.divider span { - background: rgba(255, 251, 246, 0.72); - border-radius: 999px; - padding: 0.4rem 0.95rem; -} - -.google-button { - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(31, 26, 23, 0.12); - color: var(--text); - gap: 0.8rem; - width: 100%; -} - -.google-mark { - display: grid; - gap: 0.08rem; - grid-template-columns: repeat(2, 0.55rem); -} - -.google-mark span { - border-radius: 0.12rem; - display: inline-block; - height: 0.55rem; - width: 0.55rem; -} - -.google-mark-blue { background: #4285f4; } -.google-mark-red { background: #ea4335; } -.google-mark-yellow { background: #fbbc05; } -.google-mark-green { background: #34a853; } - -.signup-copy { - color: var(--muted); - margin: 1.3rem 0 0; - text-align: center; -} - -.signup-copy a { - color: var(--text); - font-weight: 700; -} - -@keyframes drift { - 0%, - 100% { - transform: translate3d(0, 0, 0) rotate(0deg); - } - 25% { - transform: translate3d(10px, -14px, 0) rotate(8deg); - } - 50% { - transform: translate3d(-8px, 10px, 0) rotate(-6deg); - } - 75% { - transform: translate3d(12px, 8px, 0) rotate(5deg); - } -} - -@media (max-width: 640px) { - .card-body { - border-radius: 1.45rem; - padding: 1.2rem; - } - - .headline-block h1 { - font-size: 2rem; - } - - .divider span { - padding-inline: 0.7rem; - } -} -- 2.45.2 From c4eb878b0e09ec67807b91805e8a6e932532d13b Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 8 May 2026 16:03:54 +0200 Subject: [PATCH 014/546] Split components, add sessions to the DB and make the apercu page --- .zed/settings.json | 11 + go-backend/internal/db/queries.sql | 29 +- go-backend/internal/db/repository.go | 45 + go-backend/internal/db/schema.sql | 11 + go-backend/internal/db/sqlc/models.go | 9 + go-backend/internal/db/sqlc/querier.go | 3 + go-backend/internal/db/sqlc/queries.sql.go | 79 +- go-backend/internal/web/handlers/auth.go | 263 +-- .../web/handlers/in_memory_auth_repository.go | 132 ++ .../{login.templ => auth_components.templ} | 203 +-- .../web/views/auth_components_templ.go | 575 +++++++ .../web/views/dashboard_components.templ | 330 ++++ .../web/views/dashboard_components_templ.go | 1523 +++++++++++++++++ go-backend/internal/web/views/home.go | 195 +++ go-backend/internal/web/views/icons.templ | 102 ++ go-backend/internal/web/views/icons_templ.go | 145 ++ go-backend/internal/web/views/login_templ.go | 342 ---- go-backend/internal/web/views/pages.templ | 70 + go-backend/internal/web/views/pages_templ.go | 143 ++ go-backend/router.go | 7 + go-backend/router_test.go | 202 ++- go-backend/static/styles.css | 946 +++++++++- 22 files changed, 4748 insertions(+), 617 deletions(-) create mode 100644 .zed/settings.json create mode 100644 go-backend/internal/web/handlers/in_memory_auth_repository.go rename go-backend/internal/web/views/{login.templ => auth_components.templ} (52%) create mode 100644 go-backend/internal/web/views/auth_components_templ.go create mode 100644 go-backend/internal/web/views/dashboard_components.templ create mode 100644 go-backend/internal/web/views/dashboard_components_templ.go create mode 100644 go-backend/internal/web/views/home.go create mode 100644 go-backend/internal/web/views/icons.templ create mode 100644 go-backend/internal/web/views/icons_templ.go delete mode 100644 go-backend/internal/web/views/login_templ.go create mode 100644 go-backend/internal/web/views/pages.templ create mode 100644 go-backend/internal/web/views/pages_templ.go diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..a81683f --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,11 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "languages": { + "Go": { + "language_servers": ["gopls"], + }, + }, +} diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql index a338690..c272095 100644 --- a/go-backend/internal/db/queries.sql +++ b/go-backend/internal/db/queries.sql @@ -10,7 +10,7 @@ INSERT INTO auth.users ( $1, $2, $3, - jsonb_build_object('display_name', sqlc.arg(display_name)), + jsonb_build_object('display_name', sqlc.arg(display_name)::text), now(), now() ) @@ -27,3 +27,30 @@ SELECT id, email, created_at, updated_at, display_name FROM public.users WHERE id = $1 LIMIT 1; + +-- name: CreateSession :exec +INSERT INTO auth.sessions ( + id, + session_token, + user_id, + created_at, + updated_at, + expires_at +) VALUES ( + $1, + $2, + $3, + now(), + now(), + $4 +); + +-- name: GetSessionByToken :one +SELECT id, session_token, user_id, created_at, updated_at, expires_at +FROM auth.sessions +WHERE session_token = $1 +LIMIT 1; + +-- name: DeleteSessionByToken :execrows +DELETE FROM auth.sessions +WHERE session_token = $1; diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go index f288964..8402262 100644 --- a/go-backend/internal/db/repository.go +++ b/go-backend/internal/db/repository.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" @@ -100,3 +102,46 @@ func (r *PostgresAuthRepository) GetPublicUserByID(ctx context.Context, id uuid. UpdatedAt: row.UpdatedAt.Time, }, nil } + +func (r *PostgresAuthRepository) CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error { + err := r.queries.CreateSession(ctx, sqlcdb.CreateSessionParams{ + ID: uuid.New(), + SessionToken: token, + UserID: userID, + ExpiresAt: pgtypeTimestamptz(expiresAt), + }) + return err +} + +func (r *PostgresAuthRepository) GetSessionByToken(ctx context.Context, token string) (handlers.Session, error) { + row, err := r.queries.GetSessionByToken(ctx, token) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.Session{}, handlers.ErrSessionNotFound + } + return handlers.Session{}, err + } + + return handlers.Session{ + Token: row.SessionToken, + UserID: row.UserID, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + ExpiresAt: row.ExpiresAt.Time, + }, nil +} + +func (r *PostgresAuthRepository) DeleteSessionByToken(ctx context.Context, token string) error { + rows, err := r.queries.DeleteSessionByToken(ctx, token) + if err != nil { + return err + } + if rows == 0 { + return handlers.ErrSessionNotFound + } + return nil +} + +func pgtypeTimestamptz(value time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: value, Valid: true} +} diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql index 2be7b1a..af9cbfa 100644 --- a/go-backend/internal/db/schema.sql +++ b/go-backend/internal/db/schema.sql @@ -17,6 +17,17 @@ CREATE TABLE IF NOT EXISTS public.users ( display_name text NOT NULL ); +CREATE TABLE IF NOT EXISTS auth.sessions ( + id uuid PRIMARY KEY, + session_token text NOT NULL UNIQUE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth.sessions(user_id); + 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 ee1ee7d..953ca89 100644 --- a/go-backend/internal/db/sqlc/models.go +++ b/go-backend/internal/db/sqlc/models.go @@ -9,6 +9,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type AuthSession struct { + ID uuid.UUID `db:"id"` + SessionToken string `db:"session_token"` + UserID uuid.UUID `db:"user_id"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` + ExpiresAt pgtype.Timestamptz `db:"expires_at"` +} + type AuthUser 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 2a2b436..0eb3521 100644 --- a/go-backend/internal/db/sqlc/querier.go +++ b/go-backend/internal/db/sqlc/querier.go @@ -12,8 +12,11 @@ import ( type Querier interface { CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) + CreateSession(ctx context.Context, arg CreateSessionParams) 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) } 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 c24db1b..c10df4f 100644 --- a/go-backend/internal/db/sqlc/queries.sql.go +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -24,7 +24,7 @@ INSERT INTO auth.users ( $1, $2, $3, - jsonb_build_object('display_name', $4), + jsonb_build_object('display_name', $4::text), now(), now() ) @@ -32,10 +32,10 @@ RETURNING id ` type CreateAuthUserParams struct { - ID uuid.UUID `db:"id"` - Email string `db:"email"` - EncryptedPassword string `db:"encrypted_password"` - DisplayName interface{} `db:"display_name"` + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + DisplayName string `db:"display_name"` } func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) { @@ -50,6 +50,54 @@ func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) return id, err } +const createSession = `-- name: CreateSession :exec +INSERT INTO auth.sessions ( + id, + session_token, + user_id, + created_at, + updated_at, + expires_at +) VALUES ( + $1, + $2, + $3, + now(), + now(), + $4 +) +` + +type CreateSessionParams struct { + ID uuid.UUID `db:"id"` + SessionToken string `db:"session_token"` + UserID uuid.UUID `db:"user_id"` + ExpiresAt pgtype.Timestamptz `db:"expires_at"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error { + _, err := q.db.Exec(ctx, createSession, + arg.ID, + arg.SessionToken, + arg.UserID, + arg.ExpiresAt, + ) + return err +} + +const deleteSessionByToken = `-- name: DeleteSessionByToken :execrows +DELETE FROM auth.sessions +WHERE session_token = $1 +` + +func (q *Queries) DeleteSessionByToken(ctx context.Context, sessionToken string) (int64, error) { + result, err := q.db.Exec(ctx, deleteSessionByToken, sessionToken) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one SELECT id, email, encrypted_password, created_at, updated_at FROM auth.users @@ -97,3 +145,24 @@ func (q *Queries) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, er ) return i, err } + +const getSessionByToken = `-- name: GetSessionByToken :one +SELECT id, session_token, user_id, created_at, updated_at, expires_at +FROM auth.sessions +WHERE session_token = $1 +LIMIT 1 +` + +func (q *Queries) GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error) { + row := q.db.QueryRow(ctx, getSessionByToken, sessionToken) + var i AuthSession + err := row.Scan( + &i.ID, + &i.SessionToken, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.ExpiresAt, + ) + return i, err +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go index 6dfa6e0..2b3f79b 100644 --- a/go-backend/internal/web/handlers/auth.go +++ b/go-backend/internal/web/handlers/auth.go @@ -7,7 +7,6 @@ import ( "errors" "net/http" "strings" - "sync" "time" "github.com/google/uuid" @@ -15,17 +14,24 @@ import ( "golang.org/x/crypto/bcrypt" "xtablo-backend/internal/web/views" + + "github.com/a-h/templ" ) const sessionCookieName = "xtablo_session" +const sessionLifetime = 30 * 24 * time.Hour var ErrUserNotFound = errors.New("user not found") var ErrUserAlreadyExists = errors.New("user already exists") +var ErrSessionNotFound = errors.New("session not found") type AuthRepository interface { CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error) GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error) GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error) + 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 } type CreateAuthUserInput struct { @@ -50,82 +56,134 @@ type PublicUser struct { UpdatedAt time.Time } +type Session struct { + Token string + UserID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + type AuthHandler struct { - repo AuthRepository - sessions *sessionStore -} - -type sessionStore struct { - mu sync.RWMutex - sessions map[string]uuid.UUID -} - -type InMemoryAuthRepository struct { - mu sync.RWMutex - authUsers map[string]AuthUser - publicUsers map[uuid.UUID]PublicUser + repo AuthRepository } func NewAuthHandler(repo AuthRepository) *AuthHandler { - return &AuthHandler{ - repo: repo, - sessions: &sessionStore{sessions: map[string]uuid.UUID{}}, - } -} - -func NewInMemoryAuthRepository() *InMemoryAuthRepository { - repo := &InMemoryAuthRepository{ - authUsers: map[string]AuthUser{}, - publicUsers: map[uuid.UUID]PublicUser{}, - } - - demoHash, err := hashPassword("xtablo-demo") - if err != nil { - panic(err) - } - if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ - Email: "demo@xtablo.com", - EncryptedPassword: demoHash, - DisplayName: "demo", - }); err != nil { - panic(err) - } - - return repo + return &AuthHandler{repo: repo} } func (h *AuthHandler) GetHome() http.HandlerFunc { + return h.renderAppPage("/", func(user PublicUser) templ.Component { + return views.OverviewMainContent(user.DisplayName, user.Email) + }) +} + +func (h *AuthHandler) GetTasksPage() http.HandlerFunc { + return h.renderAppPage("/tasks", func(user PublicUser) templ.Component { + return views.TasksMainContent() + }) +} + +func (h *AuthHandler) GetTablosPage() http.HandlerFunc { + return h.renderAppPage("/tablos", func(user PublicUser) templ.Component { + return views.TablosMainContent() + }) +} + +func (h *AuthHandler) GetPlanningPage() http.HandlerFunc { + return h.renderAppPage("/planning", func(user PublicUser) templ.Component { + return views.PlanningMainContent() + }) +} + +func (h *AuthHandler) GetChatPage() http.HandlerFunc { + return h.renderAppPage("/chat", func(user PublicUser) templ.Component { + return views.ChatMainContent() + }) +} + +func (h *AuthHandler) GetFilesPage() http.HandlerFunc { + return h.renderAppPage("/files", func(user PublicUser) templ.Component { + return views.FilesMainContent() + }) +} + +func (h *AuthHandler) GetFeedbackPage() http.HandlerFunc { + return h.renderAppPage("/feedback", func(user PublicUser) templ.Component { + return views.FeedbackMainContent() + }) +} + +func (h *AuthHandler) GetNotFound() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.currentUserID(r) + user, ok := h.authenticatedUser(r.Context(), r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - user, err := h.repo.GetPublicUserByID(r.Context(), userID) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + content := views.NotFoundContent(user.DisplayName) + var err error + if isHXRequest(r) { + err = views.DashboardContentSwap("", content).Render(r.Context(), w) + } else { + err = views.DashboardPage("", content).Render(r.Context(), w) + } if err != nil { + http.Error(w, "failed to render not found page", http.StatusInternalServerError) + } + } +} + +func (h *AuthHandler) renderAppPage(activePath string, content func(user PublicUser) templ.Component) 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 } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil { - http.Error(w, "failed to render home page", http.StatusInternalServerError) + pageContent := content(user) + var err error + if isHXRequest(r) { + err = views.DashboardContentSwap(activePath, pageContent).Render(r.Context(), w) + } else { + err = views.DashboardPage(activePath, pageContent).Render(r.Context(), w) + } + if err != nil { + http.Error(w, "failed to render app page", http.StatusInternalServerError) } } } +func (h *AuthHandler) authenticatedUser(ctx context.Context, r *http.Request) (PublicUser, bool) { + userID, ok := h.currentUserID(ctx, r) + if !ok { + return PublicUser{}, false + } + + user, err := h.repo.GetPublicUserByID(ctx, userID) + if err != nil { + return PublicUser{}, false + } + return user, true +} + func (h *AuthHandler) PostLogout() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var email string - if userID, ok := h.currentUserID(r); ok { + if userID, ok := h.currentUserID(r.Context(), r); ok { if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil { email = user.Email } } if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { - h.sessions.delete(cookie.Value, email) + _ = h.repo.DeleteSessionByToken(r.Context(), cookie.Value) + logStoreMutation("delete_session", email, cookie.Value, 0, 0) } http.SetCookie(w, &http.Cookie{ @@ -142,13 +200,13 @@ func (h *AuthHandler) PostLogout() http.HandlerFunc { func (h *AuthHandler) GetLoginPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if _, ok := h.currentUserID(r); ok { + if _, ok := h.currentUserID(r.Context(), r); ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := views.AuthPage(views.LoginScreen()).Render(r.Context(), w); err != nil { + if err := views.LoginPage().Render(r.Context(), w); err != nil { http.Error(w, "failed to render login page", http.StatusInternalServerError) } } @@ -156,13 +214,13 @@ func (h *AuthHandler) GetLoginPage() http.HandlerFunc { func (h *AuthHandler) GetSignupPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if _, ok := h.currentUserID(r); ok { + if _, ok := h.currentUserID(r.Context(), r); ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := views.AuthPage(views.SignupScreen()).Render(r.Context(), w); err != nil { + if err := views.SignupPage().Render(r.Context(), w); err != nil { http.Error(w, "failed to render signup page", http.StatusInternalServerError) } } @@ -194,7 +252,7 @@ func (h *AuthHandler) PostLogin() http.HandlerFunc { return } - h.setSession(w, authUser.ID, authUser.Email) + h.setSession(r.Context(), w, authUser.ID, authUser.Email) w.Header().Set("HX-Redirect", "/") _ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w) } @@ -246,110 +304,51 @@ func (h *AuthHandler) PostSignup() http.HandlerFunc { _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) return } - http.Error(w, "failed to create user", http.StatusInternalServerError) + log.Err(err).Msg("failed to create user") + w.WriteHeader(http.StatusInternalServerError) + _ = views.AuthStatus("error", "Un problème est survenu.").Render(r.Context(), w) return } - h.setSession(w, userID, email) + h.setSession(r.Context(), w, userID, email) w.Header().Set("HX-Redirect", "/") _ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w) } } -func (h *AuthHandler) currentUserID(r *http.Request) (uuid.UUID, bool) { +func (h *AuthHandler) currentUserID(ctx context.Context, r *http.Request) (uuid.UUID, bool) { cookie, err := r.Cookie(sessionCookieName) if err != nil || cookie.Value == "" { return uuid.Nil, false } - return h.sessions.get(cookie.Value) + session, err := h.repo.GetSessionByToken(ctx, cookie.Value) + if err != nil { + return uuid.Nil, false + } + if session.ExpiresAt.Before(time.Now().UTC()) { + _ = h.repo.DeleteSessionByToken(ctx, cookie.Value) + return uuid.Nil, false + } + return session.UserID, true } -func (h *AuthHandler) setSession(w http.ResponseWriter, userID uuid.UUID, email string) { +func (h *AuthHandler) setSession(ctx context.Context, w http.ResponseWriter, userID uuid.UUID, email string) { sessionID := randomToken(32) - h.sessions.set(sessionID, userID, email) + expiresAt := time.Now().UTC().Add(sessionLifetime) + if err := h.repo.CreateSession(ctx, sessionID, userID, expiresAt); err != nil { + panic(err) + } + logStoreMutation("create_session", email, sessionID, 0, 0) http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sessionID, Path: "/", + Expires: expiresAt, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } -func (s *sessionStore) get(sessionID string) (uuid.UUID, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - userID, ok := s.sessions[sessionID] - return userID, ok -} - -func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) { - s.mu.Lock() - defer s.mu.Unlock() - s.sessions[sessionID] = userID - logStoreMutation("create_session", email, sessionID, 0, len(s.sessions)) -} - -func (s *sessionStore) delete(sessionID string, email string) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.sessions, sessionID) - logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions)) -} - -func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) { - r.mu.Lock() - defer r.mu.Unlock() - - if _, exists := r.authUsers[input.Email]; exists { - return uuid.Nil, ErrUserAlreadyExists - } - - id := uuid.New() - now := time.Now().UTC() - authUser := AuthUser{ - ID: id, - Email: input.Email, - EncryptedPassword: input.EncryptedPassword, - CreatedAt: now, - UpdatedAt: now, - } - publicUser := PublicUser{ - ID: id, - Email: input.Email, - DisplayName: input.DisplayName, - CreatedAt: now, - UpdatedAt: now, - } - - r.authUsers[input.Email] = authUser - r.publicUsers[id] = publicUser - logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0) - return id, nil -} - -func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - user, ok := r.authUsers[email] - if !ok { - return AuthUser{}, ErrUserNotFound - } - return user, nil -} - -func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - user, ok := r.publicUsers[id] - if !ok { - return PublicUser{}, ErrUserNotFound - } - return user, nil -} - func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } @@ -394,3 +393,7 @@ func logStoreMutation(action string, email string, sessionID string, usersCount event.Msg("auth store mutated") } + +func isHXRequest(r *http.Request) bool { + return r.Header.Get("HX-Request") == "true" +} diff --git a/go-backend/internal/web/handlers/in_memory_auth_repository.go b/go-backend/internal/web/handlers/in_memory_auth_repository.go new file mode 100644 index 0000000..c9d1d17 --- /dev/null +++ b/go-backend/internal/web/handlers/in_memory_auth_repository.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" +) + +// InMemoryAuthRepository exists only as test support. +// It must not be used as a production auth/session store. +type InMemoryAuthRepository struct { + mu sync.RWMutex + authUsers map[string]AuthUser + publicUsers map[uuid.UUID]PublicUser + sessions map[string]Session +} + +// NewInMemoryAuthRepository creates a testing-only auth repository. +// Use the Postgres-backed repository in real application flows. +func NewInMemoryAuthRepository() *InMemoryAuthRepository { + repo := &InMemoryAuthRepository{ + authUsers: map[string]AuthUser{}, + publicUsers: map[uuid.UUID]PublicUser{}, + sessions: map[string]Session{}, + } + + demoHash, err := hashPassword("xtablo-demo") + if err != nil { + panic(err) + } + if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "demo@xtablo.com", + EncryptedPassword: demoHash, + DisplayName: "demo", + }); err != nil { + panic(err) + } + + return repo +} + +func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.authUsers[input.Email]; exists { + return uuid.Nil, ErrUserAlreadyExists + } + + id := uuid.New() + now := time.Now().UTC() + authUser := AuthUser{ + ID: id, + Email: input.Email, + EncryptedPassword: input.EncryptedPassword, + CreatedAt: now, + UpdatedAt: now, + } + publicUser := PublicUser{ + ID: id, + Email: input.Email, + DisplayName: input.DisplayName, + CreatedAt: now, + UpdatedAt: now, + } + + r.authUsers[input.Email] = authUser + r.publicUsers[id] = publicUser + logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0) + return id, nil +} + +func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.authUsers[email] + if !ok { + return AuthUser{}, ErrUserNotFound + } + return user, nil +} + +func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.publicUsers[id] + if !ok { + return PublicUser{}, ErrUserNotFound + } + return user, nil +} + +func (r *InMemoryAuthRepository) CreateSession(_ context.Context, token string, userID uuid.UUID, expiresAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now().UTC() + r.sessions[token] = Session{ + Token: token, + UserID: userID, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: expiresAt, + } + return nil +} + +func (r *InMemoryAuthRepository) GetSessionByToken(_ context.Context, token string) (Session, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + session, ok := r.sessions[token] + if !ok { + return Session{}, ErrSessionNotFound + } + return session, nil +} + +func (r *InMemoryAuthRepository) DeleteSessionByToken(_ context.Context, token string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.sessions[token]; !ok { + return ErrSessionNotFound + } + delete(r.sessions, token) + return nil +} diff --git a/go-backend/internal/web/views/login.templ b/go-backend/internal/web/views/auth_components.templ similarity index 52% rename from go-backend/internal/web/views/login.templ rename to go-backend/internal/web/views/auth_components.templ index a879cd0..ec75716 100644 --- a/go-backend/internal/web/views/login.templ +++ b/go-backend/internal/web/views/auth_components.templ @@ -1,146 +1,73 @@ package views -templ AuthPage(content templ.Component) { - - - - - - - - - - - - - XTablo - - - - -
-
-
- -
-
- - -} - templ LoginScreen() { -
-

Se connecter à Xtablo

-
- -
- - @AuthDivider() - @GoogleButton() - -
+ @AuthScreen( + "Se connecter à Xtablo", + "/login-v2", + "Découvrez la nouvelle expérience de connexion", + LoginForm(), + AuthScreenFooter("Pas encore de compte ?", "/signup", "S'inscrire"), + ) } templ SignupScreen() { + @AuthScreen( + "S'inscrire à Xtablo", + "/login", + "Vous avez déjà un compte ?", + SignupForm(), + AuthScreenFooter("Vous avez déjà un compte ?", "/login", "Se connecter"), + ) +} + +templ AuthScreen(title string, helperHref string, helperLabel string, form templ.Component, footer templ.Component) {
-

S'inscrire à Xtablo

+

{ title }

- + @form @AuthDivider() @GoogleButton() - + @footer
} -templ HomePage(displayName string, email string) { - - - - - - XTablo - - - -
-
- -

Bienvenue

-

{ displayName }

-

Session active pour { email }

-
- -
-
-
- - +templ LoginForm() { + +} + +templ SignupForm() { + +} + +templ AuthField(fieldID string, fieldName string, fieldLabel string, inputType string, placeholder string) { +
+ + +
+} + +templ AuthScreenFooter(copy string, href string, label string) { + } templ AuthStatus(kind string, message string) { @@ -164,7 +91,7 @@ templ GoogleButton() {
- + +} diff --git a/go-backend/internal/web/ui/table_templ.go b/go-backend/internal/web/ui/table_templ.go new file mode 100644 index 0000000..c26c359 --- /dev/null +++ b/go-backend/internal/web/ui/table_templ.go @@ -0,0 +1,65 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package ui + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +type TableProps struct { + Head templ.Component + Body templ.Component +} + +func Table(props TableProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.Head != nil { + templ_7745c5c3_Err = props.Head.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.Body != nil { + templ_7745c5c3_Err = props.Body.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/textarea.templ b/go-backend/internal/web/ui/textarea.templ new file mode 100644 index 0000000..acffaf9 --- /dev/null +++ b/go-backend/internal/web/ui/textarea.templ @@ -0,0 +1,21 @@ +package ui + +type TextareaProps struct { + ID string + Name string + Value string + Placeholder string + Rows int + Attrs templ.Attributes +} + +templ Textarea(props TextareaProps) { + +} diff --git a/go-backend/internal/web/ui/textarea_templ.go b/go-backend/internal/web/ui/textarea_templ.go new file mode 100644 index 0000000..a454eed --- /dev/null +++ b/go-backend/internal/web/ui/textarea_templ.go @@ -0,0 +1,122 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package ui + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +type TextareaProps struct { + ID string + Name string + Value string + Placeholder string + Rows int + Attrs templ.Attributes +} + +func Textarea(props TextareaProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/ui/tokens.go b/go-backend/internal/web/ui/tokens.go new file mode 100644 index 0000000..12f3e69 --- /dev/null +++ b/go-backend/internal/web/ui/tokens.go @@ -0,0 +1,8 @@ +package ui + +const ( + TokenPrimary = "primary" + TokenDanger = "danger" + TokenWarning = "warning" + TokenInfo = "info" +) diff --git a/go-backend/internal/web/ui/ui_test.go b/go-backend/internal/web/ui/ui_test.go new file mode 100644 index 0000000..0df3a25 --- /dev/null +++ b/go-backend/internal/web/ui/ui_test.go @@ -0,0 +1,313 @@ +package ui + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/a-h/templ" +) + +func TestButtonRendersPrimaryMediumMarkup(t *testing.T) { + component := Button(ButtonProps{ + Label: "Nouveau projet", + Variant: ButtonVariantPrimary, + Size: SizeMD, + Type: "button", + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `type="button"`, + `Nouveau projet`, + `ui-button`, + `ui-button-primary`, + `ui-button-md`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestIconButtonRendersBorderlessDestructiveMarkup(t *testing.T) { + component := IconButton(IconButtonProps{ + Label: "Supprimer le projet", + Icon: "trash", + Variant: IconButtonVariantDangerGhost, + Type: "button", + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `type="button"`, + `aria-label="Supprimer le projet"`, + `borderless-icon-button`, + `lucide-trash2`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestBadgeRendersSemanticStatusVariant(t *testing.T) { + component := Badge(BadgeProps{ + Label: "En cours", + Variant: BadgeVariantWarning, + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-badge`, + `ui-badge-warning`, + `En cours`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestModalRendersShellStructure(t *testing.T) { + component := Modal(ModalProps{ + Title: "Nouveau projet", + Body: textComponent("Body copy"), + Actions: textComponent("Actions"), + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-modal-backdrop`, + `ui-modal-panel`, + `Nouveau projet`, + `Body copy`, + `Actions`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestButtonUsesSharedTokenBackedClasses(t *testing.T) { + component := Button(ButtonProps{ + Label: "Create", + Variant: ButtonVariantPrimary, + Size: SizeSM, + Type: "button", + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-button`, + `ui-button-primary`, + `ui-button-sm`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestSharedSemanticClassesExistInStylesheet(t *testing.T) { + cssPath := filepath.Join("..", "..", "..", "static", "styles.css") + body, err := os.ReadFile(cssPath) + if err != nil { + t.Fatalf("read stylesheet: %v", err) + } + + css := string(body) + for _, want := range []string{ + `.ui-button-primary`, + `.ui-button-sm`, + `.ui-badge-warning`, + `.ui-modal-panel`, + `.borderless-icon-button`, + } { + if !strings.Contains(css, want) { + t.Fatalf("expected stylesheet to contain %q", want) + } + } +} + +func TestButtonRendersDangerLargeMarkup(t *testing.T) { + component := Button(ButtonProps{ + Label: "Supprimer", + Variant: ButtonVariantDanger, + Size: SizeLG, + Type: "submit", + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `type="submit"`, + `ui-button-danger`, + `ui-button-lg`, + `Supprimer`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestInputRendersSharedControlMarkup(t *testing.T) { + component := Input(InputProps{ + Name: "name", + Value: "My project", + Placeholder: "Nom du projet", + Type: "text", + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `name="name"`, + `value="My project"`, + `placeholder="Nom du projet"`, + `class="ui-input"`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestTextareaRendersSharedControlMarkup(t *testing.T) { + component := Textarea(TextareaProps{ + Name: "description", + Value: "Longer copy", + Placeholder: "Description", + Rows: 4, + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `name="description"`, + `placeholder="Description"`, + `rows="4"`, + `class="ui-textarea"`, + `Longer copy`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestFormFieldRendersLabelAndError(t *testing.T) { + component := FormField(FormFieldProps{ + Label: "Nom", + For: "tablo-name", + Field: Input(InputProps{Name: "name", Type: "text"}), + Error: "Le nom est requis", + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-form-field`, + `for="tablo-name"`, + `Nom`, + `ui-form-error`, + `Le nom est requis`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestCardRendersSharedRegions(t *testing.T) { + component := Card(CardProps{ + Header: textComponent("Header"), + Body: textComponent("Body"), + Footer: textComponent("Footer"), + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-card`, + `ui-card-header`, + `ui-card-body`, + `ui-card-footer`, + `Body`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestTableRendersSharedShell(t *testing.T) { + component := Table(TableProps{ + Head: textComponent("Projet"), + Body: textComponent("Hello"), + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-table-shell`, + `class="ui-table"`, + ``, + ``, + `Projet`, + `Hello`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestEmptyStateRendersTitleDescriptionAndAction(t *testing.T) { + component := EmptyState(EmptyStateProps{ + Title: "Aucun projet", + Description: "Créez votre premier projet.", + Action: textComponent("Créer"), + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `ui-empty-state`, + `Aucun projet`, + `Créez votre premier projet.`, + `Créer`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func renderToString(t *testing.T, component templ.Component) string { + t.Helper() + + var buf bytes.Buffer + if err := component.Render(context.Background(), &buf); err != nil { + t.Fatalf("render component: %v", err) + } + return buf.String() +} + +func textComponent(text string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + _, err := w.Write([]byte(text)) + return err + }) +} diff --git a/go-backend/internal/web/ui/variants.go b/go-backend/internal/web/ui/variants.go new file mode 100644 index 0000000..4e738b5 --- /dev/null +++ b/go-backend/internal/web/ui/variants.go @@ -0,0 +1,78 @@ +package ui + +type Size string + +const ( + SizeSM Size = "sm" + SizeMD Size = "md" + SizeLG Size = "lg" +) + +type ButtonVariant string + +const ( + ButtonVariantPrimary ButtonVariant = "primary" + ButtonVariantSecondary ButtonVariant = "secondary" + ButtonVariantGhost ButtonVariant = "ghost" + ButtonVariantDanger ButtonVariant = "danger" +) + +type IconButtonVariant string + +const ( + IconButtonVariantNeutral IconButtonVariant = "neutral" + IconButtonVariantDangerGhost IconButtonVariant = "danger-ghost" +) + +type BadgeVariant string + +const ( + BadgeVariantInfo BadgeVariant = "info" + BadgeVariantWarning BadgeVariant = "warning" + BadgeVariantSuccess BadgeVariant = "success" + BadgeVariantDanger BadgeVariant = "danger" +) + +func buttonClass(variant ButtonVariant, size Size) string { + return "ui-button ui-button-" + string(normalizedButtonVariant(variant)) + " ui-button-" + string(normalizedSize(size)) +} + +func iconButtonClass(variant IconButtonVariant) string { + switch variant { + case IconButtonVariantDangerGhost: + return "borderless-icon-button" + default: + return "ui-icon-button" + } +} + +func badgeClass(variant BadgeVariant) string { + return "ui-badge ui-badge-" + string(normalizedBadgeVariant(variant)) +} + +func normalizedSize(size Size) Size { + switch size { + case SizeSM, SizeLG: + return size + default: + return SizeMD + } +} + +func normalizedButtonVariant(variant ButtonVariant) ButtonVariant { + switch variant { + case ButtonVariantSecondary, ButtonVariantGhost, ButtonVariantDanger: + return variant + default: + return ButtonVariantPrimary + } +} + +func normalizedBadgeVariant(variant BadgeVariant) BadgeVariant { + switch variant { + case BadgeVariantWarning, BadgeVariantSuccess, BadgeVariantDanger: + return variant + default: + return BadgeVariantInfo + } +} diff --git a/go-backend/internal/web/views/dashboard_components.templ b/go-backend/internal/web/views/dashboard_components.templ index eb464dd..e52efbc 100644 --- a/go-backend/internal/web/views/dashboard_components.templ +++ b/go-backend/internal/web/views/dashboard_components.templ @@ -1,6 +1,10 @@ package views templ DashboardPage(activePath string, content templ.Component) { + @DashboardPageWithMainClass(activePath, "dashboard-main flex-1 overflow-auto", content) +} + +templ DashboardPageWithMainClass(activePath string, mainClass string, content templ.Component) { @@ -8,12 +12,13 @@ templ DashboardPage(activePath string, content templ.Component) { XTablo +
@DashboardSidebar(activePath) - @DashboardMainContent(content) + @DashboardMainContentWithClass(mainClass, content)
@@ -24,13 +29,21 @@ templ DashboardNotFoundPage(displayName string, email string) { } templ DashboardMainContent(content templ.Component) { -
+ @DashboardMainContentWithClass("dashboard-main flex-1 overflow-auto", content) +} + +templ DashboardMainContentWithClass(mainClass string, content templ.Component) { +
@content
} templ DashboardContentSwap(activePath string, content templ.Component) { - @DashboardMainContent(content) + @DashboardContentSwapWithMainClass(activePath, "dashboard-main flex-1 overflow-auto", content) +} + +templ DashboardContentSwapWithMainClass(activePath string, mainClass string, content templ.Component) { + @DashboardMainContentWithClass(mainClass, content) @DashboardNavOOB(activePath) } @@ -51,8 +64,9 @@ templ DashboardSidebar(activePath string) {
@@ -103,11 +119,11 @@ templ SidebarOrganization() {
} -templ OverviewMainContent(displayName string, email string) { +templ OverviewMainContent(displayName string, email string, tablos []TabloCardView, showAllProjects bool) {
@OverviewHeader(displayName) @OverviewActions(overviewQuickActions()) - @OverviewProjects(overviewProjects()) + @OverviewProjectsSection(tablos, showAllProjects) @OverviewTasks(overviewTasks())
} @@ -190,25 +206,37 @@ templ OverviewActions(actions []quickAction) { } -templ OverviewProjects(projects []dashboardProject) { -
+templ OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) { +

Mes Projets

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

{ project.Title }

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

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

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

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

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

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

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

Page introuvable

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

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

Page introuvable

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

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

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

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

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

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

Founder

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "!
Founder
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -702,12 +807,12 @@ func OverviewActions(actions []quickAction) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var23 := templ.GetChildren(ctx) - if templ_7745c5c3_Var23 == nil { - templ_7745c5c3_Var23 = templ.NopComponent + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -717,7 +822,7 @@ func OverviewActions(actions []quickAction) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -725,7 +830,7 @@ func OverviewActions(actions []quickAction) templ.Component { }) } -func OverviewProjects(projects []dashboardProject) templ.Component { +func OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -741,25 +846,77 @@ func OverviewProjects(projects []dashboardProject) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var24 := templ.GetChildren(ctx) - if templ_7745c5c3_Var24 == nil { - templ_7745c5c3_Var24 = templ.NopComponent + templ_7745c5c3_Var29 := templ.GetChildren(ctx) + if templ_7745c5c3_Var29 == nil { + templ_7745c5c3_Var29 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Mes Projets

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

Mes Projets

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, project := range projects { - templ_7745c5c3_Err = ProjectCard(project).Render(ctx, templ_7745c5c3_Buffer) + for _, project := range visibleOverviewProjects(projects, showAllProjects) { + templ_7745c5c3_Err = TabloGridCard(project).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = SeeMoreProjects(hiddenOverviewProjectsCount(projects, showAllProjects)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SeeMoreProjects(hiddenCount int) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if hiddenCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } return nil }) } @@ -780,12 +937,12 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

Mes Tâches

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

Mes Tâches

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(project.Initial) + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(action.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 256, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 269, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(project.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 258, Col: 22} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

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

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

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

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

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var48 = []any{"task-project-badge " + projectAccentClass(task.ProjectHue)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var48...) + var templ_7745c5c3_Var42 = []any{"task-project-badge " + projectAccentClass(task.ProjectHue)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(task.ProjectKey) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 285, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(task.Project) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 287, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(task.Date) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 288, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 = []any{"task-status " + toneClass(task.StatusTone)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var48).String()) + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(task.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 291, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(task.ProjectKey) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 287, Col: 28} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(task.Project) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 289, Col: 50} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(task.Date) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 290, Col: 39} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var53 = []any{"task-status " + toneClass(task.StatusTone)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var53...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(task.Status) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 293, Col: 75} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1259,69 +1227,69 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var56 := templ.GetChildren(ctx) - if templ_7745c5c3_Var56 == nil { - templ_7745c5c3_Var56 = templ.NopComponent + templ_7745c5c3_Var50 := templ.GetChildren(ctx) + if templ_7745c5c3_Var50 == nil { + templ_7745c5c3_Var50 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var57 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var57...) + var templ_7745c5c3_Var51 = []any{sidebarNavItemClass(item.Active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1366,69 +1334,69 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var63 := templ.GetChildren(ctx) - if templ_7745c5c3_Var63 == nil { - templ_7745c5c3_Var63 = templ.NopComponent + templ_7745c5c3_Var57 := templ.GetChildren(ctx) + if templ_7745c5c3_Var57 == nil { + templ_7745c5c3_Var57 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var64 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var64...) + var templ_7745c5c3_Var58 = []any{sidebarNavItemClass(item.Active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var58...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1473,25 +1441,25 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var70 := templ.GetChildren(ctx) - if templ_7745c5c3_Var70 == nil { - templ_7745c5c3_Var70 = templ.NopComponent + templ_7745c5c3_Var64 := templ.GetChildren(ctx) + if templ_7745c5c3_Var64 == nil { + templ_7745c5c3_Var64 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1499,20 +1467,20 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 328, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 326, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/views/home.go b/go-backend/internal/web/views/home.go index e05f6ca..cd6983f 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -6,8 +6,11 @@ import ( "time" "github.com/a-h/templ" + tablomodel "xtablo-backend/internal/tablos" ) +const overviewProjectsPreviewLimit = 6 + func sidebarNavItemClass(active bool) string { if active { return "sidebar-nav-item is-active" @@ -52,16 +55,6 @@ type sidebarProjectItem struct { Icon string } -type dashboardProject struct { - Title string - Status string - StatusTone string - Initial string - Accent string - Date string - Progress int -} - type dashboardTask struct { Title string Project string @@ -101,17 +94,6 @@ func overviewQuickActions() []quickAction { } } -func overviewProjects() []dashboardProject { - return []dashboardProject{ - {Title: "Hello", Status: "En cours", StatusTone: "warning", Initial: "H", Accent: "blue", Date: "Apr 15, 2026", Progress: 50}, - {Title: "Jean Macon interet pour le produit de ta mere", Status: "En cours", StatusTone: "warning", Initial: "J", Accent: "purple", Date: "Nov 18, 2025", Progress: 50}, - {Title: "bikip56648 / Arthur Belleville", Status: "En cours", StatusTone: "warning", Initial: "B", Accent: "blue", Date: "Nov 06, 2025", Progress: 50}, - {Title: "lsdkfjsl / Arthur Belleville", Status: "À faire", StatusTone: "info", Initial: "L", Accent: "blue", Date: "Oct 26, 2025", Progress: 0}, - {Title: "Hello / Arthur Belleville", Status: "À faire", StatusTone: "info", Initial: "H", Accent: "blue", Date: "Oct 26, 2025", Progress: 0}, - {Title: "Wes Ocif / Arthur", Status: "À faire", StatusTone: "info", Initial: "W", Accent: "blue", Date: "Oct 20, 2025", Progress: 0}, - } -} - func overviewTasks() []dashboardTask { return []dashboardTask{ {Title: "yo", Project: "Hello", ProjectKey: "H", ProjectHue: "blue", Date: "Apr 16, 2026", Status: "À faire", StatusTone: "info", Completed: false}, @@ -124,6 +106,41 @@ func overviewTasks() []dashboardTask { } } +func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView { + projects := make([]TabloCardView, 0, len(tablos)) + for _, tablo := range tablos { + statusLabel, statusTone, progress := overviewProjectStatus(tablo.Status) + projects = append(projects, TabloCardView{ + ID: tablo.ID.String(), + Name: tablo.Name, + Status: string(tablo.Status), + StatusLabel: statusLabel, + StatusTone: statusTone, + Initial: projectInitial(tablo.Name), + Accent: overviewProjectAccent(tablo.Name), + CardDateLabel: tablo.CreatedAt.Format("Jan 02, 2006"), + Progress: progress, + ProgressLabel: progressPercentLabel(progress), + DeleteRequestURL: "/tablos/" + tablo.ID.String(), + }) + } + return projects +} + +func visibleOverviewProjects(projects []TabloCardView, showAll bool) []TabloCardView { + if showAll || len(projects) <= overviewProjectsPreviewLimit { + return projects + } + return projects[:overviewProjectsPreviewLimit] +} + +func hiddenOverviewProjectsCount(projects []TabloCardView, showAll bool) int { + if showAll || len(projects) <= overviewProjectsPreviewLimit { + return 0 + } + return len(projects) - overviewProjectsPreviewLimit +} + func sidebarPrimaryNavItems(activePath string) []sidebarNavItem { return []sidebarNavItem{ {Href: "/", Label: "Aperçu", Icon: "panels", Active: isActivePath(activePath, "/"), DividerAfter: true}, @@ -193,3 +210,33 @@ func progressPercentLabel(progress int) string { func progressInlineStyle(progress int) templ.SafeCSS { return templ.SanitizeCSS("width", templ.SafeCSSProperty(progressPercentLabel(progress))) } + +func overviewProjectStatus(status tablomodel.Status) (string, string, int) { + switch status { + case tablomodel.StatusInProgress: + return "En cours", "warning", 50 + case tablomodel.StatusDone: + return "Terminé", "success", 100 + default: + return "À faire", "info", 0 + } +} + +func overviewProjectAccent(name string) string { + switch len(strings.TrimSpace(name)) % 3 { + case 1: + return "purple" + case 2: + return "red" + default: + return "blue" + } +} + +func projectInitial(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "P" + } + return strings.ToUpper(name[:1]) +} diff --git a/go-backend/internal/web/views/icons.templ b/go-backend/internal/web/views/icons.templ index d7e4a44..19a2373 100644 --- a/go-backend/internal/web/views/icons.templ +++ b/go-backend/internal/web/views/icons.templ @@ -2,6 +2,11 @@ package views templ ActionIcon(kind string) { switch kind { + case "plus": + case "folder-plus": + case "grid3x3": + + case "list": + + case "search": + + case "filter": + case "check-circle": @@ -15,6 +15,7 @@ templ AuthPage(content templ.Component) { XTablo + @@ -64,7 +65,3 @@ templ LoginPage() { templ SignupPage() { @AuthPage(SignupScreen()) } - -templ HomePage(displayName string, email string) { - @DashboardPage("/", OverviewMainContent(displayName, email)) -} diff --git a/go-backend/internal/web/views/pages_templ.go b/go-backend/internal/web/views/pages_templ.go index de124ea..8c301a9 100644 --- a/go-backend/internal/web/views/pages_templ.go +++ b/go-backend/internal/web/views/pages_templ.go @@ -29,7 +29,7 @@ func AuthPage(content templ.Component) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "XTablo
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "XTablo
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -111,33 +111,4 @@ func SignupPage() templ.Component { }) } -func HomePage(displayName string, email string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = DashboardPage("/", OverviewMainContent(displayName, email)).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/views/tablos.templ b/go-backend/internal/web/views/tablos.templ new file mode 100644 index 0000000..13d6c32 --- /dev/null +++ b/go-backend/internal/web/views/tablos.templ @@ -0,0 +1,289 @@ +package views + +import "xtablo-backend/internal/web/ui" + +templ TablosPageContent(vm TablosPageViewModel) { +
+
+

Mes Projets

+ @ui.Button(ui.ButtonProps{ + Label: "Nouveau projet", + Variant: ui.ButtonVariantPrimary, + Size: ui.SizeMD, + Type: "button", + Icon: "plus", + Attrs: templ.Attributes{ + "hx-get": vm.CreateModalHref(), + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-push-url": "true", + }, + }) +
+ +
+
+ + + + @ActionIcon("search") + + +
+
+ @StatusPill(vm, "all", "Tous") + @StatusPill(vm, "todo", "Pas commencé") + @StatusPill(vm, "in_progress", "En cours") + @StatusPill(vm, "done", "Terminé") +
+
+ if vm.HasTablos() { + if vm.IsGridView() { +
+ for _, tablo := range vm.Tablos { + @TabloGridCard(tablo) + } +
+ } else { +
+ @ui.Table(ui.TableProps{ + Head: TabloListHead(), + Body: TabloListBody(vm.Tablos), + }) +
+ } + } else { + @ui.EmptyState(ui.EmptyStateProps{ + Title: "Aucun projet trouvé", + Description: "Créez votre premier projet", + Icon: ui.UIIcon("grid3x3"), + Action: ui.Button(ui.ButtonProps{ + Label: "Nouveau projet", + Variant: ui.ButtonVariantPrimary, + Size: ui.SizeMD, + Type: "button", + Icon: "plus", + Attrs: templ.Attributes{ + "hx-get": vm.CreateModalHref(), + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-push-url": "true", + }, + }), + }) + } + if vm.ModalOpen { + @CreateTabloModal(vm) + } +
+} + +templ StatusPill(vm TablosPageViewModel, status string, label string) { + + if status == "all" { + + @ActionIcon("filter") + + } + { label } + +} + +templ BorderlessDeleteButton(deleteRequestURL string) { + @ui.IconButton(ui.IconButtonProps{ + Label: "Supprimer le projet", + Icon: "trash", + Variant: ui.IconButtonVariantDangerGhost, + Type: "button", + Attrs: templ.Attributes{ + "hx-delete": deleteRequestURL, + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-confirm": "Supprimer ce projet ?", + }, + }) +} + +templ TabloGridCard(tablo TabloCardView) { +
+
+ @ui.Badge(ui.BadgeProps{ + Label: tablo.StatusLabel, + Variant: badgeVariantForTone(tablo.StatusTone), + }) + @BorderlessDeleteButton(tablo.DeleteRequestURL) +
+
+
+ { tablo.Initial } +
+

{ tablo.Name }

+
+
+ @ActionIcon("calendar") + { tablo.CardDateLabel } +
+
+
+ Progression: + { tablo.ProgressLabel } +
+
+
+
+
+
+} + +templ TabloListRow(tablo TabloCardView) { + + +
+
svg]:w-4 [&>svg]:h-4 " + tablo.IconBgClass + " " + tablo.IconFgClass }> + @ActionIcon(tablo.IconKind) +
+ { tablo.Name } +
+ + + @ui.Badge(ui.BadgeProps{ + Label: tablo.StatusLabel, + Variant: badgeVariantForTone(tablo.StatusTone), + }) + + +
+ @ActionIcon("calendar") + { tablo.CreatedAtLabel } +
+ + +
+
+
+
+ { tablo.ProgressLabel } +
+ + + @BorderlessDeleteButton(tablo.DeleteRequestURL) + + +} + +templ CreateTabloModal(vm TablosPageViewModel) { + @ui.Modal(ui.ModalProps{ + Title: "Nouveau projet", + Body: CreateTabloModalBody(vm), + }) +} + +templ TabloListHead() { + + Projet + Statut + Créé le + Progression + + +} + +templ TabloListBody(tablos []TabloCardView) { + for _, tablo := range tablos { + @TabloListRow(tablo) + } +} + +templ CreateTabloModalBody(vm TablosPageViewModel) { +
+ + + + + if vm.ErrorMessage != "" { +
{ vm.ErrorMessage }
+ } + @ui.FormField(ui.FormFieldProps{ + Label: "Nom du projet", + For: "tablo-name", + Field: ui.Input(ui.InputProps{ + ID: "tablo-name", + Name: "name", + Value: vm.FormName, + Placeholder: "Nom du projet", + Type: "text", + }), + Error: vm.ErrorMessage, + }) +
+ + Annuler + + @ui.Button(ui.ButtonProps{ + Label: "Créer le projet", + Variant: ui.ButtonVariantPrimary, + Size: ui.SizeMD, + Type: "submit", + }) +
+
+} diff --git a/go-backend/internal/web/views/tablos_templ.go b/go-backend/internal/web/views/tablos_templ.go new file mode 100644 index 0000000..f88b800 --- /dev/null +++ b/go-backend/internal/web/views/tablos_templ.go @@ -0,0 +1,992 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "xtablo-backend/internal/web/ui" + +func TablosPageContent(vm TablosPageViewModel) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Mes Projets

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Button(ui.ButtonProps{ + Label: "Nouveau projet", + Variant: ui.ButtonVariantPrimary, + Size: ui.SizeMD, + Type: "button", + Icon: "plus", + Attrs: templ.Attributes{ + "hx-get": vm.CreateModalHref(), + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-push-url": "true", + }, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 = []any{gridToggleClass(vm.IsGridView())} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ActionIcon("grid3x3").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " Vue en grille ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 = []any{listToggleClass(vm.IsGridView())} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ActionIcon("list").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " Vue en liste
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ActionIcon("search").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = StatusPill(vm, "all", "Tous").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = StatusPill(vm, "todo", "Pas commencé").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = StatusPill(vm, "in_progress", "En cours").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = StatusPill(vm, "done", "Terminé").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.HasTablos() { + if vm.IsGridView() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, tablo := range vm.Tablos { + templ_7745c5c3_Err = TabloGridCard(tablo).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Table(ui.TableProps{ + Head: TabloListHead(), + Body: TabloListBody(vm.Tablos), + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else { + templ_7745c5c3_Err = ui.EmptyState(ui.EmptyStateProps{ + Title: "Aucun projet trouvé", + Description: "Créez votre premier projet", + Icon: ui.UIIcon("grid3x3"), + Action: ui.Button(ui.ButtonProps{ + Label: "Nouveau projet", + Variant: ui.ButtonVariantPrimary, + Size: ui.SizeMD, + Type: "button", + Icon: "plus", + Attrs: templ.Attributes{ + "hx-get": vm.CreateModalHref(), + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-push-url": "true", + }, + }), + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if vm.ModalOpen { + templ_7745c5c3_Err = CreateTabloModal(vm).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func StatusPill(vm TablosPageViewModel, status string, label string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var15 = []any{statusPillClass(vm.Status == status)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if status == "all" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ActionIcon("filter").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 135, Col: 9} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func BorderlessDeleteButton(deleteRequestURL string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{ + Label: "Supprimer le projet", + Icon: "trash", + Variant: ui.IconButtonVariantDangerGhost, + Type: "button", + Attrs: templ.Attributes{ + "hx-delete": deleteRequestURL, + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-confirm": "Supprimer ce projet ?", + }, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TabloGridCard(tablo TabloCardView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Badge(ui.BadgeProps{ + Label: tablo.StatusLabel, + Variant: badgeVariantForTone(tablo.StatusTone), + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = BorderlessDeleteButton(tablo.DeleteRequestURL).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 = []any{"project-avatar " + projectAccentClass(tablo.Accent)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Initial) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 165, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 167, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ActionIcon("calendar").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.CardDateLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 171, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
Progression: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.ProgressLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 176, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 = []any{"project-progress-bar " + projectAccentClass(tablo.Accent)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TabloListRow(tablo TabloCardView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var32 = []any{"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden [&>svg]:w-4 [&>svg]:h-4 " + tablo.IconBgClass + " " + tablo.IconFgClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ActionIcon(tablo.IconKind).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 192, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Badge(ui.BadgeProps{ + Label: tablo.StatusLabel, + Variant: badgeVariantForTone(tablo.StatusTone), + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ActionIcon("calendar").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.CreatedAtLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 204, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.ProgressLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 212, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = BorderlessDeleteButton(tablo.DeleteRequestURL).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func CreateTabloModal(vm TablosPageViewModel) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var38 := templ.GetChildren(ctx) + if templ_7745c5c3_Var38 == nil { + templ_7745c5c3_Var38 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = ui.Modal(ui.ModalProps{ + Title: "Nouveau projet", + Body: CreateTabloModalBody(vm), + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TabloListHead() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var39 := templ.GetChildren(ctx) + if templ_7745c5c3_Var39 == nil { + templ_7745c5c3_Var39 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "ProjetStatutCréé leProgression") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TabloListBody(tablos []TabloCardView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + for _, tablo := range tablos { + templ_7745c5c3_Err = TabloListRow(tablo).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var41 := templ.GetChildren(ctx) + if templ_7745c5c3_Var41 == nil { + templ_7745c5c3_Var41 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.ErrorMessage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ErrorMessage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 256, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = ui.FormField(ui.FormFieldProps{ + Label: "Nom du projet", + For: "tablo-name", + Field: ui.Input(ui.InputProps{ + ID: "tablo-name", + Name: "name", + Value: vm.FormName, + Placeholder: "Nom du projet", + Type: "text", + }), + Error: vm.ErrorMessage, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
Annuler") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Button(ui.ButtonProps{ + Label: "Créer le projet", + Variant: ui.ButtonVariantPrimary, + Size: ui.SizeMD, + Type: "submit", + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/internal/web/views/tablos_view.go b/go-backend/internal/web/views/tablos_view.go new file mode 100644 index 0000000..111dce8 --- /dev/null +++ b/go-backend/internal/web/views/tablos_view.go @@ -0,0 +1,161 @@ +package views + +import ( + "fmt" + "net/url" + "strings" + + "xtablo-backend/internal/web/ui" +) + +type TabloCardView struct { + ID string + Name string + Status string + StatusLabel string + StatusClass string + StatusTone string + Progress int + CreatedAtLabel string + CardDateLabel string + ProgressLabel string + DeleteURL string + DeleteRequestURL string + IconKind string + IconBgClass string + IconFgClass string + Accent string + Initial string +} + +type TablosPageViewModel struct { + DisplayName string + View string + Query string + Status string + ModalOpen bool + FormName string + ErrorMessage string + Tablos []TabloCardView +} + +func NewTablosPageViewModel(displayName string, view string, query string, status string, modalOpen bool, formName string, errorMessage string, tablos []TabloCardView) TablosPageViewModel { + return TablosPageViewModel{ + DisplayName: displayName, + View: normalizedView(view), + Query: strings.TrimSpace(query), + Status: normalizedStatus(status), + ModalOpen: modalOpen, + FormName: strings.TrimSpace(formName), + ErrorMessage: strings.TrimSpace(errorMessage), + Tablos: tablos, + } +} + +func (vm TablosPageViewModel) IsGridView() bool { + return vm.View != "list" +} + +func (vm TablosPageViewModel) HasTablos() bool { + return len(vm.Tablos) > 0 +} + +func (vm TablosPageViewModel) StatusHref(status string) string { + values := vm.baseValues() + values.Set("status", normalizedStatus(status)) + return "/tablos?" + values.Encode() +} + +func (vm TablosPageViewModel) ViewHref(view string) string { + values := vm.baseValues() + values.Set("view", normalizedView(view)) + return "/tablos?" + values.Encode() +} + +func (vm TablosPageViewModel) SearchHref() string { + return "/tablos" +} + +func (vm TablosPageViewModel) HiddenStateFields() map[string]string { + return map[string]string{ + "view": vm.View, + "status": vm.Status, + "q": vm.Query, + } +} + +func (vm TablosPageViewModel) SearchValues() string { + return fmt.Sprintf("view=%s&status=%s", vm.View, vm.Status) +} + +func (vm TablosPageViewModel) CreateModalHref() string { + values := vm.baseValues() + values.Set("modal", "create") + return "/tablos?" + values.Encode() +} + +func (vm TablosPageViewModel) CloseModalHref() string { + values := vm.baseValues() + return "/tablos?" + values.Encode() +} + +func (vm TablosPageViewModel) HasSearch() bool { + return vm.Query != "" +} + +func normalizedView(view string) string { + if view == "list" { + return "list" + } + return "grid" +} + +func normalizedStatus(status string) string { + switch status { + case "todo", "in_progress", "done": + return status + default: + return "all" + } +} + +func (vm TablosPageViewModel) baseValues() url.Values { + values := url.Values{} + values.Set("view", vm.View) + values.Set("status", vm.Status) + if vm.Query != "" { + values.Set("q", vm.Query) + } + return values +} + +func gridToggleClass(active bool) string { + if active { + return "flex items-center gap-2 pb-3 border-b-2 transition-colors border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400 font-semibold" + } + return "flex items-center gap-2 pb-3 border-b-2 transition-colors border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" +} + +func listToggleClass(gridActive bool) string { + return gridToggleClass(!gridActive) +} + +func statusPillClass(active bool) string { + if active { + return "flex items-center gap-1.5 px-4 py-2.5 border rounded-[8px] font-medium text-sm transition-colors border-purple-600 bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400" + } + return "flex items-center gap-1.5 px-4 py-2.5 border rounded-[8px] font-medium text-sm transition-colors border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300" +} + +func badgeVariantForTone(tone string) ui.BadgeVariant { + switch tone { + case "warning": + return ui.BadgeVariantWarning + case "success": + return ui.BadgeVariantSuccess + case "danger": + return ui.BadgeVariantDanger + default: + return ui.BadgeVariantInfo + } +} diff --git a/go-backend/justfile b/go-backend/justfile index 97bc6ea..2c0c7ec 100644 --- a/go-backend/justfile +++ b/go-backend/justfile @@ -2,6 +2,8 @@ set shell := ["bash", "-cu"] database_url := "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable" compose_config_dir := ".podman-compose" +tailwind_input := "tailwind.input.css" +tailwind_output := "static/tailwind.css" default: @just --list @@ -40,9 +42,17 @@ db-logs: machine-up compose-config DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose logs -f postgres generate: + pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . go run github.com/a-h/templ/cmd/templ@latest generate go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate +design-system: + just generate + go run ./cmd/designsystem + +css-watch: + pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . --watch + fmt: gofmt -w . @@ -50,12 +60,15 @@ test: go test ./... build: + pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . go build ./... check: generate test build dev: db-up + pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . DATABASE_URL='{{database_url}}' air -c .air.toml run: db-up + pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . DATABASE_URL='{{database_url}}' go run . diff --git a/go-backend/package.json b/go-backend/package.json new file mode 100644 index 0000000..bab9545 --- /dev/null +++ b/go-backend/package.json @@ -0,0 +1,10 @@ +{ + "name": "@xtablo/go-backend", + "private": true, + "version": "0.0.0", + "packageManager": "pnpm@10.19.0", + "devDependencies": { + "@tailwindcss/cli": "4.1.15", + "tailwindcss": "4.1.15" + } +} diff --git a/go-backend/router.go b/go-backend/router.go index 999b974..95c5d10 100644 --- a/go-backend/router.go +++ b/go-backend/router.go @@ -37,6 +37,8 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler { mux.Get("/chat", authHandler.GetChatPage()) mux.Get("/files", authHandler.GetFilesPage()) mux.Get("/feedback", authHandler.GetFeedbackPage()) + mux.Post("/tablos", authHandler.PostTablos()) + mux.Delete("/tablos/{tabloID}", authHandler.DeleteTablo()) mux.Get("/login", authHandler.GetLoginPage()) mux.Get("/signup", authHandler.GetSignupPage()) mux.Post("/login", authHandler.PostLogin()) diff --git a/go-backend/router_test.go b/go-backend/router_test.go index f720c68..20a5eac 100644 --- a/go-backend/router_test.go +++ b/go-backend/router_test.go @@ -1,9 +1,11 @@ package main import ( + "context" "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" @@ -56,6 +58,7 @@ func TestLoginPageRenders(t *testing.T) { "Se connecter à Xtablo", `hx-post="/login"`, "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", + `href="/static/tailwind.css"`, `href="/pwa-icons/favicon-32x32.png"`, `href="/pwa-icons/favicon-16x16.png"`, `href="/pwa-icons/apple-touch-icon-180x180.png"`, @@ -72,6 +75,30 @@ func TestLoginPageRenders(t *testing.T) { } } +func TestTailwindStylesheetIsServed(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/static/tailwind.css", nil) + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{ + ".text-2xl", + ".grid-cols-1", + ".whitespace-nowrap", + ".justify-end", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected tailwind.css to contain %q", want) + } + } +} + func TestSignupPageRenders(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/signup", nil) rec := httptest.NewRecorder() @@ -239,6 +266,175 @@ func TestTasksPageRendersFullDashboardPage(t *testing.T) { } } +func TestHomePageProjectsUseSharedTabloGridCardWithDeleteAction(t *testing.T) { + repo := handlers.NewInMemoryAuthRepository() + authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error: %v", err) + } + if _, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{ + OwnerID: authUser.ID, + Name: "Hello", + Status: handlers.TabloStatusInProgress, + }); err != nil { + t.Fatalf("expected tablo creation to succeed, got error: %v", err) + } + + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + + router := newRouterWithHandler(handlers.NewAuthHandler(repo)) + router.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{ + `class="project-card"`, + `class="project-date-row"`, + `hx-delete="/tablos/`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected home page to contain %q", want) + } + } +} + +func TestHomePageProjectsCollapseAfterSixByDefault(t *testing.T) { + repo := handlers.NewInMemoryAuthRepository() + authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error: %v", err) + } + for i := 0; i < 8; i++ { + if _, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{ + OwnerID: authUser.ID, + Name: "Project " + string(rune('A'+i)), + Status: handlers.TabloStatusTodo, + }); err != nil { + t.Fatalf("expected tablo creation to succeed, got error: %v", err) + } + } + + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + + router := newRouterWithHandler(handlers.NewAuthHandler(repo)) + router.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + if count := strings.Count(body, `class="project-card"`); count != 6 { + t.Fatalf("expected 6 visible project cards by default, got %d", count) + } + for _, want := range []string{ + `id="overview-projects-section"`, + `Voir 2 de plus`, + `hx-get="/?show_projects=all"`, + `hx-target="#overview-projects-section"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected home page to contain %q", want) + } + } +} + +func TestHomePageProjectsExpandViaHTMXSectionSwap(t *testing.T) { + repo := handlers.NewInMemoryAuthRepository() + authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error: %v", err) + } + for i := 0; i < 8; i++ { + if _, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{ + OwnerID: authUser.ID, + Name: "Project " + string(rune('A'+i)), + Status: handlers.TabloStatusTodo, + }); err != nil { + t.Fatalf("expected tablo creation to succeed, got error: %v", err) + } + } + + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + + router := newRouterWithHandler(handlers.NewAuthHandler(repo)) + router.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + req := httptest.NewRequest(http.MethodGet, "/?show_projects=all", nil) + req.Header.Set("HX-Request", "true") + req.Header.Set("HX-Target", "section#overview-projects-section") + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + if count := strings.Count(body, `class="project-card"`); count != 8 { + t.Fatalf("expected 8 visible project cards after expansion, got %d", count) + } + if !strings.Contains(body, `id="overview-projects-section"`) { + t.Fatalf("expected section swap root in response, got %q", body) + } + if strings.Contains(body, `id="app-main-content"`) { + t.Fatalf("expected projects section response, got main content swap %q", body) + } + if strings.Contains(body, `Voir 2 de plus`) { + t.Fatalf("expected see more button to disappear after expansion, got %q", body) + } + if strings.Contains(body, `class="sidebar-nav-shell"`) { + t.Fatalf("expected projects section swap to avoid rerendering the full dashboard shell") + } +} + func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") @@ -283,6 +479,116 @@ func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) { } } +func TestTablosPageRendersFullDashboardPage(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + req := httptest.NewRequest(http.MethodGet, "/tablos", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{ + `class="sidebar-nav-shell"`, + `id="app-main-content" class="flex-1 overflow-auto"`, + "Mes Projets", + "Nouveau projet", + "Vue en grille", + "Rechercher...", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected tablos page to contain %q", want) + } + } +} + +func TestTablosPageReturnsHTMXMainContentSwap(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + req := httptest.NewRequest(http.MethodGet, "/tablos?view=list&status=all", nil) + req.Header.Set("HX-Request", "true") + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{ + `id="app-main-content"`, + `hx-swap-oob="outerHTML"`, + `id="sidebar-nav-tablos"`, + "Mes Projets", + "Vue en liste", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected HTMX tablos response to contain %q", want) + } + } + if strings.Contains(body, `class="sidebar-nav-shell"`) { + t.Fatalf("expected HTMX tablos response to avoid rerendering the full sidebar") + } +} + +func TestTablosPageUtilityStylesExist(t *testing.T) { + content, err := os.ReadFile("static/tailwind.css") + if err != nil { + t.Fatalf("read tailwind.css: %v", err) + } + + css := string(content) + for _, want := range []string{ + ".flex-1", + ".overflow-auto", + ".text-2xl", + ".bg-purple-600", + ".grid-cols-1", + ".rounded-xl", + ".md\\:flex-row", + ".sm\\:grid-cols-2", + ".lg\\:grid-cols-3", + ".xl\\:grid-cols-4", + } { + if !strings.Contains(css, want) { + t.Fatalf("expected tailwind.css to contain utility %q", want) + } + } +} + func TestSignupCreatesUserSessionAndRedirects(t *testing.T) { form := url.Values{} form.Set("email", "new@xtablo.com") diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css index 38689b5..0ced644 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -1017,19 +1017,510 @@ input { color: #16a34a; } -.project-delete-button { +.ui-button { + align-items: center; + border: 0; + border-radius: 0.75rem; + cursor: pointer; + display: inline-flex; + font-weight: 600; + gap: 0.5rem; + justify-content: center; + line-height: 1; + min-height: 44px; + text-decoration: none; + transition: + background-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease, + opacity 0.2s ease; +} + +.ui-button-icon, +.ui-button-icon svg { + height: 1rem; + width: 1rem; +} + +.ui-button:focus-visible, +.ui-icon-button:focus-visible, +.borderless-icon-button:focus-visible { + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2); + outline: none; +} + +.ui-button-sm { + font-size: 0.875rem; + min-height: 40px; + padding: 0.625rem 0.9rem; +} + +.ui-button-md { + font-size: 0.95rem; + padding: 0.75rem 1.1rem; +} + +.ui-button-lg { + font-size: 1rem; + padding: 0.9rem 1.25rem; +} + +.ui-button-primary { + background: var(--secondary); + color: #fff; +} + +.ui-button-primary:hover { + background: #6d28d9; +} + +.ui-button-secondary { + background: #f3f4f6; + color: #111827; +} + +.ui-button-secondary:hover { + background: #e5e7eb; +} + +.ui-button-ghost { + background: transparent; + color: #4b5563; +} + +.ui-button-ghost:hover { + background: #f9fafb; + color: #111827; +} + +.ui-button-danger { + background: #dc2626; + color: #fff; +} + +.ui-button-danger:hover { + background: #b91c1c; +} + +.ui-badge { + border: 1px solid transparent; + border-radius: 999px; + display: inline-flex; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; + padding: 0.3rem 0.75rem; +} + +.ui-badge-info { + background: #eff6ff; + border-color: #bfdbfe; + color: #2563eb; +} + +.ui-badge-warning { + background: #fff4e2; + border-color: #db9729; + color: #db9729; +} + +.ui-badge-success { + background: #ecfdf3; + border-color: #bbf7d0; + color: #16a34a; +} + +.ui-badge-danger { + background: #fef2f2; + border-color: #fecaca; + color: #dc2626; +} + +.ui-input, +.ui-textarea { + appearance: none; + background: #fff; + border: 1px solid #eaecf0; + border-radius: 0.75rem; + color: #111827; + font: inherit; + line-height: 1.4; + width: 100%; +} + +.ui-input { + min-height: 44px; + padding: 0.75rem 0.95rem; +} + +.ui-textarea { + min-height: 7rem; + padding: 0.85rem 0.95rem; + resize: vertical; +} + +.ui-input::placeholder, +.ui-textarea::placeholder { + color: #9ca3af; +} + +.ui-input:focus, +.ui-textarea:focus { + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); + outline: none; +} + +.ui-form-field { + display: grid; + gap: 0.5rem; +} + +.ui-form-label { + color: #111827; + font-size: 0.95rem; + font-weight: 600; +} + +.ui-form-hint { + color: #6b7280; + font-size: 0.875rem; + margin: 0; +} + +.ui-form-error { + color: #dc2626; + font-size: 0.875rem; + margin: 0; +} + +.ui-card { + background: #fff; + border: 1px solid #eaecf0; + border-radius: 1rem; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); +} + +.ui-card-header, +.ui-card-body, +.ui-card-footer { + padding: 1.25rem 1.5rem; +} + +.ui-card-header, +.ui-card-footer { + border-color: #eaecf0; +} + +.ui-card-header { + border-bottom-style: solid; + border-bottom-width: 1px; +} + +.ui-card-footer { + border-top-style: solid; + border-top-width: 1px; +} + +.ui-table-shell { + overflow-x: auto; + width: 100%; +} + +.ui-table { + border-collapse: collapse; + min-width: 100%; + width: 100%; +} + +.ui-empty-state { + align-items: center; + border: 1px dashed #d0d5dd; + border-radius: 1rem; + color: #6b7280; + display: flex; + flex-direction: column; + gap: 0.75rem; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; +} + +.ui-empty-state-title { + color: #111827; + font-size: 1.125rem; + font-weight: 700; + margin: 0; +} + +.ui-empty-state-icon { + align-items: center; + background: #f3f4f6; + border-radius: 999px; + color: #9ca3af; + display: inline-flex; + height: 4rem; + justify-content: center; + width: 4rem; +} + +.ui-empty-state-icon svg { + height: 2rem; + width: 2rem; +} + +.ui-empty-state-description { + margin: 0; + max-width: 32rem; +} + +.catalog-page { + margin: 0 auto; + max-width: 72rem; + padding: 3rem 1.5rem 4rem; +} + +.catalog-nav { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin-bottom: 1.5rem; +} + +.catalog-home-link, +.catalog-nav-link { + border-radius: 999px; + color: #6b7280; + display: inline-flex; + font-size: 0.9rem; + font-weight: 600; + padding: 0.55rem 0.9rem; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.catalog-home-link:hover, +.catalog-nav-link:hover { + background: #f3f4f6; + color: #111827; +} + +.catalog-nav-links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.catalog-nav-link.is-active { + background: #ede9fe; + color: #6d28d9; +} + +.catalog-page-header { + margin-bottom: 2rem; +} + +.catalog-page-header h1 { + color: #111827; + font-size: 2.25rem; + line-height: 1.1; + margin: 0 0 0.75rem; +} + +.catalog-page-header p { + color: #6b7280; + margin: 0; + max-width: 42rem; +} + +.catalog-eyebrow { + color: #7c3aed !important; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + margin-bottom: 0.75rem !important; + text-transform: uppercase; +} + +.catalog-example-list, +.catalog-page-list { + display: grid; + gap: 1.25rem; +} + +.catalog-example, +.catalog-page-link-card { + background: #fff; + border: 1px solid #eaecf0; + border-radius: 1rem; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05); + padding: 1.5rem; +} + +.catalog-page-link-card { + display: block; +} + +.catalog-example-copy h2, +.catalog-page-link-card h2 { + color: #111827; + font-size: 1.125rem; + margin: 0 0 0.5rem; +} + +.catalog-example-copy p, +.catalog-page-link-card p { + color: #6b7280; + margin: 0; +} + +.catalog-example-preview { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.catalog-inline { + display: inline-flex; +} + +.catalog-example-snippet { + background: #111827; + border-radius: 0.875rem; + color: #f9fafb; + margin: 1rem 0 0; + overflow-x: auto; + padding: 1rem; +} + +.catalog-example-snippet code { + font-family: + ui-monospace, + SFMono-Regular, + "SF Mono", + Menlo, + Monaco, + Consolas, + "Liberation Mono", + monospace; + font-size: 0.875rem; +} + +.catalog-page-link { + color: #7c3aed !important; + font-family: + ui-monospace, + SFMono-Regular, + "SF Mono", + Menlo, + Monaco, + Consolas, + "Liberation Mono", + monospace; + font-size: 0.875rem; + margin-top: 1rem !important; +} + +.ui-icon-button { + align-items: center; + appearance: none; background: transparent; border: 0; + border-radius: 0.5rem; + color: #6b7280; + cursor: pointer; + display: inline-flex; + justify-content: center; + min-height: 44px; + min-width: 44px; + padding: 0.5rem; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.ui-icon-button:hover { + background: #f9fafb; + color: #111827; +} + +.ui-modal-backdrop { + align-items: center; + background: rgba(17, 24, 39, 0.52); + display: flex; + inset: 0; + justify-content: center; + padding: 1rem; + position: fixed; + z-index: 40; +} + +.ui-modal-panel { + background: #fff; + border: 1px solid #eaecf0; + border-radius: 1rem; + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18); + max-width: 32rem; + width: min(100%, 32rem); +} + +.ui-modal-header, +.ui-modal-body, +.ui-modal-actions { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.ui-modal-header { + border-bottom: 1px solid #eaecf0; + padding-bottom: 1rem; + padding-top: 1.25rem; +} + +.ui-modal-header h2 { + color: #111827; + font-size: 1.125rem; + font-weight: 700; + margin: 0; +} + +.ui-modal-body { + padding-bottom: 1.25rem; + padding-top: 1.25rem; +} + +.ui-modal-actions { + border-top: 1px solid #eaecf0; + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding-bottom: 1rem; + padding-top: 1rem; +} + +.borderless-icon-button { + background: transparent; + border: 0; + box-shadow: none; + appearance: none; color: #9ca3af; cursor: pointer; + outline: none; +} + +.project-card-top .borderless-icon-button { padding: 0; } -.project-delete-button:hover { +.project-card-top .borderless-icon-button:hover { color: #ef4444; } -.project-delete-button svg, +.borderless-icon-button svg, .project-date-row svg, .overview-more-button svg, .tasks-add-button svg, @@ -1038,6 +1529,22 @@ input { width: 1rem; } +td.text-right .borderless-icon-button { + align-items: center; + border-radius: 0.25rem; + color: #9ca3af; + display: inline-flex; + justify-content: center; + min-height: 44px; + min-width: 44px; + padding: 0.5rem; + transition: color 0.2s; +} + +td.text-right .borderless-icon-button:hover { + color: #ef4444; +} + .project-card-title-row { align-items: center; display: flex; diff --git a/go-backend/static/tailwind.css b/go-backend/static/tailwind.css new file mode 100644 index 0000000..6314adb --- /dev/null +++ b/go-backend/static/tailwind.css @@ -0,0 +1,833 @@ +/*! tailwindcss v4.1.15 | MIT License | https://tailwindcss.com */ +@layer properties; +:root, :host { + --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-green-50: oklch(98.2% 0.018 155.826); + --color-green-200: oklch(92.5% 0.084 155.995); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-800: oklch(44.8% 0.119 151.328); + --color-green-950: oklch(26.6% 0.065 152.934); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-blue-50: oklch(97% 0.014 254.604); + --color-blue-200: oklch(88.2% 0.059 254.128); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-blue-950: oklch(28.2% 0.091 267.935); + --color-purple-50: oklch(97.7% 0.014 308.299); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-950: oklch(29.1% 0.149 302.717); + --color-gray-50: oklch(98.5% 0.002 247.839); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-white: #fff; + --spacing: 0.25rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --tracking-wider: 0.05em; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} +.pointer-events-none { + pointer-events: none; +} +.visible { + visibility: visible; +} +.absolute { + position: absolute; +} +.relative { + position: relative; +} +.static { + position: static; +} +.top-1\/2 { + top: calc(1/2 * 100%); +} +.left-3 { + left: calc(var(--spacing) * 3); +} +.isolate { + isolation: isolate; +} +.-mx-4 { + margin-inline: calc(var(--spacing) * -4); +} +.mb-1 { + margin-bottom: calc(var(--spacing) * 1); +} +.mb-6 { + margin-bottom: calc(var(--spacing) * 6); +} +.mb-8 { + margin-bottom: calc(var(--spacing) * 8); +} +.flex { + display: flex; +} +.grid { + display: grid; +} +.hidden { + display: none; +} +.inline { + display: inline; +} +.table { + display: table; +} +.size-10 { + width: calc(var(--spacing) * 10); + height: calc(var(--spacing) * 10); +} +.size-11 { + width: calc(var(--spacing) * 11); + height: calc(var(--spacing) * 11); +} +.size-12 { + width: calc(var(--spacing) * 12); + height: calc(var(--spacing) * 12); +} +.size-13 { + width: calc(var(--spacing) * 13); + height: calc(var(--spacing) * 13); +} +.size-14 { + width: calc(var(--spacing) * 14); + height: calc(var(--spacing) * 14); +} +.size-15 { + width: calc(var(--spacing) * 15); + height: calc(var(--spacing) * 15); +} +.size-16 { + width: calc(var(--spacing) * 16); + height: calc(var(--spacing) * 16); +} +.size-18 { + width: calc(var(--spacing) * 18); + height: calc(var(--spacing) * 18); +} +.size-20 { + width: calc(var(--spacing) * 20); + height: calc(var(--spacing) * 20); +} +.h-2 { + height: calc(var(--spacing) * 2); +} +.h-4 { + height: calc(var(--spacing) * 4); +} +.h-5 { + height: calc(var(--spacing) * 5); +} +.h-8 { + height: calc(var(--spacing) * 8); +} +.w-4 { + width: calc(var(--spacing) * 4); +} +.w-5 { + width: calc(var(--spacing) * 5); +} +.w-8 { + width: calc(var(--spacing) * 8); +} +.w-12 { + width: calc(var(--spacing) * 12); +} +.w-full { + width: 100%; +} +.min-w-\[80px\] { + min-width: 80px; +} +.flex-1 { + flex: 1; +} +.shrink-0 { + flex-shrink: 0; +} +.-translate-y-1\/2 { + --tw-translate-y: calc(calc(1/2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); +} +.cursor-pointer { + cursor: pointer; +} +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} +.flex-col { + flex-direction: column; +} +.flex-wrap { + flex-wrap: wrap; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.justify-end { + justify-content: flex-end; +} +.gap-1\.5 { + gap: calc(var(--spacing) * 1.5); +} +.gap-2 { + gap: calc(var(--spacing) * 2); +} +.gap-3 { + gap: calc(var(--spacing) * 3); +} +.gap-4 { + gap: calc(var(--spacing) * 4); +} +.gap-6 { + gap: calc(var(--spacing) * 6); +} +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.overflow-auto { + overflow: auto; +} +.overflow-hidden { + overflow: hidden; +} +.overflow-x-auto { + overflow-x: auto; +} +.rounded-\[8px\] { + border-radius: 8px; +} +.rounded-full { + border-radius: calc(infinity * 1px); +} +.rounded-lg { + border-radius: var(--radius-lg); +} +.rounded-xl { + border-radius: var(--radius-xl); +} +.border { + border-style: var(--tw-border-style); + border-width: 1px; +} +.border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; +} +.border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; +} +.border-b-2 { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 2px; +} +.border-\[\#DB9729\] { + border-color: #DB9729; +} +.border-\[\#EAECF0\] { + border-color: #EAECF0; +} +.border-blue-200 { + border-color: var(--color-blue-200); +} +.border-green-200 { + border-color: var(--color-green-200); +} +.border-purple-600 { + border-color: var(--color-purple-600); +} +.border-red-200 { + border-color: var(--color-red-200); +} +.border-transparent { + border-color: transparent; +} +.bg-\[\#FFF4E2\] { + background-color: #FFF4E2; +} +.bg-blue-50 { + background-color: var(--color-blue-50); +} +.bg-blue-500 { + background-color: var(--color-blue-500); +} +.bg-cyan-500 { + background-color: var(--color-cyan-500); +} +.bg-gray-50 { + background-color: var(--color-gray-50); +} +.bg-gray-200 { + background-color: var(--color-gray-200); +} +.bg-green-50 { + background-color: var(--color-green-50); +} +.bg-green-500 { + background-color: var(--color-green-500); +} +.bg-purple-50 { + background-color: var(--color-purple-50); +} +.bg-purple-500 { + background-color: var(--color-purple-500); +} +.bg-purple-600 { + background-color: var(--color-purple-600); +} +.bg-red-50 { + background-color: var(--color-red-50); +} +.bg-white { + background-color: var(--color-white); +} +.px-4 { + padding-inline: calc(var(--spacing) * 4); +} +.px-6 { + padding-inline: calc(var(--spacing) * 6); +} +.py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); +} +.py-3 { + padding-block: calc(var(--spacing) * 3); +} +.py-4 { + padding-block: calc(var(--spacing) * 4); +} +.pt-8 { + padding-top: calc(var(--spacing) * 8); +} +.pr-4 { + padding-right: calc(var(--spacing) * 4); +} +.pb-3 { + padding-bottom: calc(var(--spacing) * 3); +} +.pb-6 { + padding-bottom: calc(var(--spacing) * 6); +} +.pl-10 { + padding-left: calc(var(--spacing) * 10); +} +.text-left { + text-align: left; +} +.text-right { + text-align: right; +} +.text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); +} +.text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); +} +.text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); +} +.font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); +} +.font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); +} +.font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); +} +.tracking-wider { + --tw-tracking: var(--tracking-wider); + letter-spacing: var(--tracking-wider); +} +.whitespace-nowrap { + white-space: nowrap; +} +.text-\[\#DB9729\] { + color: #DB9729; +} +.text-blue-600 { + color: var(--color-blue-600); +} +.text-gray-400 { + color: var(--color-gray-400); +} +.text-gray-500 { + color: var(--color-gray-500); +} +.text-gray-600 { + color: var(--color-gray-600); +} +.text-gray-700 { + color: var(--color-gray-700); +} +.text-gray-900 { + color: var(--color-gray-900); +} +.text-green-600 { + color: var(--color-green-600); +} +.text-purple-600 { + color: var(--color-purple-600); +} +.text-red-700 { + color: var(--color-red-700); +} +.text-white { + color: var(--color-white); +} +.uppercase { + text-transform: uppercase; +} +.placeholder-gray-400 { + &::placeholder { + color: var(--color-gray-400); + } +} +.filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); +} +.transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); +} +.transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); +} +.hover\:bg-gray-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-50); + } + } +} +.hover\:text-gray-700 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-700); + } + } +} +.focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } +} +.focus\:ring-purple-500 { + &:focus { + --tw-ring-color: var(--color-purple-500); + } +} +.focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } +} +.sm\:mx-0 { + @media (width >= 40rem) { + margin-inline: calc(var(--spacing) * 0); + } +} +.sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.sm\:gap-6 { + @media (width >= 40rem) { + gap: calc(var(--spacing) * 6); + } +} +.md\:w-\[350px\] { + @media (width >= 48rem) { + width: 350px; + } +} +.md\:flex-row { + @media (width >= 48rem) { + flex-direction: row; + } +} +.md\:items-center { + @media (width >= 48rem) { + align-items: center; + } +} +.md\:justify-between { + @media (width >= 48rem) { + justify-content: space-between; + } +} +.lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +.xl\:grid-cols-4 { + @media (width >= 80rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} +.dark\:border-blue-800 { + &:is(.dark *) { + border-color: var(--color-blue-800); + } +} +.dark\:border-gray-700 { + &:is(.dark *) { + border-color: var(--color-gray-700); + } +} +.dark\:border-green-800 { + &:is(.dark *) { + border-color: var(--color-green-800); + } +} +.dark\:border-purple-400 { + &:is(.dark *) { + border-color: var(--color-purple-400); + } +} +.dark\:bg-blue-950\/30 { + &:is(.dark *) { + background-color: color-mix(in srgb, oklch(28.2% 0.091 267.935) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + & { + background-color: color-mix(in oklab, var(--color-blue-950) 30%, transparent); + } + } + } +} +.dark\:bg-gray-700 { + &:is(.dark *) { + background-color: var(--color-gray-700); + } +} +.dark\:bg-gray-800 { + &:is(.dark *) { + background-color: var(--color-gray-800); + } +} +.dark\:bg-gray-800\/80 { + &:is(.dark *) { + background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + & { + background-color: color-mix(in oklab, var(--color-gray-800) 80%, transparent); + } + } + } +} +.dark\:bg-green-950\/30 { + &:is(.dark *) { + background-color: color-mix(in srgb, oklch(26.6% 0.065 152.934) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + & { + background-color: color-mix(in oklab, var(--color-green-950) 30%, transparent); + } + } + } +} +.dark\:bg-purple-950\/30 { + &:is(.dark *) { + background-color: color-mix(in srgb, oklch(29.1% 0.149 302.717) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + & { + background-color: color-mix(in oklab, var(--color-purple-950) 30%, transparent); + } + } + } +} +.dark\:text-blue-400 { + &:is(.dark *) { + color: var(--color-blue-400); + } +} +.dark\:text-gray-100 { + &:is(.dark *) { + color: var(--color-gray-100); + } +} +.dark\:text-gray-300 { + &:is(.dark *) { + color: var(--color-gray-300); + } +} +.dark\:text-gray-400 { + &:is(.dark *) { + color: var(--color-gray-400); + } +} +.dark\:text-green-400 { + &:is(.dark *) { + color: var(--color-green-400); + } +} +.dark\:text-purple-400 { + &:is(.dark *) { + color: var(--color-purple-400); + } +} +.dark\:hover\:bg-gray-800 { + &:is(.dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-800); + } + } + } +} +.dark\:hover\:text-gray-200 { + &:is(.dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-gray-200); + } + } + } +} +.\[\&\>svg\]\:h-4 { + &>svg { + height: calc(var(--spacing) * 4); + } +} +.\[\&\>svg\]\:w-4 { + &>svg { + width: calc(var(--spacing) * 4); + } +} +.\[\&\>svg\]\:shrink-0 { + &>svg { + flex-shrink: 0; + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-border-style: solid; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + } + } +} diff --git a/go-backend/tailwind.input.css b/go-backend/tailwind.input.css new file mode 100644 index 0000000..00077a6 --- /dev/null +++ b/go-backend/tailwind.input.css @@ -0,0 +1,28 @@ +@import "tailwindcss/theme.css"; +@import "tailwindcss/utilities.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --color-surface: #ffffff; + --color-surface-muted: #f9fafb; + --color-text-strong: #111827; + --color-text-muted: #6b7280; + --color-border-subtle: #eaecf0; + --color-primary: #7c3aed; + --color-primary-strong: #6d28d9; + --color-danger: #dc2626; + --color-danger-strong: #b91c1c; + --color-warning-bg: #fff4e2; + --color-warning-fg: #db9729; + --color-warning-border: #db9729; + --color-info-bg: #eff6ff; + --color-info-fg: #2563eb; + --color-info-border: #bfdbfe; + --color-success-bg: #ecfdf3; + --color-success-fg: #16a34a; + --color-success-border: #bbf7d0; +} + +@source "./internal/web/views/**/*.templ"; +@source "./internal/web/ui/**/*.templ"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b8c7a5..dcfb60d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -720,6 +720,15 @@ importers: specifier: ^4.24.3 version: 4.44.0(@cloudflare/workers-types@4.20260411.1) + go-backend: + devDependencies: + '@tailwindcss/cli': + specifier: 4.1.15 + version: 4.1.15 + tailwindcss: + specifier: 4.1.15 + version: 4.1.15 + packages/auth-ui: dependencies: '@xtablo/shared': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f8f575a..41169ed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - 'apps/*' + - 'go-backend' - 'packages/*' - -- 2.45.2 From c14af76fb29de5a9045efeb2f9312481a815e4ad Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 9 May 2026 21:46:56 +0200 Subject: [PATCH 019/546] feat: toggle overview projects with vanilla js --- go-backend/internal/web/handlers/auth.go | 18 +- .../web/views/dashboard_components.templ | 69 ++- .../web/views/dashboard_components_templ.go | 495 ++++++++++-------- go-backend/internal/web/views/home.go | 15 +- go-backend/internal/web/views/tablos.templ | 6 +- go-backend/internal/web/views/tablos_templ.go | 337 ++++++------ go-backend/router_test.go | 58 +- 7 files changed, 578 insertions(+), 420 deletions(-) diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go index a73c553..0698240 100644 --- a/go-backend/internal/web/handlers/auth.go +++ b/go-backend/internal/web/handlers/auth.go @@ -91,18 +91,10 @@ func (h *AuthHandler) GetHome() http.HandlerFunc { 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) + content := views.OverviewMainContent(user.DisplayName, user.Email, projects) var renderErr error if isHXRequest(r) { renderErr = views.DashboardContentSwap("/", content).Render(r.Context(), w) @@ -434,11 +426,3 @@ 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/views/dashboard_components.templ b/go-backend/internal/web/views/dashboard_components.templ index e52efbc..12d8e57 100644 --- a/go-backend/internal/web/views/dashboard_components.templ +++ b/go-backend/internal/web/views/dashboard_components.templ @@ -1,5 +1,7 @@ package views +import "strconv" + templ DashboardPage(activePath string, content templ.Component) { @DashboardPageWithMainClass(activePath, "dashboard-main flex-1 overflow-auto", content) } @@ -119,11 +121,11 @@ templ SidebarOrganization() {
} -templ OverviewMainContent(displayName string, email string, tablos []TabloCardView, showAllProjects bool) { +templ OverviewMainContent(displayName string, email string, tablos []TabloCardView) {
@OverviewHeader(displayName) @OverviewActions(overviewQuickActions()) - @OverviewProjectsSection(tablos, showAllProjects) + @OverviewProjectsSection(tablos) @OverviewTasks(overviewTasks())
} @@ -206,18 +208,25 @@ templ OverviewActions(actions []quickAction) { } -templ OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) { +templ OverviewProjectsSection(projects []TabloCardView) {

Mes Projets

- for _, project := range visibleOverviewProjects(projects, showAllProjects) { + for _, project := range visibleOverviewProjects(projects) { @TabloGridCard(project) } + for _, project := range hiddenOverviewProjects(projects) { + @TabloGridCardWithAttrs(project, templ.Attributes{ + "data-overview-project-hidden": "true", + "hidden": true, + }) + }
- @SeeMoreProjects(hiddenOverviewProjectsCount(projects, showAllProjects)) + @SeeMoreProjects(hiddenOverviewProjectsCount(projects))
+ @OverviewProjectsScript() } templ SeeMoreProjects(hiddenCount int) { @@ -226,19 +235,59 @@ templ SeeMoreProjects(hiddenCount int) {
} } +templ OverviewProjectsScript() { + +} + templ OverviewTasks(tasks []dashboardTask) {
diff --git a/go-backend/internal/web/views/dashboard_components_templ.go b/go-backend/internal/web/views/dashboard_components_templ.go index e1cb113..f59a1ff 100644 --- a/go-backend/internal/web/views/dashboard_components_templ.go +++ b/go-backend/internal/web/views/dashboard_components_templ.go @@ -8,6 +8,8 @@ package views import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" +import "strconv" + func DashboardPage(activePath string, content templ.Component) 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 @@ -416,7 +418,7 @@ func SidebarOrganization() templ.Component { }) } -func OverviewMainContent(displayName string, email string, tablos []TabloCardView, showAllProjects bool) templ.Component { +func OverviewMainContent(displayName string, email string, 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 { @@ -449,7 +451,7 @@ func OverviewMainContent(displayName string, email string, tablos []TabloCardVie if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = OverviewProjectsSection(tablos, showAllProjects).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = OverviewProjectsSection(tablos).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -667,7 +669,7 @@ func AppSectionMainContent(title string, description string) templ.Component { 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: 159, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 161, Col: 14} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -680,7 +682,7 @@ func AppSectionMainContent(title string, description string) templ.Component { 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: 160, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 162, Col: 19} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -722,7 +724,7 @@ func NotFoundContent(displayName string) templ.Component { 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: 180, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 182, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -764,7 +766,7 @@ func OverviewHeader(displayName string) templ.Component { 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: 188, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 190, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -777,7 +779,7 @@ func OverviewHeader(displayName string) templ.Component { 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: 190, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 192, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -830,7 +832,7 @@ func OverviewActions(actions []quickAction) templ.Component { }) } -func OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) templ.Component { +func OverviewProjectsSection(projects []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 { @@ -855,17 +857,26 @@ func OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) tem if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, project := range visibleOverviewProjects(projects, showAllProjects) { + for _, project := range visibleOverviewProjects(projects) { templ_7745c5c3_Err = TabloGridCard(project).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } + for _, project := range hiddenOverviewProjects(projects) { + templ_7745c5c3_Err = TabloGridCardWithAttrs(project, templ.Attributes{ + "data-overview-project-hidden": "true", + "hidden": true, + }).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 } - templ_7745c5c3_Err = SeeMoreProjects(hiddenOverviewProjectsCount(projects, showAllProjects)).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = SeeMoreProjects(hiddenOverviewProjectsCount(projects)).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -873,6 +884,10 @@ func OverviewProjectsSection(projects []TabloCardView, showAllProjects bool) tem if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = OverviewProjectsScript().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } return nil }) } @@ -899,23 +914,65 @@ func SeeMoreProjects(hiddenCount int) templ.Component { } ctx = templ.ClearChildren(ctx) if hiddenCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-overview-hide-label=\"Masquer\" data-overview-chevron-down=\"m6 9 6 6 6-6\" data-overview-chevron-up=\"m6 15 6-6 6 6\">Voir ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(hiddenCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 245, Col: 64} + } + _, 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, 36, " de plus
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func OverviewProjectsScript() 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_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, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err } return nil }) @@ -937,12 +994,12 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var32 := templ.GetChildren(ctx) - if templ_7745c5c3_Var32 == nil { - templ_7745c5c3_Var32 = templ.NopComponent + templ_7745c5c3_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Mes Tâches

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

Mes Tâches

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -952,7 +1009,7 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -976,12 +1033,12 @@ func QuickActionCard(action quickAction) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var33 := templ.GetChildren(ctx) - if templ_7745c5c3_Var33 == nil { - templ_7745c5c3_Var33 = templ.NopComponent + templ_7745c5c3_Var35 := templ.GetChildren(ctx) + if templ_7745c5c3_Var35 == nil { + templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1039,39 +1096,17 @@ func TaskRow(task dashboardTask) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var36 := templ.GetChildren(ctx) - if templ_7745c5c3_Var36 == nil { - templ_7745c5c3_Var36 = templ.NopComponent + templ_7745c5c3_Var38 := templ.GetChildren(ctx) + if templ_7745c5c3_Var38 == nil { + templ_7745c5c3_Var38 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - 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, 42, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var39 = []any{taskCheckClass(task.Completed)} + var templ_7745c5c3_Var39 = []any{taskRowClass(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, 44, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - 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: 282, Col: 18} - } - _, 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, 47, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - 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, 48, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var42).String()) + templ_7745c5c3_Var43, 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: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 331, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

") 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)) + var templ_7745c5c3_Var44 = []any{"task-project-badge " + projectAccentClass(task.ProjectHue)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...) 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, 50, "
") + 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) + templ_7745c5c3_Var46, 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: 288, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 334, Col: 28} } _, 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, "
") + 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...) + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, 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: 336, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var47).String()) + templ_7745c5c3_Var48, 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: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 337, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var49 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: 291, Col: 75} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + var templ_7745c5c3_Var49 = []any{"task-status " + toneClass(task.StatusTone)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var49...) 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, 55, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, 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: 340, Col: 75} + } + _, 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, 57, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1227,69 +1284,69 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var50 := templ.GetChildren(ctx) - if templ_7745c5c3_Var50 == nil { - templ_7745c5c3_Var50 = templ.NopComponent + templ_7745c5c3_Var52 := templ.GetChildren(ctx) + if templ_7745c5c3_Var52 == nil { + templ_7745c5c3_Var52 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var51 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...) + var templ_7745c5c3_Var53 = []any{sidebarNavItemClass(item.Active)} + 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, 56, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1334,69 +1391,69 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var57 := templ.GetChildren(ctx) - if templ_7745c5c3_Var57 == nil { - templ_7745c5c3_Var57 = templ.NopComponent + templ_7745c5c3_Var59 := templ.GetChildren(ctx) + if templ_7745c5c3_Var59 == nil { + templ_7745c5c3_Var59 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var58 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var58...) + var templ_7745c5c3_Var60 = []any{sidebarNavItemClass(item.Active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var60...) 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 } @@ -1441,25 +1498,25 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var64 := templ.GetChildren(ctx) - if templ_7745c5c3_Var64 == nil { - templ_7745c5c3_Var64 = templ.NopComponent + templ_7745c5c3_Var66 := templ.GetChildren(ctx) + if templ_7745c5c3_Var66 == nil { + templ_7745c5c3_Var66 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1467,20 +1524,20 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var66 string - templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, 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: 326, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 375, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") 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 cd6983f..35e60e9 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -127,15 +127,22 @@ func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView { return projects } -func visibleOverviewProjects(projects []TabloCardView, showAll bool) []TabloCardView { - if showAll || len(projects) <= overviewProjectsPreviewLimit { +func visibleOverviewProjects(projects []TabloCardView) []TabloCardView { + if len(projects) <= overviewProjectsPreviewLimit { return projects } return projects[:overviewProjectsPreviewLimit] } -func hiddenOverviewProjectsCount(projects []TabloCardView, showAll bool) int { - if showAll || len(projects) <= overviewProjectsPreviewLimit { +func hiddenOverviewProjects(projects []TabloCardView) []TabloCardView { + if len(projects) <= overviewProjectsPreviewLimit { + return nil + } + return projects[overviewProjectsPreviewLimit:] +} + +func hiddenOverviewProjectsCount(projects []TabloCardView) int { + if len(projects) <= overviewProjectsPreviewLimit { return 0 } return len(projects) - overviewProjectsPreviewLimit diff --git a/go-backend/internal/web/views/tablos.templ b/go-backend/internal/web/views/tablos.templ index 13d6c32..f53c35a 100644 --- a/go-backend/internal/web/views/tablos.templ +++ b/go-backend/internal/web/views/tablos.templ @@ -152,7 +152,11 @@ templ BorderlessDeleteButton(deleteRequestURL string) { } templ TabloGridCard(tablo TabloCardView) { -
+ @TabloGridCardWithAttrs(tablo, nil) +} + +templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) { +
@ui.Badge(ui.BadgeProps{ Label: tablo.StatusLabel, diff --git a/go-backend/internal/web/views/tablos_templ.go b/go-backend/internal/web/views/tablos_templ.go index f88b800..1066c77 100644 --- a/go-backend/internal/web/views/tablos_templ.go +++ b/go-backend/internal/web/views/tablos_templ.go @@ -478,7 +478,44 @@ func TabloGridCard(tablo TabloCardView) templ.Component { templ_7745c5c3_Var21 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = TabloGridCardWithAttrs(tablo, nil).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) 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_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -493,55 +530,55 @@ func TabloGridCard(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") 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...) + var templ_7745c5c3_Var23 = []any{"project-avatar " + projectAccentClass(tablo.Accent)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

") + 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) + templ_7745c5c3_Var25, 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: 167, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 169, Col: 25} } _, 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, "

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

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, 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: 171, Col: 19} + } + _, 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, 37, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -549,68 +586,68 @@ func TabloGridCard(tablo TabloCardView) templ.Component { 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: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.ProgressLabel) + templ_7745c5c3_Var27, 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: 176, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 175, Col: 30} } _, 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
Progression: ") 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...) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, 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: 180, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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 } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var28).String()) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + var templ_7745c5c3_Var29 = []any{"project-progress-bar " + projectAccentClass(tablo.Accent)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" style=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" style=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressInlineStyle(tablo.Progress)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 183, Col: 121} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + 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 } @@ -634,34 +671,34 @@ func TabloListRow(tablo TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var31 := templ.GetChildren(ctx) - if templ_7745c5c3_Var31 == nil { - templ_7745c5c3_Var31 = 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, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") 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...) + var templ_7745c5c3_Var33 = []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_Var33...) 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, 46, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -669,20 +706,20 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 196, Col: 84} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, 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, 47, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -693,7 +730,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { 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\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -701,42 +738,42 @@ func TabloListRow(tablo TabloCardView) templ.Component { 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, 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: 216, Col: 109} + } + _, 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, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -744,7 +781,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -768,9 +805,9 @@ func CreateTabloModal(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var38 := templ.GetChildren(ctx) - if templ_7745c5c3_Var38 == nil { - templ_7745c5c3_Var38 = templ.NopComponent + templ_7745c5c3_Var39 := templ.GetChildren(ctx) + if templ_7745c5c3_Var39 == nil { + templ_7745c5c3_Var39 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = ui.Modal(ui.ModalProps{ @@ -800,12 +837,12 @@ func TabloListHead() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var39 := templ.GetChildren(ctx) - if templ_7745c5c3_Var39 == nil { - templ_7745c5c3_Var39 = templ.NopComponent + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "ProjetStatutCréé leProgression") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "ProjetStatutCréé leProgression") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -829,9 +866,9 @@ func TabloListBody(tablos []TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var40 := templ.GetChildren(ctx) - if templ_7745c5c3_Var40 == nil { - templ_7745c5c3_Var40 = templ.NopComponent + templ_7745c5c3_Var41 := templ.GetChildren(ctx) + if templ_7745c5c3_Var41 == nil { + templ_7745c5c3_Var41 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, tablo := range tablos { @@ -860,69 +897,69 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var41 := templ.GetChildren(ctx) - if templ_7745c5c3_Var41 == nil { - templ_7745c5c3_Var41 = templ.NopComponent + templ_7745c5c3_Var42 := templ.GetChildren(ctx) + if templ_7745c5c3_Var42 == nil { + templ_7745c5c3_Var42 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if vm.ErrorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ErrorMessage) + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 260, Col: 112} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + _, 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, 59, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -942,33 +979,33 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
Annuler") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-get=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(vm.CloseModalHref()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 277, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"ui-button ui-button-secondary ui-button-md\">Annuler") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -981,7 +1018,7 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/router_test.go b/go-backend/router_test.go index 20a5eac..1126b76 100644 --- a/go-backend/router_test.go +++ b/go-backend/router_test.go @@ -359,22 +359,39 @@ func TestHomePageProjectsCollapseAfterSixByDefault(t *testing.T) { } 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) + if count := strings.Count(body, `class="project-card"`); count != 8 { + t.Fatalf("expected all 8 project cards to be rendered, got %d", count) + } + if count := strings.Count(body, `data-overview-project-hidden="true"`); count != 2 { + t.Fatalf("expected 2 project cards to be hidden 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"`, + `data-overview-see-more="true"`, + `data-overview-expanded="false"`, + `data-overview-hidden-count="2"`, + `data-overview-hide-label="Masquer"`, + `data-overview-chevron-down="m6 9 6 6 6-6"`, + `data-overview-chevron-up="m6 15 6-6 6 6"`, + `data-overview-see-more-chevron="true"`, + `window.xtabloOverviewProjectsInitialized`, } { if !strings.Contains(body, want) { t.Fatalf("expected home page to contain %q", want) } } + for _, unwanted := range []string{ + `hx-get="/?show_projects=all"`, + `hx-target="#overview-projects-section"`, + } { + if strings.Contains(body, unwanted) { + t.Fatalf("expected home page to avoid HTMX see-more wiring %q", unwanted) + } + } } -func TestHomePageProjectsExpandViaHTMXSectionSwap(t *testing.T) { +func TestHomePageProjectsUseVanillaJSSeeMoreToggle(t *testing.T) { repo := handlers.NewInMemoryAuthRepository() authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") if err != nil { @@ -408,7 +425,6 @@ func TestHomePageProjectsExpandViaHTMXSectionSwap(t *testing.T) { 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) @@ -418,20 +434,24 @@ func TestHomePageProjectsExpandViaHTMXSectionSwap(t *testing.T) { } 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) + for _, want := range []string{ + `id="app-main-content"`, + `id="overview-projects-section"`, + `data-overview-see-more="true"`, + `data-overview-expanded="false"`, + `data-overview-project-hidden="true"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected HTMX home response to contain %q", want) + } } - 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") + for _, unwanted := range []string{ + `hx-get="/?show_projects=all"`, + `hx-target="#overview-projects-section"`, + } { + if strings.Contains(body, unwanted) { + t.Fatalf("expected HTMX home response to avoid HTMX see-more wiring %q", unwanted) + } } } -- 2.45.2 From a089f35a9b4952d0540dea0026197a942354faec Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 09:21:34 +0200 Subject: [PATCH 020/546] docs: add go-backend tablo edit color design --- ...5-10-go-backend-tablo-edit-color-design.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-10-go-backend-tablo-edit-color-design.md diff --git a/docs/superpowers/specs/2026-05-10-go-backend-tablo-edit-color-design.md b/docs/superpowers/specs/2026-05-10-go-backend-tablo-edit-color-design.md new file mode 100644 index 0000000..c49714d --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-go-backend-tablo-edit-color-design.md @@ -0,0 +1,295 @@ +# Go Backend Tablo Edit And Color Design + +**Date:** 2026-05-10 + +**Goal** + +Add edit support to the Go backend `/tablos` page so each project card and list row exposes an edit action immediately to the left of delete, opening a modal that allows the owner to update the tablo `name` and `color`. + +**Chosen Approach** + +This change follows the minimal additive path: + +- keep the existing `/tablos` page structure, HTMX flow, and delete behavior +- keep the current persisted `status` field and status-based filtering unchanged +- add a persisted `color` field +- extend the existing create modal to accept `color` +- add a dedicated edit modal and update route for `name` and `color` + +This intentionally does not start the broader `status` deprecation effort. `status` remains in place as-is for this slice. + +**Scope** + +- Add a `color` field to Go backend tablo persistence. +- Render an edit action on project cards and list rows. +- Open a server-rendered edit modal for a specific tablo. +- Allow create and edit flows to submit `name` and `color`. +- Validate `color` as a full 6-digit hexadecimal value in `#RRGGBB` format. +- Re-render the `/tablos` content after successful create, update, or delete while preserving current page state. + +**Out Of Scope** + +- Removing or deprecating the existing `status` field +- Task-derived status inference +- Reworking the current search or filter model +- Introducing custom JavaScript beyond the existing HTMX-driven pattern +- Adding color pickers, preset palettes, or browser-native advanced color UI +- Building a `/tablos/:id` detail edit page + +**User Experience** + +On the Go backend `Mes Projets` page: + +- every grid card shows an edit icon button immediately left of the trash icon +- every list row shows the same edit icon button immediately left of the trash icon +- clicking edit opens a modal prefilled with the tablo's current `name` and `color` +- the modal lets the user update: + - `name` + - `color` +- `status` is not editable in the create or edit modal for this slice + +The color is entered as a text value using a strict full hex format such as `#3B82F6`. + +**Data Model** + +Extend `public.tablos` with: + +- `color text not null` + +The Go domain model should add: + +- `Color string` on `tablos.Record` + +Create and update inputs should both include: + +- `Name string` +- `Color string` + +The current `status` field remains present and unchanged. + +**Validation** + +Validation rules: + +- `name` is required after trimming whitespace +- `color` is required +- `color` must match `^#[0-9A-Fa-f]{6}$` + +Validation happens in the handler layer for this slice. The handler should return inline modal errors with HTTP `422` when validation fails. + +Examples: + +- valid: `#3B82F6` +- valid: `#ff6600` +- invalid: `3B82F6` +- invalid: `#0af` +- invalid: `blue` + +**Routes** + +Add or extend the following routes: + +- `GET /tablos` + - unchanged page render entrypoint + - supports existing query params: + - `view=grid|list` + - `q=` + - `status=all|todo|in_progress|done` + - `modal=create` +- `POST /tablos` + - create a new tablo using `name` and `color` + - continue assigning the existing default `status` +- `GET /tablos/{id}/edit` + - render the edit modal for the owner + - preserve current page state through query params +- `POST /tablos/{id}` + - update `name` and `color` for the owner +- `DELETE /tablos/{id}` + - unchanged behavior, aside from adjacent edit action in the UI + +`POST /tablos/{id}` is preferred here over introducing `PATCH` because it keeps the modal form flow simple and consistent with the current server-rendered HTMX form handling. + +**Repository Design** + +Minimum repository behavior: + +- create tablo with `name`, `color`, and current default `status` +- list tablos including `color` +- update `name`, `color`, and `updated_at` for an owned non-deleted tablo +- soft delete owned tablos as before + +Minimum repository methods: + +- `CreateTablo` +- `ListTablos` +- `UpdateTablo` +- `SoftDeleteTablo` + +Update semantics: + +- scope by `id` +- require `owner_id = current user` +- reject deleted rows +- update `name`, `color`, and `updated_at` +- treat missing, foreign-owned, or deleted rows as not found for this slice + +**Rendering Contract** + +The `/tablos` page should keep its current HTMX-rendered dashboard composition and add edit support without restructuring the page. + +Grid cards: + +- keep the existing status badge +- render an edit icon button immediately before the delete icon button +- continue using the current card structure and layout + +List rows: + +- keep the current trailing action cell +- render edit and delete as adjacent icon buttons with edit on the left + +Modal behavior: + +- create modal collects `name` and `color` +- edit modal collects `name` and `color` +- both modals render inline validation errors +- cancel closes the modal and preserves current page state + +**Color Rendering** + +The page should use the stored hex value directly rather than only relying on predefined accent classes. + +Expected usage: + +- avatar or visual accent background +- progress or supporting color accent where appropriate +- any existing accent mapping should be replaced or bypassed only where needed for real color rendering + +Implementation should keep style usage narrow and predictable, for example through inline `style` attributes generated from validated values. Since the input is strictly validated server-side, using the stored value in inline styles is acceptable for this slice. + +**HTMX Flow** + +Open create modal: + +- `hx-get` to `/tablos?...&modal=create` +- swap the main content region as today + +Open edit modal: + +- `hx-get` to `/tablos/{id}/edit?...current state...` +- swap the main content region with the page content that includes the modal + +Submit create: + +- `hx-post` to `/tablos` +- on success: + - return refreshed `/tablos` content + - modal closes because returned state omits modal-open flag +- on failure: + - return `422` + - re-render create modal with entered values and inline errors + +Submit edit: + +- `hx-post` to `/tablos/{id}` +- on success: + - return refreshed `/tablos` content + - modal closes +- on failure: + - return `422` + - re-render edit modal with entered values and inline errors + +Delete: + +- keep existing HTMX delete flow +- preserve `view`, `q`, and `status` + +**View Model Changes** + +`TabloCardView` should add fields needed for edit and color support, for example: + +- `Color string` +- `EditRequestURL string` + +`TablosPageViewModel` should continue carrying current page state and modal form values. To support both create and edit cleanly, the modal state will likely need to expand beyond the current boolean-only create modal approach. + +A pragmatic shape is: + +- modal kind: none, create, edit +- form values: + - name + - color +- optional editing tablo id +- error message or field-level errors + +The exact struct layout can be chosen during implementation, but it should support both modal variants without duplicating page-state plumbing. + +**Error Handling** + +Create or update validation failure: + +- return HTTP `422` +- keep modal open +- show clear inline error message near the relevant field or top of form + +Unknown or unauthorized tablo on edit open or update: + +- return not found behavior +- do not leak whether the tablo exists for another owner + +Repository or rendering failures: + +- return the existing server error behavior + +**Testing Strategy** + +Repository coverage: + +- create persists `color` +- list returns `color` +- update changes `name`, `color`, and `updated_at` +- update rejects different owner +- update rejects deleted tablo + +Handler coverage: + +- `GET /tablos` create modal includes `color` field +- `GET /tablos/{id}/edit` renders prefilled `name` and `color` +- `POST /tablos` rejects missing or invalid `color` +- `POST /tablos/{id}` rejects missing or invalid `color` +- `POST /tablos/{id}` updates visible name and color in returned HTML +- grid card markup contains edit action before delete +- list row markup contains edit action before delete + +HTML assertions should verify: + +- the edit trigger exists with the expected icon/button semantics +- the edit trigger appears before delete in the rendered action area +- the modal contains both `Nom du projet` and `Couleur` +- invalid hex values return a `422` response with inline feedback mentioning `#RRGGBB` + +**Implementation Notes** + +- preserve the current page query state on create, edit open, edit submit, and delete +- avoid introducing unrelated refactors to status handling +- prefer reuse of the existing modal and shared UI primitives +- keep changes scoped to the Go backend vertical slice: + - schema + - queries + - repository + - handlers + - `templ` views + - tests + +**Acceptance Criteria** + +The feature is complete when: + +- the Go backend `/tablos` page shows an edit action to the left of delete +- clicking edit opens a modal for the selected tablo +- the modal allows changing the tablo `name` +- the modal allows changing the tablo `color` +- create also accepts `color` +- `color` only accepts full 6-digit hex values like `#3B82F6` +- successful edits update the rendered project card or list row +- current search, filter, and view state are preserved throughout the HTMX flow -- 2.45.2 From cf64404d251d842fe39e66a0e608739f7c1f8935 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 09:22:22 +0200 Subject: [PATCH 021/546] feat: refresh go-backend button variants and tablos styling --- docs/design-system/buttons.html | 13 ++- docs/design-system/empty-states.html | 2 +- docs/design-system/modals.html | 2 +- .../internal/web/handlers/tablos_test.go | 2 +- go-backend/internal/web/ui/button.templ | 3 +- go-backend/internal/web/ui/button_templ.go | 7 +- .../internal/web/ui/catalog/catalog_test.go | 3 +- .../internal/web/ui/catalog/examples.go | 34 ++++-- go-backend/internal/web/ui/ui_test.go | 41 ++++++- go-backend/internal/web/ui/variants.go | 33 ++++-- go-backend/internal/web/views/tablos.templ | 8 +- go-backend/internal/web/views/tablos_templ.go | 8 +- go-backend/static/styles.css | 106 +++++++++++++++--- 13 files changed, 209 insertions(+), 53 deletions(-) diff --git a/docs/design-system/buttons.html b/docs/design-system/buttons.html index 8160a1a..a660e1f 100644 --- a/docs/design-system/buttons.html +++ b/docs/design-system/buttons.html @@ -8,14 +8,21 @@ -

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

Design System

Buttons

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

Default solid action

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

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

Danger action

Used for irreversible actions after explicit confirmation.

@ui.Button(ui.ButtonProps{
+})

Soft warning action

Used for inline actions that need emphasis without the weight of a solid button.

@ui.Button(ui.ButtonProps{
+	Label:   "Relancer",
+	Variant: ui.ButtonVariantWarning,
+	Tone:    ui.ButtonToneSoft,
+	Size:    ui.SizeMD,
+	Type:    "button",
+})

Soft danger action

Used for irreversible actions after explicit confirmation.

@ui.Button(ui.ButtonProps{
 	Label:   "Supprimer",
 	Variant: ui.ButtonVariantDanger,
+	Tone:    ui.ButtonToneSoft,
 	Size:    ui.SizeLG,
 	Type:    "submit",
 })
diff --git a/docs/design-system/empty-states.html b/docs/design-system/empty-states.html index d8cb259..6d27b37 100644 --- a/docs/design-system/empty-states.html +++ b/docs/design-system/empty-states.html @@ -8,7 +8,7 @@ -

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

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"),
diff --git a/docs/design-system/modals.html b/docs/design-system/modals.html
index 91f74cd..dffde7f 100644
--- a/docs/design-system/modals.html
+++ b/docs/design-system/modals.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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/go-backend/internal/web/handlers/tablos_test.go b/go-backend/internal/web/handlers/tablos_test.go
index d520392..a23657c 100644
--- a/go-backend/internal/web/handlers/tablos_test.go
+++ b/go-backend/internal/web/handlers/tablos_test.go
@@ -237,7 +237,7 @@ func TestGetTablosPageUsesSharedToolbarButtonAndStatusBadge(t *testing.T) {
 
 	body := rec.Body.String()
 	for _, want := range []string{
-		`ui-button ui-button-primary ui-button-md`,
+		`ui-button ui-button-solid ui-button-default ui-button-md`,
 		`ui-badge ui-badge-warning`,
 	} {
 		if !strings.Contains(body, want) {
diff --git a/go-backend/internal/web/ui/button.templ b/go-backend/internal/web/ui/button.templ
index 90d7fb6..cc23ce2 100644
--- a/go-backend/internal/web/ui/button.templ
+++ b/go-backend/internal/web/ui/button.templ
@@ -3,6 +3,7 @@ package ui
 type ButtonProps struct {
 	Label   string
 	Variant ButtonVariant
+	Tone    ButtonTone
 	Size    Size
 	Type    string
 	Icon    string
@@ -10,7 +11,7 @@ type ButtonProps struct {
 }
 
 templ Button(props ButtonProps) {
-	
@ui.Button(ui.ButtonProps{
+

Design System

Buttons

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

Default solid action

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

@ui.Button(ui.ButtonProps{
 	Label:   "Nouveau projet",
 	Variant: ui.ButtonVariantDefault,
 	Size:    ui.SizeMD,
diff --git a/docs/design-system/cards.html b/docs/design-system/cards.html
index 386336d..4b7f232 100644
--- a/docs/design-system/cards.html
+++ b/docs/design-system/cards.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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
index 6d27b37..1aa9494 100644
--- a/docs/design-system/empty-states.html
+++ b/docs/design-system/empty-states.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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"),
diff --git a/docs/design-system/form-fields.html b/docs/design-system/form-fields.html
index a397c96..8efee19 100644
--- a/docs/design-system/form-fields.html
+++ b/docs/design-system/form-fields.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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{
diff --git a/docs/design-system/icon-buttons.html b/docs/design-system/icon-buttons.html
index bb555d2..c2c632a 100644
--- a/docs/design-system/icon-buttons.html
+++ b/docs/design-system/icon-buttons.html
@@ -8,10 +8,17 @@
   
 
 
-

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

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,
+	Variant: ui.IconButtonVariantDanger,
+	Tone:    ui.IconButtonToneGhost,
+	Type:    "button",
+})

Borderless neutral action

Used for lightweight edit or details actions inside cards and list rows.

@ui.IconButton(ui.IconButtonProps{
+	Label:   "Modifier le projet",
+	Icon:    "pencil",
+	Variant: ui.IconButtonVariantNeutral,
+	Tone:    ui.IconButtonToneGhost,
 	Type:    "button",
 })
diff --git a/docs/design-system/index.html b/docs/design-system/index.html index fc9632a..ec86913 100644 --- a/docs/design-system/index.html +++ b/docs/design-system/index.html @@ -8,6 +8,6 @@ -

Design System

Component Catalog

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

+

Design System

Component Catalog

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

diff --git a/docs/design-system/inputs.html b/docs/design-system/inputs.html index a1dc5c3..324f30a 100644 --- a/docs/design-system/inputs.html +++ b/docs/design-system/inputs.html @@ -8,7 +8,7 @@ -

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

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",
diff --git a/docs/design-system/modals.html b/docs/design-system/modals.html
index dffde7f..a543d12 100644
--- a/docs/design-system/modals.html
+++ b/docs/design-system/modals.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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
index 8d6f797..ae87212 100644
--- a/docs/design-system/tables.html
+++ b/docs/design-system/tables.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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 index 02e7b03..1784085 100644 --- a/docs/design-system/tokens.html +++ b/docs/design-system/tokens.html @@ -8,6 +8,6 @@ -

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})
+

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/specs/2026-05-10-go-backend-tablo-edit-color-design.md b/docs/superpowers/specs/2026-05-10-go-backend-tablo-edit-color-design.md index c49714d..bc4f6b5 100644 --- a/docs/superpowers/specs/2026-05-10-go-backend-tablo-edit-color-design.md +++ b/docs/superpowers/specs/2026-05-10-go-backend-tablo-edit-color-design.md @@ -33,7 +33,6 @@ This intentionally does not start the broader `status` deprecation effort. `stat - Task-derived status inference - Reworking the current search or filter model - Introducing custom JavaScript beyond the existing HTMX-driven pattern -- Adding color pickers, preset palettes, or browser-native advanced color UI - Building a `/tablos/:id` detail edit page **User Experience** @@ -46,9 +45,10 @@ On the Go backend `Mes Projets` page: - the modal lets the user update: - `name` - `color` +- the edit modal includes a color picker control bound to the same `color` value - `status` is not editable in the create or edit modal for this slice -The color is entered as a text value using a strict full hex format such as `#3B82F6`. +The stored color value remains a strict full hex string such as `#3B82F6`. **Data Model** @@ -152,6 +152,8 @@ Modal behavior: - create modal collects `name` and `color` - edit modal collects `name` and `color` +- edit modal includes a native color picker control, prefilled from the current tablo color +- the picker and color text input stay in sync so submissions always send one canonical `#RRGGBB` value - both modals render inline validation errors - cancel closes the modal and preserves current page state @@ -224,6 +226,13 @@ A pragmatic shape is: The exact struct layout can be chosen during implementation, but it should support both modal variants without duplicating page-state plumbing. +For the edit modal specifically, the view model should provide the current validated hex color so both: + +- the text input +- the native color picker + +can render from the same source of truth. + **Error Handling** Create or update validation failure: @@ -254,10 +263,11 @@ Repository coverage: Handler coverage: - `GET /tablos` create modal includes `color` field -- `GET /tablos/{id}/edit` renders prefilled `name` and `color` +- `GET /tablos/{id}/edit` renders prefilled `name`, `color`, and color picker value - `POST /tablos` rejects missing or invalid `color` - `POST /tablos/{id}` rejects missing or invalid `color` - `POST /tablos/{id}` updates visible name and color in returned HTML +- edit modal keeps color picker and submitted hex value aligned - grid card markup contains edit action before delete - list row markup contains edit action before delete @@ -266,6 +276,7 @@ HTML assertions should verify: - the edit trigger exists with the expected icon/button semantics - the edit trigger appears before delete in the rendered action area - the modal contains both `Nom du projet` and `Couleur` +- the edit modal contains a color picker control in addition to the hex color input - invalid hex values return a `422` response with inline feedback mentioning `#RRGGBB` **Implementation Notes** @@ -289,6 +300,7 @@ The feature is complete when: - clicking edit opens a modal for the selected tablo - the modal allows changing the tablo `name` - the modal allows changing the tablo `color` +- the edit modal includes a color picker for choosing the tablo color - create also accepts `color` - `color` only accepts full 6-digit hex values like `#3B82F6` - successful edits update the rendered project card or list row diff --git a/go-backend/cmd/designsystem/main_test.go b/go-backend/cmd/designsystem/main_test.go index ca0cef4..68e1342 100644 --- a/go-backend/cmd/designsystem/main_test.go +++ b/go-backend/cmd/designsystem/main_test.go @@ -22,6 +22,7 @@ func TestGenerateSiteWritesExpectedPages(t *testing.T) { "inputs.html", "form-fields.html", "modals.html", + "spacing.html", "tables.html", "empty-states.html", "cards.html", diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql index b10df7c..116aff7 100644 --- a/go-backend/internal/db/queries.sql +++ b/go-backend/internal/db/queries.sql @@ -60,6 +60,7 @@ INSERT INTO public.tablos ( id, owner_id, name, + color, status, created_at, updated_at @@ -68,13 +69,14 @@ INSERT INTO public.tablos ( $2, $3, $4, + $5, now(), now() ) -RETURNING id, owner_id, name, status, created_at, updated_at, deleted_at; +RETURNING id, owner_id, name, color, status, created_at, updated_at, deleted_at; -- name: ListTablos :many -SELECT id, owner_id, name, status, created_at, updated_at, deleted_at +SELECT id, owner_id, name, color, status, created_at, updated_at, deleted_at FROM public.tablos WHERE owner_id = sqlc.arg(owner_id) AND deleted_at IS NULL @@ -86,6 +88,15 @@ WHERE owner_id = sqlc.arg(owner_id) ) ORDER BY created_at DESC; +-- name: UpdateTablo :execrows +UPDATE public.tablos +SET name = $3, + color = $4, + updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL; + -- name: SoftDeleteTablo :execrows UPDATE public.tablos SET deleted_at = now(), updated_at = now() diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go index 2e7b32c..ae50ac0 100644 --- a/go-backend/internal/db/repository.go +++ b/go-backend/internal/db/repository.go @@ -24,6 +24,8 @@ type PostgresAuthRepository struct { queries *sqlcdb.Queries } +const defaultTabloColor = "#3B82F6" + func NewPostgresAuthRepository(ctx context.Context, databaseURL string) (*PostgresAuthRepository, error) { if databaseURL == "" { return nil, errors.New("DATABASE_URL is required") @@ -149,6 +151,7 @@ func (r *PostgresAuthRepository) CreateTablo(ctx context.Context, input tablomod ID: uuid.New(), OwnerID: input.OwnerID, Name: strings.TrimSpace(input.Name), + Color: storedTabloColor(input.Color), Status: string(input.Status), }) if err != nil { @@ -177,6 +180,22 @@ func (r *PostgresAuthRepository) ListTablos(ctx context.Context, input tablomode return tablos, nil } +func (r *PostgresAuthRepository) UpdateTablo(ctx context.Context, input tablomodel.UpdateInput) error { + rows, err := r.queries.UpdateTablo(ctx, sqlcdb.UpdateTabloParams{ + ID: input.ID, + OwnerID: input.OwnerID, + Name: strings.TrimSpace(input.Name), + Color: storedTabloColor(input.Color), + }) + if err != nil { + return err + } + if rows == 0 { + return tablomodel.ErrNotFound + } + return 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, @@ -202,6 +221,14 @@ func nullableText(value string) pgtype.Text { return pgtype.Text{String: value, Valid: true} } +func storedTabloColor(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return defaultTabloColor + } + return trimmed +} + func nullableStatus(value *tablomodel.Status) pgtype.Text { if value == nil { return pgtype.Text{} @@ -214,6 +241,7 @@ func mapTabloRecord(row sqlcdb.Tablo) tablomodel.Record { ID: row.ID, OwnerID: row.OwnerID, Name: row.Name, + Color: storedTabloColor(row.Color), Status: tablomodel.Status(row.Status), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql index 9370d34..58bc0f7 100644 --- a/go-backend/internal/db/schema.sql +++ b/go-backend/internal/db/schema.sql @@ -32,6 +32,7 @@ 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, + color text NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go index 3d23e7d..d8ea423 100644 --- a/go-backend/internal/db/sqlc/models.go +++ b/go-backend/internal/db/sqlc/models.go @@ -31,6 +31,7 @@ type Tablo struct { ID uuid.UUID `db:"id"` OwnerID uuid.UUID `db:"owner_id"` Name string `db:"name"` + Color string `db:"color"` Status string `db:"status"` CreatedAt pgtype.Timestamptz `db:"created_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at"` diff --git a/go-backend/internal/db/sqlc/querier.go b/go-backend/internal/db/sqlc/querier.go index 1024771..c60deef 100644 --- a/go-backend/internal/db/sqlc/querier.go +++ b/go-backend/internal/db/sqlc/querier.go @@ -20,6 +20,7 @@ type Querier interface { GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error) ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo, error) SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams) (int64, error) + UpdateTablo(ctx context.Context, arg UpdateTabloParams) (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 9fa94aa..85adafd 100644 --- a/go-backend/internal/db/sqlc/queries.sql.go +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -90,6 +90,7 @@ INSERT INTO public.tablos ( id, owner_id, name, + color, status, created_at, updated_at @@ -98,16 +99,18 @@ INSERT INTO public.tablos ( $2, $3, $4, + $5, now(), now() ) -RETURNING id, owner_id, name, status, created_at, updated_at, deleted_at +RETURNING id, owner_id, name, color, 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"` + Color string `db:"color"` Status string `db:"status"` } @@ -116,6 +119,7 @@ func (q *Queries) CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo arg.ID, arg.OwnerID, arg.Name, + arg.Color, arg.Status, ) var i Tablo @@ -123,6 +127,7 @@ func (q *Queries) CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo &i.ID, &i.OwnerID, &i.Name, + &i.Color, &i.Status, &i.CreatedAt, &i.UpdatedAt, @@ -214,7 +219,7 @@ func (q *Queries) GetSessionByToken(ctx context.Context, sessionToken string) (A } const listTablos = `-- name: ListTablos :many -SELECT id, owner_id, name, status, created_at, updated_at, deleted_at +SELECT id, owner_id, name, color, status, created_at, updated_at, deleted_at FROM public.tablos WHERE owner_id = $1 AND deleted_at IS NULL @@ -246,6 +251,7 @@ func (q *Queries) ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo &i.ID, &i.OwnerID, &i.Name, + &i.Color, &i.Status, &i.CreatedAt, &i.UpdatedAt, @@ -281,3 +287,33 @@ func (q *Queries) SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams } return result.RowsAffected(), nil } + +const updateTablo = `-- name: UpdateTablo :execrows +UPDATE public.tablos +SET name = $3, + color = $4, + updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +` + +type UpdateTabloParams struct { + ID uuid.UUID `db:"id"` + OwnerID uuid.UUID `db:"owner_id"` + Name string `db:"name"` + Color string `db:"color"` +} + +func (q *Queries) UpdateTablo(ctx context.Context, arg UpdateTabloParams) (int64, error) { + result, err := q.db.Exec(ctx, updateTablo, + arg.ID, + arg.OwnerID, + arg.Name, + arg.Color, + ) + 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 index db60831..c15bec6 100644 --- a/go-backend/internal/tablos/model.go +++ b/go-backend/internal/tablos/model.go @@ -21,6 +21,7 @@ type Record struct { ID uuid.UUID OwnerID uuid.UUID Name string + Color string Status Status CreatedAt time.Time UpdatedAt time.Time @@ -30,9 +31,17 @@ type Record struct { type CreateInput struct { OwnerID uuid.UUID Name string + Color string Status Status } +type UpdateInput struct { + ID uuid.UUID + OwnerID uuid.UUID + Name string + Color string +} + type ListInput struct { OwnerID uuid.UUID Query string diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go index 0698240..bd8dbab 100644 --- a/go-backend/internal/web/handlers/auth.go +++ b/go-backend/internal/web/handlers/auth.go @@ -33,6 +33,7 @@ type AuthRepository interface { GetSessionByToken(ctx context.Context, token string) (Session, error) DeleteSessionByToken(ctx context.Context, token string) error CreateTablo(ctx context.Context, input CreateTabloInput) (TabloRecord, error) + UpdateTablo(ctx context.Context, input UpdateTabloInput) error ListTablos(ctx context.Context, input ListTablosInput) ([]TabloRecord, error) SoftDeleteTablo(ctx context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error } diff --git a/go-backend/internal/web/handlers/tablos.go b/go-backend/internal/web/handlers/tablos.go index 9688630..9dc6884 100644 --- a/go-backend/internal/web/handlers/tablos.go +++ b/go-backend/internal/web/handlers/tablos.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strings" "time" @@ -16,6 +17,11 @@ import ( var ErrTabloNotFound = tablomodel.ErrNotFound +var tabloColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +const defaultTabloColor = "#3B82F6" +const tabloColorValidationMessage = "La couleur du projet doit être un code hexadécimal au format #RRGGBB" + type TabloStatus = tablomodel.Status const ( @@ -26,13 +32,15 @@ const ( type TabloRecord = tablomodel.Record type CreateTabloInput = tablomodel.CreateInput +type UpdateTabloInput = tablomodel.UpdateInput type ListTablosInput = tablomodel.ListInput type TablosPageState struct { - View string - Query string - Status string - ModalOpen bool + View string + Query string + Status string + ModalKind string + EditingTabloID string } func normalizeTabloQuery(query string) string { @@ -58,7 +66,7 @@ func parseTablosPageState(values interface { View: view, Query: strings.TrimSpace(values.Get("q")), Status: status, - ModalOpen: strings.TrimSpace(values.Get("modal")) == "create", + ModalKind: normalizedModalKind(strings.TrimSpace(values.Get("modal"))), } } @@ -78,6 +86,30 @@ func (s TablosPageState) statusFilter() *TabloStatus { } } +func normalizedModalKind(kind string) string { + switch kind { + case "create", "edit": + return kind + default: + return "" + } +} + +func normalizeTabloColor(raw string) (string, bool) { + color := strings.TrimSpace(raw) + if !tabloColorPattern.MatchString(color) { + return "", false + } + return strings.ToUpper(color), true +} + +func storedTabloColor(raw string) string { + if color, ok := normalizeTabloColor(raw); ok { + return color + } + return defaultTabloColor +} + func (h *AuthHandler) PostTablos() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := h.authenticatedUser(r.Context(), r) @@ -92,35 +124,153 @@ func (h *AuthHandler) PostTablos() http.HandlerFunc { } state := parseTablosPageState(r.Form) - state.ModalOpen = true + state.ModalKind = "create" 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) + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) + if err != nil { + http.Error(w, "failed to list tablos", http.StatusInternalServerError) + return + } + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), "Le nom du projet est requis"), http.StatusUnprocessableEntity) + return + } + + color, ok := normalizeTabloColor(r.FormValue("color")) + if !ok { + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) + if err != nil { + http.Error(w, "failed to list tablos", http.StatusInternalServerError) + return + } + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), tabloColorValidationMessage), http.StatusUnprocessableEntity) return } if _, err := h.repo.CreateTablo(r.Context(), CreateTabloInput{ OwnerID: user.ID, Name: name, + Color: color, 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(), - }) + state.ModalKind = "" + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) if err != nil { http.Error(w, "failed to list tablos", http.StatusInternalServerError) return } - renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK) + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK) + } +} + +func (h *AuthHandler) GetEditTabloModal() 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 + } + + state := parseTablosPageState(r.URL.Query()) + state.ModalKind = "edit" + state.EditingTabloID = tabloID.String() + + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) + if err != nil { + http.Error(w, "failed to list tablos", http.StatusInternalServerError) + return + } + + tablo, ok := findTabloByID(tablos, tabloID) + if !ok { + http.Error(w, "tablo not found", http.StatusNotFound) + return + } + + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, tablo.Name, tablo.Color, ""), http.StatusOK) + } +} + +func (h *AuthHandler) PostTabloUpdate() 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 := r.ParseForm(); err != nil { + http.Error(w, "invalid form payload", http.StatusBadRequest) + return + } + + state := parseTablosPageState(r.Form) + state.ModalKind = "edit" + state.EditingTabloID = tabloID.String() + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) + if err != nil { + http.Error(w, "failed to list tablos", http.StatusInternalServerError) + return + } + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), "Le nom du projet est requis"), http.StatusUnprocessableEntity) + return + } + + color, colorOK := normalizeTabloColor(r.FormValue("color")) + if !colorOK { + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) + if err != nil { + http.Error(w, "failed to list tablos", http.StatusInternalServerError) + return + } + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, name, r.FormValue("color"), tabloColorValidationMessage), http.StatusUnprocessableEntity) + return + } + + if err := h.repo.UpdateTablo(r.Context(), UpdateTabloInput{ + ID: tabloID, + OwnerID: user.ID, + Name: name, + Color: color, + }); err != nil { + if errors.Is(err, ErrTabloNotFound) { + http.Error(w, "tablo not found", http.StatusNotFound) + return + } + http.Error(w, "failed to update tablo", http.StatusInternalServerError) + return + } + + state.ModalKind = "" + state.EditingTabloID = "" + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) + if err != nil { + http.Error(w, "failed to list tablos", http.StatusInternalServerError) + return + } + + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK) } } @@ -148,17 +298,13 @@ func (h *AuthHandler) DeleteTablo() http.HandlerFunc { } state := parseTablosPageState(r.URL.Query()) - tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{ - OwnerID: user.ID, - Query: state.Query, - Status: state.statusFilter(), - }) + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) if err != nil { http.Error(w, "failed to list tablos", http.StatusInternalServerError) return } - renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK) + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK) } } @@ -170,32 +316,47 @@ func (h *AuthHandler) renderTablosPage(w http.ResponseWriter, r *http.Request) { } state := parseTablosPageState(r.URL.Query()) - tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{ - OwnerID: user.ID, - Query: state.Query, - Status: state.statusFilter(), - }) + tablos, err := listTablosForState(r.Context(), h.repo, user.ID, state) if err != nil { http.Error(w, "failed to list tablos", http.StatusInternalServerError) return } - renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK) + renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", "", ""), http.StatusOK) } -func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloRecord, formName string, errorMessage string) views.TablosPageViewModel { +func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloRecord, formName string, formColor string, errorMessage string) views.TablosPageViewModel { return views.NewTablosPageViewModel( user.DisplayName, state.View, state.Query, state.Status, - state.ModalOpen, + state.ModalKind, + state.EditingTabloID, formName, + formColor, errorMessage, buildTabloCardViews(tablos, state), ) } +func listTablosForState(ctx context.Context, repo AuthRepository, ownerID uuid.UUID, state TablosPageState) ([]TabloRecord, error) { + return repo.ListTablos(ctx, ListTablosInput{ + OwnerID: ownerID, + Query: state.Query, + Status: state.statusFilter(), + }) +} + +func findTabloByID(tablos []TabloRecord, targetID uuid.UUID) (TabloRecord, bool) { + for _, tablo := range tablos { + if tablo.ID == targetID { + return tablo, true + } + } + return TabloRecord{}, false +} + 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) @@ -221,6 +382,7 @@ func (r *InMemoryAuthRepository) CreateTablo(_ context.Context, input CreateTabl ID: uuid.New(), OwnerID: input.OwnerID, Name: strings.TrimSpace(input.Name), + Color: storedTabloColor(input.Color), Status: input.Status, CreatedAt: now, UpdatedAt: now, @@ -258,6 +420,22 @@ func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosI return tablos, nil } +func (r *InMemoryAuthRepository) UpdateTablo(_ context.Context, input UpdateTabloInput) error { + r.mu.Lock() + defer r.mu.Unlock() + + tablo, ok := r.tablos[input.ID] + if !ok || tablo.OwnerID != input.OwnerID || tablo.DeletedAt != nil { + return ErrTabloNotFound + } + + tablo.Name = strings.TrimSpace(input.Name) + tablo.Color = storedTabloColor(input.Color) + tablo.UpdatedAt = time.Now().UTC() + r.tablos[input.ID] = tablo + return nil +} + func (r *InMemoryAuthRepository) SoftDeleteTablo(_ context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error { r.mu.Lock() defer r.mu.Unlock() @@ -293,6 +471,7 @@ func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.Ta items = append(items, views.TabloCardView{ ID: tablo.ID.String(), Name: tablo.Name, + Color: storedTabloColor(tablo.Color), Status: string(tablo.Status), StatusLabel: statusLabel, StatusClass: statusClass, @@ -303,6 +482,7 @@ func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.Ta ProgressLabel: fmt.Sprintf("%d%%", progress), DeleteURL: "/tablos/" + tablo.ID.String(), DeleteRequestURL: buildDeleteRequestURL("/tablos/"+tablo.ID.String(), state), + EditRequestURL: buildEditRequestURL("/tablos/"+tablo.ID.String()+"/edit", state), IconKind: iconKind, IconBgClass: bgClass, IconFgClass: fgClass, @@ -314,6 +494,14 @@ func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.Ta } func buildDeleteRequestURL(path string, state TablosPageState) string { + return buildStatefulRequestURL(path, state) +} + +func buildEditRequestURL(path string, state TablosPageState) string { + return buildStatefulRequestURL(path, state) +} + +func buildStatefulRequestURL(path string, state TablosPageState) string { values := url.Values{} values.Set("view", state.View) values.Set("status", state.Status) diff --git a/go-backend/internal/web/handlers/tablos_test.go b/go-backend/internal/web/handlers/tablos_test.go index a23657c..03fd88b 100644 --- a/go-backend/internal/web/handlers/tablos_test.go +++ b/go-backend/internal/web/handlers/tablos_test.go @@ -127,6 +127,28 @@ func TestInMemoryTablosSoftDeleteRejectsDifferentOwner(t *testing.T) { } } +func TestInMemoryTablosCreatePersistsColor(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) + } + + created, err := repo.CreateTablo(context.Background(), CreateTabloInput{ + OwnerID: user.ID, + Name: "Roadmap", + Color: "#3B82F6", + Status: TabloStatusTodo, + }) + if err != nil { + t.Fatalf("create colored tablo: %v", err) + } + + if created.Color != "#3B82F6" { + t.Fatalf("expected color to persist, got %q", created.Color) + } +} + func TestGetTablosPageDefaultsToGridAndAllStatus(t *testing.T) { handler := newTestAuthHandler(t) sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") @@ -376,7 +398,7 @@ func TestGetTablosPageListViewUsesDirectTableIconMarkup(t *testing.T) { body := rec.Body.String() for _, want := range []string{ `class="flex items-center gap-1.5 [&>svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0">`, + `
`); err != nil { + return err + } + for _, component := range []templ.Component{ + ui.Button(ui.ButtonProps{ + Label: "Précédent", + Variant: ui.ButtonVariantNeutral, + Size: ui.SizeMD, + Type: "button", + }), + ui.SpaceX(ui.SpaceProps{Size: ui.SpacingStepLG}), + ui.Button(ui.ButtonProps{ + Label: "Suivant", + Variant: ui.ButtonVariantDefault, + Size: ui.SizeMD, + Type: "button", + }), + } { + if err := component.Render(ctx, w); err != nil { + return err + } + } + _, err := io.WriteString(w, `
`) + return err + }), + Snippet: `@ui.SpaceX(ui.SpaceProps{Size: ui.SpacingStepLG})`, + }, + { + Title: "Vertical spacing", + Description: "Use SpaceY to insert fixed vertical gaps between stacked blocks.", + Preview: componentFunc(func(ctx context.Context, w io.Writer) error { + if _, err := io.WriteString(w, `
`); err != nil { + return err + } + for _, component := range []templ.Component{ + ui.Card(ui.CardProps{ + Body: textComponent("Bloc 1"), + }), + ui.SpaceY(ui.SpaceProps{Size: ui.SpacingStepMD}), + ui.Card(ui.CardProps{ + Body: textComponent("Bloc 2"), + }), + } { + if err := component.Render(ctx, w); err != nil { + return err + } + } + _, err := io.WriteString(w, `
`) + return err + }), + Snippet: `@ui.SpaceY(ui.SpaceProps{Size: ui.SpacingStepMD})`, + }, + } +} + func tableExamples() []Example { return []Example{ { diff --git a/go-backend/internal/web/ui/catalog/pages.go b/go-backend/internal/web/ui/catalog/pages.go index bce8cf7..3d9f2bc 100644 --- a/go-backend/internal/web/ui/catalog/pages.go +++ b/go-backend/internal/web/ui/catalog/pages.go @@ -60,6 +60,12 @@ func Pages() []Page { Description: "Shared modal shell for focused create, edit, and confirm flows.", Examples: modalExamples(), }, + { + Slug: "spacing", + Title: "Spacing", + Description: "Fixed horizontal and vertical spacer primitives for composing gaps between UI components.", + Examples: spacingExamples(), + }, { Slug: "tables", Title: "Tables", diff --git a/go-backend/internal/web/ui/icon_button.templ b/go-backend/internal/web/ui/icon_button.templ index 1e1c510..bd7efe2 100644 --- a/go-backend/internal/web/ui/icon_button.templ +++ b/go-backend/internal/web/ui/icon_button.templ @@ -4,12 +4,13 @@ type IconButtonProps struct { Label string Icon string Variant IconButtonVariant + Tone IconButtonTone Type string Attrs templ.Attributes } templ IconButton(props IconButtonProps) { - } @@ -54,6 +55,11 @@ templ UIIcon(kind string) { + case "pencil": + case "trash": ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 72, 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, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/ui_test.go b/go-backend/internal/web/ui/ui_test.go index 113921e..a01097d 100644 --- a/go-backend/internal/web/ui/ui_test.go +++ b/go-backend/internal/web/ui/ui_test.go @@ -40,7 +40,8 @@ func TestIconButtonRendersBorderlessDestructiveMarkup(t *testing.T) { component := IconButton(IconButtonProps{ Label: "Supprimer le projet", Icon: "trash", - Variant: IconButtonVariantDangerGhost, + Variant: IconButtonVariantDanger, + Tone: IconButtonToneGhost, Type: "button", }) @@ -50,6 +51,8 @@ func TestIconButtonRendersBorderlessDestructiveMarkup(t *testing.T) { `type="button"`, `aria-label="Supprimer le projet"`, `borderless-icon-button`, + `ui-icon-button-ghost`, + `ui-icon-button-danger`, `lucide-trash2`, } { if !strings.Contains(html, want) { @@ -58,6 +61,31 @@ func TestIconButtonRendersBorderlessDestructiveMarkup(t *testing.T) { } } +func TestIconButtonRendersBorderlessNeutralMarkup(t *testing.T) { + component := IconButton(IconButtonProps{ + Label: "Modifier le projet", + Icon: "pencil", + Variant: IconButtonVariantNeutral, + Tone: IconButtonToneGhost, + Type: "button", + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `type="button"`, + `aria-label="Modifier le projet"`, + `borderless-icon-button`, + `ui-icon-button-ghost`, + `ui-icon-button-neutral`, + `M12 20h9`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + func TestBadgeRendersSemanticStatusVariant(t *testing.T) { component := Badge(BadgeProps{ Label: "En cours", @@ -79,8 +107,8 @@ func TestBadgeRendersSemanticStatusVariant(t *testing.T) { func TestModalRendersShellStructure(t *testing.T) { component := Modal(ModalProps{ - Title: "Nouveau projet", - Body: textComponent("Body copy"), + Title: "Nouveau projet", + Body: textComponent("Body copy"), Actions: textComponent("Actions"), }) @@ -99,6 +127,38 @@ func TestModalRendersShellStructure(t *testing.T) { } } +func TestSpaceXRendersDefaultMediumMarkup(t *testing.T) { + component := SpaceX(SpaceProps{}) + + html := renderToString(t, component) + + for _, want := range []string{ + `aria-hidden="true"`, + `ui-space-x`, + `ui-space-x-md`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestSpaceYRendersExplicitExtraLargeMarkup(t *testing.T) { + component := SpaceY(SpaceProps{Size: SpacingStepXL}) + + html := renderToString(t, component) + + for _, want := range []string{ + `aria-hidden="true"`, + `ui-space-y`, + `ui-space-y-xl`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + func TestButtonUsesSharedTokenBackedClasses(t *testing.T) { component := Button(ButtonProps{ Label: "Create", @@ -134,7 +194,12 @@ func TestSharedSemanticClassesExistInStylesheet(t *testing.T) { `.ui-button-sm`, `.ui-badge-warning`, `.ui-modal-panel`, + `.ui-space-x-md`, + `.ui-space-y-md`, `.borderless-icon-button`, + `.ui-icon-button-solid.ui-icon-button-neutral`, + `.ui-icon-button-ghost.ui-icon-button-neutral`, + `.ui-icon-button-ghost.ui-icon-button-danger`, `.ui-button-soft.ui-button-danger`, } { if !strings.Contains(css, want) { diff --git a/go-backend/internal/web/ui/variants.go b/go-backend/internal/web/ui/variants.go index d754ac6..53950dd 100644 --- a/go-backend/internal/web/ui/variants.go +++ b/go-backend/internal/web/ui/variants.go @@ -28,8 +28,27 @@ const ( type IconButtonVariant string const ( - IconButtonVariantNeutral IconButtonVariant = "neutral" - IconButtonVariantDangerGhost IconButtonVariant = "danger-ghost" + IconButtonVariantNeutral IconButtonVariant = "neutral" + IconButtonVariantWarning IconButtonVariant = "warning" + IconButtonVariantSuccess IconButtonVariant = "success" + IconButtonVariantDanger IconButtonVariant = "danger" +) + +type IconButtonTone string + +const ( + IconButtonToneSolid IconButtonTone = "solid" + IconButtonToneGhost IconButtonTone = "ghost" +) + +type SpacingStep string + +const ( + SpacingStepXS SpacingStep = "xs" + SpacingStepSM SpacingStep = "sm" + SpacingStepMD SpacingStep = "md" + SpacingStepLG SpacingStep = "lg" + SpacingStepXL SpacingStep = "xl" ) type BadgeVariant string @@ -45,12 +64,14 @@ func buttonClass(variant ButtonVariant, tone ButtonTone, size Size) string { return "ui-button ui-button-" + string(normalizedButtonTone(tone)) + " ui-button-" + string(normalizedButtonVariant(variant)) + " ui-button-" + string(normalizedSize(size)) } -func iconButtonClass(variant IconButtonVariant) string { - switch variant { - case IconButtonVariantDangerGhost: - return "borderless-icon-button" +func iconButtonClass(variant IconButtonVariant, tone IconButtonTone) string { + normalizedVariant := normalizedIconButtonVariant(variant) + + switch normalizedIconButtonTone(tone) { + case IconButtonToneGhost: + return "borderless-icon-button ui-icon-button-ghost ui-icon-button-" + string(normalizedVariant) default: - return "ui-icon-button" + return "ui-icon-button ui-icon-button-solid ui-icon-button-" + string(normalizedVariant) } } @@ -58,6 +79,14 @@ func badgeClass(variant BadgeVariant) string { return "ui-badge ui-badge-" + string(normalizedBadgeVariant(variant)) } +func spaceXClass(step SpacingStep) string { + return "ui-space-x ui-space-x-" + string(normalizedSpacingStep(step)) +} + +func spaceYClass(step SpacingStep) string { + return "ui-space-y ui-space-y-" + string(normalizedSpacingStep(step)) +} + func normalizedSize(size Size) Size { switch size { case SizeSM, SizeLG: @@ -85,6 +114,33 @@ func normalizedButtonTone(tone ButtonTone) ButtonTone { } } +func normalizedIconButtonVariant(variant IconButtonVariant) IconButtonVariant { + switch variant { + case IconButtonVariantWarning, IconButtonVariantSuccess, IconButtonVariantDanger: + return variant + default: + return IconButtonVariantNeutral + } +} + +func normalizedIconButtonTone(tone IconButtonTone) IconButtonTone { + switch tone { + case IconButtonToneGhost: + return tone + default: + return IconButtonToneSolid + } +} + +func normalizedSpacingStep(step SpacingStep) SpacingStep { + switch step { + case SpacingStepXS, SpacingStepSM, SpacingStepLG, SpacingStepXL: + return step + default: + return SpacingStepMD + } +} + func normalizedBadgeVariant(variant BadgeVariant) BadgeVariant { switch variant { case BadgeVariantWarning, BadgeVariantSuccess, BadgeVariantDanger: diff --git a/go-backend/internal/web/views/dashboard_components.templ b/go-backend/internal/web/views/dashboard_components.templ index 12d8e57..2734eae 100644 --- a/go-backend/internal/web/views/dashboard_components.templ +++ b/go-backend/internal/web/views/dashboard_components.templ @@ -1,6 +1,7 @@ package views import "strconv" +import "xtablo-backend/internal/web/ui" templ DashboardPage(activePath string, content templ.Component) { @DashboardPageWithMainClass(activePath, "dashboard-main flex-1 overflow-auto", content) @@ -193,7 +194,13 @@ templ OverviewHeader(displayName string) {
Founder
- + @ui.Button(ui.ButtonProps{ + Label: "Se déconnecter", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "submit", + })
@@ -220,7 +227,7 @@ templ OverviewProjectsSection(projects []TabloCardView) { for _, project := range hiddenOverviewProjects(projects) { @TabloGridCardWithAttrs(project, templ.Attributes{ "data-overview-project-hidden": "true", - "hidden": true, + "hidden": true, }) }
diff --git a/go-backend/internal/web/views/dashboard_components_templ.go b/go-backend/internal/web/views/dashboard_components_templ.go index f59a1ff..5cd09c0 100644 --- a/go-backend/internal/web/views/dashboard_components_templ.go +++ b/go-backend/internal/web/views/dashboard_components_templ.go @@ -9,6 +9,7 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import "strconv" +import "xtablo-backend/internal/web/ui" func DashboardPage(activePath string, content templ.Component) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { @@ -669,7 +670,7 @@ func AppSectionMainContent(title string, description string) templ.Component { 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: 161, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 162, Col: 14} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -682,7 +683,7 @@ func AppSectionMainContent(title string, description string) templ.Component { 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: 162, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 163, Col: 19} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -724,7 +725,7 @@ func NotFoundContent(displayName string) templ.Component { 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: 182, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 183, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -766,7 +767,7 @@ func OverviewHeader(displayName string) templ.Component { 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: 190, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 191, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -779,13 +780,27 @@ func OverviewHeader(displayName string) templ.Component { 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: 192, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 193, Col: 84} } _, 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, 28, "!
Founder
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "!
Founder
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Button(ui.ButtonProps{ + Label: "Se déconnecter", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSoft, + 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, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -814,7 +829,7 @@ func OverviewActions(actions []quickAction) templ.Component { templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - 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 } @@ -824,7 +839,7 @@ func OverviewActions(actions []quickAction) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -853,7 +868,7 @@ func OverviewProjectsSection(projects []TabloCardView) templ.Component { templ_7745c5c3_Var29 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

Mes Projets

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

Mes Projets

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -872,7 +887,7 @@ func OverviewProjectsSection(projects []TabloCardView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -880,7 +895,7 @@ func OverviewProjectsSection(projects []TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -914,33 +929,33 @@ func SeeMoreProjects(hiddenCount int) templ.Component { } ctx = templ.ClearChildren(ctx) if hiddenCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " de plus
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -970,7 +985,7 @@ func OverviewProjectsScript() templ.Component { templ_7745c5c3_Var33 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -999,7 +1014,7 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { templ_7745c5c3_Var34 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

Mes Tâches

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

Mes Tâches

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1009,7 +1024,7 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1038,7 +1053,7 @@ func QuickActionCard(action quickAction) templ.Component { templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1106,7 +1121,7 @@ func TaskRow(task dashboardTask) templ.Component { 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, 46, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1128,7 +1143,7 @@ func TaskRow(task dashboardTask) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string templ_7745c5c3_Var43, 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: 331, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 338, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1173,7 +1188,7 @@ func TaskRow(task dashboardTask) templ.Component { 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, 52, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var46 string templ_7745c5c3_Var46, 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: 334, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 341, Col: 28} } _, 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var47 string templ_7745c5c3_Var47, 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: 336, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 343, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var48 string templ_7745c5c3_Var48, 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: 337, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 344, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1234,7 +1249,7 @@ func TaskRow(task dashboardTask) templ.Component { 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, 57, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var51 string templ_7745c5c3_Var51, 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: 340, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 347, Col: 75} } _, 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, 57, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1294,20 +1309,20 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1401,20 +1416,20 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1503,20 +1518,20 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { templ_7745c5c3_Var66 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1524,20 +1539,20 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var68 string templ_7745c5c3_Var68, 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: 375, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 382, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") 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 35e60e9..de089b9 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -113,6 +113,7 @@ func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView { projects = append(projects, TabloCardView{ ID: tablo.ID.String(), Name: tablo.Name, + Color: strings.TrimSpace(tablo.Color), Status: string(tablo.Status), StatusLabel: statusLabel, StatusTone: statusTone, @@ -122,6 +123,7 @@ func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView { Progress: progress, ProgressLabel: progressPercentLabel(progress), DeleteRequestURL: "/tablos/" + tablo.ID.String(), + EditRequestURL: "/tablos/" + tablo.ID.String() + "/edit", }) } return projects diff --git a/go-backend/internal/web/views/tablos.templ b/go-backend/internal/web/views/tablos.templ index 7c87ffe..cbe9fd4 100644 --- a/go-backend/internal/web/views/tablos.templ +++ b/go-backend/internal/web/views/tablos.templ @@ -112,8 +112,12 @@ templ TablosPageContent(vm TablosPageViewModel) { }), }) } - if vm.ModalOpen { - @CreateTabloModal(vm) + if vm.HasModal() { + if vm.IsCreateModal() { + @CreateTabloModal(vm) + } else if vm.IsEditModal() { + @EditTabloModal(vm) + } }
} @@ -140,7 +144,8 @@ templ BorderlessDeleteButton(deleteRequestURL string) { @ui.IconButton(ui.IconButtonProps{ Label: "Supprimer le projet", Icon: "trash", - Variant: ui.IconButtonVariantDangerGhost, + Variant: ui.IconButtonVariantDanger, + Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{ "hx-delete": deleteRequestURL, @@ -151,21 +156,40 @@ templ BorderlessDeleteButton(deleteRequestURL string) { }) } +templ EditTabloButton(editRequestURL string) { + @ui.IconButton(ui.IconButtonProps{ + Label: "Modifier le projet", + Icon: "pencil", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": editRequestURL, + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-push-url": "true", + }, + }) +} + templ TabloGridCard(tablo TabloCardView) { @TabloGridCardWithAttrs(tablo, nil) } templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) { -
+
@ui.Badge(ui.BadgeProps{ Label: tablo.StatusLabel, Variant: badgeVariantForTone(tablo.StatusTone), }) - @BorderlessDeleteButton(tablo.DeleteRequestURL) +
+ @EditTabloButton(tablo.EditRequestURL) + @BorderlessDeleteButton(tablo.DeleteRequestURL) +
-
+
{ tablo.Initial }

{ tablo.Name }

@@ -180,17 +204,17 @@ templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) { { tablo.ProgressLabel }
-
+
} templ TabloListRow(tablo TabloCardView) { - +
-
svg]:w-4 [&>svg]:h-4 " + tablo.IconBgClass + " " + tablo.IconFgClass }> +
@ActionIcon(tablo.IconKind)
{ tablo.Name } @@ -211,13 +235,16 @@ templ TabloListRow(tablo TabloCardView) {
-
+
{ tablo.ProgressLabel }
- @BorderlessDeleteButton(tablo.DeleteRequestURL) +
+ @EditTabloButton(tablo.EditRequestURL) + @BorderlessDeleteButton(tablo.DeleteRequestURL) +
} @@ -269,7 +296,21 @@ templ CreateTabloModalBody(vm TablosPageViewModel) { Placeholder: "Nom du projet", Type: "text", }), - Error: vm.ErrorMessage, + }) + @ui.FormField(ui.FormFieldProps{ + Label: "Couleur", + For: "tablo-color", + Field: ui.Input(ui.InputProps{ + ID: "tablo-color", + Name: "color", + Value: vm.FormColor, + Placeholder: "#3B82F6", + Type: "text", + Attrs: templ.Attributes{ + "pattern": "^#[0-9A-Fa-f]{6}$", + "autocomplete": "off", + }, + }), })
} + +templ EditTabloModal(vm TablosPageViewModel) { + @ui.Modal(ui.ModalProps{ + Title: "Modifier le projet", + Body: EditTabloModalBody(vm), + }) +} + +templ EditTabloColorField(vm TablosPageViewModel) { +
+ @ui.Input(ui.InputProps{ + ID: "edit-tablo-color", + Name: "color", + Value: vm.FormColor, + Placeholder: "#3B82F6", + Type: "text", + Attrs: templ.Attributes{ + "pattern": "^#[0-9A-Fa-f]{6}$", + "autocomplete": "off", + "oninput": "document.getElementById('edit-tablo-color-picker').value=this.value", + }, + }) + +
+} + +templ EditTabloModalBody(vm TablosPageViewModel) { +
+ + + + if vm.ErrorMessage != "" { +
{ vm.ErrorMessage }
+ } + @ui.FormField(ui.FormFieldProps{ + Label: "Nom du projet", + For: "edit-tablo-name", + Field: ui.Input(ui.InputProps{ + ID: "edit-tablo-name", + Name: "name", + Value: vm.FormName, + Placeholder: "Nom du projet", + Type: "text", + }), + }) + @ui.FormField(ui.FormFieldProps{ + Label: "Couleur", + For: "edit-tablo-color", + Field: EditTabloColorField(vm), + Hint: "Utilisez le champ hex ou le sélecteur pour choisir une couleur au format #RRGGBB.", + }) +
+ + Annuler + + @ui.Button(ui.ButtonProps{ + Label: "Enregistrer", + Variant: ui.ButtonVariantDefault, + 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 index 51c5c0a..1b9ad4a 100644 --- a/go-backend/internal/web/views/tablos_templ.go +++ b/go-backend/internal/web/views/tablos_templ.go @@ -303,10 +303,17 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { 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 + if vm.HasModal() { + if vm.IsCreateModal() { + templ_7745c5c3_Err = CreateTabloModal(vm).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if vm.IsEditModal() { + templ_7745c5c3_Err = EditTabloModal(vm).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") @@ -350,7 +357,7 @@ func StatusPill(vm TablosPageViewModel, status string, label string) templ.Compo var templ_7745c5c3_Var16 templ.SafeURL templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(vm.StatusHref(status))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 123, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 127, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -363,7 +370,7 @@ func StatusPill(vm TablosPageViewModel, status string, label string) templ.Compo var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(vm.StatusHref(status)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 124, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 128, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -403,7 +410,7 @@ func StatusPill(vm TablosPageViewModel, status string, label string) templ.Compo 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 139, Col: 9} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -441,7 +448,8 @@ func BorderlessDeleteButton(deleteRequestURL string) templ.Component { templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{ Label: "Supprimer le projet", Icon: "trash", - Variant: ui.IconButtonVariantDangerGhost, + Variant: ui.IconButtonVariantDanger, + Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{ "hx-delete": deleteRequestURL, @@ -457,6 +465,47 @@ func BorderlessDeleteButton(deleteRequestURL string) templ.Component { }) } +func EditTabloButton(editRequestURL 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_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{ + Label: "Modifier le projet", + Icon: "pencil", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": editRequestURL, + "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 + } + 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 @@ -473,9 +522,9 @@ func TabloGridCard(tablo TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var21 := templ.GetChildren(ctx) - if templ_7745c5c3_Var21 == nil { - templ_7745c5c3_Var21 = templ.NopComponent + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = TabloGridCardWithAttrs(tablo, nil).Render(ctx, templ_7745c5c3_Buffer) @@ -502,12 +551,25 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var22 := templ.GetChildren(ctx) - if templ_7745c5c3_Var22 == nil { - templ_7745c5c3_Var22 = 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, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -526,40 +588,26 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = EditTabloButton(tablo.EditRequestURL).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, 33, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var23 = []any{"project-avatar " + projectAccentClass(tablo.Accent)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + 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.Initial) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 169, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 193, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -572,7 +620,7 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, 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: 171, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 195, Col: 19} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -593,7 +641,7 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, 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: 175, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 199, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -606,48 +654,26 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, 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: 180, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 204, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -671,34 +697,25 @@ func TabloListRow(tablo TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var32 := templ.GetChildren(ctx) - if templ_7745c5c3_Var32 == nil { - templ_7745c5c3_Var32 = templ.NopComponent + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "svg]:w-4 [&>svg]:h-4 " + tablo.IconBgClass + " " + tablo.IconFgClass} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...) + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(projectColorVariableStyle(tablo.Color)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 214, Col: 179} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">
svg]:w-4 [&>svg]:h-4\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -706,20 +723,20 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name) + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, 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: 196, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 220, Col: 84} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + _, 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, 48, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -730,7 +747,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -738,42 +755,46 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.CreatedAtLabel) + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, 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: 208, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 232, Col: 26} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.ProgressLabel) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, 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: 216, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 240, Col: 109} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + _, 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, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = EditTabloButton(tablo.EditRequestURL).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -781,7 +802,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { 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, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -805,9 +826,9 @@ func CreateTabloModal(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var39 := templ.GetChildren(ctx) - if templ_7745c5c3_Var39 == nil { - templ_7745c5c3_Var39 = templ.NopComponent + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = ui.Modal(ui.ModalProps{ @@ -837,12 +858,12 @@ func TabloListHead() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var40 := templ.GetChildren(ctx) - if templ_7745c5c3_Var40 == nil { - templ_7745c5c3_Var40 = templ.NopComponent + templ_7745c5c3_Var37 := templ.GetChildren(ctx) + if templ_7745c5c3_Var37 == nil { + templ_7745c5c3_Var37 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "ProjetStatutCréé leProgression") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "ProjetStatutCréé leProgression") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -866,9 +887,9 @@ func TabloListBody(tablos []TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var41 := templ.GetChildren(ctx) - if templ_7745c5c3_Var41 == nil { - templ_7745c5c3_Var41 = templ.NopComponent + templ_7745c5c3_Var38 := templ.GetChildren(ctx) + if templ_7745c5c3_Var38 == nil { + templ_7745c5c3_Var38 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, tablo := range tablos { @@ -897,69 +918,69 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var42 := templ.GetChildren(ctx) - if templ_7745c5c3_Var42 == nil { - templ_7745c5c3_Var42 = templ.NopComponent + 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, 55, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if vm.ErrorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ErrorMessage) + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, 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: 260, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 287, Col: 112} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -974,38 +995,55 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { 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, 61, "
Annuler") + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(vm.CloseModalHref()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 318, Col: 32} + } + _, 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, 60, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"ui-button ui-button-solid ui-button-neutral ui-button-md\">Annuler") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1018,7 +1056,266 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func EditTabloModal(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_Var46 := templ.GetChildren(ctx) + if templ_7745c5c3_Var46 == nil { + templ_7745c5c3_Var46 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = ui.Modal(ui.ModalProps{ + Title: "Modifier le projet", + Body: EditTabloModalBody(vm), + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func EditTabloColorField(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_Var47 := templ.GetChildren(ctx) + if templ_7745c5c3_Var47 == nil { + templ_7745c5c3_Var47 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Input(ui.InputProps{ + ID: "edit-tablo-color", + Name: "color", + Value: vm.FormColor, + Placeholder: "#3B82F6", + Type: "text", + Attrs: templ.Attributes{ + "pattern": "^#[0-9A-Fa-f]{6}$", + "autocomplete": "off", + "oninput": "document.getElementById('edit-tablo-color-picker').value=this.value", + }, + }).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 + }) +} + +func EditTabloModalBody(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_Var49 := templ.GetChildren(ctx) + if templ_7745c5c3_Var49 == nil { + templ_7745c5c3_Var49 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.ErrorMessage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, 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: 378, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = ui.FormField(ui.FormFieldProps{ + Label: "Nom du projet", + For: "edit-tablo-name", + Field: ui.Input(ui.InputProps{ + ID: "edit-tablo-name", + Name: "name", + Value: vm.FormName, + Placeholder: "Nom du projet", + Type: "text", + }), + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.FormField(ui.FormFieldProps{ + Label: "Couleur", + For: "edit-tablo-color", + Field: EditTabloColorField(vm), + Hint: "Utilisez le champ hex ou le sélecteur pour choisir une couleur au format #RRGGBB.", + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
Annuler") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.Button(ui.ButtonProps{ + Label: "Enregistrer", + Variant: ui.ButtonVariantDefault, + 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, 75, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/views/tablos_view.go b/go-backend/internal/web/views/tablos_view.go index 111dce8..6625902 100644 --- a/go-backend/internal/web/views/tablos_view.go +++ b/go-backend/internal/web/views/tablos_view.go @@ -5,50 +5,57 @@ import ( "net/url" "strings" + "github.com/a-h/templ" "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 + ID string + Name string + Color 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 + EditRequestURL 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 + DisplayName string + View string + Query string + Status string + ModalKind string + EditingTabloID string + FormName string + FormColor string + ErrorMessage string + Tablos []TabloCardView } -func NewTablosPageViewModel(displayName string, view string, query string, status string, modalOpen bool, formName string, errorMessage string, tablos []TabloCardView) TablosPageViewModel { +func NewTablosPageViewModel(displayName string, view string, query string, status string, modalKind string, editingTabloID string, formName string, formColor 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, + DisplayName: displayName, + View: normalizedView(view), + Query: strings.TrimSpace(query), + Status: normalizedStatus(status), + ModalKind: normalizedModalKind(modalKind), + EditingTabloID: strings.TrimSpace(editingTabloID), + FormName: strings.TrimSpace(formName), + FormColor: normalizedFormColor(modalKind, formColor), + ErrorMessage: strings.TrimSpace(errorMessage), + Tablos: tablos, } } @@ -60,6 +67,18 @@ func (vm TablosPageViewModel) HasTablos() bool { return len(vm.Tablos) > 0 } +func (vm TablosPageViewModel) HasModal() bool { + return vm.ModalKind != "" +} + +func (vm TablosPageViewModel) IsCreateModal() bool { + return vm.ModalKind == "create" +} + +func (vm TablosPageViewModel) IsEditModal() bool { + return vm.ModalKind == "edit" +} + func (vm TablosPageViewModel) StatusHref(status string) string { values := vm.baseValues() values.Set("status", normalizedStatus(status)) @@ -94,11 +113,20 @@ func (vm TablosPageViewModel) CreateModalHref() string { return "/tablos?" + values.Encode() } +func (vm TablosPageViewModel) EditModalHref(tabloID string) string { + values := vm.baseValues() + return "/tablos/" + strings.TrimSpace(tabloID) + "/edit?" + values.Encode() +} + func (vm TablosPageViewModel) CloseModalHref() string { values := vm.baseValues() return "/tablos?" + values.Encode() } +func (vm TablosPageViewModel) EditSubmitHref() string { + return "/tablos/" + vm.EditingTabloID +} + func (vm TablosPageViewModel) HasSearch() bool { return vm.Query != "" } @@ -119,6 +147,26 @@ func normalizedStatus(status string) string { } } +func normalizedModalKind(kind string) string { + switch kind { + case "create", "edit": + return kind + default: + return "" + } +} + +func normalizedFormColor(modalKind string, color string) string { + trimmed := strings.TrimSpace(color) + if trimmed != "" { + return trimmed + } + if normalizedModalKind(modalKind) == "create" { + return "#3B82F6" + } + return "" +} + func (vm TablosPageViewModel) baseValues() url.Values { values := url.Values{} values.Set("view", vm.View) @@ -159,3 +207,11 @@ func badgeVariantForTone(tone string) ui.BadgeVariant { return ui.BadgeVariantInfo } } + +func projectColorVariableStyle(color string) templ.SafeCSS { + return templ.SanitizeCSS("--project-color", templ.SafeCSSProperty(strings.TrimSpace(color))) +} + +func backgroundColorStyle(color string) templ.SafeCSS { + return templ.SanitizeCSS("background-color", templ.SafeCSSProperty(strings.TrimSpace(color))) +} diff --git a/go-backend/router.go b/go-backend/router.go index 95c5d10..6038acb 100644 --- a/go-backend/router.go +++ b/go-backend/router.go @@ -38,6 +38,8 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler { mux.Get("/files", authHandler.GetFilesPage()) mux.Get("/feedback", authHandler.GetFeedbackPage()) mux.Post("/tablos", authHandler.PostTablos()) + mux.Get("/tablos/{tabloID}/edit", authHandler.GetEditTabloModal()) + mux.Post("/tablos/{tabloID}", authHandler.PostTabloUpdate()) mux.Delete("/tablos/{tabloID}", authHandler.DeleteTablo()) mux.Get("/login", authHandler.GetLoginPage()) mux.Get("/signup", authHandler.GetSignupPage()) diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css index 41ca139..1dd7aec 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -983,7 +983,7 @@ input { } .project-card-top { - align-items: flex-start; + align-items: center; display: flex; justify-content: space-between; margin-bottom: 1rem; @@ -1468,6 +1468,19 @@ input { display: inline-flex; } +.catalog-spacing-row { + align-items: center; + display: flex; + gap: 0; +} + +.catalog-spacing-column { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} + .catalog-example-snippet { background: #111827; border-radius: 0.875rem; @@ -1511,7 +1524,6 @@ input { background: transparent; border: 0; border-radius: 0.5rem; - color: #6b7280; cursor: pointer; display: inline-flex; justify-content: center; @@ -1523,11 +1535,64 @@ input { color 0.2s ease; } -.ui-icon-button:hover { +.ui-icon-button-solid.ui-icon-button-neutral { + color: #6b7280; +} + +.ui-icon-button-solid.ui-icon-button-neutral:hover { background: #f9fafb; color: #111827; } +.ui-space-x { + display: inline-block; + flex-shrink: 0; +} + +.ui-space-y { + display: block; +} + +.ui-space-x-xs { + width: 0.25rem; +} + +.ui-space-x-sm { + width: 0.5rem; +} + +.ui-space-x-md { + width: 0.75rem; +} + +.ui-space-x-lg { + width: 1rem; +} + +.ui-space-x-xl { + width: 1.5rem; +} + +.ui-space-y-xs { + height: 0.25rem; +} + +.ui-space-y-sm { + height: 0.5rem; +} + +.ui-space-y-md { + height: 0.75rem; +} + +.ui-space-y-lg { + height: 1rem; +} + +.ui-space-y-xl { + height: 1.5rem; +} + .ui-modal-backdrop { align-items: center; background: rgba(17, 24, 39, 0.52); @@ -1587,16 +1652,24 @@ input { border: 0; box-shadow: none; appearance: none; - color: #9ca3af; cursor: pointer; outline: none; } +.ui-icon-button-ghost.ui-icon-button-neutral, +.ui-icon-button-ghost.ui-icon-button-danger { + color: #9ca3af; +} + .project-card-top .borderless-icon-button { padding: 0; } -.project-card-top .borderless-icon-button:hover { +.project-card-top .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-neutral:hover { + color: #111827; +} + +.project-card-top .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger:hover { color: #ef4444; } @@ -1621,7 +1694,11 @@ td.text-right .borderless-icon-button { transition: color 0.2s; } -td.text-right .borderless-icon-button:hover { +td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-neutral:hover { + color: #111827; +} + +td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger:hover { color: #ef4444; } @@ -1634,6 +1711,7 @@ td.text-right .borderless-icon-button:hover { .project-avatar { align-items: center; + background: var(--project-color, #3b82f6); border-radius: 0.85rem; color: #fff; display: inline-flex; @@ -1645,6 +1723,11 @@ td.text-right .borderless-icon-button:hover { width: 3rem; } +.project-list-icon { + background: var(--project-color, #3b82f6); + color: #fff; +} + .project-accent-blue { background: #3b82f6; } @@ -1697,10 +1780,17 @@ td.text-right .borderless-icon-button:hover { } .project-progress-bar { + background: var(--project-color, #3b82f6); border-radius: 999px; height: 100%; } +.tablo-color-picker { + max-width: 5rem; + min-height: 44px; + padding: 0.4rem; +} + .overview-more-row { display: flex; justify-content: center; diff --git a/go-backend/static/tailwind.css b/go-backend/static/tailwind.css index 6314adb..98b2ad0 100644 --- a/go-backend/static/tailwind.css +++ b/go-backend/static/tailwind.css @@ -7,7 +7,6 @@ --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); @@ -60,6 +59,9 @@ .absolute { position: absolute; } +.fixed { + position: fixed; +} .relative { position: relative; } @@ -199,6 +201,9 @@ .justify-end { justify-content: flex-end; } +.gap-1 { + gap: calc(var(--spacing) * 1); +} .gap-1\.5 { gap: calc(var(--spacing) * 1.5); } @@ -298,9 +303,6 @@ .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); } -- 2.45.2 From 2e52daa81d1ef86f6d7921a8950826bf42dc6ca1 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 10:38:07 +0200 Subject: [PATCH 024/546] Add space X and space Y to components --- docs/design-system/spacing.html | 13 + ...026-05-10-go-backend-spacing-primitives.md | 444 +++++++++++++++ .../2026-05-10-go-backend-tablo-edit-color.md | 522 ++++++++++++++++++ go-backend/internal/web/ui/space.templ | 13 + go-backend/internal/web/ui/space_templ.go | 109 ++++ .../web/views/dashboard_components_test.go | 91 +++ 6 files changed, 1192 insertions(+) create mode 100644 docs/design-system/spacing.html create mode 100644 docs/superpowers/plans/2026-05-10-go-backend-spacing-primitives.md create mode 100644 docs/superpowers/plans/2026-05-10-go-backend-tablo-edit-color.md create mode 100644 go-backend/internal/web/ui/space.templ create mode 100644 go-backend/internal/web/ui/space_templ.go create mode 100644 go-backend/internal/web/views/dashboard_components_test.go diff --git a/docs/design-system/spacing.html b/docs/design-system/spacing.html new file mode 100644 index 0000000..c5d8028 --- /dev/null +++ b/docs/design-system/spacing.html @@ -0,0 +1,13 @@ + + + + + + Spacing + + + + +

Design System

Spacing

Fixed horizontal and vertical spacer primitives for composing gaps between UI components.

Horizontal spacing

Use SpaceX to insert fixed horizontal gaps between inline or row-aligned components.

@ui.SpaceX(ui.SpaceProps{Size: ui.SpacingStepLG})

Vertical spacing

Use SpaceY to insert fixed vertical gaps between stacked blocks.

Bloc 1
Bloc 2
@ui.SpaceY(ui.SpaceProps{Size: ui.SpacingStepMD})
+ + diff --git a/docs/superpowers/plans/2026-05-10-go-backend-spacing-primitives.md b/docs/superpowers/plans/2026-05-10-go-backend-spacing-primitives.md new file mode 100644 index 0000000..74e9244 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-go-backend-spacing-primitives.md @@ -0,0 +1,444 @@ +# Go Backend Spacing Primitives Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add fixed-size `SpaceX` and `SpaceY` primitives to the Go backend UI library, document them in the catalog, and cover them with direct UI and design-system tests. + +**Architecture:** Implement spacing as two presentational templ primitives backed by a shared `SpacingStep` enum and helper normalization functions. Keep the component API explicit, keep layout behavior in CSS classes rather than inline styles, and update the catalog with inner wrappers so the preview container's existing `gap` does not distort the examples. + +**Tech Stack:** Go, templ, generated templ Go files, static CSS, catalog page generation via `cmd/designsystem`, Go `testing` + +--- + +## File Map + +**Create** + +- `go-backend/internal/web/ui/space.templ` + - New `SpaceProps`, `SpaceX`, and `SpaceY` templ primitives. + +**Modify** + +- `go-backend/internal/web/ui/variants.go` + - Add `SpacingStep` constants plus `normalizedSpacingStep`, `spaceXClass`, and `spaceYClass`. +- `go-backend/internal/web/ui/space_templ.go` + - Generated templ output for the new spacing primitive. +- `go-backend/internal/web/ui/ui_test.go` + - Add direct markup tests for default and explicit spacing steps. +- `go-backend/internal/web/ui/catalog/examples.go` + - Add catalog examples that render real `SpaceX` and `SpaceY` markup inside zero-gap wrappers. +- `go-backend/internal/web/ui/catalog/pages.go` + - Register the new `spacing` catalog page. +- `go-backend/internal/web/ui/catalog/catalog_test.go` + - Add page registration and rendered markup assertions for the spacing page. +- `go-backend/cmd/designsystem/main_test.go` + - Expect `spacing.html` in generated output. +- `go-backend/static/styles.css` + - Add spacing utility classes and, if needed, tiny catalog helper wrappers for zero-gap preview rows/columns. +- `docs/design-system/index.html` + - Generated design-system index with the spacing page link. +- `docs/design-system/tokens.html` + - Generated page with updated catalog navigation. +- `docs/design-system/buttons.html` + - Generated page with updated catalog navigation. +- `docs/design-system/badges.html` + - Generated page with updated catalog navigation. +- `docs/design-system/icon-buttons.html` + - Generated page with updated catalog navigation. +- `docs/design-system/inputs.html` + - Generated page with updated catalog navigation. +- `docs/design-system/form-fields.html` + - Generated page with updated catalog navigation. +- `docs/design-system/modals.html` + - Generated page with updated catalog navigation. +- `docs/design-system/tables.html` + - Generated page with updated catalog navigation. +- `docs/design-system/empty-states.html` + - Generated page with updated catalog navigation. +- `docs/design-system/cards.html` + - Generated page with updated catalog navigation. +- `docs/design-system/spacing.html` + - New generated design-system page for spacing. + +**Commands / Generated Artifacts** + +- `cd go-backend && just generate` + - Regenerates `internal/web/ui/space_templ.go` and refreshes CSS build outputs. +- `cd go-backend && just design-system` + - Regenerates `docs/design-system/*.html`. +- `cd go-backend && go test ./internal/web/ui ./internal/web/ui/catalog ./cmd/designsystem -count=1` +- `cd go-backend && go test ./internal/web/... ./cmd/designsystem -count=1` + +## Task 1: Lock Down Spacing Behavior With Failing Tests + +**Files:** +- Modify: `go-backend/internal/web/ui/ui_test.go` +- Modify: `go-backend/internal/web/ui/catalog/catalog_test.go` +- Modify: `go-backend/cmd/designsystem/main_test.go` + +- [ ] **Step 1: Add failing direct UI tests for spacing markup** + +Add two tests to `go-backend/internal/web/ui/ui_test.go` that lock down the default `md` size and an explicit `xl` size. + +```go +func TestSpaceXRendersDefaultMediumMarkup(t *testing.T) { + component := SpaceX(SpaceProps{}) + + html := renderToString(t, component) + + for _, want := range []string{ + `aria-hidden="true"`, + `class="ui-space-x ui-space-x-md"`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestSpaceYRendersExplicitExtraLargeMarkup(t *testing.T) { + component := SpaceY(SpaceProps{Size: SpacingStepXL}) + + html := renderToString(t, component) + + for _, want := range []string{ + `aria-hidden="true"`, + `class="ui-space-y ui-space-y-xl"`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} +``` + +- [ ] **Step 2: Extend stylesheet coverage assertions for the new classes** + +In `TestSharedSemanticClassesExistInStylesheet`, add the spacing classes that must exist after implementation: + +```go +for _, want := range []string{ + `.ui-space-x-md`, + `.ui-space-y-md`, +} { + if !strings.Contains(css, want) { + t.Fatalf("expected stylesheet to contain %q", want) + } +} +``` + +- [ ] **Step 3: Add failing catalog tests for the new page and generated site output** + +Update `go-backend/internal/web/ui/catalog/catalog_test.go` and `go-backend/cmd/designsystem/main_test.go`. + +```go +if _, ok := FindPage("spacing"); !ok { + t.Fatalf("expected catalog page %q", "spacing") +} +``` + +```go +{slug: "spacing", want: []string{`ui-space-x`, `ui-space-y`}}, +``` + +```go +for _, name := range []string{ + "spacing.html", +} { + path := filepath.Join(outputDir, name) + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected generated file %q: %v", path, err) + } +} +``` + +- [ ] **Step 4: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/ui ./internal/web/ui/catalog ./cmd/designsystem -count=1` + +Expected: FAIL with undefined identifiers such as `SpaceX`, `SpaceY`, `SpaceProps`, and missing `spacing` page expectations. + +- [ ] **Step 5: Commit the failing test expectations** + +```bash +git add go-backend/internal/web/ui/ui_test.go go-backend/internal/web/ui/catalog/catalog_test.go go-backend/cmd/designsystem/main_test.go +git commit -m "test: define spacing primitive expectations" +``` + +## Task 2: Implement The Spacing Primitive And CSS Contract + +**Files:** +- Create: `go-backend/internal/web/ui/space.templ` +- Modify: `go-backend/internal/web/ui/variants.go` +- Modify: `go-backend/internal/web/ui/space_templ.go` +- Modify: `go-backend/internal/web/ui/ui_test.go` +- Modify: `go-backend/static/styles.css` + +- [ ] **Step 1: Add the spacing type and helper functions** + +Update `go-backend/internal/web/ui/variants.go` with a small shared scale and deterministic normalization: + +```go +type SpacingStep string + +const ( + SpacingStepXS SpacingStep = "xs" + SpacingStepSM SpacingStep = "sm" + SpacingStepMD SpacingStep = "md" + SpacingStepLG SpacingStep = "lg" + SpacingStepXL SpacingStep = "xl" +) + +func normalizedSpacingStep(step SpacingStep) SpacingStep { + switch step { + case SpacingStepXS, SpacingStepSM, SpacingStepLG, SpacingStepXL: + return step + default: + return SpacingStepMD + } +} + +func spaceXClass(step SpacingStep) string { + return "ui-space-x ui-space-x-" + string(normalizedSpacingStep(step)) +} + +func spaceYClass(step SpacingStep) string { + return "ui-space-y ui-space-y-" + string(normalizedSpacingStep(step)) +} +``` + +- [ ] **Step 2: Create the templ primitive** + +Create `go-backend/internal/web/ui/space.templ`: + +```go +package ui + +type SpaceProps struct { + Size SpacingStep +} + +templ SpaceX(props SpaceProps) { + +} + +templ SpaceY(props SpaceProps) { + +} +``` + +- [ ] **Step 3: Add the CSS classes for base behavior and all spacing steps** + +Append the spacing contract to `go-backend/static/styles.css` near the other `ui-` primitives: + +```css +.ui-space-x { + display: inline-block; + flex-shrink: 0; +} + +.ui-space-y { + display: block; +} + +.ui-space-x-xs { width: 0.25rem; } +.ui-space-x-sm { width: 0.5rem; } +.ui-space-x-md { width: 0.75rem; } +.ui-space-x-lg { width: 1rem; } +.ui-space-x-xl { width: 1.5rem; } + +.ui-space-y-xs { height: 0.25rem; } +.ui-space-y-sm { height: 0.5rem; } +.ui-space-y-md { height: 0.75rem; } +.ui-space-y-lg { height: 1rem; } +.ui-space-y-xl { height: 1.5rem; } +``` + +- [ ] **Step 4: Regenerate templ output** + +Run: `cd go-backend && just generate` + +Expected: PASS with an updated `internal/web/ui/space_templ.go` and rebuilt static CSS assets. + +- [ ] **Step 5: Run the focused component tests to verify they pass** + +Run: `cd go-backend && go test ./internal/web/ui -run 'TestSpace|TestSharedSemanticClassesExistInStylesheet' -count=1` + +Expected: PASS with the new spacing tests green. + +- [ ] **Step 6: Commit the primitive implementation** + +```bash +git add go-backend/internal/web/ui/variants.go go-backend/internal/web/ui/space.templ go-backend/internal/web/ui/space_templ.go go-backend/internal/web/ui/ui_test.go go-backend/static/styles.css +git commit -m "feat: add spacing primitives" +``` + +## Task 3: Register The Catalog Page And Refresh Generated Docs + +**Files:** +- Modify: `go-backend/internal/web/ui/catalog/examples.go` +- Modify: `go-backend/internal/web/ui/catalog/pages.go` +- Modify: `go-backend/internal/web/ui/catalog/catalog_test.go` +- Modify: `go-backend/cmd/designsystem/main_test.go` +- Modify: `go-backend/static/styles.css` +- Modify: `docs/design-system/index.html` +- Modify: `docs/design-system/spacing.html` + +- [ ] **Step 1: Register the new catalog page** + +Update `go-backend/internal/web/ui/catalog/pages.go`: + +```go +{ + Slug: "spacing", + Title: "Spacing", + Description: "Fixed horizontal and vertical spacer primitives for composing gaps between UI components.", + Examples: spacingExamples(), +}, +``` + +- [ ] **Step 2: Add real spacing examples with zero-gap inner wrappers** + +Add `spacingExamples()` to `go-backend/internal/web/ui/catalog/examples.go`. Use inner wrappers because `catalog-example-preview` already applies `gap: 0.75rem`. + +```go +func spacingExamples() []Example { + return []Example{ + { + Title: "Horizontal spacing", + Description: "Use SpaceX to insert fixed horizontal gaps between inline or flex-row components.", + Preview: componentFunc(func(ctx context.Context, w io.Writer) error { + if _, err := io.WriteString(w, `
`); err != nil { + return err + } + if err := ui.Button(ui.ButtonProps{Label: "Précédent", Variant: ui.ButtonVariantNeutral, Size: ui.SizeMD, Type: "button"}).Render(ctx, w); err != nil { + return err + } + if err := ui.SpaceX(ui.SpaceProps{Size: ui.SpacingStepLG}).Render(ctx, w); err != nil { + return err + } + if err := ui.Button(ui.ButtonProps{Label: "Suivant", Variant: ui.ButtonVariantDefault, Size: ui.SizeMD, Type: "button"}).Render(ctx, w); err != nil { + return err + } + _, err := io.WriteString(w, `
`) + return err + }), + Snippet: `@ui.SpaceX(ui.SpaceProps{Size: ui.SpacingStepLG})`, + }, + { + Title: "Vertical spacing", + Description: "Use SpaceY to insert fixed vertical gaps between stacked blocks.", + Preview: componentFunc(func(ctx context.Context, w io.Writer) error { + if _, err := io.WriteString(w, `
`); err != nil { + return err + } + if err := ui.Card(ui.CardProps{Body: textComponent("Bloc 1")}).Render(ctx, w); err != nil { + return err + } + if err := ui.SpaceY(ui.SpaceProps{Size: ui.SpacingStepMD}).Render(ctx, w); err != nil { + return err + } + if err := ui.Card(ui.CardProps{Body: textComponent("Bloc 2")}).Render(ctx, w); err != nil { + return err + } + _, err := io.WriteString(w, `
`) + return err + }), + Snippet: `@ui.SpaceY(ui.SpaceProps{Size: ui.SpacingStepMD})`, + }, + } +} +``` + +- [ ] **Step 3: Add tiny catalog-only helper wrappers** + +Append these helpers to `go-backend/static/styles.css` so the previews show only spacer-controlled gaps: + +```css +.catalog-spacing-row { + align-items: center; + display: flex; + gap: 0; +} + +.catalog-spacing-column { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} +``` + +- [ ] **Step 4: Regenerate the design-system site** + +Run: `cd go-backend && just design-system` + +Expected: PASS with refreshed `docs/design-system/index.html` and a new `docs/design-system/spacing.html`. + +- [ ] **Step 5: Run catalog and design-system tests** + +Run: `cd go-backend && go test ./internal/web/ui/catalog ./cmd/designsystem -count=1` + +Expected: PASS with the spacing page present in both rendered catalog output and generated static site output. + +- [ ] **Step 6: Commit the catalog integration** + +```bash +git add go-backend/internal/web/ui/catalog/examples.go go-backend/internal/web/ui/catalog/pages.go go-backend/internal/web/ui/catalog/catalog_test.go go-backend/cmd/designsystem/main_test.go go-backend/static/styles.css docs/design-system/*.html +git commit -m "feat: document spacing primitives" +``` + +## Task 4: Final Verification + +**Files:** +- Modify: `go-backend/internal/web/ui/space.templ` +- Modify: `go-backend/internal/web/ui/variants.go` +- Modify: `go-backend/internal/web/ui/catalog/examples.go` +- Modify: `go-backend/static/styles.css` + +- [ ] **Step 1: Run the full relevant test suite** + +Run: `cd go-backend && go test ./internal/web/... ./cmd/designsystem -count=1` + +Expected: PASS with: + +- `xtablo-backend/internal/web/ui` +- `xtablo-backend/internal/web/ui/catalog` +- `xtablo-backend/cmd/designsystem` + +all green. + +- [ ] **Step 2: Inspect the final diff for spacing-only scope** + +Run: `git diff --stat -- go-backend/internal/web/ui/space.templ go-backend/internal/web/ui/space_templ.go go-backend/internal/web/ui/variants.go go-backend/internal/web/ui/ui_test.go go-backend/internal/web/ui/catalog/examples.go go-backend/internal/web/ui/catalog/pages.go go-backend/internal/web/ui/catalog/catalog_test.go go-backend/cmd/designsystem/main_test.go go-backend/static/styles.css docs/design-system` + +Expected: only spacing primitive, catalog, generated docs, and test files are included. + +- [ ] **Step 3: Create the final verification commit if any follow-up polish was needed** + +```bash +git add go-backend/internal/web/ui/space.templ go-backend/internal/web/ui/space_templ.go go-backend/internal/web/ui/variants.go go-backend/internal/web/ui/ui_test.go go-backend/internal/web/ui/catalog/examples.go go-backend/internal/web/ui/catalog/pages.go go-backend/internal/web/ui/catalog/catalog_test.go go-backend/cmd/designsystem/main_test.go go-backend/static/styles.css docs/design-system/*.html +git commit -m "chore: verify spacing primitives release" +``` + +## Self-Review + +**Spec coverage check** + +- `SpaceX` and `SpaceY`: covered in Task 2 +- `SpacingStep` scale and `md` fallback: covered in Task 2 plus Task 1 tests +- stylesheet classes: covered in Task 2 +- catalog page and examples: covered in Task 3 +- design-system generated output: covered in Task 3 and Task 4 +- no app call-site migrations: respected by file scope + +**Placeholder scan** + +- No `TODO`, `TBD`, or “appropriate handling” placeholders remain. +- All commands are explicit. +- All code-changing steps include concrete snippets. + +**Type consistency** + +- Shared type names are consistent throughout: `SpacingStep`, `SpaceProps`, `SpaceX`, `SpaceY` +- Class names are consistent throughout: `ui-space-x`, `ui-space-y`, `ui-space-x-{step}`, `ui-space-y-{step}` diff --git a/docs/superpowers/plans/2026-05-10-go-backend-tablo-edit-color.md b/docs/superpowers/plans/2026-05-10-go-backend-tablo-edit-color.md new file mode 100644 index 0000000..47ee939 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-go-backend-tablo-edit-color.md @@ -0,0 +1,522 @@ +# Go Backend Tablo Edit And Color 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:** Add Go backend `/tablos` edit support with an edit icon beside delete, persisted hex colors, create validation for color, and an edit modal that includes both a hex input and a synced native color picker. + +**Architecture:** Extend the existing `tablos` vertical slice in place. Persist `color` through the schema, sqlc, repository, handlers, and view models; add a small modal-state expansion so HTMX can render either the create modal or a per-tablo edit modal; keep status behavior untouched and validate `color` server-side as a canonical `#RRGGBB` string. + +**Tech Stack:** Go, chi, templ, HTMX, PostgreSQL, pgx, sqlc, Go `net/http` tests, Tailwind-generated static CSS + +--- + +## File Map + +**Modify** + +- `go-backend/router.go` + - Register the edit-open and update routes for `/tablos/{tabloID}`. +- `go-backend/internal/db/schema.sql` + - Add the new `color` column for persisted tablos. +- `go-backend/internal/db/queries.sql` + - Extend create/list queries with `color` and add an update query. +- `go-backend/internal/db/repository.go` + - Pass `color` through sqlc calls and map it back into the domain record. +- `go-backend/internal/db/sqlc/models.go` + - Generated sqlc model updates for the new column. +- `go-backend/internal/db/sqlc/querier.go` + - Generated sqlc interface updates for the new query. +- `go-backend/internal/db/sqlc/queries.sql.go` + - Generated sqlc query bindings for create/list/update. +- `go-backend/internal/tablos/model.go` + - Add `Color` to the record and create/update inputs. +- `go-backend/internal/web/handlers/auth.go` + - Extend the repository interface with the update method if it is declared here. +- `go-backend/internal/web/handlers/tablos.go` + - Add hex validation, modal state handling, edit-open handler, update handler, and in-memory repository support. +- `go-backend/internal/web/handlers/tablos_test.go` + - Add repository and handler coverage for color persistence, modal rendering, and update behavior. +- `go-backend/internal/web/views/tablos_view.go` + - Expand page and card view models with color, modal kind, edit URLs, and form values. +- `go-backend/internal/web/views/tablos.templ` + - Add edit buttons, render color-driven styles, add create color field, and add the edit modal with hex input plus native color picker. +- `go-backend/internal/web/views/tablos_templ.go` + - Generated templ output after updating `tablos.templ`. + +**Commands / Generated Artifacts** + +- `cd go-backend && just generate` + - Regenerates: + - `internal/db/sqlc/*.go` + - `internal/web/views/*_templ.go` +- `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos' -v` +- `cd go-backend && go test ./...` + +## Chunk 1: Lock Down Tests And Domain Inputs + +### Task 1: Add failing tests for color-aware create and edit behavior + +**Files:** +- Modify: `go-backend/internal/web/handlers/tablos_test.go` +- Modify: `go-backend/internal/tablos/model.go` + +- [ ] **Step 1: Add failing in-memory repository assertions for `Color`** + +Add tests or extend existing ones so repository fixtures assert that `CreateTabloInput` now accepts `Color` and that the returned/listed `TabloRecord` includes it. + +```go +created, err := repo.CreateTablo(context.Background(), CreateTabloInput{ + OwnerID: user.ID, + Name: "Roadmap", + Color: "#3B82F6", + Status: TabloStatusTodo, +}) +if err != nil { + t.Fatalf("create tablo: %v", err) +} +if created.Color != "#3B82F6" { + t.Fatalf("expected color to persist, got %q", created.Color) +} +``` + +- [ ] **Step 2: Add failing handler tests for create validation and edit modal rendering** + +Add tests covering: + +- create rejects missing color +- create rejects invalid color such as `#0af` +- edit modal renders current `name` +- edit modal renders current hex `color` +- edit modal renders a native color picker input with the same value +- update rejects another user's tablo + +```go +if !strings.Contains(body, `type="color"`) { + t.Fatalf("expected edit modal to render a color picker, got %q", body) +} +if !strings.Contains(body, `value="#3B82F6"`) { + t.Fatalf("expected edit modal to use the current color, got %q", body) +} +``` + +- [ ] **Step 3: Run handler tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos' -v` + +Expected: FAIL because `Color`, edit handlers, update behavior, and modal markup do not exist yet. + +- [ ] **Step 4: Add domain input fields needed by the tests** + +Update `go-backend/internal/tablos/model.go` so the types can express the feature: + +```go +type Record struct { + ID uuid.UUID + OwnerID uuid.UUID + Name string + Color string + Status Status + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +type CreateInput struct { + OwnerID uuid.UUID + Name string + Color string + Status Status +} + +type UpdateInput struct { + ID uuid.UUID + OwnerID uuid.UUID + Name string + Color string +} +``` + +- [ ] **Step 5: Re-run handler tests to confirm the remaining failures are implementation failures, not compile-shape failures** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos' -v` + +Expected: FAIL, but now due to missing repository/handler/view behavior rather than missing domain fields. + +- [ ] **Step 6: Commit the test-and-domain scaffold** + +```bash +git add go-backend/internal/tablos/model.go go-backend/internal/web/handlers/tablos_test.go +git commit -m "test: define tablo color and edit behavior" +``` + +## Chunk 2: Persist Color And Update Repository Paths + +### Task 2: Add `color` to schema, sqlc, and repository behavior + +**Files:** +- Modify: `go-backend/internal/db/schema.sql` +- Modify: `go-backend/internal/db/queries.sql` +- Modify: `go-backend/internal/db/repository.go` +- Modify: `go-backend/internal/db/sqlc/models.go` +- Modify: `go-backend/internal/db/sqlc/querier.go` +- Modify: `go-backend/internal/db/sqlc/queries.sql.go` +- Modify: `go-backend/internal/web/handlers/auth.go` +- Modify: `go-backend/internal/web/handlers/tablos.go` + +- [ ] **Step 1: Add the database column to the schema** + +Update the `public.tablos` table definition: + +```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, + color 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 +); +``` + +- [ ] **Step 2: Extend sqlc queries and add an update statement** + +Update `go-backend/internal/db/queries.sql`: + +```sql +-- name: CreateTablo :one +INSERT INTO public.tablos ( + id, owner_id, name, color, status, created_at, updated_at +) VALUES ( + $1, $2, $3, $4, $5, now(), now() +) +RETURNING id, owner_id, name, color, status, created_at, updated_at, deleted_at; + +-- name: ListTablos :many +SELECT id, owner_id, name, color, status, created_at, updated_at, deleted_at +FROM public.tablos +... + +-- name: UpdateTablo :execrows +UPDATE public.tablos +SET name = $3, color = $4, updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL; +``` + +- [ ] **Step 3: Regenerate generated files** + +Run: `cd go-backend && just generate` + +Expected: PASS with regenerated `internal/db/sqlc/*.go` and `internal/web/views/tablos_templ.go`. + +- [ ] **Step 4: Thread `color` through repository and repository interface code** + +Update: + +- `go-backend/internal/db/repository.go` +- `go-backend/internal/web/handlers/auth.go` +- `go-backend/internal/web/handlers/tablos.go` + +Representative implementation: + +```go +row, err := r.queries.CreateTablo(ctx, sqlcdb.CreateTabloParams{ + ID: uuid.New(), + OwnerID: input.OwnerID, + Name: strings.TrimSpace(input.Name), + Color: input.Color, + Status: string(input.Status), +}) +``` + +and: + +```go +func (r *PostgresAuthRepository) UpdateTablo(ctx context.Context, input tablomodel.UpdateInput) error { + rows, err := r.queries.UpdateTablo(ctx, sqlcdb.UpdateTabloParams{ + ID: input.ID, + OwnerID: input.OwnerID, + Name: strings.TrimSpace(input.Name), + Color: input.Color, + }) + if err != nil { + return err + } + if rows == 0 { + return tablomodel.ErrNotFound + } + return nil +} +``` + +- [ ] **Step 5: Update the in-memory repository implementation** + +Add `Color` storage in `CreateTablo` and implement `UpdateTablo` in the in-memory repository so handler tests can pass without Postgres. + +```go +tablo.Color = input.Color +... +existing.Name = strings.TrimSpace(input.Name) +existing.Color = input.Color +existing.UpdatedAt = time.Now().UTC() +``` + +- [ ] **Step 6: Run handler tests to verify repository-backed failures are cleared** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos' -v` + +Expected: FAIL only on handler/view/modal behavior that still needs implementation. + +- [ ] **Step 7: Commit persistence changes** + +```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/auth.go go-backend/internal/web/handlers/tablos.go +git commit -m "feat: persist go-backend tablo colors" +``` + +## Chunk 3: Add Edit Handlers, Validation, And Modal State + +### Task 3: Implement hex validation and edit/update request handling + +**Files:** +- Modify: `go-backend/router.go` +- Modify: `go-backend/internal/web/handlers/auth.go` +- Modify: `go-backend/internal/web/handlers/tablos.go` +- Modify: `go-backend/internal/web/views/tablos_view.go` + +- [ ] **Step 1: Register the new routes** + +Update `go-backend/router.go`: + +```go +mux.Get("/tablos/{tabloID}/edit", authHandler.GetEditTabloModal()) +mux.Post("/tablos/{tabloID}", authHandler.PostTabloUpdate()) +``` + +- [ ] **Step 2: Add validation helpers in `tablos.go`** + +Add a strict hex validator and normalize accepted values: + +```go +var tabloColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +func normalizeTabloColor(raw string) (string, bool) { + color := strings.TrimSpace(raw) + if !tabloColorPattern.MatchString(color) { + return "", false + } + return strings.ToUpper(color), true +} +``` + +- [ ] **Step 3: Expand modal state beyond `ModalOpen bool`** + +Refactor page state and view model so the page can represent: + +- no modal +- create modal +- edit modal + +Suggested shape: + +```go +type TabloModalKind string + +const ( + TabloModalNone TabloModalKind = "" + TabloModalCreate TabloModalKind = "create" + TabloModalEdit TabloModalKind = "edit" +) +``` + +Carry through: + +- active modal kind +- form name +- form color +- editing tablo ID +- error message + +- [ ] **Step 4: Update create handling to require `color`** + +In `PostTablos()`: + +- parse `name` and `color` +- reject empty/invalid `color` with `422` +- keep the create modal open on validation failure +- pass normalized hex color into `CreateTabloInput` + +- [ ] **Step 5: Implement edit-open and update handlers** + +Add: + +- `GetEditTabloModal() http.HandlerFunc` +- `PostTabloUpdate() http.HandlerFunc` + +Behavior: + +- parse `tabloID` +- find the owner-visible tablo from `ListTablos(...)` or add a narrow lookup helper if needed +- prefill modal values from the existing record +- on update, validate `name` and `color` +- call `UpdateTablo(...)` +- re-render `/tablos` with the current `view`, `q`, and `status` + +- [ ] **Step 6: Expose edit URLs and modal helpers from the view model** + +Update `go-backend/internal/web/views/tablos_view.go` so it can generate: + +- `CreateModalHref()` +- `EditModalHref(tabloID string)` +- `CloseModalHref()` +- card/list-level edit request URLs preserving current query state + +- [ ] **Step 7: Run handler tests to verify only templ rendering remains** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos' -v` + +Expected: FAIL only where rendered markup still lacks the new buttons/fields or where templ generation has not been refreshed yet. + +- [ ] **Step 8: Commit handler and view-model logic** + +```bash +git add go-backend/router.go go-backend/internal/web/handlers/tablos.go go-backend/internal/web/views/tablos_view.go +git commit -m "feat: add go-backend tablo edit handlers" +``` + +## Chunk 4: Render Edit UI, Color Picker, And Final Verification + +### Task 4: Update templ views for edit action, color input, and color picker + +**Files:** +- Modify: `go-backend/internal/web/views/tablos.templ` +- Modify: `go-backend/internal/web/views/tablos_templ.go` +- Modify: `go-backend/internal/web/handlers/tablos_test.go` + +- [ ] **Step 1: Add edit icon buttons before delete in grid and list views** + +In `go-backend/internal/web/views/tablos.templ`, add an edit button component immediately before the delete button in: + +- `TabloGridCardWithAttrs` +- `TabloListRow` + +Representative shape: + +```templ +@ui.IconButton(ui.IconButtonProps{ + Label: "Modifier le projet", + Icon: "pencil", + Variant: ui.IconButtonVariantGhost, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": tablo.EditRequestURL, + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-push-url": "true", + }, +}) +``` + +- [ ] **Step 2: Render stored colors in card/list accents** + +Replace or bypass accent-token-only rendering where needed so the card uses the persisted hex color directly. + +Representative shape: + +```templ +
+``` + +and: + +```templ +
+``` + +Keep this narrow; do not refactor unrelated styles. + +- [ ] **Step 3: Extend the create modal with a hex color input** + +Add a `Couleur` field to `CreateTabloModalBody` using the shared form primitives, with placeholder/example text like `#3B82F6`. + +- [ ] **Step 4: Add the edit modal with both controls** + +Add an edit modal body that renders: + +- `Nom du projet` +- a hex text input +- a native color picker input + +Use the same `vm.FormColor` source for both values. + +Representative shape: + +```templ +@ui.FormField(ui.FormFieldProps{ + Label: "Couleur", + For: "tablo-color", + Field: templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + return templ.Join( + ui.Input(ui.InputProps{ + ID: "tablo-color", + Name: "color", + Value: vm.FormColor, + Placeholder: "#3B82F6", + Type: "text", + }), + templ.Raw(``), + ).Render(ctx, w) + }), +}) +``` + +If inline JS is undesirable inside templ, use the smallest equivalent attribute approach already accepted by the codebase. Keep the submission source canonical: the posted `color` field must still carry the validated hex value. + +- [ ] **Step 5: Switch modal rendering by modal kind and regenerate templ** + +Update `TablosPageContent` so it renders: + +- create modal when modal kind is `create` +- edit modal when modal kind is `edit` + +Run: `cd go-backend && just generate` + +Expected: PASS with regenerated `go-backend/internal/web/views/tablos_templ.go`. + +- [ ] **Step 6: Run the focused handler suite** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos' -v` + +Expected: PASS. + +- [ ] **Step 7: Run the full Go backend suite** + +Run: `cd go-backend && go test ./...` + +Expected: PASS. + +- [ ] **Step 8: Commit the UI and final verification** + +```bash +git add go-backend/internal/web/views/tablos.templ go-backend/internal/web/views/tablos_templ.go go-backend/internal/web/handlers/tablos_test.go +git commit -m "feat: add go-backend tablo edit modal" +``` + +## Final Verification Checklist + +- [ ] `cd go-backend && just generate` +- [ ] `cd go-backend && go test ./internal/web/handlers -run 'Tablos|InMemoryTablos' -v` +- [ ] `cd go-backend && go test ./...` +- [ ] Manually verify in the browser via `cd go-backend && just dev`: + - create modal requires a valid hex color + - grid cards show edit to the left of delete + - list rows show edit to the left of delete + - edit modal preloads current name and color + - edit modal shows both hex input and color picker + - changing the picker updates the submitted hex value + - successful save refreshes `/tablos` and closes the modal + +Plan complete and saved to `docs/superpowers/plans/2026-05-10-go-backend-tablo-edit-color.md`. Ready to execute? diff --git a/go-backend/internal/web/ui/space.templ b/go-backend/internal/web/ui/space.templ new file mode 100644 index 0000000..0c97414 --- /dev/null +++ b/go-backend/internal/web/ui/space.templ @@ -0,0 +1,13 @@ +package ui + +type SpaceProps struct { + Size SpacingStep +} + +templ SpaceX(props SpaceProps) { + +} + +templ SpaceY(props SpaceProps) { + +} diff --git a/go-backend/internal/web/ui/space_templ.go b/go-backend/internal/web/ui/space_templ.go new file mode 100644 index 0000000..dde7e27 --- /dev/null +++ b/go-backend/internal/web/ui/space_templ.go @@ -0,0 +1,109 @@ +// 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 SpaceProps struct { + Size SpacingStep +} + +func SpaceX(props SpaceProps) 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{spaceXClass(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 + }) +} + +func SpaceY(props SpaceProps) 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) + var templ_7745c5c3_Var5 = []any{spaceYClass(props.Size)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) + 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/views/dashboard_components_test.go b/go-backend/internal/web/views/dashboard_components_test.go new file mode 100644 index 0000000..57bc770 --- /dev/null +++ b/go-backend/internal/web/views/dashboard_components_test.go @@ -0,0 +1,91 @@ +package views + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/a-h/templ" + "github.com/google/uuid" + tablomodel "xtablo-backend/internal/tablos" +) + +func TestOverviewProjectsFromTablosCarriesColorAndEditURL(t *testing.T) { + record := tablomodel.Record{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "Palette", + Color: "#3B82F6", + Status: tablomodel.StatusTodo, + CreatedAt: time.Date(2026, time.May, 10, 9, 0, 0, 0, time.UTC), + } + + projects := OverviewProjectsFromTablos([]tablomodel.Record{record}) + if len(projects) != 1 { + t.Fatalf("expected one project, got %d", len(projects)) + } + + project := projects[0] + if project.Color != "#3B82F6" { + t.Fatalf("expected color to be preserved, got %q", project.Color) + } + if project.EditRequestURL != "/tablos/11111111-1111-1111-1111-111111111111/edit" { + t.Fatalf("expected edit request url to be set, got %q", project.EditRequestURL) + } +} + +func TestOverviewProjectsSectionRendersColorAndEditAction(t *testing.T) { + record := tablomodel.Record{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "Palette", + Color: "#3B82F6", + Status: tablomodel.StatusTodo, + CreatedAt: time.Date(2026, time.May, 10, 9, 0, 0, 0, time.UTC), + } + + html := renderViewToString(t, OverviewProjectsSection(OverviewProjectsFromTablos([]tablomodel.Record{record}))) + + for _, want := range []string{ + `style="--project-color:#3B82F6;"`, + `aria-label="Modifier le projet"`, + `hx-get="/tablos/11111111-1111-1111-1111-111111111111/edit"`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestTabloListRowDoesNotRenderSpacerBetweenEditAndDelete(t *testing.T) { + component := TabloListRow(TabloCardView{ + ID: "11111111-1111-1111-1111-111111111111", + Name: "Palette", + Color: "#3B82F6", + StatusLabel: "À faire", + StatusTone: "info", + Progress: 0, + ProgressLabel: "0%", + CreatedAtLabel: "10 mai 2026", + DeleteRequestURL: "/tablos/11111111-1111-1111-1111-111111111111", + EditRequestURL: "/tablos/11111111-1111-1111-1111-111111111111/edit", + IconKind: "bolt", + Initial: "P", + }) + + html := renderViewToString(t, component) + + if strings.Contains(html, `ui-space-x`) { + t.Fatalf("expected no spacer markup in list row actions, got %q", html) + } +} + +func renderViewToString(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() +} -- 2.45.2 From 69a68443247b92761c068bb7e792b32befec06a0 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 10:42:13 +0200 Subject: [PATCH 025/546] docs: add css-sources-per-primitive design spec --- ...ackend-css-sources-per-primitive-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md diff --git a/docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md b/docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md new file mode 100644 index 0000000..cb046f0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md @@ -0,0 +1,162 @@ +# Go Backend CSS Sources Per Primitive Design + +**Date:** 2026-05-10 + +**Goal** + +Refactor the Go backend design-system CSS so each UI primitive owns its own source stylesheet while the app and generated catalog continue shipping a single compiled `go-backend/static/styles.css`. + +**Chosen Approach** + +Split CSS at the source level by primitive and aggregate those source files into the existing `styles.css` output during the build. This keeps runtime behavior unchanged while making the CSS easier to scale, review, and extend. + +**Scope** + +- Move shared design-system CSS rules into source files organized by primitive. +- Keep one final shipped stylesheet: `go-backend/static/styles.css`. +- Keep app templates and generated design-system pages linking the same single output file. +- Add a small verification layer that proves the final built stylesheet still contains representative selectors for the primitives. + +**Out Of Scope** + +- Shipping multiple CSS files to the browser +- Changing component HTML contracts just to fit the refactor +- Replacing the current Tailwind pipeline +- Reworking unrelated dashboard or app styles outside the design-system slice +- Introducing CSS-in-JS or Go-generated CSS + +**Architecture** + +The design-system CSS should have two layers: + +1. **Primitive source files** + - one file per UI primitive or catalog responsibility + - these files are the new source of truth for design-system styling + +2. **Aggregate build entry** + - a single stylesheet entry imports those primitive files in a deterministic order + - the build continues emitting `go-backend/static/styles.css` + +This preserves the current runtime contract: + +- app pages still load `/static/styles.css` +- generated catalog pages still load `../../go-backend/static/styles.css` + +**Source File Structure** + +Recommended source directory: + +- `go-backend/static/css/catalog.css` +- `go-backend/static/css/button.css` +- `go-backend/static/css/badge.css` +- `go-backend/static/css/icon-button.css` +- `go-backend/static/css/input.css` +- `go-backend/static/css/form-field.css` +- `go-backend/static/css/modal.css` +- `go-backend/static/css/table.css` +- `go-backend/static/css/empty-state.css` +- `go-backend/static/css/card.css` +- `go-backend/static/css/spacing.css` + +Recommended aggregate entry: + +- `go-backend/static/css/styles.css` + +The aggregate entry should import the primitive files in a stable order so later files can intentionally override earlier shared primitive rules where needed. + +**Responsibility Boundaries** + +`catalog.css` + +- catalog page chrome +- catalog example wrappers +- catalog-only preview helpers + +Primitive CSS files + +- only selectors directly tied to that primitive’s API or rendering contract +- examples: + - `button.css` owns `.ui-button*` + - `badge.css` owns `.ui-badge*` + - `icon-button.css` owns `.ui-icon-button*` and `.borderless-icon-button*` + - `card.css` owns `.ui-card*` + - `spacing.css` owns `.ui-space-*` + +Shared non-design-system app selectors such as dashboard-only `.project-*` styles should not be mixed into primitive CSS just because they are nearby in the current file. If they must remain in this refactor slice, they should be isolated intentionally rather than silently attached to a primitive that does not own them. + +**Build Contract** + +The build should continue producing: + +- `go-backend/static/styles.css` + +Preferred shape: + +- the build reads the new aggregate source entry +- the output path remains unchanged + +If the current tooling expects a specific input file location, update that input path rather than changing all HTML consumers. + +**Ordering Rules** + +Import order should be explicit and stable: + +1. catalog layout and wrappers +2. low-level primitives used broadly +3. higher-level primitives +4. any intentionally shared compatibility rules last + +This avoids accidental cascade regressions during future additions. + +**Migration Strategy** + +Refactor incrementally but land as one coherent slice: + +- create the new source directory and aggregate entry +- move selectors from the monolithic stylesheet into primitive files without renaming selectors +- regenerate `go-backend/static/styles.css` +- verify component tests and catalog generation still pass + +Selector names should remain stable during this slice. The purpose is ownership and scalability, not visual redesign. + +**Testing** + +Verification should cover: + +- UI tests still passing for existing primitives +- catalog tests still passing +- design-system generator tests still passing +- representative selectors from each extracted primitive still present in the built `styles.css` + +Representative examples: + +- `.ui-button-solid.ui-button-default` +- `.ui-badge-warning` +- `.ui-icon-button-solid.ui-icon-button-neutral` +- `.ui-card` +- `.ui-space-x-md` +- `.catalog-page` + +**Risks** + +Primary risks: + +- breaking cascade order while moving selectors +- accidentally mixing app-only selectors into primitive files +- changing build inputs without regenerating the checked-in output + +Mitigation: + +- keep selectors unchanged +- move files in clearly bounded groups +- verify the built artifact and tests after the refactor + +**Success Criteria** + +This work is complete when: + +- each design-system primitive has its own source CSS file +- catalog-specific CSS is separated from primitive CSS +- the app still ships one `go-backend/static/styles.css` +- app and catalog HTML consumers remain unchanged +- tests and generated catalog output still pass -- 2.45.2 From 90da6646ea4052d1cc5ad8ade63e5bce2e1805ea Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 11:03:53 +0200 Subject: [PATCH 026/546] docs: update css co-location design spec --- ...ackend-css-sources-per-primitive-design.md | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md b/docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md index cb046f0..21dd5f9 100644 --- a/docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md +++ b/docs/superpowers/specs/2026-05-10-go-backend-css-sources-per-primitive-design.md @@ -4,15 +4,15 @@ **Goal** -Refactor the Go backend design-system CSS so each UI primitive owns its own source stylesheet while the app and generated catalog continue shipping a single compiled `go-backend/static/styles.css`. +Refactor the Go backend design-system CSS so each UI primitive owns its own source stylesheet beside its UI code while the app and generated catalog continue shipping a single compiled `go-backend/static/styles.css`. **Chosen Approach** -Split CSS at the source level by primitive and aggregate those source files into the existing `styles.css` output during the build. This keeps runtime behavior unchanged while making the CSS easier to scale, review, and extend. +Split CSS at the source level by primitive and co-locate those source files with the UI primitives that own them, then aggregate them into the existing `styles.css` output during the build. This keeps runtime behavior unchanged while making the CSS easier to scale, review, and extend. **Scope** -- Move shared design-system CSS rules into source files organized by primitive. +- Move shared design-system CSS rules into source files organized by primitive and stored beside the primitive code. - Keep one final shipped stylesheet: `go-backend/static/styles.css`. - Keep app templates and generated design-system pages linking the same single output file. - Add a small verification layer that proves the final built stylesheet still contains representative selectors for the primitives. @@ -31,6 +31,7 @@ The design-system CSS should have two layers: 1. **Primitive source files** - one file per UI primitive or catalog responsibility + - stored beside the owning primitive or catalog code - these files are the new source of truth for design-system styling 2. **Aggregate build entry** @@ -44,29 +45,28 @@ This preserves the current runtime contract: **Source File Structure** -Recommended source directory: +Recommended source locations: -- `go-backend/static/css/catalog.css` -- `go-backend/static/css/button.css` -- `go-backend/static/css/badge.css` -- `go-backend/static/css/icon-button.css` -- `go-backend/static/css/input.css` -- `go-backend/static/css/form-field.css` -- `go-backend/static/css/modal.css` -- `go-backend/static/css/table.css` -- `go-backend/static/css/empty-state.css` -- `go-backend/static/css/card.css` -- `go-backend/static/css/spacing.css` +- `go-backend/internal/web/ui/base.css` +- `go-backend/internal/web/ui/button.css` +- `go-backend/internal/web/ui/badge.css` +- `go-backend/internal/web/ui/icon-button.css` +- `go-backend/internal/web/ui/input.css` +- `go-backend/internal/web/ui/textarea.css` +- `go-backend/internal/web/ui/form-field.css` +- `go-backend/internal/web/ui/modal.css` +- `go-backend/internal/web/ui/table.css` +- `go-backend/internal/web/ui/empty-state.css` +- `go-backend/internal/web/ui/card.css` +- `go-backend/internal/web/ui/spacing.css` +- `go-backend/internal/web/ui/app.css` +- `go-backend/internal/web/ui/catalog/catalog.css` -Recommended aggregate entry: - -- `go-backend/static/css/styles.css` - -The aggregate entry should import the primitive files in a stable order so later files can intentionally override earlier shared primitive rules where needed. +The build should read those files in a stable explicit order so later files can intentionally override earlier shared rules where needed. **Responsibility Boundaries** -`catalog.css` +`catalog/catalog.css` - catalog page chrome - catalog example wrappers @@ -82,7 +82,7 @@ Primitive CSS files - `card.css` owns `.ui-card*` - `spacing.css` owns `.ui-space-*` -Shared non-design-system app selectors such as dashboard-only `.project-*` styles should not be mixed into primitive CSS just because they are nearby in the current file. If they must remain in this refactor slice, they should be isolated intentionally rather than silently attached to a primitive that does not own them. +Shared non-design-system app selectors such as dashboard-only `.project-*` styles should not be mixed into primitive CSS just because they are nearby in the current file. They should live in an explicit `internal/web/ui/app.css` source file so ownership stays within the UI layer without falsely attaching them to a primitive. **Build Contract** @@ -92,19 +92,20 @@ The build should continue producing: Preferred shape: -- the build reads the new aggregate source entry +- the build reads an explicit ordered list of co-located source files - the output path remains unchanged -If the current tooling expects a specific input file location, update that input path rather than changing all HTML consumers. +The builder should not rely on a single `static/css` source directory anymore. HTML consumers stay unchanged. **Ordering Rules** Import order should be explicit and stable: -1. catalog layout and wrappers -2. low-level primitives used broadly -3. higher-level primitives -4. any intentionally shared compatibility rules last +1. base and global UI-layer rules +2. catalog layout and wrappers +3. low-level primitives used broadly +4. higher-level primitives +5. app-only UI-layer rules last This avoids accidental cascade regressions during future additions. @@ -112,8 +113,8 @@ This avoids accidental cascade regressions during future additions. Refactor incrementally but land as one coherent slice: -- create the new source directory and aggregate entry -- move selectors from the monolithic stylesheet into primitive files without renaming selectors +- create the new co-located source files beside UI primitives and catalog code +- move selectors from the monolithic stylesheet into those files without renaming selectors - regenerate `go-backend/static/styles.css` - verify component tests and catalog generation still pass @@ -155,8 +156,8 @@ Mitigation: This work is complete when: -- each design-system primitive has its own source CSS file -- catalog-specific CSS is separated from primitive CSS +- each design-system primitive has its own source CSS file beside its UI code +- catalog-specific CSS is separated from primitive CSS and stored beside catalog code - the app still ships one `go-backend/static/styles.css` - app and catalog HTML consumers remain unchanged - tests and generated catalog output still pass -- 2.45.2 From 0ac8bd0fc9f49eb4f37a065a36043022c1778249 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 11:23:48 +0200 Subject: [PATCH 027/546] Update templ --- go-backend/go.mod | 4 +- go-backend/go.sum | 4 + go-backend/internal/web/ui/badge_templ.go | 6 +- go-backend/internal/web/ui/button_templ.go | 10 +-- go-backend/internal/web/ui/card_templ.go | 2 +- .../internal/web/ui/catalog/catalog_templ.go | 6 +- .../internal/web/ui/empty_state_templ.go | 2 +- .../internal/web/ui/form_field_templ.go | 6 +- .../internal/web/ui/icon_button_templ.go | 14 ++-- go-backend/internal/web/ui/input_templ.go | 22 ++--- go-backend/internal/web/ui/modal_templ.go | 2 +- go-backend/internal/web/ui/space_templ.go | 10 +-- go-backend/internal/web/ui/table_templ.go | 2 +- go-backend/internal/web/ui/textarea_templ.go | 18 ++-- go-backend/internal/web/ui/ui_test.go | 5 +- .../web/views/auth_components_templ.go | 22 ++--- .../web/views/dashboard_components_templ.go | 50 +++++------ go-backend/internal/web/views/icons_templ.go | 2 +- go-backend/internal/web/views/pages_templ.go | 2 +- go-backend/internal/web/views/tablos_templ.go | 82 +++++++++---------- go-backend/justfile | 10 ++- 21 files changed, 148 insertions(+), 133 deletions(-) diff --git a/go-backend/go.mod b/go-backend/go.mod index 20f94e9..82d2b38 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require github.com/go-chi/chi/v5 v5.2.0 require ( - github.com/a-h/templ v0.3.1001 + github.com/a-h/templ v0.3.1020 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/sqlc-dev/sqlc v1.31.1 @@ -44,7 +44,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect diff --git a/go-backend/go.sum b/go-backend/go.sum index 46529df..7eb420d 100644 --- a/go-backend/go.sum +++ b/go-backend/go.sum @@ -5,6 +5,8 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -151,6 +153,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= diff --git a/go-backend/internal/web/ui/badge_templ.go b/go-backend/internal/web/ui/badge_templ.go index d9b8261..d64f8bb 100644 --- a/go-backend/internal/web/ui/badge_templ.go +++ b/go-backend/internal/web/ui/badge_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -44,11 +44,11 @@ func Badge(props BadgeProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/badge.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/button_templ.go b/go-backend/internal/web/ui/button_templ.go index ed620cb..212a5a0 100644 --- a/go-backend/internal/web/ui/button_templ.go +++ b/go-backend/internal/web/ui/button_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -49,11 +49,11 @@ func Button(props ButtonProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(buttonType(props.Type)) + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(buttonType(props.Type)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/button.templ`, Line: 14, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -62,11 +62,11 @@ func Button(props ButtonProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/button.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/card_templ.go b/go-backend/internal/web/ui/card_templ.go index e6ed134..f3b3413 100644 --- a/go-backend/internal/web/ui/card_templ.go +++ b/go-backend/internal/web/ui/card_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/go-backend/internal/web/ui/catalog/catalog_templ.go b/go-backend/internal/web/ui/catalog/catalog_templ.go index 7fc8085..1176f87 100644 --- a/go-backend/internal/web/ui/catalog/catalog_templ.go +++ b/go-backend/internal/web/ui/catalog/catalog_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package catalog //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -57,11 +57,11 @@ func CatalogPage(page Page) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/catalog/catalog.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/empty_state_templ.go b/go-backend/internal/web/ui/empty_state_templ.go index 51f310f..7c26b0a 100644 --- a/go-backend/internal/web/ui/empty_state_templ.go +++ b/go-backend/internal/web/ui/empty_state_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/go-backend/internal/web/ui/form_field_templ.go b/go-backend/internal/web/ui/form_field_templ.go index 8abf32d..4fef332 100644 --- a/go-backend/internal/web/ui/form_field_templ.go +++ b/go-backend/internal/web/ui/form_field_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -47,11 +47,11 @@ func FormField(props FormFieldProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(props.For) + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(props.For) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/form_field.templ`, Line: 14, Col: 25} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/icon_button_templ.go b/go-backend/internal/web/ui/icon_button_templ.go index 69571ec..156879b 100644 --- a/go-backend/internal/web/ui/icon_button_templ.go +++ b/go-backend/internal/web/ui/icon_button_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -48,11 +48,11 @@ func IconButton(props IconButtonProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(buttonType(props.Type)) + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(buttonType(props.Type)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 13, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -61,11 +61,11 @@ func IconButton(props IconButtonProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -74,11 +74,11 @@ func IconButton(props IconButtonProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(props.Label) + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(props.Label) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/icon_button.templ`, Line: 13, Col: 118} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/input_templ.go b/go-backend/internal/web/ui/input_templ.go index b6d057e..75d7f76 100644 --- a/go-backend/internal/web/ui/input_templ.go +++ b/go-backend/internal/web/ui/input_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -43,11 +43,11 @@ func Input(props InputProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(inputID(props.ID, props.Name)) + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(inputID(props.ID, props.Name)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 14, Col: 36} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -56,11 +56,11 @@ func Input(props InputProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(inputType(props.Type)) + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(inputType(props.Type)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 15, Col: 30} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -69,11 +69,11 @@ func Input(props InputProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.Name) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(props.Name) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 16, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -82,11 +82,11 @@ func Input(props InputProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(props.Value) + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(props.Value) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 17, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -95,11 +95,11 @@ func Input(props InputProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(props.Placeholder) + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(props.Placeholder) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/input.templ`, Line: 18, Col: 33} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/modal_templ.go b/go-backend/internal/web/ui/modal_templ.go index 231e5b2..91476ca 100644 --- a/go-backend/internal/web/ui/modal_templ.go +++ b/go-backend/internal/web/ui/modal_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/go-backend/internal/web/ui/space_templ.go b/go-backend/internal/web/ui/space_templ.go index dde7e27..f35a5df 100644 --- a/go-backend/internal/web/ui/space_templ.go +++ b/go-backend/internal/web/ui/space_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -43,11 +43,11 @@ func SpaceX(props SpaceProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/space.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -90,11 +90,11 @@ func SpaceY(props SpaceProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String()) + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/space.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/table_templ.go b/go-backend/internal/web/ui/table_templ.go index c26c359..58375c9 100644 --- a/go-backend/internal/web/ui/table_templ.go +++ b/go-backend/internal/web/ui/table_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/go-backend/internal/web/ui/textarea_templ.go b/go-backend/internal/web/ui/textarea_templ.go index a454eed..754aca7 100644 --- a/go-backend/internal/web/ui/textarea_templ.go +++ b/go-backend/internal/web/ui/textarea_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package ui //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -43,11 +43,11 @@ func Textarea(props TextareaProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(inputID(props.ID, props.Name)) + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(inputID(props.ID, props.Name)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 14, Col: 36} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -56,11 +56,11 @@ func Textarea(props TextareaProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(props.Name) + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(props.Name) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 15, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -69,11 +69,11 @@ func Textarea(props TextareaProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.Placeholder) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(props.Placeholder) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 16, Col: 33} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -82,11 +82,11 @@ func Textarea(props TextareaProps) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(textareaRows(props.Rows)) + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(textareaRows(props.Rows)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/ui/textarea.templ`, Line: 17, Col: 33} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/ui/ui_test.go b/go-backend/internal/web/ui/ui_test.go index a01097d..645d32a 100644 --- a/go-backend/internal/web/ui/ui_test.go +++ b/go-backend/internal/web/ui/ui_test.go @@ -190,9 +190,12 @@ func TestSharedSemanticClassesExistInStylesheet(t *testing.T) { css := string(body) for _, want := range []string{ + `Code generated by cmd/buildstyles`, `.ui-button-solid.ui-button-default`, - `.ui-button-sm`, `.ui-badge-warning`, + `.ui-card`, + `.catalog-page`, + `.ui-button-sm`, `.ui-modal-panel`, `.ui-space-x-md`, `.ui-space-y-md`, diff --git a/go-backend/internal/web/views/auth_components_templ.go b/go-backend/internal/web/views/auth_components_templ.go index 0c424ab..5c14dcd 100644 --- a/go-backend/internal/web/views/auth_components_templ.go +++ b/go-backend/internal/web/views/auth_components_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -274,11 +274,11 @@ func AuthField(fieldID string, fieldName string, fieldLabel string, inputType st return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fieldID) + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fieldID) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/auth_components.templ`, Line: 61, Col: 22} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -300,11 +300,11 @@ func AuthField(fieldID string, fieldName string, fieldLabel string, inputType st return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fieldID) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fieldID) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/auth_components.templ`, Line: 62, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -313,11 +313,11 @@ func AuthField(fieldID string, fieldName string, fieldLabel string, inputType st return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fieldName) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fieldName) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/auth_components.templ`, Line: 62, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -326,11 +326,11 @@ func AuthField(fieldID string, fieldName string, fieldLabel string, inputType st return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(inputType) + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(inputType) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/auth_components.templ`, Line: 62, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -339,11 +339,11 @@ func AuthField(fieldID string, fieldName string, fieldLabel string, inputType st return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(placeholder) + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(placeholder) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/auth_components.templ`, Line: 62, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/views/dashboard_components_templ.go b/go-backend/internal/web/views/dashboard_components_templ.go index 5cd09c0..dbfbf32 100644 --- a/go-backend/internal/web/views/dashboard_components_templ.go +++ b/go-backend/internal/web/views/dashboard_components_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -170,11 +170,11 @@ func DashboardMainContentWithClass(mainClass string, content templ.Component) te return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String()) + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -934,11 +934,11 @@ func SeeMoreProjects(hiddenCount int) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(hiddenCount)) + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(hiddenCount)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 247, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1126,11 +1126,11 @@ func TaskRow(task dashboardTask) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var39).String()) + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var39).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1148,11 +1148,11 @@ func TaskRow(task dashboardTask) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var41).String()) + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var41).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1193,11 +1193,11 @@ func TaskRow(task dashboardTask) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var44).String()) + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var44).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1254,11 +1254,11 @@ func TaskRow(task dashboardTask) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var49).String()) + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var49).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1314,11 +1314,11 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var54 string - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(sidebarNavItemID(item.Href)) + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(sidebarNavItemID(item.Href)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 352, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1327,11 +1327,11 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var53).String()) + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var53).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1353,11 +1353,11 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var57 string - templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(item.Href) + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Href) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 353, Col: 82} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1421,11 +1421,11 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var61 string - templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(sidebarNavItemID(item.Href)) + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(sidebarNavItemID(item.Href)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 365, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1434,11 +1434,11 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var62 string - templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var60).String()) + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var60).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1460,11 +1460,11 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var64 string - templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(item.Href) + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Href) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 366, Col: 82} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/views/icons_templ.go b/go-backend/internal/web/views/icons_templ.go index dbdaa90..04349e8 100644 --- a/go-backend/internal/web/views/icons_templ.go +++ b/go-backend/internal/web/views/icons_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/go-backend/internal/web/views/pages_templ.go b/go-backend/internal/web/views/pages_templ.go index 8c301a9..4757b8b 100644 --- a/go-backend/internal/web/views/pages_templ.go +++ b/go-backend/internal/web/views/pages_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/go-backend/internal/web/views/tablos_templ.go b/go-backend/internal/web/views/tablos_templ.go index 1b9ad4a..9c56d62 100644 --- a/go-backend/internal/web/views/tablos_templ.go +++ b/go-backend/internal/web/views/tablos_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -78,11 +78,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ViewHref("grid")) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.ViewHref("grid")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 26, Col: 32} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -91,11 +91,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -134,11 +134,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ViewHref("list")) + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.ViewHref("list")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 39, Col: 32} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -147,11 +147,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String()) + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -168,11 +168,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(vm.SearchHref()) + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.SearchHref()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 54, Col: 28} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -181,11 +181,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(vm.View) + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.View) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 60, Col: 52} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -194,11 +194,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Status) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.Status) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 61, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -215,11 +215,11 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Query) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.Query) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 67, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -368,11 +368,11 @@ func StatusPill(vm TablosPageViewModel, status string, label string) templ.Compo return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(vm.StatusHref(status)) + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.StatusHref(status)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 128, Col: 32} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -381,11 +381,11 @@ func StatusPill(vm TablosPageViewModel, status string, label string) templ.Compo return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String()) + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -928,11 +928,11 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(vm.View) + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.View) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 282, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -941,11 +941,11 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Status) + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.Status) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 283, Col: 54} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -954,11 +954,11 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Query) + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.Query) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 284, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1035,11 +1035,11 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(vm.CloseModalHref()) + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.CloseModalHref()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 318, Col: 32} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1141,11 +1141,11 @@ func EditTabloColorField(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(vm.FormColor) + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.FormColor) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 360, Col: 23} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1183,11 +1183,11 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(vm.EditSubmitHref()) + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.EditSubmitHref()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 369, Col: 31} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1196,11 +1196,11 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(vm.View) + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.View) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 374, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1209,11 +1209,11 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Status) + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.Status) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 375, Col: 54} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1222,11 +1222,11 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var53 string - templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Query) + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.Query) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 376, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1294,11 +1294,11 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var56 string - templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(vm.CloseModalHref()) + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(vm.CloseModalHref()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 400, Col: 32} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/justfile b/go-backend/justfile index 2c0c7ec..9f5cb85 100644 --- a/go-backend/justfile +++ b/go-backend/justfile @@ -8,6 +8,9 @@ tailwind_output := "static/tailwind.css" default: @just --list +build-styles: + go run ./cmd/buildstyles + compose-config: mkdir -p {{compose_config_dir}} printf '%s\n' '{"auths":{}}' > {{compose_config_dir}}/config.json @@ -42,8 +45,9 @@ db-logs: machine-up compose-config DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose logs -f postgres generate: + just build-styles 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/a-h/templ/cmd/templ@v0.3.1020 generate go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate design-system: @@ -57,18 +61,22 @@ fmt: gofmt -w . test: + just build-styles go test ./... build: + just build-styles pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . go build ./... check: generate test build dev: db-up + just build-styles pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . DATABASE_URL='{{database_url}}' air -c .air.toml run: db-up + just build-styles pnpm exec tailwindcss -i {{tailwind_input}} -o {{tailwind_output}} --cwd . DATABASE_URL='{{database_url}}' go run . -- 2.45.2 From 8bcf81a3f12c8ea6761339a36b9309393afe7d2c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 11:47:42 +0200 Subject: [PATCH 028/546] Add co-located CSS sources and semantic token infrastructure to Go backend Create the foundational structure for managing design-system CSS with co-located sources and semantic tokens: - Add `cmd/buildstyles` to concatenate ordered CSS sources into a single shipped stylesheet - Define semantic color and effect tokens in `internal/web/ui/base.css` - Move primitive and catalog CSS sources from `static/css/` to co-located locations under `internal/web/ui/` - Update test contract to verify token presence and proper stylesheet generation - Regenerate `static/styles.css` with new semantic token layer and source annotations --- ...10-go-backend-css-sources-per-primitive.md | 333 +++ ...10-go-backend-semantic-css-color-tokens.md | 329 +++ ...ackend-semantic-css-color-tokens-design.md | 184 ++ go-backend/cmd/buildstyles/main.go | 65 + go-backend/cmd/buildstyles/main_test.go | 63 + go-backend/internal/web/ui/app.css | 1891 +++++++++++++++++ go-backend/internal/web/ui/badge.css | 33 + go-backend/internal/web/ui/base.css | 223 ++ go-backend/internal/web/ui/button.css | 162 ++ go-backend/internal/web/ui/card.css | 27 + .../internal/web/ui/catalog/catalog.css | 163 ++ go-backend/internal/web/ui/empty-state.css | 40 + go-backend/internal/web/ui/form-field.css | 22 + go-backend/internal/web/ui/icon-button.css | 50 + go-backend/internal/web/ui/input.css | 22 + go-backend/internal/web/ui/modal.css | 53 + go-backend/internal/web/ui/spacing.css | 48 + go-backend/internal/web/ui/table.css | 10 + go-backend/internal/web/ui/textarea.css | 23 + go-backend/internal/web/ui/ui_test.go | 5 + go-backend/internal/web/views/tablos.templ | 2 +- go-backend/internal/web/views/tablos_templ.go | 2 +- go-backend/static/styles.css | 1780 +++++++++------- go-backend/static/tailwind.css | 3 + 24 files changed, 4733 insertions(+), 800 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-10-go-backend-css-sources-per-primitive.md create mode 100644 docs/superpowers/plans/2026-05-10-go-backend-semantic-css-color-tokens.md create mode 100644 docs/superpowers/specs/2026-05-10-go-backend-semantic-css-color-tokens-design.md create mode 100644 go-backend/cmd/buildstyles/main.go create mode 100644 go-backend/cmd/buildstyles/main_test.go create mode 100644 go-backend/internal/web/ui/app.css create mode 100644 go-backend/internal/web/ui/badge.css create mode 100644 go-backend/internal/web/ui/base.css create mode 100644 go-backend/internal/web/ui/button.css create mode 100644 go-backend/internal/web/ui/card.css create mode 100644 go-backend/internal/web/ui/catalog/catalog.css create mode 100644 go-backend/internal/web/ui/empty-state.css create mode 100644 go-backend/internal/web/ui/form-field.css create mode 100644 go-backend/internal/web/ui/icon-button.css create mode 100644 go-backend/internal/web/ui/input.css create mode 100644 go-backend/internal/web/ui/modal.css create mode 100644 go-backend/internal/web/ui/spacing.css create mode 100644 go-backend/internal/web/ui/table.css create mode 100644 go-backend/internal/web/ui/textarea.css diff --git a/docs/superpowers/plans/2026-05-10-go-backend-css-sources-per-primitive.md b/docs/superpowers/plans/2026-05-10-go-backend-css-sources-per-primitive.md new file mode 100644 index 0000000..e35e23c --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-go-backend-css-sources-per-primitive.md @@ -0,0 +1,333 @@ +# Go Backend CSS Sources Per Primitive 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:** Refactor the Go backend design-system CSS so each primitive owns its own co-located source stylesheet while the app and generated catalog still ship a single `go-backend/static/styles.css`. + +**Architecture:** Keep `go-backend/static/styles.css` as the only runtime stylesheet, but move CSS source ownership next to the UI code in `internal/web/ui` and `internal/web/ui/catalog`. Extend `cmd/buildstyles` so it concatenates an explicit ordered list of co-located CSS source files into the existing shipped artifact. + +**Tech Stack:** Go, static CSS, `just`, Go `testing`, templ-generated HTML consumers + +--- + +## Chunk 1: Build Contract + +## Task 1: Tighten The Generator Contract Around Co-Located Sources + +**Files:** +- Modify: `go-backend/cmd/buildstyles/main_test.go` +- Modify: `go-backend/internal/web/ui/ui_test.go` + +- [ ] **Step 1: Write the failing generator test against explicit source paths** + +Update `go-backend/cmd/buildstyles/main_test.go` so the test uses co-located paths instead of a single css directory. + +```go +func TestGenerateStylesConcatenatesSourcesInOrder(t *testing.T) { + root := t.TempDir() + basePath := filepath.Join(root, "internal", "web", "ui", "base.css") + buttonPath := filepath.Join(root, "internal", "web", "ui", "button.css") + + for path, body := range map[string]string{ + basePath: "/* base */\n.base { color: red; }\n", + buttonPath: "/* button */\n.ui-button { color: blue; }\n", + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + + outputPath := filepath.Join(root, "static", "styles.css") + if err := generateStyles(outputPath, []sourceFile{ + {Label: "base.css", Path: basePath}, + {Label: "button.css", Path: buttonPath}, + }); err != nil { + t.Fatalf("generate styles: %v", err) + } + + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + + got := string(content) + for _, want := range []string{ + "/* Source: base.css */", + ".base { color: red; }", + "/* Source: button.css */", + ".ui-button { color: blue; }", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in %q", want, got) + } + } + if strings.Index(got, "/* Source: base.css */") > strings.Index(got, "/* Source: button.css */") { + t.Fatalf("expected base.css before button.css, got %q", got) + } +} +``` + +- [ ] **Step 2: Keep the built stylesheet assertions focused on the runtime contract** + +Keep `TestSharedSemanticClassesExistInStylesheet` in `go-backend/internal/web/ui/ui_test.go` asserting the generated header and representative selectors: + +```go +for _, want := range []string{ + `Code generated by cmd/buildstyles`, + `.ui-button-solid.ui-button-default`, + `.ui-badge-warning`, + `.ui-icon-button-solid.ui-icon-button-neutral`, + `.ui-card`, + `.ui-space-x-md`, + `.catalog-page`, +} { + if !strings.Contains(css, want) { + t.Fatalf("expected stylesheet to contain %q", want) + } +} +``` + +- [ ] **Step 3: Run the focused tests to verify the generator test fails first** + +Run: `cd go-backend && go test ./cmd/buildstyles ./internal/web/ui -run 'TestGenerateStylesConcatenatesSourcesInOrder|TestSharedSemanticClassesExistInStylesheet' -count=1` + +Expected: FAIL in `TestGenerateStylesConcatenatesSourcesInOrder` until `cmd/buildstyles` supports explicit co-located source paths. + +- [ ] **Step 4: Commit the updated contract tests** + +```bash +git add go-backend/cmd/buildstyles/main_test.go go-backend/internal/web/ui/ui_test.go +git commit -m "test: define co-located css build contract" +``` + +## Chunk 2: Generator + +## Task 2: Update The Stylesheet Generator For Co-Located Inputs + +**Files:** +- Modify: `go-backend/cmd/buildstyles/main.go` +- Modify: `go-backend/justfile` + +- [ ] **Step 1: Replace the single-directory model with explicit source file metadata** + +Update `go-backend/cmd/buildstyles/main.go` to define each source file by label and path. + +```go +type sourceFile struct { + Label string + Path string +} + +var sourceFiles = []sourceFile{ + {Label: "base.css", Path: filepath.Join("internal", "web", "ui", "base.css")}, + {Label: "catalog.css", Path: filepath.Join("internal", "web", "ui", "catalog", "catalog.css")}, + {Label: "button.css", Path: filepath.Join("internal", "web", "ui", "button.css")}, + {Label: "badge.css", Path: filepath.Join("internal", "web", "ui", "badge.css")}, + {Label: "icon-button.css", Path: filepath.Join("internal", "web", "ui", "icon-button.css")}, + {Label: "input.css", Path: filepath.Join("internal", "web", "ui", "input.css")}, + {Label: "textarea.css", Path: filepath.Join("internal", "web", "ui", "textarea.css")}, + {Label: "form-field.css", Path: filepath.Join("internal", "web", "ui", "form-field.css")}, + {Label: "modal.css", Path: filepath.Join("internal", "web", "ui", "modal.css")}, + {Label: "table.css", Path: filepath.Join("internal", "web", "ui", "table.css")}, + {Label: "empty-state.css", Path: filepath.Join("internal", "web", "ui", "empty-state.css")}, + {Label: "card.css", Path: filepath.Join("internal", "web", "ui", "card.css")}, + {Label: "spacing.css", Path: filepath.Join("internal", "web", "ui", "spacing.css")}, + {Label: "app.css", Path: filepath.Join("internal", "web", "ui", "app.css")}, +} +``` + +- [ ] **Step 2: Adjust `generateStyles` to read from `[]sourceFile`** + +Implement the signature and loop like this: + +```go +func generateStyles(outputPath string, sources []sourceFile) error { + var buf bytes.Buffer + buf.WriteString("/* Code generated by cmd/buildstyles; DO NOT EDIT. */\n\n") + + for i, source := range sources { + body, err := os.ReadFile(source.Path) + if err != nil { + return fmt.Errorf("read %s: %w", source.Path, err) + } + + buf.WriteString("/* Source: ") + buf.WriteString(source.Label) + buf.WriteString(" */\n") + buf.Write(bytes.TrimSpace(body)) + buf.WriteByte('\n') + if i < len(sources)-1 { + buf.WriteByte('\n') + } + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return fmt.Errorf("mkdir output dir: %w", err) + } + return os.WriteFile(outputPath, buf.Bytes(), 0o644) +} +``` + +- [ ] **Step 3: Simplify `main()` to keep only the output flag** + +Use: + +```go +func main() { + output := flag.String("out", filepath.Join("static", "styles.css"), "output stylesheet path") + flag.Parse() + + if err := generateStyles(*output, sourceFiles); err != nil { + fmt.Fprintf(os.Stderr, "build styles: %v\n", err) + os.Exit(1) + } +} +``` + +- [ ] **Step 4: Keep `just` building the stylesheet before consumers run** + +Ensure `go-backend/justfile` still calls `just build-styles` before `generate`, `test`, `build`, `dev`, and `run`. No change to the generated artifact path. + +- [ ] **Step 5: Run the generator-focused tests** + +Run: `cd go-backend && go test ./cmd/buildstyles -count=1` + +Expected: PASS. + +- [ ] **Step 6: Commit the generator changes** + +```bash +git add go-backend/cmd/buildstyles/main.go go-backend/cmd/buildstyles/main_test.go go-backend/justfile +git commit -m "feat: build styles from co-located sources" +``` + +## Chunk 3: Co-Locate CSS Sources + +## Task 3: Move Primitive And Catalog CSS Beside Their UI Code + +**Files:** +- Create: `go-backend/internal/web/ui/base.css` +- Create: `go-backend/internal/web/ui/button.css` +- Create: `go-backend/internal/web/ui/badge.css` +- Create: `go-backend/internal/web/ui/icon-button.css` +- Create: `go-backend/internal/web/ui/input.css` +- Create: `go-backend/internal/web/ui/textarea.css` +- Create: `go-backend/internal/web/ui/form-field.css` +- Create: `go-backend/internal/web/ui/modal.css` +- Create: `go-backend/internal/web/ui/table.css` +- Create: `go-backend/internal/web/ui/empty-state.css` +- Create: `go-backend/internal/web/ui/card.css` +- Create: `go-backend/internal/web/ui/spacing.css` +- Create: `go-backend/internal/web/ui/app.css` +- Create: `go-backend/internal/web/ui/catalog/catalog.css` +- Delete: `go-backend/static/css/base.css` +- Delete: `go-backend/static/css/button.css` +- Delete: `go-backend/static/css/badge.css` +- Delete: `go-backend/static/css/icon-button.css` +- Delete: `go-backend/static/css/input.css` +- Delete: `go-backend/static/css/textarea.css` +- Delete: `go-backend/static/css/form-field.css` +- Delete: `go-backend/static/css/modal.css` +- Delete: `go-backend/static/css/table.css` +- Delete: `go-backend/static/css/empty-state.css` +- Delete: `go-backend/static/css/card.css` +- Delete: `go-backend/static/css/spacing.css` +- Delete: `go-backend/static/css/app.css` +- Delete: `go-backend/static/css/catalog.css` +- Modify: `go-backend/static/styles.css` + +- [ ] **Step 1: Move the base rules into `internal/web/ui/base.css`** + +This file owns: +- root tokens +- global reset rules +- `html`, `body`, `a`, `button`, `input` +- `.light-only`, `.dark-only`, `.visually-hidden` + +- [ ] **Step 2: Move primitive selectors into matching co-located files without renaming selectors** + +Keep each file limited to the selectors owned by that primitive: +- `button.css` owns `.ui-button*` +- `badge.css` owns `.ui-badge*` +- `icon-button.css` owns `.ui-icon-button*` and `.borderless-icon-button*` +- `input.css` owns `.ui-input*` +- `textarea.css` owns `.ui-textarea*` +- `form-field.css` owns `.ui-form-*` +- `modal.css` owns `.ui-modal*` +- `table.css` owns `.ui-table*` +- `empty-state.css` owns `.ui-empty-state*` +- `card.css` owns `.ui-card*` +- `spacing.css` owns `.ui-space-*` + +- [ ] **Step 3: Move catalog-only selectors into `internal/web/ui/catalog/catalog.css`** + +This file owns: +- `.catalog-page*` +- `.catalog-nav*` +- `.catalog-example*` +- `.catalog-inline` +- `.catalog-spacing-row` +- `.catalog-spacing-column` +- `.catalog-page-link*` + +- [ ] **Step 4: Move remaining UI-layer app selectors into `internal/web/ui/app.css`** + +This file owns non-primitive selectors that still belong to the UI layer, including: +- auth and home page classes such as `.login-screen`, `.auth-card-shell`, `.home-card` +- dashboard and overview classes such as `.dashboard-shell`, `.sidebar-*`, `.overview-*`, `.project-*`, `.tasks-*` +- responsive media queries and animation keyframes that support those selectors + +- [ ] **Step 5: Delete the old `go-backend/static/css/` source files once the new copies exist** + +Do not leave duplicated CSS sources behind. + +- [ ] **Step 6: Rebuild the shipped stylesheet** + +Run: `cd go-backend && go run ./cmd/buildstyles` + +Expected: `go-backend/static/styles.css` contains the generated header and `/* Source: ... */` sections for the co-located files. + +- [ ] **Step 7: Commit the co-located CSS move** + +```bash +git add go-backend/internal/web/ui/*.css go-backend/internal/web/ui/catalog/catalog.css go-backend/static/styles.css go-backend/static/css +git commit -m "refactor: co-locate ui css sources" +``` + +## Chunk 4: Verification + +## Task 4: Verify The Runtime Stylesheet And Consumers + +**Files:** +- Verify: `go-backend/static/styles.css` +- Verify: `go-backend/internal/web/ui/ui_test.go` +- Verify: `go-backend/internal/web/ui/catalog/catalog_test.go` +- Verify: `go-backend/cmd/designsystem/main_test.go` + +- [ ] **Step 1: Run the focused UI and catalog tests** + +Run: `cd go-backend && go test ./internal/web/ui ./internal/web/ui/catalog ./cmd/designsystem -count=1` + +Expected: PASS. + +- [ ] **Step 2: Run the broader web package verification** + +Run: `cd go-backend && go test ./internal/web/... ./cmd/designsystem ./cmd/buildstyles -count=1` + +Expected: PASS. + +- [ ] **Step 3: Smoke-check the generated stylesheet headers** + +Run: `cd go-backend && rg -n "Code generated by cmd/buildstyles|Source: button.css|Source: catalog.css|Source: app.css" static/styles.css` + +Expected: matching lines for the generated header and representative source sections. + +- [ ] **Step 4: Commit the final verification-safe state if anything changed during verification** + +```bash +git add go-backend/static/styles.css +git commit -m "chore: refresh generated stylesheet" +``` diff --git a/docs/superpowers/plans/2026-05-10-go-backend-semantic-css-color-tokens.md b/docs/superpowers/plans/2026-05-10-go-backend-semantic-css-color-tokens.md new file mode 100644 index 0000000..d3dedf3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-go-backend-semantic-css-color-tokens.md @@ -0,0 +1,329 @@ +# Go Backend Semantic CSS Color Tokens 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:** Replace hardcoded color literals across the co-located Go backend CSS sources with semantic tokens defined in `go-backend/internal/web/ui/base.css`. + +**Architecture:** Extend `base.css` into the single shared visual token layer for colors, overlays, gradients, and shadows. Then update every co-located CSS file under `internal/web/ui` and `internal/web/ui/catalog` to consume those tokens, keeping the existing selector API and single generated `go-backend/static/styles.css`. + +**Tech Stack:** CSS custom properties, Go, `cmd/buildstyles`, `just`, Go `testing` + +--- + +## Chunk 1: Token Contract + +## Task 1: Define The Token Contract In Tests + +**Files:** +- Modify: `go-backend/internal/web/ui/ui_test.go` +- Modify: `go-backend/cmd/buildstyles/main_test.go` + +- [ ] **Step 1: Extend the stylesheet contract test with representative semantic tokens** + +Update `TestSharedSemanticClassesExistInStylesheet` in `go-backend/internal/web/ui/ui_test.go` so it asserts token presence in addition to selector presence. + +Add checks like: + +```go +for _, want := range []string{ + `--color-text-primary`, + `--color-surface-default`, + `--color-status-warning-soft-bg`, + `--shadow-surface-md`, + `--overlay-backdrop-default`, + `.ui-button-solid.ui-button-default`, + `.ui-badge-warning`, + `.ui-modal-panel`, + `.catalog-page`, +} { + if !strings.Contains(css, want) { + t.Fatalf("expected stylesheet to contain %q", want) + } +} +``` + +- [ ] **Step 2: Keep the buildstyles generator test format-stable** + +Ensure `go-backend/cmd/buildstyles/main_test.go` still only verifies ordered concatenation and generated headers. Do not add token semantics there; that belongs in the stylesheet contract test. + +- [ ] **Step 3: Run the focused contract tests and confirm failure if tokens are not present yet** + +Run: `cd go-backend && go test ./cmd/buildstyles ./internal/web/ui -run 'TestGenerateStylesConcatenatesSourcesInOrder|TestSharedSemanticClassesExistInStylesheet' -count=1` + +Expected: FAIL in `TestSharedSemanticClassesExistInStylesheet` until `base.css` defines the new semantic tokens and `styles.css` is rebuilt. + +- [ ] **Step 4: Commit the failing contract tests** + +```bash +git add go-backend/internal/web/ui/ui_test.go go-backend/cmd/buildstyles/main_test.go +git commit -m "test: define semantic css token contract" +``` + +## Chunk 2: Base Tokens + +## Task 2: Introduce Semantic Color And Effect Tokens In `base.css` + +**Files:** +- Modify: `go-backend/internal/web/ui/base.css` + +- [ ] **Step 1: Group the existing core variables into a clearer semantic structure** + +Keep the existing root block, but reorganize it into sections for: +- core neutrals +- borders and surfaces +- brand/action +- semantic statuses +- overlays and shadows +- runtime fallbacks + +- [ ] **Step 2: Add semantic text, surface, and border tokens** + +Define tokens such as: + +```css +--color-text-primary: hsl(0 0% 9%); +--color-text-muted: hsl(0 0% 43.5%); +--color-surface-page: hsl(0 0% 100%); +--color-surface-default: #ffffff; +--color-surface-subtle: hsl(0 0% 96.1%); +--color-border-default: hsl(0 0% 90.9%); +--color-border-strong: #d1d5db; +``` + +Use the existing values already present in the repo unless there is a documented reason to consolidate. + +- [ ] **Step 3: Add semantic brand/action tokens** + +Define tokens such as: + +```css +--color-brand-primary: #804eec; +--color-brand-primary-hover: #6d28d9; +--color-brand-primary-active: #5b21b6; +--color-focus-ring: rgba(124, 58, 237, 0.2); +--color-brand-foreground: #ffffff; +``` + +- [ ] **Step 4: Add semantic status token triplets** + +For each status family, define soft backgrounds, borders, foregrounds, and stronger action values where the current CSS needs them. + +Representative examples: + +```css +--color-status-info-soft-bg: #eff6ff; +--color-status-info-soft-border: #bfdbfe; +--color-status-info-foreground: #2563eb; + +--color-status-warning-soft-bg: #fff4e2; +--color-status-warning-soft-border: #db9729; +--color-status-warning-foreground: #db9729; +--color-status-warning-strong: #db9729; +--color-status-warning-strong-hover: #c37f12; +--color-status-warning-strong-active: #a9670c; +``` + +Add equivalent success and danger tokens using the current CSS values. + +- [ ] **Step 5: Add shadow, overlay, and fallback tokens** + +Define shared tokens used repeatedly across the UI layer, for example: + +```css +--shadow-surface-sm: 0 10px 30px rgba(15, 23, 42, 0.05); +--shadow-surface-md: 0 10px 30px rgba(15, 23, 42, 0.06); +--shadow-surface-lg: 0 24px 48px rgba(15, 23, 42, 0.18); +--overlay-backdrop-default: rgba(17, 24, 39, 0.52); +--color-project-fallback: #3b82f6; +``` + +- [ ] **Step 6: Run the focused contract test** + +Run: `cd go-backend && go test ./internal/web/ui -run TestSharedSemanticClassesExistInStylesheet -count=1` + +Expected: still FAIL until the rest of the CSS files consume the tokens and `styles.css` is regenerated. + +- [ ] **Step 7: Commit the token vocabulary** + +```bash +git add go-backend/internal/web/ui/base.css go-backend/internal/web/ui/ui_test.go +git commit -m "feat: add semantic css token vocabulary" +``` + +## Chunk 3: Primitive And Catalog Adoption + +## Task 3: Replace Hardcoded Literals In Primitive And Catalog CSS + +**Files:** +- Modify: `go-backend/internal/web/ui/button.css` +- Modify: `go-backend/internal/web/ui/badge.css` +- Modify: `go-backend/internal/web/ui/icon-button.css` +- Modify: `go-backend/internal/web/ui/input.css` +- Modify: `go-backend/internal/web/ui/textarea.css` +- Modify: `go-backend/internal/web/ui/form-field.css` +- Modify: `go-backend/internal/web/ui/modal.css` +- Modify: `go-backend/internal/web/ui/card.css` +- Modify: `go-backend/internal/web/ui/empty-state.css` +- Modify: `go-backend/internal/web/ui/catalog/catalog.css` + +- [ ] **Step 1: Convert button variants to semantic tokens** + +In `button.css`, replace all hardcoded foreground, background, hover, and active values with tokens from `base.css`. + +Examples: + +```css +.ui-button:focus-visible { + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +.ui-button-solid.ui-button-default { + background: var(--color-brand-primary); + color: var(--color-brand-foreground); +} +``` + +- [ ] **Step 2: Convert badges to semantic status tokens** + +In `badge.css`, replace each family with status tokens: + +```css +.ui-badge-warning { + background: var(--color-status-warning-soft-bg); + border-color: var(--color-status-warning-soft-border); + color: var(--color-status-warning-foreground); +} +``` + +- [ ] **Step 3: Convert shared control styles** + +In `input.css`, `textarea.css`, and `form-field.css`, replace hardcoded text, placeholder, border, and focus values with semantic tokens. + +- [ ] **Step 4: Convert modal, card, and empty-state files** + +Use semantic surface, border, text, shadow, and overlay tokens in: +- `modal.css` +- `card.css` +- `empty-state.css` + +- [ ] **Step 5: Convert icon button and catalog styling** + +Replace hardcoded muted text, hover backgrounds, snippet backgrounds, and accent colors in: +- `icon-button.css` +- `catalog/catalog.css` + +- [ ] **Step 6: Rebuild the generated stylesheet** + +Run: `cd go-backend && go run ./cmd/buildstyles` + +Expected: `go-backend/static/styles.css` now contains the semantic token names and no longer relies on hardcoded values in these source files. + +- [ ] **Step 7: Run focused UI and catalog verification** + +Run: `cd go-backend && go test ./internal/web/ui ./internal/web/ui/catalog ./cmd/buildstyles -count=1` + +Expected: PASS. + +- [ ] **Step 8: Commit primitive/catalog adoption** + +```bash +git add go-backend/internal/web/ui/*.css go-backend/internal/web/ui/catalog/catalog.css go-backend/static/styles.css +git commit -m "refactor: replace primitive css colors with semantic tokens" +``` + +## Chunk 4: App Layer Adoption + +## Task 4: Replace Hardcoded Literals In `app.css` + +**Files:** +- Modify: `go-backend/internal/web/ui/app.css` +- Modify: `go-backend/internal/web/ui/base.css` + +- [ ] **Step 1: Identify repeated app-only literals and map them to existing tokens first** + +Before adding any new token, check whether an existing semantic token already fits. Reuse it unless the app needs a distinct visual meaning. + +- [ ] **Step 2: Add only the additional semantic tokens that `app.css` truly needs** + +Examples likely needed: +- app panel surface variants +- sidebar/background translucency variants +- stronger app-specific shadow tokens +- gradient stop tokens +- project accent fallback tokens + +Do not add raw-value-named variables. + +- [ ] **Step 3: Replace hardcoded app colors section by section** + +Update: +- auth/login surfaces and gradients +- sidebar surfaces, text, borders, shadows +- overview/project/task status styling +- not-found and hero surfaces +- task widgets and quick-action cards + +Preserve the current rendered appearance. + +- [ ] **Step 4: Keep runtime variables semantic** + +Update patterns like: + +```css +background: var(--project-color, var(--color-project-fallback)); +``` + +instead of falling back to raw hex values. + +- [ ] **Step 5: Rebuild the generated stylesheet** + +Run: `cd go-backend && go run ./cmd/buildstyles` + +Expected: `static/styles.css` reflects the tokenized app layer. + +- [ ] **Step 6: Run broader verification** + +Run: `cd go-backend && go test ./internal/web/... ./cmd/designsystem ./cmd/buildstyles -count=1` + +Expected: PASS. + +- [ ] **Step 7: Commit app-layer token adoption** + +```bash +git add go-backend/internal/web/ui/app.css go-backend/internal/web/ui/base.css go-backend/static/styles.css +git commit -m "refactor: replace app css colors with semantic tokens" +``` + +## Chunk 5: Final Audit + +## Task 5: Prove The Refactor Removed Hardcoded Color Drift + +**Files:** +- Verify: `go-backend/internal/web/ui/*.css` +- Verify: `go-backend/internal/web/ui/catalog/catalog.css` +- Verify: `go-backend/static/styles.css` + +- [ ] **Step 1: Search for remaining hardcoded colors in the co-located CSS sources** + +Run: `cd go-backend && rg -n "#[0-9a-fA-F]{3,8}|rgba\\(|hsl\\(" internal/web/ui/*.css internal/web/ui/catalog/*.css` + +Expected: only intentional token definitions remain in `base.css`, plus any explicitly justified one-off literals that were consciously retained. + +- [ ] **Step 2: Search the generated stylesheet for representative token names** + +Run: `cd go-backend && rg -n -- '--color-text-primary|--color-surface-default|--color-status-warning-soft-bg|--shadow-surface-md|--overlay-backdrop-default' static/styles.css` + +Expected: matching lines exist. + +- [ ] **Step 3: Re-run the full verification set fresh** + +Run: `cd go-backend && go test ./internal/web/... ./cmd/designsystem ./cmd/buildstyles -count=1` + +Expected: PASS. + +- [ ] **Step 4: Commit any final cleanup needed after audit** + +```bash +git add go-backend/internal/web/ui/*.css go-backend/internal/web/ui/catalog/catalog.css go-backend/static/styles.css go-backend/internal/web/ui/ui_test.go +git commit -m "chore: finalize semantic css token refactor" +``` diff --git a/docs/superpowers/specs/2026-05-10-go-backend-semantic-css-color-tokens-design.md b/docs/superpowers/specs/2026-05-10-go-backend-semantic-css-color-tokens-design.md new file mode 100644 index 0000000..0dc50a5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-go-backend-semantic-css-color-tokens-design.md @@ -0,0 +1,184 @@ +# Go Backend Semantic CSS Color Tokens Design + +**Date:** 2026-05-10 + +**Goal** + +Replace hardcoded colors across the co-located Go backend CSS sources with a semantic token layer defined in `go-backend/internal/web/ui/base.css`. + +**Chosen Approach** + +Introduce a structured set of semantic CSS custom properties in `base.css`, then update every co-located CSS file to consume those variables instead of raw hex, rgba, or hsl color literals. This keeps the styling readable, centralizes visual decisions, and prevents component files like `button.css` from hardcoding their own palette values. + +**Scope** + +- Add semantic color, surface, border, shadow, and overlay tokens to `go-backend/internal/web/ui/base.css`. +- Replace hardcoded color literals in all co-located CSS source files: + - `go-backend/internal/web/ui/*.css` + - `go-backend/internal/web/ui/catalog/catalog.css` +- Keep the existing single shipped output: `go-backend/static/styles.css`. +- Preserve the current visual appearance as closely as possible while changing only the source of truth for values. + +**Out Of Scope** + +- Changing component HTML contracts +- Redesigning the visual theme +- Adding dark mode +- Replacing dynamic runtime variables such as `--project-color` +- Tokenizing spacing, radii, typography, or layout values in this slice unless directly necessary for a color/shadow token + +**Architecture** + +The CSS should have two layers of visual definition: + +1. **Semantic token layer in `base.css`** + - neutral text, surface, and border tokens + - brand and action tokens + - semantic status tokens for info, warning, success, and danger + - overlay, gradient, and shadow tokens + +2. **Component and app usage layer** + - every other CSS file references tokens from `base.css` + - component files stop introducing their own color literals + +This preserves the current compiled stylesheet shape while moving visual decisions into one place. + +**Token Strategy** + +Use semantic names, not raw-value names. + +Good examples: + +- `--color-text-primary` +- `--color-text-muted` +- `--color-surface-default` +- `--color-surface-subtle` +- `--color-border-default` +- `--color-brand-primary` +- `--color-brand-primary-hover` +- `--color-status-success-soft-bg` +- `--color-status-danger-strong` +- `--shadow-surface-md` +- `--overlay-backdrop-default` + +Bad examples: + +- `--color-7c3aed` +- `--purple-500` +- `--gray-200` + +The token names should describe intent so components remain readable when using them. + +**Token Categories** + +Recommended categories in `base.css`: + +1. **Core neutrals** + - page background + - primary text + - muted text + - default border + - subtle surface + - raised surface + +2. **Brand / action** + - primary action base, hover, active + - accent / highlight + - focus ring + +3. **Semantic statuses** + - info background, border, foreground + - warning background, border, foreground + - success background, border, foreground + - danger background, border, foreground + - stronger action-oriented variants where needed for buttons + +4. **Effects** + - shadows used by cards, modals, panels, and app surfaces + - overlay/backdrop colors + - shared gradient stops if reused in multiple places + +5. **Runtime-aware fallbacks** + - values that back `var(--project-color, ...)` should use semantic fallbacks such as `var(--project-color, var(--color-project-fallback))` + +**Responsibility Boundaries** + +`base.css` + +- owns all reusable visual tokens +- remains the only file allowed to define shared palette decisions + +Other CSS files + +- consume tokens only +- may still define non-color properties such as layout and spacing +- should not introduce new raw color literals unless there is a strong, deliberate reason + +`app.css` + +- remains app-specific in selector ownership +- still uses the same shared semantic token layer rather than inventing app-only raw values everywhere + +**Migration Strategy** + +Refactor in one coherent slice: + +- define the token vocabulary in `base.css` +- replace hardcoded literals in primitive CSS files +- replace hardcoded literals in `catalog.css` +- replace hardcoded literals in `app.css` +- regenerate `go-backend/static/styles.css` +- verify the generated stylesheet still exposes the expected selectors and that tests still pass + +The work should preserve current behavior. This is a source-of-truth refactor, not a visual refresh. + +**Testing** + +Verification should cover: + +- existing UI tests still passing +- catalog tests still passing +- buildstyles tests still passing +- representative token names present in the generated stylesheet +- representative component selectors still present in the generated stylesheet + +Representative generated-token checks: + +- `--color-text-primary` +- `--color-surface-default` +- `--color-status-warning-soft-bg` +- `--shadow-surface-md` +- `--overlay-backdrop-default` + +Representative selector checks: + +- `.ui-button-solid.ui-button-default` +- `.ui-badge-warning` +- `.ui-modal-panel` +- `.catalog-page` + +**Risks** + +Primary risks: + +- introducing token names that are too literal and not reusable +- accidentally changing appearance while replacing values +- leaving scattered raw colors behind in `app.css` +- over-tokenizing one-off values that do not represent shared decisions + +Mitigation: + +- define token categories before replacing usages +- preserve current values exactly unless consolidation is clearly correct +- use semantic names tied to UI intent +- verify the built stylesheet and tests after the refactor + +**Success Criteria** + +This work is complete when: + +- `base.css` defines a semantic token layer for shared colors and effects +- component and app CSS files use those tokens instead of raw color literals +- the generated `go-backend/static/styles.css` is rebuilt successfully +- the app still ships one stylesheet +- tests still pass diff --git a/go-backend/cmd/buildstyles/main.go b/go-backend/cmd/buildstyles/main.go new file mode 100644 index 0000000..4cdd36c --- /dev/null +++ b/go-backend/cmd/buildstyles/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "os" + "path/filepath" +) + +var sourceOrder = []string{ + filepath.Join("internal", "web", "ui", "base.css"), + filepath.Join("internal", "web", "ui", "catalog", "catalog.css"), + filepath.Join("internal", "web", "ui", "button.css"), + filepath.Join("internal", "web", "ui", "badge.css"), + filepath.Join("internal", "web", "ui", "icon-button.css"), + filepath.Join("internal", "web", "ui", "input.css"), + filepath.Join("internal", "web", "ui", "textarea.css"), + filepath.Join("internal", "web", "ui", "form-field.css"), + filepath.Join("internal", "web", "ui", "modal.css"), + filepath.Join("internal", "web", "ui", "table.css"), + filepath.Join("internal", "web", "ui", "empty-state.css"), + filepath.Join("internal", "web", "ui", "card.css"), + filepath.Join("internal", "web", "ui", "spacing.css"), + filepath.Join("internal", "web", "ui", "app.css"), +} + +func main() { + output := flag.String("out", filepath.Join("static", "styles.css"), "output stylesheet path") + flag.Parse() + + if err := generateStyles(sourceOrder, *output); err != nil { + fmt.Fprintf(os.Stderr, "build styles: %v\n", err) + os.Exit(1) + } +} + +func generateStyles(sourcePaths []string, outputPath string) error { + var buf bytes.Buffer + buf.WriteString("/* Code generated by cmd/buildstyles; DO NOT EDIT. */\n\n") + + for i, path := range sourcePaths { + body, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + buf.WriteString("/* Source: ") + buf.WriteString(filepath.ToSlash(path)) + buf.WriteString(" */\n") + buf.Write(bytes.TrimSpace(body)) + buf.WriteByte('\n') + if i < len(sourcePaths)-1 { + buf.WriteByte('\n') + } + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return fmt.Errorf("mkdir output dir: %w", err) + } + if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write %s: %w", outputPath, err) + } + return nil +} diff --git a/go-backend/cmd/buildstyles/main_test.go b/go-backend/cmd/buildstyles/main_test.go new file mode 100644 index 0000000..2db3b17 --- /dev/null +++ b/go-backend/cmd/buildstyles/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateStylesConcatenatesSourcesInOrder(t *testing.T) { + root := t.TempDir() + uiDir := filepath.Join(root, "internal", "web", "ui") + catalogDir := filepath.Join(uiDir, "catalog") + if err := os.MkdirAll(catalogDir, 0o755); err != nil { + t.Fatalf("mkdir css dirs: %v", err) + } + + sources := map[string]string{ + filepath.Join(uiDir, "base.css"): "/* base */\n.base { color: red; }\n", + filepath.Join(catalogDir, "catalog.css"): "/* catalog */\n.catalog-page { color: green; }\n", + filepath.Join(uiDir, "button.css"): "/* button */\n.ui-button { color: blue; }\n", + } + for path, body := range sources { + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + + outputPath := filepath.Join(root, "static", "styles.css") + sourcePaths := []string{ + filepath.Join(uiDir, "base.css"), + filepath.Join(catalogDir, "catalog.css"), + filepath.Join(uiDir, "button.css"), + } + if err := generateStyles(sourcePaths, outputPath); err != nil { + t.Fatalf("generate styles: %v", err) + } + + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + + got := string(content) + for _, want := range []string{ + "/* base */", + ".base { color: red; }", + "/* catalog */", + ".catalog-page { color: green; }", + "/* button */", + ".ui-button { color: blue; }", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in %q", want, got) + } + } + if strings.Index(got, "/* base */") > strings.Index(got, "/* catalog */") { + t.Fatalf("expected base.css before catalog.css, got %q", got) + } + if strings.Index(got, "/* catalog */") > strings.Index(got, "/* button */") { + t.Fatalf("expected catalog.css before button.css, got %q", got) + } +} diff --git a/go-backend/internal/web/ui/app.css b/go-backend/internal/web/ui/app.css new file mode 100644 index 0000000..026863b --- /dev/null +++ b/go-backend/internal/web/ui/app.css @@ -0,0 +1,1891 @@ +.app-shell, +.login-screen { + min-height: 100vh; +} + +.app-shell { + background: var(--background); +} + +.login-screen { + align-items: center; + background: var(--gradient-shell); + display: flex; + justify-content: center; + overflow: hidden; + padding: 2rem 1rem; + position: relative; +} + +.background-layer { + inset: 0; + overflow: hidden; + pointer-events: none; + position: absolute; +} + +.background-logo { + position: absolute; +} + +.logo-asset { + display: block; + height: auto; + object-fit: contain; + width: 100%; +} + +.size-06 { width: 1.5rem; height: 1.5rem; } +.size-07 { width: 1.75rem; height: 1.75rem; } +.size-08 { width: 2rem; height: 2rem; } +.size-09 { width: 2.25rem; height: 2.25rem; } +.size-10 { width: 2.5rem; height: 2.5rem; } +.size-11 { width: 2.75rem; height: 2.75rem; } +.size-12 { width: 3rem; height: 3rem; } +.size-13 { width: 3.25rem; height: 3.25rem; } +.size-14 { width: 3.5rem; height: 3.5rem; } +.size-15 { width: 3.75rem; height: 3.75rem; } +.size-16 { width: 4rem; height: 4rem; } +.size-18 { width: 4.5rem; height: 4.5rem; } +.size-20 { width: 5rem; height: 5rem; } + +.opacity-02 { opacity: 0.2; } +.opacity-03 { opacity: 0.3; } +.opacity-04 { opacity: 0.4; } +.opacity-05 { opacity: 0.5; } + +.bg-01 { top: 25%; left: 0; } +.bg-02 { top: 33%; left: 0; } +.bg-03 { top: 50%; left: 0; } +.bg-04 { top: 66%; left: 0; } +.bg-05 { top: 75%; left: 0; } +.bg-06 { top: 0; left: 25%; } +.bg-07 { top: 0; left: 50%; } +.bg-08 { top: 0; left: 75%; } +.bg-09 { top: 0; left: 16.66%; } +.bg-10 { top: 0; left: 83.33%; } +.bg-11, +.bg-12, +.bg-13, +.bg-14, +.bg-15 { + left: 50%; + top: 50%; +} +.bg-16 { top: 25%; left: 0; } +.bg-17 { top: 50%; left: 0; } +.bg-18 { top: 75%; left: 0; } +.bg-19 { top: 0; left: 25%; } +.bg-20 { top: 0; left: 75%; } +.bg-21 { top: 16.66%; left: 33.33%; } +.bg-22 { top: 33.33%; left: 66.66%; } +.bg-23 { top: 66.66%; left: 25%; } +.bg-24 { top: 83.33%; left: 75%; } +.bg-25 { top: 12.5%; left: 0; } +.bg-26 { top: 37.5%; left: 0; } +.bg-27 { top: 62.5%; left: 0; } +.bg-28 { top: 87.5%; left: 0; } +.bg-29 { top: 0; left: 0; } +.bg-30 { top: 0; right: 0; } +.bg-31 { bottom: 0; left: 0; } +.bg-32 { bottom: 0; right: 0; } +.bg-33 { top: 20%; left: 20%; } +.bg-34 { top: 40%; left: 80%; } +.bg-35 { top: 80%; left: 40%; } + +.card-wrap { + max-width: 32rem; + position: relative; + transition: transform 0.2s ease-out; + width: 100%; + z-index: 1; +} + +.card-glow { + background: var(--gradient-card-glow); + border-radius: 1rem; + filter: blur(24px); + inset: 0; + position: absolute; + z-index: -1; +} + +.auth-card-shell { + backdrop-filter: blur(12px); + background: var(--card); + border: 1px solid var(--border); + border-radius: 1rem; + box-shadow: var(--shadow-auth-card); + padding: 1.25rem; + position: relative; +} + +.auth-card-topbar { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.back-home-link { + align-items: center; + color: var(--muted-foreground); + display: inline-flex; + font-size: 0.875rem; + transition: color 0.2s ease; +} + +.back-home-link:hover, +.theme-toggle-button:hover, +.signup-link:hover { + color: var(--foreground); +} + +.back-home-icon { + height: 1rem; + margin-right: 0.5rem; + width: 1rem; +} + +.theme-toggle-button { + align-items: center; + background: transparent; + border: 0; + border-radius: 0.5rem; + color: var(--muted-foreground); + cursor: pointer; + display: inline-flex; + height: 2.25rem; + justify-content: center; + padding: 0.5rem; + transition: + background-color 0.2s ease, + color 0.2s ease; + width: 2.25rem; +} + +.theme-toggle-button:hover { + background: var(--accent); +} + +.theme-toggle-icon { + height: 1.25rem; + width: 1.25rem; +} + +.brand-header { + display: flex; + justify-content: center; + margin-bottom: 1.5rem; +} + +.brand-logo { + display: block; + height: 4rem; + object-fit: contain; + width: 4rem; +} + +.title-group { + margin-bottom: 1.5rem; + text-align: center; +} + +.title-group h1 { + font-size: clamp(1.5rem, 4vw, 1.875rem); + font-weight: 700; + margin: 0; +} + +.new-experience-link-wrap { + margin-bottom: 1.5rem; + text-align: center; +} + +.new-experience-link { + color: var(--color-text-brand); + display: inline-flex; + font-size: 0.875rem; + font-weight: 500; + transition: color 0.2s ease; +} + +.new-experience-link:hover, +.forgot-password-link:hover { + color: var(--color-text-brand-hover); +} + +.auth-body { + align-items: center; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 0 auto; + max-width: 28rem; + width: 100%; +} + +.field-stack { + display: grid; + gap: 0.5rem; +} + +.field-stack label { + font-size: 0.875rem; + font-weight: 500; +} + +.field-stack input { + background: var(--background); + border: 1px solid var(--input); + border-radius: 0.375rem; + color: var(--foreground); + height: 2.5rem; + padding: 0.5rem 0.75rem; + width: 100%; +} + +.field-stack input:focus { + box-shadow: 0 0 0 1px var(--ring); + outline: none; +} + +.field-stack input::placeholder { + color: var(--muted-foreground); +} + +.forgot-password-row { + display: flex; + justify-content: flex-end; +} + +.forgot-password-link { + color: var(--color-text-brand-strong); + font-size: 0.875rem; + transition: color 0.2s ease; +} + +.submit-button { + align-items: center; + background: var(--primary); + border: 0; + border-radius: 0.375rem; + color: var(--primary-foreground); + cursor: pointer; + display: inline-flex; + font-size: 0.875rem; + font-weight: 500; + height: 2.25rem; + justify-content: center; + padding: 0.5rem 1rem; + transition: opacity 0.2s ease; + width: 100%; +} + +.submit-button:hover { + opacity: 0.9; +} + +.divider-row { + align-items: center; + display: flex; + gap: 0.25rem; + margin: 0.5rem 0 0; + position: relative; + width: 100%; +} + +.divider-line { + border-top: 1px solid var(--border); + flex: 1; +} + +.divider-pill { + background: var(--background); + border-radius: 999px; + color: var(--muted-foreground); + font-size: 0.875rem; + font-weight: 500; + padding: 0.25rem 1rem; + position: relative; + z-index: 1; +} + +.signup-copy { + color: var(--muted-foreground); + font-size: 0.875rem; + margin: 0; + text-align: center; +} + +.signup-link { + border-radius: 0.375rem; + color: var(--foreground); + display: inline-block; + font-weight: 500; + margin-left: 0.2rem; + padding: 0.25rem 0.5rem; + transition: + color 0.2s ease, + background-color 0.2s ease; +} + +.signup-link:hover { + background: var(--accent); +} + +.status-slot { + min-height: 0.25rem; +} + +.status-banner { + border: 1px solid; + border-radius: 0.5rem; + font-size: 0.875rem; + padding: 0.75rem 0.875rem; +} + +.status-success { + background: var(--color-status-success-banner-bg); + border-color: var(--color-status-success-banner-border); + color: var(--color-status-success-banner-foreground); +} + +.status-error { + background: var(--color-status-danger-banner-bg); + border-color: var(--color-status-danger-banner-border); + color: var(--color-status-danger-banner-foreground); +} + +.gsi-material-button { + -moz-user-select: none; + -ms-user-select: none; + -webkit-appearance: none; + -webkit-user-select: none; + background-color: var(--color-surface-default); + background-image: none; + border: 1px solid var(--color-border-google); + border-radius: 20px; + box-sizing: border-box; + color: var(--color-text-google); + cursor: pointer; + font-family: "Roboto", Arial, sans-serif; + font-size: 14px; + height: 40px; + letter-spacing: 0.25px; + max-width: 400px; + min-width: min-content; + outline: none; + overflow: hidden; + padding: 0 12px; + position: relative; + text-align: center; + transition: + background-color 0.218s, + border-color 0.218s, + box-shadow 0.218s; + vertical-align: middle; + white-space: nowrap; + width: 100%; +} + +.gsi-material-button .gsi-material-button-icon { + height: 20px; + margin-right: 12px; + min-width: 20px; + width: 20px; +} + +.gsi-material-button .gsi-material-button-content-wrapper { + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + height: 100%; + justify-content: space-between; + position: relative; + width: 100%; +} + +.gsi-material-button .gsi-material-button-contents { + flex-grow: 1; + font-family: "Roboto", Arial, sans-serif; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} + +.gsi-material-button .gsi-material-button-state { + bottom: 0; + left: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; + transition: opacity 0.218s; +} + +.gsi-material-button:not(:disabled):active .gsi-material-button-state, +.gsi-material-button:not(:disabled):focus .gsi-material-button-state { + background-color: var(--overlay-google-state); + opacity: 0.12; +} + +.gsi-material-button:not(:disabled):hover { + box-shadow: var(--shadow-google-button); +} + +.gsi-material-button:not(:disabled):hover .gsi-material-button-state { + background-color: var(--overlay-google-state); + opacity: 0.08; +} + +.home-shell { + background: var(--gradient-shell); + min-height: 100vh; +} + +.dashboard-shell { + background: var(--gradient-shell); + color: var(--foreground); + display: grid; + grid-template-columns: minmax(16rem, 18rem) 1fr; + min-height: 100vh; +} + +.dashboard-sidebar { + padding-left: env(safe-area-inset-left, 0px); +} + +.sidebar-nav-shell { + background: var(--color-surface-elevated); + border-right: 1px solid var(--color-border-panel); + box-shadow: var(--shadow-sidebar); + display: flex; + flex-direction: column; + height: 100vh; + overflow-x: hidden; + overflow-y: auto; + padding: env(safe-area-inset-top, 0px) 0.75rem env(safe-area-inset-bottom, 0px) 0; + position: sticky; + top: 0; +} + +.sidebar-brand { + align-items: center; + display: flex; + justify-content: center; + padding: 0.75rem 0.5rem; + position: relative; +} + +.sidebar-brand-link { + align-items: center; + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +} + +.sidebar-brand-logo { + border-radius: 0.75rem; + height: 4rem; + object-fit: cover; + width: 4rem; +} + +.sidebar-brand-title { + color: var(--color-text-heading-alt); + font-size: 1.125rem; + font-weight: 700; + margin: 0; +} + +.sidebar-collapse-button { + align-items: center; + background: var(--color-surface-elevated-strong); + border: 0; + border-radius: 999px; + box-shadow: var(--shadow-floating-control); + color: var(--color-text-muted); + cursor: pointer; + display: inline-flex; + height: 1.5rem; + justify-content: center; + padding: 0.25rem; + position: absolute; + right: 0.75rem; + top: 0.5rem; + width: 1.5rem; +} + +.sidebar-collapse-button svg { + height: 1rem; + width: 1rem; +} + +.sidebar-primary { + display: flex; + flex: 1; + flex-direction: column; +} + +.sidebar-list { + display: grid; + gap: 0; + list-style: none; + margin: 0; + padding: 0.75rem 0 0; +} + +.sidebar-divider { + margin: 0.5rem 0; + padding: 0 0.875rem; +} + +.sidebar-divider hr, +.sidebar-projects hr { + border: 0; + border-top: 1px solid var(--color-border-panel-muted); + margin: 0; +} + +.sidebar-nav-item { + border-radius: 0.9rem; + color: var(--color-text-muted); + font-weight: 500; + margin: 0 0.5rem; + padding: 0.15rem 0; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.sidebar-nav-item:hover { + background: var(--overlay-dark-soft); + color: var(--color-surface-muted-inverse); +} + +.sidebar-nav-item.is-active { + background: var(--overlay-brand-soft-strong); + color: var(--color-text-brand); + font-weight: 600; +} + +.sidebar-nav-link { + display: block; + width: 100%; +} + +.sidebar-nav-link-inner { + align-items: center; + display: flex; + gap: 0.75rem; + padding: 0.6rem 0.95rem; +} + +.sidebar-nav-icon { + align-items: center; + display: inline-flex; + justify-content: center; +} + +.sidebar-nav-icon svg { + height: 1.35rem; + width: 1.35rem; +} + +.sidebar-nav-label { + font-size: 1rem; +} + +.sidebar-projects { + margin-top: 0.4rem; + padding: 0 0.75rem 0.75rem; +} + +.sidebar-section-label { + color: var(--color-text-muted); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.14em; + margin: 0.9rem 0 0.65rem; + padding: 0 0.5rem; + text-transform: uppercase; +} + +.sidebar-project-list { + display: grid; + gap: 0.1rem; + list-style: none; + margin: 0; + padding: 0; +} + +.sidebar-project-link { + align-items: center; + border-radius: 0.85rem; + color: var(--color-text-muted); + display: flex; + gap: 0.65rem; + padding: 0.48rem 0.5rem; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.sidebar-project-link:hover { + background: var(--overlay-dark-soft); + color: var(--color-surface-muted-inverse); +} + +.sidebar-project-icon { + align-items: center; + border: 1px solid var(--color-border-panel-strong); + border-radius: 999px; + display: inline-flex; + flex-shrink: 0; + height: 1.55rem; + justify-content: center; + width: 1.55rem; +} + +.sidebar-project-icon svg { + height: 0.9rem; + width: 0.9rem; +} + +.sidebar-project-label { + flex: 1; + font-size: 0.9rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-footer-links { + margin-top: auto; + padding-bottom: 0.25rem; +} + +.sidebar-organization { + background: var(--color-surface-elevated); + padding: 0 0.5rem 0.9rem; +} + +.organization-button { + align-items: center; + background: transparent; + border: 0; + border-radius: 0.95rem; + cursor: pointer; + display: flex; + gap: 0.65rem; + padding: 0.55rem 0.65rem; + text-align: left; + transition: background-color 0.2s ease; + width: 100%; +} + +.organization-button:hover { + background: var(--overlay-dark-soft); +} + +.organization-avatar { + border-radius: 999px; + display: inline-flex; + flex-shrink: 0; + height: 1.75rem; + overflow: hidden; + width: 1.75rem; +} + +.organization-avatar img { + aspect-ratio: 1; + height: 100%; + object-fit: cover; + width: 100%; +} + +.organization-copy { + display: flex; + flex-direction: column; + min-width: 0; +} + +.organization-name { + color: var(--color-text-body-subtle); + font-size: 0.95rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.organization-meta { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.dashboard-main { + display: flex; + flex-direction: column; + gap: 1.5rem; + min-width: 0; + padding: 2rem; +} + +.overview-page { + display: flex; + flex-direction: column; + gap: 1.5rem; + min-height: 100%; +} + +.overview-header { + padding: 0 0 0.25rem; +} + +.overview-date { + color: var(--color-text-secondary); + font-size: 1rem; + font-weight: 500; + margin: 0 0 0.5rem; +} + +.overview-header-row { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; +} + +.overview-greeting { + color: var(--color-text-secondary); + font-size: clamp(1.4rem, 2vw, 1.75rem); + font-weight: 500; + margin: 0; +} + +.overview-greeting span { + color: var(--color-surface-muted-inverse); +} + +.overview-header-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.overview-badge { + align-items: center; + background: var(--gradient-overview-badge); + border: 0; + border-radius: 999px; + color: var(--color-text-inverse); + display: inline-flex; + font-size: 0.75rem; + font-weight: 600; + justify-content: center; + min-height: 1.85rem; + padding: 0.25rem 0.75rem; +} + +.overview-logout-form { + margin: 0; +} + +.overview-logout-button { + background: var(--color-surface-elevated); + border: 1px solid var(--color-border-subtle); + border-radius: 0.85rem; + color: var(--color-text-secondary); + cursor: pointer; + font-weight: 600; + min-height: 2.75rem; + padding: 0.65rem 1rem; +} + +.overview-actions { + display: grid; + gap: 1rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.quick-action-card { + align-items: center; + background: var(--color-surface-default); + border: 1px solid var(--color-border-subtle); + border-radius: 1rem; + cursor: pointer; + display: flex; + gap: 0.9rem; + min-height: 5rem; + padding: 0.9rem; + text-align: left; + transition: box-shadow 0.2s ease, transform 0.2s ease; +} + +.quick-action-card:hover, +.project-card:hover { + box-shadow: var(--shadow-surface-hover); +} + +.quick-action-icon { + align-items: center; + background: var(--color-surface-brand-muted); + border-radius: 0.7rem; + color: var(--color-text-brand-accent); + display: inline-flex; + flex-shrink: 0; + height: 2.5rem; + justify-content: center; + width: 2.5rem; +} + +.quick-action-icon svg { + height: 1.5rem; + width: 1.5rem; +} + +.quick-action-copy { + flex: 1; + min-width: 0; +} + +.quick-action-title { + color: var(--color-surface-muted-inverse); + font-size: 1.05rem; + font-weight: 600; + line-height: 1.2; +} + +.quick-action-copy p { + color: var(--color-text-muted); + font-size: 0.9rem; + margin: 0.25rem 0 0; +} + +.overview-section { + padding: 0.25rem 0 0; +} + +.overview-section-heading { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.overview-section-heading h3, +.tasks-section-header h3 { + color: var(--color-surface-muted-inverse); + font-size: 1.6rem; + font-weight: 600; + margin: 0; +} + +.project-grid { + display: grid; + gap: 1.25rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.project-card { + background: var(--color-surface-default); + border: 1px solid var(--color-border-subtle); + border-radius: 1rem; + cursor: pointer; + padding: 1rem; + transition: box-shadow 0.2s ease; +} + +.project-card-top { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 1rem; +} + +.project-status, +.task-status { + border: 1px solid transparent; + border-radius: 999px; + display: inline-flex; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.7rem; +} + +.tone-warning { + background: var(--color-status-warning-emphasis-bg); + border-color: var(--color-status-warning-emphasis-border); + color: var(--color-status-warning-emphasis-foreground); +} + +.tone-info { + background: var(--color-status-info-soft-bg); + border-color: var(--color-status-info-soft-border); + color: var(--color-status-info-foreground); +} + +.tone-success { + background: var(--color-status-success-soft-bg); + border-color: var(--color-status-success-soft-border); + color: var(--color-status-success-foreground); +} + + +.project-card-top .borderless-icon-button { + padding: 0; +} + +.project-card-top .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-neutral:hover { + color: var(--color-surface-muted-inverse); +} + +.project-card-top .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger:hover { + color: var(--color-status-danger-icon-hover); +} + +.borderless-icon-button svg, +.project-date-row svg, +.overview-more-button svg, +.tasks-add-button svg, +.task-check svg { + height: 1rem; + width: 1rem; +} + +td.text-right .borderless-icon-button { + align-items: center; + border-radius: 0.25rem; + color: var(--color-text-faint); + 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.ui-icon-button-ghost.ui-icon-button-neutral:hover { + color: var(--color-surface-muted-inverse); +} + +td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger:hover { + color: var(--color-status-danger-icon-hover); +} + +.project-card-title-row { + align-items: center; + display: flex; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.project-avatar { + align-items: center; + background: var(--project-color, var(--color-project-fallback)); + border-radius: 0.85rem; + color: var(--color-text-inverse); + display: inline-flex; + flex-shrink: 0; + font-size: 1.1rem; + font-weight: 700; + height: 3rem; + justify-content: center; + width: 3rem; +} + +.project-list-icon { + background: var(--project-color, var(--color-project-fallback)); + color: var(--color-text-inverse); +} + +.project-accent-blue { + background: var(--color-project-fallback); +} + +.project-accent-purple { + background: var(--color-project-accent-purple); +} + +.project-accent-red { + background: var(--color-project-accent-red); +} + +.project-card-title-row h4 { + color: var(--color-surface-muted-inverse); + flex: 1; + font-size: 1rem; + font-weight: 600; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.project-date-row { + align-items: center; + color: var(--color-text-muted); + display: flex; + font-size: 0.875rem; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.project-progress-label { + align-items: center; + color: var(--color-text-muted); + display: flex; + font-size: 0.875rem; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.project-progress-label strong { + color: var(--color-surface-muted-inverse); +} + +.project-progress-track { + background: var(--color-surface-muted); + border-radius: 999px; + height: 0.5rem; + overflow: hidden; +} + +.project-progress-bar { + background: var(--project-color, var(--color-project-fallback)); + border-radius: 999px; + height: 100%; +} + +.tablo-color-picker { + max-width: 5rem; + min-height: 44px; + padding: 0.4rem; +} + +.overview-more-row { + display: flex; + justify-content: center; + margin-top: 1.5rem; +} + +.overview-more-button { + align-items: center; + background: transparent; + border: 0; + color: var(--color-text-brand-strong); + cursor: pointer; + display: inline-flex; + font-size: 0.875rem; + font-weight: 600; + gap: 0.4rem; +} + +.app-section-page { + min-height: 100vh; + padding: 2rem; +} + +.app-section-surface { + background: var(--gradient-app-surface); + border: 1px solid var(--color-border-subtle); + border-radius: 1.5rem; + box-shadow: var(--shadow-surface-lg); + max-width: 52rem; + padding: 2rem; +} + +.app-section-eyebrow { + color: var(--color-text-brand-strong); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + margin-bottom: 0.9rem; + text-transform: uppercase; +} + +.app-section-surface h2 { + color: var(--color-surface-muted-inverse); + font-size: clamp(1.8rem, 4vw, 2.6rem); + line-height: 1.05; + margin: 0; +} + +.app-section-surface p { + color: var(--color-text-secondary); + font-size: 1rem; + line-height: 1.8; + margin: 1rem 0 0; + max-width: 40rem; +} + +.not-found-page { + align-items: center; + background: var(--gradient-not-found-bg); + display: flex; + justify-content: center; + min-height: 100vh; + padding: 3rem 1.5rem; +} + +.not-found-surface { + backdrop-filter: blur(18px); + background: var(--color-surface-overlay); + border: 1px solid var(--color-border-overlay); + border-radius: 2rem; + box-shadow: var(--shadow-surface-xl); + padding: 3rem; + width: min(100%, 44rem); +} + +.not-found-eyebrow { + align-items: center; + background: var(--overlay-brand-soft); + border-radius: 999px; + color: var(--color-text-brand-strong); + display: inline-flex; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + padding: 0.45rem 0.85rem; + text-transform: uppercase; +} + +.not-found-code { + color: var(--color-surface-muted-inverse); + font-size: clamp(4.75rem, 11vw, 7.5rem); + font-weight: 800; + letter-spacing: -0.08em; + line-height: 0.95; + margin-top: 1.4rem; +} + +.not-found-surface h2 { + color: var(--color-surface-muted-inverse); + font-size: clamp(1.9rem, 4vw, 2.8rem); + line-height: 1.05; + margin: 1rem 0 0; +} + +.not-found-surface p { + color: var(--color-text-secondary); + font-size: 1rem; + line-height: 1.75; + margin: 1rem 0 0; + max-width: 36rem; +} + +.not-found-actions { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + margin-top: 2rem; +} + +.not-found-primary, +.not-found-secondary { + align-items: center; + border: 1px solid transparent; + border-radius: 0.95rem; + display: inline-flex; + font-size: 0.95rem; + font-weight: 600; + justify-content: center; + min-height: 2.9rem; + padding: 0.85rem 1.25rem; + text-decoration: none; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + background-color 0.18s ease, + border-color 0.18s ease; +} + +.not-found-primary { + background: var(--gradient-not-found-primary); + box-shadow: var(--shadow-brand-action); + color: var(--color-text-inverse); +} + +.not-found-primary:hover, +.not-found-secondary:hover { + transform: translateY(-1px); +} + +.not-found-secondary-form { + margin: 0; +} + +.not-found-secondary { + background: var(--color-surface-elevated-soft); + border-color: var(--color-border-overlay-strong); + color: var(--color-text-overlay); + cursor: pointer; +} + +.not-found-meta { + align-items: center; + color: var(--color-text-disabled); + display: flex; + font-size: 0.95rem; + gap: 0.5rem; + margin-top: 1.5rem; +} + +.not-found-meta strong { + color: var(--color-surface-muted-inverse); +} + +.tasks-section { + background: var(--color-surface-default); + border: 1px solid var(--color-border-subtle); + border-radius: 1rem; + overflow: hidden; +} + +.tasks-section-header { + align-items: center; + border-bottom: 1px solid var(--color-border-muted); + display: flex; + justify-content: space-between; + padding: 1.2rem 1rem; +} + +.tasks-add-button { + align-items: center; + background: var(--color-surface-default); + border: 1px solid var(--color-border-muted); + border-radius: 0.7rem; + color: var(--color-text-secondary); + cursor: pointer; + display: inline-flex; + font-weight: 500; + gap: 0.5rem; + min-height: 2.75rem; + padding: 0.7rem 1rem; +} + +.task-list { + display: flex; + flex-direction: column; +} + +.task-row { + align-items: center; + border-bottom: 1px solid var(--color-border-muted); + cursor: pointer; + display: flex; + gap: 0.75rem; + padding: 0.9rem 1rem; + transition: background-color 0.2s ease; +} + +.task-row:hover { + background: var(--color-surface-neutral-hover); +} + +.task-check { + align-items: center; + background: var(--color-surface-default); + border: 2px solid var(--color-border-strong); + border-radius: 999px; + color: var(--color-text-inverse); + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + height: 2rem; + justify-content: center; + width: 2rem; +} + +.task-check.is-complete { + background: var(--color-text-brand-strong); + border-color: var(--color-text-brand-strong); +} + +.task-body { + flex: 1; + min-width: 0; +} + +.task-body p { + color: var(--color-surface-muted-inverse); + font-size: 0.95rem; + font-weight: 500; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-row.is-complete .task-body p { + color: var(--color-text-faint); + text-decoration: line-through; +} + +.task-meta { + align-items: center; + color: var(--color-text-muted); + display: flex; + flex-wrap: wrap; + font-size: 0.75rem; + gap: 0.45rem; + margin-top: 0.3rem; +} + +.task-project-badge { + align-items: center; + border-radius: 0.35rem; + color: var(--color-text-inverse); + display: inline-flex; + font-size: 0.5rem; + font-weight: 700; + height: 1rem; + justify-content: center; + width: 1rem; +} + +.task-date { + white-space: nowrap; +} + +.home-card { + backdrop-filter: blur(12px); + background: var(--card); + border: 1px solid var(--border); + border-radius: 1rem; + box-shadow: var(--shadow-auth-card); + max-width: 28rem; + padding: 2rem; + text-align: center; + width: 100%; +} + +.home-card h1 { + margin: 0 0 0.75rem; +} + +.home-card p { + color: var(--muted-foreground); + margin: 0; +} + +.home-logo { + display: block; + height: 4rem; + margin: 0 auto 1rem; + object-fit: contain; + width: 4rem; +} + +.logout-form { + margin-top: 1.5rem; +} + +.logout-button { + max-width: 14rem; + width: 100%; +} + +@media (max-width: 980px) { + .dashboard-shell { + grid-template-columns: 1fr; + } + + .sidebar-nav-shell { + border-bottom: 1px solid var(--color-border-panel); + border-right: 0; + box-shadow: var(--shadow-surface-hover); + height: auto; + position: static; + } + + .overview-actions, + .project-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .dashboard-main { + padding: 1rem; + } + + .overview-header-row, + .tasks-section-header { + align-items: flex-start; + flex-direction: column; + } + + .overview-actions, + .project-grid { + grid-template-columns: 1fr; + } + + .task-row { + align-items: flex-start; + flex-wrap: wrap; + } + + .task-status { + margin-left: 2.75rem; + } + + .not-found-surface { + border-radius: 1.5rem; + padding: 2rem; + } + + .not-found-actions { + flex-direction: column; + } + + .not-found-primary, + .not-found-secondary, + .not-found-secondary-form { + width: 100%; + } + + .app-section-page { + padding: 1rem; + } + + .app-section-surface { + padding: 1.5rem; + } +} + +@keyframes move-right-slow { + from { transform: translateX(-6rem); } + to { transform: translateX(calc(100vw + 6rem)); } +} + +@keyframes move-right-medium { + from { transform: translateX(-5rem); } + to { transform: translateX(calc(100vw + 5rem)); } +} + +@keyframes move-right-fast { + from { transform: translateX(-7rem); } + to { transform: translateX(calc(100vw + 7rem)); } +} + +@keyframes move-down-slow { + from { transform: translateY(-6rem); } + to { transform: translateY(calc(100vh + 6rem)); } +} + +@keyframes move-down-medium { + from { transform: translateY(-5rem); } + to { transform: translateY(calc(100vh + 5rem)); } +} + +@keyframes move-diagonal-1 { + from { transform: translate(-4rem, -4rem); } + to { transform: translate(52vw, 70vh); } +} + +@keyframes move-diagonal-2 { + from { transform: translate(0, -4rem); } + to { transform: translate(-18vw, 76vh); } +} + +@keyframes move-diagonal-3 { + from { transform: translate(0, -4rem); } + to { transform: translate(12vw, 72vh); } +} + +@keyframes orbit-1 { + from { transform: translate(-50%, -50%) rotate(0deg) translateX(11rem); } + to { transform: translate(-50%, -50%) rotate(360deg) translateX(11rem); } +} + +@keyframes orbit-2 { + from { transform: translate(-50%, -50%) rotate(0deg) translateX(7rem); } + to { transform: translate(-50%, -50%) rotate(-360deg) translateX(7rem); } +} + +@keyframes orbit-3 { + from { transform: translate(-50%, -50%) rotate(0deg) translateX(5rem); } + to { transform: translate(-50%, -50%) rotate(360deg) translateX(5rem); } +} + +@keyframes orbit-4 { + from { transform: translate(-50%, -50%) rotate(0deg) translateX(15rem); } + to { transform: translate(-50%, -50%) rotate(360deg) translateX(15rem); } +} + +@keyframes orbit-5 { + from { transform: translate(-50%, -50%) rotate(0deg) translateX(8rem); } + to { transform: translate(-50%, -50%) rotate(-360deg) translateX(8rem); } +} + +@keyframes zigzag-1 { + 0% { transform: translateX(-6rem) translateY(0); } + 25% { transform: translateX(25vw) translateY(-3rem); } + 50% { transform: translateX(50vw) translateY(3rem); } + 75% { transform: translateX(75vw) translateY(-2rem); } + 100% { transform: translateX(calc(100vw + 6rem)) translateY(1rem); } +} + +@keyframes zigzag-2 { + 0% { transform: translateX(-5rem) translateY(0); } + 25% { transform: translateX(25vw) translateY(2rem); } + 50% { transform: translateX(50vw) translateY(-3rem); } + 75% { transform: translateX(75vw) translateY(1.5rem); } + 100% { transform: translateX(calc(100vw + 5rem)) translateY(0); } +} + +@keyframes zigzag-3 { + 0% { transform: translateX(-7rem) translateY(0); } + 20% { transform: translateX(20vw) translateY(-4rem); } + 40% { transform: translateX(40vw) translateY(4rem); } + 60% { transform: translateX(60vw) translateY(-2rem); } + 80% { transform: translateX(80vw) translateY(3rem); } + 100% { transform: translateX(calc(100vw + 7rem)) translateY(0); } +} + +@keyframes spiral-1 { + 0% { transform: translate(0, 0) rotate(0deg) scale(0.6); } + 100% { transform: translate(90vw, 90vh) rotate(360deg) scale(1.3); } +} + +@keyframes spiral-2 { + 0% { transform: translate(0, 0) rotate(0deg) scale(1.4); } + 100% { transform: translate(-70vw, 90vh) rotate(-360deg) scale(0.7); } +} + +@keyframes float-random-1 { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(1.4rem, -1rem); } +} + +@keyframes float-random-2 { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(-1.2rem, 1.1rem); } +} + +@keyframes float-random-3 { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(0.9rem, -1.4rem); } +} + +@keyframes float-random-4 { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(-1rem, 0.8rem); } +} + +@keyframes wave-1 { + from { transform: translateX(-4rem); } + to { transform: translateX(calc(100vw + 4rem)); } +} + +@keyframes wave-2 { + from { transform: translateX(-5rem); } + to { transform: translateX(calc(100vw + 5rem)); } +} + +@keyframes wave-3 { + from { transform: translateX(-4rem); } + to { transform: translateX(calc(100vw + 4rem)); } +} + +@keyframes wave-4 { + from { transform: translateX(-6rem); } + to { transform: translateX(calc(100vw + 6rem)); } +} + +@keyframes corner-shoot-1 { + from { transform: translate(0, 0); } + to { transform: translate(110vw, 110vh); } +} + +@keyframes corner-shoot-2 { + from { transform: translate(0, 0); } + to { transform: translate(-110vw, 110vh); } +} + +@keyframes corner-shoot-3 { + from { transform: translate(0, 0); } + to { transform: translate(110vw, -110vh); } +} + +@keyframes corner-shoot-4 { + from { transform: translate(0, 0); } + to { transform: translate(-110vw, -110vh); } +} + +@keyframes bounce-ball-1 { + 0%, 100% { transform: translateY(0) translateX(0); } + 50% { transform: translateY(-5rem) translateX(2rem); } +} + +@keyframes bounce-ball-2 { + 0%, 100% { transform: translateY(0) translateX(0); } + 50% { transform: translateY(4rem) translateX(-2rem); } +} + +@keyframes bounce-ball-3 { + 0%, 100% { transform: translateY(0) translateX(0); } + 50% { transform: translateY(-4rem) translateX(1.5rem); } +} + +@keyframes spin-slow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes spin-reverse { + from { transform: rotate(0deg); } + to { transform: rotate(-360deg); } +} + +@keyframes bounce-gentle { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-0.6rem); } +} + +@keyframes pulse-gentle { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.08); opacity: 0.85; } +} + +@keyframes wiggle { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-4deg); } + 75% { transform: rotate(4deg); } +} + +@keyframes float-gentle { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-0.8rem); } +} + +@keyframes scale-gentle { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.12); } +} + +@keyframes rotate-gentle { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(12deg); } +} + +@keyframes bounce-soft { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-0.9rem); } +} + +@keyframes sway { + 0%, 100% { transform: translateX(0); } + 50% { transform: translateX(0.8rem); } +} + +@keyframes spin-fast { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes pulse-fast { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.2); } +} + +@keyframes wobble { + 0%, 100% { transform: translateX(0); } + 15% { transform: translateX(-0.35rem) rotate(-5deg); } + 30% { transform: translateX(0.25rem) rotate(4deg); } + 45% { transform: translateX(-0.2rem) rotate(-2deg); } + 60% { transform: translateX(0.12rem) rotate(1deg); } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-0.12rem); } + 75% { transform: translateX(0.12rem); } +} + +@keyframes bounce-crazy { + 0%, 100% { transform: translateY(0) scale(1); } + 25% { transform: translateY(-0.5rem) scale(1.05); } + 75% { transform: translateY(0.2rem) scale(0.98); } +} + +@keyframes spin-wobble { + 0% { transform: rotate(0deg) scale(1); } + 50% { transform: rotate(180deg) scale(1.12); } + 100% { transform: rotate(360deg) scale(1); } +} + +@keyframes flip { + 0%, 100% { transform: rotateY(0deg); } + 50% { transform: rotateY(180deg); } +} + +@keyframes twirl { + 0%, 100% { transform: rotate(0deg) scale(1); } + 50% { transform: rotate(180deg) scale(1.15); } +} + +@keyframes dance { + 0%, 100% { transform: translateY(0) rotate(0deg); } + 25% { transform: translateY(-0.3rem) rotate(-6deg); } + 75% { transform: translateY(0.3rem) rotate(6deg); } +} + +@keyframes jiggle { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-0.18rem); } + 50% { transform: translateX(0.18rem); } + 75% { transform: translateX(-0.1rem); } +} + +@keyframes vibrate { + 0%, 100% { transform: translate(0); } + 20% { transform: translate(-1px, 1px); } + 40% { transform: translate(1px, -1px); } + 60% { transform: translate(-1px, -1px); } + 80% { transform: translate(1px, 1px); } +} + +@keyframes swing { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(10deg); } +} + +@keyframes pendulum { + 0%, 100% { transform: rotate(-8deg); } + 50% { transform: rotate(8deg); } +} + +@keyframes elastic { + 0%, 100% { transform: scale(1); } + 30% { transform: scale(1.15, 0.9); } + 60% { transform: scale(0.95, 1.08); } +} + +@keyframes rubber { + 0%, 100% { transform: scale(1); } + 35% { transform: scale(1.2, 0.9); } + 65% { transform: scale(0.9, 1.15); } +} + +@keyframes rocket { + 0%, 100% { transform: translateY(0) rotate(-8deg); } + 50% { transform: translateY(-0.8rem) rotate(-12deg); } +} + +@keyframes comet { + 0%, 100% { transform: translateX(0) rotate(12deg); } + 50% { transform: translateX(0.8rem) rotate(18deg); } +} + +@keyframes meteor { + 0%, 100% { transform: translateY(0) rotate(8deg); } + 50% { transform: translateY(-0.7rem) rotate(14deg); } +} + +@keyframes blast { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.2) rotate(10deg); } +} + +@keyframes spin-bounce { + 0%, 100% { transform: rotate(0deg) translateY(0); } + 50% { transform: rotate(180deg) translateY(-0.5rem); } +} + +@keyframes flip-bounce { + 0%, 100% { transform: rotateY(0deg) translateY(0); } + 50% { transform: rotateY(180deg) translateY(-0.5rem); } +} + +@keyframes scale-bounce { + 0%, 100% { transform: scale(1) translateY(0); } + 50% { transform: scale(1.14) translateY(-0.55rem); } +} + +.animate-move-right-slow { animation: move-right-slow 25s linear infinite; } +.animate-move-right-medium { animation: move-right-medium 20s linear infinite; } +.animate-move-right-fast { animation: move-right-fast 15s linear infinite; } +.animate-move-down-slow { animation: move-down-slow 30s linear infinite; } +.animate-move-down-medium { animation: move-down-medium 25s linear infinite; } +.animate-move-diagonal-1 { animation: move-diagonal-1 35s linear infinite; } +.animate-move-diagonal-2 { animation: move-diagonal-2 28s linear infinite; } +.animate-move-diagonal-3 { animation: move-diagonal-3 32s linear infinite; } +.animate-orbit-1 { animation: orbit-1 20s linear infinite; } +.animate-orbit-2 { animation: orbit-2 25s linear infinite reverse; } +.animate-orbit-3 { animation: orbit-3 15s linear infinite; } +.animate-orbit-4 { animation: orbit-4 22s linear infinite; } +.animate-orbit-5 { animation: orbit-5 18s linear infinite; } +.animate-zigzag-1 { animation: zigzag-1 18s linear infinite; } +.animate-zigzag-2 { animation: zigzag-2 22s linear infinite; } +.animate-zigzag-3 { animation: zigzag-3 16s linear infinite; } +.animate-spiral-1 { animation: spiral-1 30s linear infinite; } +.animate-spiral-2 { animation: spiral-2 25s linear infinite; } +.animate-float-random-1 { animation: float-random-1 8s ease-in-out infinite; } +.animate-float-random-2 { animation: float-random-2 10s ease-in-out infinite; } +.animate-float-random-3 { animation: float-random-3 12s ease-in-out infinite; } +.animate-float-random-4 { animation: float-random-4 9s ease-in-out infinite; } +.animate-wave-1 { animation: wave-1 20s linear infinite; } +.animate-wave-2 { animation: wave-2 24s linear infinite; } +.animate-wave-3 { animation: wave-3 18s linear infinite; } +.animate-wave-4 { animation: wave-4 26s linear infinite; } +.animate-corner-shoot-1 { animation: corner-shoot-1 15s linear infinite; } +.animate-corner-shoot-2 { animation: corner-shoot-2 18s linear infinite; } +.animate-corner-shoot-3 { animation: corner-shoot-3 20s linear infinite; } +.animate-corner-shoot-4 { animation: corner-shoot-4 16s linear infinite; } +.animate-bounce-ball-1 { animation: bounce-ball-1 12s ease-in-out infinite; } +.animate-bounce-ball-2 { animation: bounce-ball-2 14s ease-in-out infinite; } +.animate-bounce-ball-3 { animation: bounce-ball-3 10s ease-in-out infinite; } +.animate-spin-slow { animation: spin-slow 8s linear infinite; } +.animate-spin-reverse { animation: spin-reverse 6s linear infinite; } +.animate-bounce-gentle { animation: bounce-gentle 3s ease-in-out infinite; } +.animate-bounce-soft { animation: bounce-soft 4s ease-in-out infinite; } +.animate-pulse-gentle { animation: pulse-gentle 4s ease-in-out infinite; } +.animate-wiggle { animation: wiggle 2s ease-in-out infinite; } +.animate-float-gentle { animation: float-gentle 5s ease-in-out infinite; } +.animate-scale-gentle { animation: scale-gentle 6s ease-in-out infinite; } +.animate-rotate-gentle { animation: rotate-gentle 8s ease-in-out infinite; } +.animate-sway { animation: sway 3s ease-in-out infinite; } +.animate-spin-fast { animation: spin-fast 2s linear infinite; } +.animate-pulse-fast { animation: pulse-fast 1.5s ease-in-out infinite; } +.animate-wobble { animation: wobble 2s ease-in-out infinite; } +.animate-shake { animation: shake 0.5s ease-in-out infinite; } +.animate-bounce-crazy { animation: bounce-crazy 1s ease-in-out infinite; } +.animate-spin-wobble { animation: spin-wobble 4s ease-in-out infinite; } +.animate-flip { animation: flip 3s ease-in-out infinite; } +.animate-twirl { animation: twirl 5s ease-in-out infinite; } +.animate-dance { animation: dance 3s ease-in-out infinite; } +.animate-jiggle { animation: jiggle 1s ease-in-out infinite; } +.animate-vibrate { animation: vibrate 0.3s ease-in-out infinite; } +.animate-swing { animation: swing 2.8s ease-in-out infinite; } +.animate-pendulum { animation: pendulum 2.4s ease-in-out infinite; } +.animate-elastic { animation: elastic 2.2s ease-in-out infinite; } +.animate-rubber { animation: rubber 2.5s ease-in-out infinite; } +.animate-rocket { animation: rocket 1.8s ease-in-out infinite; } +.animate-comet { animation: comet 2s ease-in-out infinite; } +.animate-meteor { animation: meteor 1.7s ease-in-out infinite; } +.animate-blast { animation: blast 2.2s ease-in-out infinite; } +.animate-spin-bounce { animation: spin-bounce 2.4s ease-in-out infinite; } +.animate-flip-bounce { animation: flip-bounce 2.6s ease-in-out infinite; } +.animate-scale-bounce { animation: scale-bounce 2.1s ease-in-out infinite; } + +@media (max-width: 640px) { + .login-screen { + padding: 2rem 1rem; + } + + .auth-card-shell { + padding: 1.25rem; + } +} diff --git a/go-backend/internal/web/ui/badge.css b/go-backend/internal/web/ui/badge.css new file mode 100644 index 0000000..0adaed4 --- /dev/null +++ b/go-backend/internal/web/ui/badge.css @@ -0,0 +1,33 @@ +.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: var(--color-status-info-soft-bg); + border-color: var(--color-status-info-soft-border); + color: var(--color-status-info-foreground); +} + +.ui-badge-warning { + background: var(--color-status-warning-soft-bg); + border-color: var(--color-status-warning-soft-border); + color: var(--color-status-warning-foreground); +} + +.ui-badge-success { + background: var(--color-status-success-soft-bg); + border-color: var(--color-status-success-soft-border); + color: var(--color-status-success-foreground); +} + +.ui-badge-danger { + background: var(--color-status-danger-soft-bg); + border-color: var(--color-status-danger-soft-border); + color: var(--color-status-danger-foreground); +} diff --git a/go-backend/internal/web/ui/base.css b/go-backend/internal/web/ui/base.css new file mode 100644 index 0000000..6c6df95 --- /dev/null +++ b/go-backend/internal/web/ui/base.css @@ -0,0 +1,223 @@ +:root { + /* Text */ + --color-text-primary: hsl(0 0% 9%); + --color-text-secondary: #475467; + --color-text-muted: hsl(0 0% 43.5%); + --color-text-faint: #9ca3af; + --color-text-inverse: #ffffff; + --color-text-brand: #804eec; + --color-text-brand-hover: #6f3fd4; + --color-text-brand-strong: #7c3aed; + --color-text-brand-accent: #7f56d9; + --color-text-heading-alt: #1f2937; + --color-text-body-subtle: #374151; + --color-text-google: #1f1f1f; + --color-text-overlay: #344054; + --color-text-disabled: #667085; + + /* Surfaces */ + --color-surface-page: hsl(0 0% 100%); + --color-surface-default: #ffffff; + --color-surface-card: rgba(255, 255, 255, 0.8); + --color-surface-subtle: hsl(0 0% 96.1%); + --color-surface-muted: #f3f4f6; + --color-surface-muted-hover: #e5e7eb; + --color-surface-muted-active: #d1d5db; + --color-surface-muted-inverse: #111827; + --color-surface-elevated: rgba(255, 255, 255, 0.92); + --color-surface-elevated-strong: rgba(255, 255, 255, 0.95); + --color-surface-elevated-soft: rgba(255, 255, 255, 0.9); + --color-surface-overlay: rgba(255, 255, 255, 0.88); + --color-surface-overlay-strong: rgba(255, 255, 255, 0.96); + --color-surface-brand-soft: #ede9fe; + --color-surface-brand-soft-hover: #ddd6fe; + --color-surface-brand-soft-active: #c4b5fd; + --color-surface-brand-muted: #f4f3ff; + --color-surface-neutral-hover: rgba(249, 250, 251, 0.9); + --color-surface-page-tint: #f8f7ff; + --color-surface-page-tint-alt: #f4f7fb; + + /* Borders */ + --color-border-default: hsl(0 0% 90.9%); + --color-border-strong: #d1d5db; + --color-border-muted: #e5e7eb; + --color-border-subtle: #d0d5dd; + --color-border-google: #747775; + --color-border-panel: rgba(30, 27, 46, 0.08); + --color-border-panel-muted: rgba(107, 114, 128, 0.22); + --color-border-panel-strong: rgba(107, 114, 128, 0.35); + --color-border-overlay: rgba(148, 163, 184, 0.22); + --color-border-overlay-strong: rgba(148, 163, 184, 0.3); + + /* Brand and focus */ + --color-brand-ink: #1e1b2e; + --color-brand-primary: #804eec; + --color-brand-primary-hover: #6d28d9; + --color-brand-primary-active: #5b21b6; + --color-brand-secondary: #a855f7; + --color-brand-accent: #3b82f6; + --color-focus-ring: rgba(124, 58, 237, 0.2); + --color-focus-ring-strong: rgba(139, 92, 246, 0.16); + --color-ring-subtle: rgba(30, 27, 46, 0.35); + + /* Status: info */ + --color-status-info-soft-bg: #eff6ff; + --color-status-info-soft-border: #bfdbfe; + --color-status-info-foreground: #2563eb; + + /* Status: warning */ + --color-status-warning-soft-bg: #fff4e2; + --color-status-warning-soft-border: #db9729; + --color-status-warning-foreground: #db9729; + --color-status-warning-strong: #db9729; + --color-status-warning-strong-hover: #c37f12; + --color-status-warning-strong-active: #a9670c; + --color-status-warning-strong-foreground: #ffffff; + --color-status-warning-soft-foreground-strong: #b86e00; + --color-status-warning-soft-bg-hover: #fee6b7; + --color-status-warning-soft-bg-active: #fdd58e; + --color-status-warning-emphasis-bg: #fffbeb; + --color-status-warning-emphasis-border: #fde68a; + --color-status-warning-emphasis-foreground: #ca8a04; + + /* Status: success */ + --color-status-success-soft-bg: #ecfdf3; + --color-status-success-soft-border: #bbf7d0; + --color-status-success-foreground: #16a34a; + --color-status-success-strong: #16a34a; + --color-status-success-strong-hover: #15803d; + --color-status-success-strong-active: #166534; + --color-status-success-strong-foreground: #ffffff; + --color-status-success-soft-foreground-strong: #15803d; + --color-status-success-soft-bg-hover: #d1fadf; + --color-status-success-soft-bg-active: #a6f4c5; + --color-status-success-banner-bg: hsl(143 85% 96%); + --color-status-success-banner-border: hsl(145 92% 87%); + --color-status-success-banner-foreground: hsl(140 100% 27%); + + /* Status: danger */ + --color-status-danger-soft-bg: #fef2f2; + --color-status-danger-soft-bg-alt: #fef3f2; + --color-status-danger-soft-border: #fecaca; + --color-status-danger-foreground: #dc2626; + --color-status-danger-strong: #dc2626; + --color-status-danger-strong-hover: #b91c1c; + --color-status-danger-strong-active: #991b1b; + --color-status-danger-strong-foreground: #ffffff; + --color-status-danger-soft-foreground-strong: #b42318; + --color-status-danger-soft-bg-hover: #fee4e2; + --color-status-danger-soft-bg-active: #fecdca; + --color-status-danger-icon-hover: #ef4444; + --color-status-danger-banner-bg: hsl(359 100% 97%); + --color-status-danger-banner-border: hsl(359 100% 94%); + --color-status-danger-banner-foreground: hsl(360 100% 45%); + + /* Effects */ + --overlay-backdrop-default: rgba(17, 24, 39, 0.52); + --overlay-dark-soft: rgba(30, 27, 46, 0.05); + --overlay-dark-soft-alt: rgba(30, 27, 46, 0.06); + --overlay-dark-border: rgba(30, 27, 46, 0.08); + --overlay-dark-strong: rgba(30, 27, 46, 0.14); + --overlay-brand-soft: rgba(124, 58, 237, 0.1); + --overlay-brand-soft-strong: rgba(124, 58, 237, 0.14); + --overlay-brand-muted: rgba(128, 78, 236, 0.08); + --overlay-brand-faint: rgba(128, 78, 236, 0.04); + --overlay-brand-glow: rgba(128, 78, 236, 0.1); + --overlay-google-state: #303030; + --shadow-auth-card: 0 20px 45px rgba(0, 0, 0, 0.1); + --shadow-surface-sm: 0 10px 30px rgba(15, 23, 42, 0.05); + --shadow-surface-md: 0 10px 30px rgba(15, 23, 42, 0.06); + --shadow-surface-hover: 0 12px 30px rgba(15, 23, 42, 0.08); + --shadow-surface-lg: 0 24px 48px rgba(15, 23, 42, 0.18); + --shadow-surface-xl: 0 32px 70px rgba(15, 23, 42, 0.12); + --shadow-sidebar: 20px 0 45px rgba(30, 27, 46, 0.06); + --shadow-floating-control: 0 10px 24px rgba(30, 27, 46, 0.14); + --shadow-google-button: + 0 1px 2px 0 rgba(60, 64, 67, 0.3), + 0 1px 3px 1px rgba(60, 64, 67, 0.15); + --shadow-brand-action: 0 18px 35px rgba(124, 58, 237, 0.25); + --gradient-shell: + linear-gradient(135deg, var(--overlay-brand-muted), transparent 30%), + linear-gradient(160deg, var(--overlay-dark-soft), transparent 42%), + linear-gradient(to bottom right, var(--overlay-dark-border), var(--color-surface-page), var(--overlay-brand-faint)); + --gradient-card-glow: + linear-gradient(to bottom right, rgba(30, 27, 46, 0.1), var(--overlay-dark-soft), var(--overlay-brand-glow)); + --gradient-overview-badge: + linear-gradient(to right, var(--color-brand-secondary), var(--color-brand-accent)); + --gradient-app-surface: + linear-gradient(180deg, var(--color-surface-overlay-strong) 0%, var(--color-surface-default) 100%); + --gradient-not-found-bg: + radial-gradient(circle at top, var(--overlay-brand-soft-strong), transparent 35%), + linear-gradient(180deg, var(--color-surface-page-tint) 0%, var(--color-surface-page-tint-alt) 100%); + --gradient-not-found-primary: + linear-gradient(135deg, var(--color-text-brand-strong) 0%, var(--color-status-info-foreground) 100%); + + /* Runtime fallbacks */ + --color-project-fallback: #3b82f6; + --color-project-accent-purple: #a855f7; + --color-project-accent-red: #ef4444; + + /* Legacy aliases */ + --background: var(--color-surface-page); + --foreground: var(--color-text-primary); + --muted-foreground: var(--color-text-muted); + --border: var(--color-border-default); + --input: var(--color-border-default); + --card: var(--color-surface-card); + --accent: var(--color-surface-subtle); + --primary: var(--color-brand-ink); + --primary-foreground: var(--color-text-inverse); + --secondary: var(--color-brand-primary); + --ring: var(--color-ring-subtle); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} + +.light-only { + display: block; +} + +.dark-only { + display: none; +} + +.visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} diff --git a/go-backend/internal/web/ui/button.css b/go-backend/internal/web/ui/button.css new file mode 100644 index 0000000..a7e912c --- /dev/null +++ b/go-backend/internal/web/ui/button.css @@ -0,0 +1,162 @@ +.ui-button { + align-items: center; + border: 0; + border-radius: 0rem; + 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 { + box-shadow: 0 0 0 3px var(--color-focus-ring); + 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.82rem 1.15rem; +} + +.ui-button-solid.ui-button-default { + background: var(--color-brand-primary); + color: var(--color-brand-foreground, var(--color-text-inverse)); +} + +.ui-button-solid.ui-button-default:hover { + background: var(--color-brand-primary-hover); +} + +.ui-button-solid.ui-button-default:active { + background: var(--color-brand-primary-active); +} + +.ui-button-solid.ui-button-neutral { + background: var(--color-surface-muted); + color: var(--color-text-primary); +} + +.ui-button-solid.ui-button-neutral:hover { + background: var(--color-surface-muted-hover); +} + +.ui-button-solid.ui-button-neutral:active { + background: var(--color-surface-muted-active); +} + +.ui-button-solid.ui-button-warning { + background: var(--color-status-warning-strong); + color: var(--color-status-warning-strong-foreground); +} + +.ui-button-solid.ui-button-warning:hover { + background: var(--color-status-warning-strong-hover); +} + +.ui-button-solid.ui-button-warning:active { + background: var(--color-status-warning-strong-active); +} + +.ui-button-solid.ui-button-success { + background: var(--color-status-success-strong); + color: var(--color-status-success-strong-foreground); +} + +.ui-button-solid.ui-button-success:hover { + background: var(--color-status-success-strong-hover); +} + +.ui-button-solid.ui-button-success:active { + background: var(--color-status-success-strong-active); +} + +.ui-button-solid.ui-button-danger { + background: var(--color-status-danger-strong); + color: var(--color-status-danger-strong-foreground); +} + +.ui-button-solid.ui-button-danger:hover { + background: var(--color-status-danger-strong-hover); +} + +.ui-button-solid.ui-button-danger:active { + background: var(--color-status-danger-strong-active); +} + +.ui-button-soft.ui-button-default { + background: var(--color-surface-brand-soft); + color: var(--color-brand-primary-hover); +} + +.ui-button-soft.ui-button-default:hover { + background: var(--color-surface-brand-soft-hover); +} + +.ui-button-soft.ui-button-default:active { + background: var(--color-surface-brand-soft-active); +} + +.ui-button-soft.ui-button-warning { + background: var(--color-status-warning-soft-bg); + color: var(--color-status-warning-soft-foreground-strong); +} + +.ui-button-soft.ui-button-warning:hover { + background: var(--color-status-warning-soft-bg-hover); +} + +.ui-button-soft.ui-button-warning:active { + background: var(--color-status-warning-soft-bg-active); +} + +.ui-button-soft.ui-button-success { + background: var(--color-status-success-soft-bg); + color: var(--color-status-success-soft-foreground-strong); +} + +.ui-button-soft.ui-button-success:hover { + background: var(--color-status-success-soft-bg-hover); +} + +.ui-button-soft.ui-button-success:active { + background: var(--color-status-success-soft-bg-active); +} + +.ui-button-soft.ui-button-danger { + background: var(--color-status-danger-soft-bg-alt); + color: var(--color-status-danger-soft-foreground-strong); +} + +.ui-button-soft.ui-button-danger:hover { + background: var(--color-status-danger-soft-bg-hover); +} + +.ui-button-soft.ui-button-danger:active { + background: var(--color-status-danger-soft-bg-active); +} diff --git a/go-backend/internal/web/ui/card.css b/go-backend/internal/web/ui/card.css new file mode 100644 index 0000000..57dae0f --- /dev/null +++ b/go-backend/internal/web/ui/card.css @@ -0,0 +1,27 @@ +.ui-card { + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 1rem; + box-shadow: var(--shadow-surface-md); +} + +.ui-card-header, +.ui-card-body, +.ui-card-footer { + padding: 1.25rem 1.5rem; +} + +.ui-card-header, +.ui-card-footer { + border-color: var(--color-border-default); +} + +.ui-card-header { + border-bottom-style: solid; + border-bottom-width: 1px; +} + +.ui-card-footer { + border-top-style: solid; + border-top-width: 1px; +} diff --git a/go-backend/internal/web/ui/catalog/catalog.css b/go-backend/internal/web/ui/catalog/catalog.css new file mode 100644 index 0000000..bfa09c1 --- /dev/null +++ b/go-backend/internal/web/ui/catalog/catalog.css @@ -0,0 +1,163 @@ +.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: var(--color-text-muted); + 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: var(--color-surface-muted); + color: var(--color-text-primary); +} + +.catalog-nav-links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.catalog-nav-link.is-active { + background: var(--color-surface-brand-soft); + color: var(--color-brand-primary-hover); +} + +.catalog-page-header { + margin-bottom: 2rem; +} + +.catalog-page-header h1 { + color: var(--color-text-primary); + font-size: 2.25rem; + line-height: 1.1; + margin: 0 0 0.75rem; +} + +.catalog-page-header p { + color: var(--color-text-muted); + margin: 0; + max-width: 42rem; +} + +.catalog-eyebrow { + color: var(--color-text-brand-strong) !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: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 1rem; + box-shadow: var(--shadow-surface-sm); + padding: 1.5rem; +} + +.catalog-page-link-card { + display: block; +} + +.catalog-example-copy h2, +.catalog-page-link-card h2 { + color: var(--color-text-primary); + font-size: 1.125rem; + margin: 0 0 0.5rem; +} + +.catalog-example-copy p, +.catalog-page-link-card p { + color: var(--color-text-muted); + 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-spacing-row { + align-items: center; + display: flex; + gap: 0; +} + +.catalog-spacing-column { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} + +.catalog-example-snippet { + background: var(--color-surface-muted-inverse); + border-radius: 0.875rem; + color: var(--color-surface-neutral-hover); + 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: var(--color-text-brand-strong) !important; + font-family: + ui-monospace, + SFMono-Regular, + "SF Mono", + Menlo, + Monaco, + Consolas, + "Liberation Mono", + monospace; + font-size: 0.875rem; + margin-top: 1rem !important; +} diff --git a/go-backend/internal/web/ui/empty-state.css b/go-backend/internal/web/ui/empty-state.css new file mode 100644 index 0000000..b361f0a --- /dev/null +++ b/go-backend/internal/web/ui/empty-state.css @@ -0,0 +1,40 @@ +.ui-empty-state { + align-items: center; + border: 1px dashed var(--color-border-subtle); + border-radius: 1rem; + color: var(--color-text-muted); + display: flex; + flex-direction: column; + gap: 0.75rem; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; +} + +.ui-empty-state-title { + color: var(--color-text-primary); + font-size: 1.125rem; + font-weight: 700; + margin: 0; +} + +.ui-empty-state-icon { + align-items: center; + background: var(--color-surface-muted); + border-radius: 999px; + color: var(--color-text-faint); + 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; +} diff --git a/go-backend/internal/web/ui/form-field.css b/go-backend/internal/web/ui/form-field.css new file mode 100644 index 0000000..922a3c8 --- /dev/null +++ b/go-backend/internal/web/ui/form-field.css @@ -0,0 +1,22 @@ +.ui-form-field { + display: grid; + gap: 0.5rem; +} + +.ui-form-label { + color: var(--color-text-primary); + font-size: 0.95rem; + font-weight: 600; +} + +.ui-form-hint { + color: var(--color-text-muted); + font-size: 0.875rem; + margin: 0; +} + +.ui-form-error { + color: var(--color-status-danger-foreground); + font-size: 0.875rem; + margin: 0; +} diff --git a/go-backend/internal/web/ui/icon-button.css b/go-backend/internal/web/ui/icon-button.css new file mode 100644 index 0000000..a60bdcc --- /dev/null +++ b/go-backend/internal/web/ui/icon-button.css @@ -0,0 +1,50 @@ +.ui-icon-button { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 0.5rem; + 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:focus-visible, +.borderless-icon-button:focus-visible { + box-shadow: 0 0 0 3px var(--color-focus-ring); + outline: none; +} + +.ui-icon-button-solid.ui-icon-button-neutral { + color: var(--color-text-muted); +} + +.ui-icon-button-solid.ui-icon-button-neutral:hover { + background: var(--color-surface-neutral-hover); + color: var(--color-text-primary); +} + +.borderless-icon-button { + appearance: none; + background: transparent; + border: 0; + box-shadow: none; + cursor: pointer; + outline: none; +} + +.ui-icon-button-ghost.ui-icon-button-neutral, +.ui-icon-button-ghost.ui-icon-button-danger { + color: var(--color-text-faint); +} + +.borderless-icon-button svg { + height: 1rem; + width: 1rem; +} diff --git a/go-backend/internal/web/ui/input.css b/go-backend/internal/web/ui/input.css new file mode 100644 index 0000000..222d7fa --- /dev/null +++ b/go-backend/internal/web/ui/input.css @@ -0,0 +1,22 @@ +.ui-input { + appearance: none; + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.75rem; + color: var(--color-text-primary); + font: inherit; + line-height: 1.4; + min-height: 44px; + padding: 0.75rem 0.95rem; + width: 100%; +} + +.ui-input::placeholder { + color: var(--color-text-faint); +} + +.ui-input:focus { + border-color: var(--color-brand-focus); + box-shadow: 0 0 0 3px var(--color-focus-ring-strong); + outline: none; +} diff --git a/go-backend/internal/web/ui/modal.css b/go-backend/internal/web/ui/modal.css new file mode 100644 index 0000000..854e1cd --- /dev/null +++ b/go-backend/internal/web/ui/modal.css @@ -0,0 +1,53 @@ +.ui-modal-backdrop { + align-items: center; + background: var(--overlay-backdrop-default); + display: flex; + inset: 0; + justify-content: center; + padding: 1rem; + position: fixed; + z-index: 40; +} + +.ui-modal-panel { + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 1rem; + box-shadow: var(--shadow-surface-lg); + 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 var(--color-border-default); + padding-bottom: 1rem; + padding-top: 1.25rem; +} + +.ui-modal-header h2 { + color: var(--color-text-primary); + 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 var(--color-border-default); + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding-bottom: 1rem; + padding-top: 1rem; +} diff --git a/go-backend/internal/web/ui/spacing.css b/go-backend/internal/web/ui/spacing.css new file mode 100644 index 0000000..2d0e782 --- /dev/null +++ b/go-backend/internal/web/ui/spacing.css @@ -0,0 +1,48 @@ +.ui-space-x { + display: inline-block; + flex-shrink: 0; +} + +.ui-space-y { + display: block; +} + +.ui-space-x-xs { + width: 0.25rem; +} + +.ui-space-x-sm { + width: 0.5rem; +} + +.ui-space-x-md { + width: 0.75rem; +} + +.ui-space-x-lg { + width: 1rem; +} + +.ui-space-x-xl { + width: 1.5rem; +} + +.ui-space-y-xs { + height: 0.25rem; +} + +.ui-space-y-sm { + height: 0.5rem; +} + +.ui-space-y-md { + height: 0.75rem; +} + +.ui-space-y-lg { + height: 1rem; +} + +.ui-space-y-xl { + height: 1.5rem; +} diff --git a/go-backend/internal/web/ui/table.css b/go-backend/internal/web/ui/table.css new file mode 100644 index 0000000..292f192 --- /dev/null +++ b/go-backend/internal/web/ui/table.css @@ -0,0 +1,10 @@ +.ui-table-shell { + overflow-x: auto; + width: 100%; +} + +.ui-table { + border-collapse: collapse; + min-width: 100%; + width: 100%; +} diff --git a/go-backend/internal/web/ui/textarea.css b/go-backend/internal/web/ui/textarea.css new file mode 100644 index 0000000..7142068 --- /dev/null +++ b/go-backend/internal/web/ui/textarea.css @@ -0,0 +1,23 @@ +.ui-textarea { + appearance: none; + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.75rem; + color: var(--color-text-primary); + font: inherit; + line-height: 1.4; + min-height: 7rem; + padding: 0.85rem 0.95rem; + resize: vertical; + width: 100%; +} + +.ui-textarea::placeholder { + color: var(--color-text-faint); +} + +.ui-textarea:focus { + border-color: var(--color-brand-focus); + box-shadow: 0 0 0 3px var(--color-focus-ring-strong); + outline: none; +} diff --git a/go-backend/internal/web/ui/ui_test.go b/go-backend/internal/web/ui/ui_test.go index 645d32a..4d56475 100644 --- a/go-backend/internal/web/ui/ui_test.go +++ b/go-backend/internal/web/ui/ui_test.go @@ -191,6 +191,11 @@ func TestSharedSemanticClassesExistInStylesheet(t *testing.T) { css := string(body) for _, want := range []string{ `Code generated by cmd/buildstyles`, + `--color-text-primary`, + `--color-surface-default`, + `--color-status-warning-soft-bg`, + `--shadow-surface-md`, + `--overlay-backdrop-default`, `.ui-button-solid.ui-button-default`, `.ui-badge-warning`, `.ui-card`, diff --git a/go-backend/internal/web/views/tablos.templ b/go-backend/internal/web/views/tablos.templ index cbe9fd4..19eb1a3 100644 --- a/go-backend/internal/web/views/tablos.templ +++ b/go-backend/internal/web/views/tablos.templ @@ -183,7 +183,7 @@ templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) { Label: tablo.StatusLabel, Variant: badgeVariantForTone(tablo.StatusTone), }) -
+
@EditTabloButton(tablo.EditRequestURL) @BorderlessDeleteButton(tablo.DeleteRequestURL)
diff --git a/go-backend/internal/web/views/tablos_templ.go b/go-backend/internal/web/views/tablos_templ.go index 9c56d62..e27ac9f 100644 --- a/go-backend/internal/web/views/tablos_templ.go +++ b/go-backend/internal/web/views/tablos_templ.go @@ -588,7 +588,7 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css index 1dd7aec..a6d490b 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -1,15 +1,177 @@ +/* Code generated by cmd/buildstyles; DO NOT EDIT. */ + +/* Source: internal/web/ui/base.css */ :root { - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 9%); - --muted-foreground: hsl(0 0% 43.5%); - --border: hsl(0 0% 90.9%); - --input: hsl(0 0% 90.9%); - --card: rgba(255, 255, 255, 0.8); - --accent: hsl(0 0% 96.1%); - --primary: #1e1b2e; - --primary-foreground: #ffffff; - --secondary: #804eec; - --ring: rgba(30, 27, 46, 0.35); + /* Text */ + --color-text-primary: hsl(0 0% 9%); + --color-text-secondary: #475467; + --color-text-muted: hsl(0 0% 43.5%); + --color-text-faint: #9ca3af; + --color-text-inverse: #ffffff; + --color-text-brand: #804eec; + --color-text-brand-hover: #6f3fd4; + --color-text-brand-strong: #7c3aed; + --color-text-brand-accent: #7f56d9; + --color-text-heading-alt: #1f2937; + --color-text-body-subtle: #374151; + --color-text-google: #1f1f1f; + --color-text-overlay: #344054; + --color-text-disabled: #667085; + + /* Surfaces */ + --color-surface-page: hsl(0 0% 100%); + --color-surface-default: #ffffff; + --color-surface-card: rgba(255, 255, 255, 0.8); + --color-surface-subtle: hsl(0 0% 96.1%); + --color-surface-muted: #f3f4f6; + --color-surface-muted-hover: #e5e7eb; + --color-surface-muted-active: #d1d5db; + --color-surface-muted-inverse: #111827; + --color-surface-elevated: rgba(255, 255, 255, 0.92); + --color-surface-elevated-strong: rgba(255, 255, 255, 0.95); + --color-surface-elevated-soft: rgba(255, 255, 255, 0.9); + --color-surface-overlay: rgba(255, 255, 255, 0.88); + --color-surface-overlay-strong: rgba(255, 255, 255, 0.96); + --color-surface-brand-soft: #ede9fe; + --color-surface-brand-soft-hover: #ddd6fe; + --color-surface-brand-soft-active: #c4b5fd; + --color-surface-brand-muted: #f4f3ff; + --color-surface-neutral-hover: rgba(249, 250, 251, 0.9); + --color-surface-page-tint: #f8f7ff; + --color-surface-page-tint-alt: #f4f7fb; + + /* Borders */ + --color-border-default: hsl(0 0% 90.9%); + --color-border-strong: #d1d5db; + --color-border-muted: #e5e7eb; + --color-border-subtle: #d0d5dd; + --color-border-google: #747775; + --color-border-panel: rgba(30, 27, 46, 0.08); + --color-border-panel-muted: rgba(107, 114, 128, 0.22); + --color-border-panel-strong: rgba(107, 114, 128, 0.35); + --color-border-overlay: rgba(148, 163, 184, 0.22); + --color-border-overlay-strong: rgba(148, 163, 184, 0.3); + + /* Brand and focus */ + --color-brand-ink: #1e1b2e; + --color-brand-primary: #804eec; + --color-brand-primary-hover: #6d28d9; + --color-brand-primary-active: #5b21b6; + --color-brand-secondary: #a855f7; + --color-brand-accent: #3b82f6; + --color-focus-ring: rgba(124, 58, 237, 0.2); + --color-focus-ring-strong: rgba(139, 92, 246, 0.16); + --color-ring-subtle: rgba(30, 27, 46, 0.35); + + /* Status: info */ + --color-status-info-soft-bg: #eff6ff; + --color-status-info-soft-border: #bfdbfe; + --color-status-info-foreground: #2563eb; + + /* Status: warning */ + --color-status-warning-soft-bg: #fff4e2; + --color-status-warning-soft-border: #db9729; + --color-status-warning-foreground: #db9729; + --color-status-warning-strong: #db9729; + --color-status-warning-strong-hover: #c37f12; + --color-status-warning-strong-active: #a9670c; + --color-status-warning-strong-foreground: #ffffff; + --color-status-warning-soft-foreground-strong: #b86e00; + --color-status-warning-soft-bg-hover: #fee6b7; + --color-status-warning-soft-bg-active: #fdd58e; + --color-status-warning-emphasis-bg: #fffbeb; + --color-status-warning-emphasis-border: #fde68a; + --color-status-warning-emphasis-foreground: #ca8a04; + + /* Status: success */ + --color-status-success-soft-bg: #ecfdf3; + --color-status-success-soft-border: #bbf7d0; + --color-status-success-foreground: #16a34a; + --color-status-success-strong: #16a34a; + --color-status-success-strong-hover: #15803d; + --color-status-success-strong-active: #166534; + --color-status-success-strong-foreground: #ffffff; + --color-status-success-soft-foreground-strong: #15803d; + --color-status-success-soft-bg-hover: #d1fadf; + --color-status-success-soft-bg-active: #a6f4c5; + --color-status-success-banner-bg: hsl(143 85% 96%); + --color-status-success-banner-border: hsl(145 92% 87%); + --color-status-success-banner-foreground: hsl(140 100% 27%); + + /* Status: danger */ + --color-status-danger-soft-bg: #fef2f2; + --color-status-danger-soft-bg-alt: #fef3f2; + --color-status-danger-soft-border: #fecaca; + --color-status-danger-foreground: #dc2626; + --color-status-danger-strong: #dc2626; + --color-status-danger-strong-hover: #b91c1c; + --color-status-danger-strong-active: #991b1b; + --color-status-danger-strong-foreground: #ffffff; + --color-status-danger-soft-foreground-strong: #b42318; + --color-status-danger-soft-bg-hover: #fee4e2; + --color-status-danger-soft-bg-active: #fecdca; + --color-status-danger-icon-hover: #ef4444; + --color-status-danger-banner-bg: hsl(359 100% 97%); + --color-status-danger-banner-border: hsl(359 100% 94%); + --color-status-danger-banner-foreground: hsl(360 100% 45%); + + /* Effects */ + --overlay-backdrop-default: rgba(17, 24, 39, 0.52); + --overlay-dark-soft: rgba(30, 27, 46, 0.05); + --overlay-dark-soft-alt: rgba(30, 27, 46, 0.06); + --overlay-dark-border: rgba(30, 27, 46, 0.08); + --overlay-dark-strong: rgba(30, 27, 46, 0.14); + --overlay-brand-soft: rgba(124, 58, 237, 0.1); + --overlay-brand-soft-strong: rgba(124, 58, 237, 0.14); + --overlay-brand-muted: rgba(128, 78, 236, 0.08); + --overlay-brand-faint: rgba(128, 78, 236, 0.04); + --overlay-brand-glow: rgba(128, 78, 236, 0.1); + --overlay-google-state: #303030; + --shadow-auth-card: 0 20px 45px rgba(0, 0, 0, 0.1); + --shadow-surface-sm: 0 10px 30px rgba(15, 23, 42, 0.05); + --shadow-surface-md: 0 10px 30px rgba(15, 23, 42, 0.06); + --shadow-surface-hover: 0 12px 30px rgba(15, 23, 42, 0.08); + --shadow-surface-lg: 0 24px 48px rgba(15, 23, 42, 0.18); + --shadow-surface-xl: 0 32px 70px rgba(15, 23, 42, 0.12); + --shadow-sidebar: 20px 0 45px rgba(30, 27, 46, 0.06); + --shadow-floating-control: 0 10px 24px rgba(30, 27, 46, 0.14); + --shadow-google-button: + 0 1px 2px 0 rgba(60, 64, 67, 0.3), + 0 1px 3px 1px rgba(60, 64, 67, 0.15); + --shadow-brand-action: 0 18px 35px rgba(124, 58, 237, 0.25); + --gradient-shell: + linear-gradient(135deg, var(--overlay-brand-muted), transparent 30%), + linear-gradient(160deg, var(--overlay-dark-soft), transparent 42%), + linear-gradient(to bottom right, var(--overlay-dark-border), var(--color-surface-page), var(--overlay-brand-faint)); + --gradient-card-glow: + linear-gradient(to bottom right, rgba(30, 27, 46, 0.1), var(--overlay-dark-soft), var(--overlay-brand-glow)); + --gradient-overview-badge: + linear-gradient(to right, var(--color-brand-secondary), var(--color-brand-accent)); + --gradient-app-surface: + linear-gradient(180deg, var(--color-surface-overlay-strong) 0%, var(--color-surface-default) 100%); + --gradient-not-found-bg: + radial-gradient(circle at top, var(--overlay-brand-soft-strong), transparent 35%), + linear-gradient(180deg, var(--color-surface-page-tint) 0%, var(--color-surface-page-tint-alt) 100%); + --gradient-not-found-primary: + linear-gradient(135deg, var(--color-text-brand-strong) 0%, var(--color-status-info-foreground) 100%); + + /* Runtime fallbacks */ + --color-project-fallback: #3b82f6; + --color-project-accent-purple: #a855f7; + --color-project-accent-red: #ef4444; + + /* Legacy aliases */ + --background: var(--color-surface-page); + --foreground: var(--color-text-primary); + --muted-foreground: var(--color-text-muted); + --border: var(--color-border-default); + --input: var(--color-border-default); + --card: var(--color-surface-card); + --accent: var(--color-surface-subtle); + --primary: var(--color-brand-ink); + --primary-foreground: var(--color-text-inverse); + --secondary: var(--color-brand-primary); + --ring: var(--color-ring-subtle); } * { @@ -63,6 +225,684 @@ input { width: 1px; } +/* Source: internal/web/ui/catalog/catalog.css */ +.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: var(--color-text-muted); + 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: var(--color-surface-muted); + color: var(--color-text-primary); +} + +.catalog-nav-links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.catalog-nav-link.is-active { + background: var(--color-surface-brand-soft); + color: var(--color-brand-primary-hover); +} + +.catalog-page-header { + margin-bottom: 2rem; +} + +.catalog-page-header h1 { + color: var(--color-text-primary); + font-size: 2.25rem; + line-height: 1.1; + margin: 0 0 0.75rem; +} + +.catalog-page-header p { + color: var(--color-text-muted); + margin: 0; + max-width: 42rem; +} + +.catalog-eyebrow { + color: var(--color-text-brand-strong) !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: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 1rem; + box-shadow: var(--shadow-surface-sm); + padding: 1.5rem; +} + +.catalog-page-link-card { + display: block; +} + +.catalog-example-copy h2, +.catalog-page-link-card h2 { + color: var(--color-text-primary); + font-size: 1.125rem; + margin: 0 0 0.5rem; +} + +.catalog-example-copy p, +.catalog-page-link-card p { + color: var(--color-text-muted); + 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-spacing-row { + align-items: center; + display: flex; + gap: 0; +} + +.catalog-spacing-column { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} + +.catalog-example-snippet { + background: var(--color-surface-muted-inverse); + border-radius: 0.875rem; + color: var(--color-surface-neutral-hover); + 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: var(--color-text-brand-strong) !important; + font-family: + ui-monospace, + SFMono-Regular, + "SF Mono", + Menlo, + Monaco, + Consolas, + "Liberation Mono", + monospace; + font-size: 0.875rem; + margin-top: 1rem !important; +} + +/* Source: internal/web/ui/button.css */ +.ui-button { + align-items: center; + border: 0; + border-radius: 0rem; + 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 { + box-shadow: 0 0 0 3px var(--color-focus-ring); + 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.82rem 1.15rem; +} + +.ui-button-solid.ui-button-default { + background: var(--color-brand-primary); + color: var(--color-brand-foreground, var(--color-text-inverse)); +} + +.ui-button-solid.ui-button-default:hover { + background: var(--color-brand-primary-hover); +} + +.ui-button-solid.ui-button-default:active { + background: var(--color-brand-primary-active); +} + +.ui-button-solid.ui-button-neutral { + background: var(--color-surface-muted); + color: var(--color-text-primary); +} + +.ui-button-solid.ui-button-neutral:hover { + background: var(--color-surface-muted-hover); +} + +.ui-button-solid.ui-button-neutral:active { + background: var(--color-surface-muted-active); +} + +.ui-button-solid.ui-button-warning { + background: var(--color-status-warning-strong); + color: var(--color-status-warning-strong-foreground); +} + +.ui-button-solid.ui-button-warning:hover { + background: var(--color-status-warning-strong-hover); +} + +.ui-button-solid.ui-button-warning:active { + background: var(--color-status-warning-strong-active); +} + +.ui-button-solid.ui-button-success { + background: var(--color-status-success-strong); + color: var(--color-status-success-strong-foreground); +} + +.ui-button-solid.ui-button-success:hover { + background: var(--color-status-success-strong-hover); +} + +.ui-button-solid.ui-button-success:active { + background: var(--color-status-success-strong-active); +} + +.ui-button-solid.ui-button-danger { + background: var(--color-status-danger-strong); + color: var(--color-status-danger-strong-foreground); +} + +.ui-button-solid.ui-button-danger:hover { + background: var(--color-status-danger-strong-hover); +} + +.ui-button-solid.ui-button-danger:active { + background: var(--color-status-danger-strong-active); +} + +.ui-button-soft.ui-button-default { + background: var(--color-surface-brand-soft); + color: var(--color-brand-primary-hover); +} + +.ui-button-soft.ui-button-default:hover { + background: var(--color-surface-brand-soft-hover); +} + +.ui-button-soft.ui-button-default:active { + background: var(--color-surface-brand-soft-active); +} + +.ui-button-soft.ui-button-warning { + background: var(--color-status-warning-soft-bg); + color: var(--color-status-warning-soft-foreground-strong); +} + +.ui-button-soft.ui-button-warning:hover { + background: var(--color-status-warning-soft-bg-hover); +} + +.ui-button-soft.ui-button-warning:active { + background: var(--color-status-warning-soft-bg-active); +} + +.ui-button-soft.ui-button-success { + background: var(--color-status-success-soft-bg); + color: var(--color-status-success-soft-foreground-strong); +} + +.ui-button-soft.ui-button-success:hover { + background: var(--color-status-success-soft-bg-hover); +} + +.ui-button-soft.ui-button-success:active { + background: var(--color-status-success-soft-bg-active); +} + +.ui-button-soft.ui-button-danger { + background: var(--color-status-danger-soft-bg-alt); + color: var(--color-status-danger-soft-foreground-strong); +} + +.ui-button-soft.ui-button-danger:hover { + background: var(--color-status-danger-soft-bg-hover); +} + +.ui-button-soft.ui-button-danger:active { + background: var(--color-status-danger-soft-bg-active); +} + +/* Source: internal/web/ui/badge.css */ +.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: var(--color-status-info-soft-bg); + border-color: var(--color-status-info-soft-border); + color: var(--color-status-info-foreground); +} + +.ui-badge-warning { + background: var(--color-status-warning-soft-bg); + border-color: var(--color-status-warning-soft-border); + color: var(--color-status-warning-foreground); +} + +.ui-badge-success { + background: var(--color-status-success-soft-bg); + border-color: var(--color-status-success-soft-border); + color: var(--color-status-success-foreground); +} + +.ui-badge-danger { + background: var(--color-status-danger-soft-bg); + border-color: var(--color-status-danger-soft-border); + color: var(--color-status-danger-foreground); +} + +/* Source: internal/web/ui/icon-button.css */ +.ui-icon-button { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 0.5rem; + 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:focus-visible, +.borderless-icon-button:focus-visible { + box-shadow: 0 0 0 3px var(--color-focus-ring); + outline: none; +} + +.ui-icon-button-solid.ui-icon-button-neutral { + color: var(--color-text-muted); +} + +.ui-icon-button-solid.ui-icon-button-neutral:hover { + background: var(--color-surface-neutral-hover); + color: var(--color-text-primary); +} + +.borderless-icon-button { + appearance: none; + background: transparent; + border: 0; + box-shadow: none; + cursor: pointer; + outline: none; +} + +.ui-icon-button-ghost.ui-icon-button-neutral, +.ui-icon-button-ghost.ui-icon-button-danger { + color: var(--color-text-faint); +} + +.borderless-icon-button svg { + height: 1rem; + width: 1rem; +} + +/* Source: internal/web/ui/input.css */ +.ui-input { + appearance: none; + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.75rem; + color: var(--color-text-primary); + font: inherit; + line-height: 1.4; + min-height: 44px; + padding: 0.75rem 0.95rem; + width: 100%; +} + +.ui-input::placeholder { + color: var(--color-text-faint); +} + +.ui-input:focus { + border-color: var(--color-brand-focus); + box-shadow: 0 0 0 3px var(--color-focus-ring-strong); + outline: none; +} + +/* Source: internal/web/ui/textarea.css */ +.ui-textarea { + appearance: none; + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.75rem; + color: var(--color-text-primary); + font: inherit; + line-height: 1.4; + min-height: 7rem; + padding: 0.85rem 0.95rem; + resize: vertical; + width: 100%; +} + +.ui-textarea::placeholder { + color: var(--color-text-faint); +} + +.ui-textarea:focus { + border-color: var(--color-brand-focus); + box-shadow: 0 0 0 3px var(--color-focus-ring-strong); + outline: none; +} + +/* Source: internal/web/ui/form-field.css */ +.ui-form-field { + display: grid; + gap: 0.5rem; +} + +.ui-form-label { + color: var(--color-text-primary); + font-size: 0.95rem; + font-weight: 600; +} + +.ui-form-hint { + color: var(--color-text-muted); + font-size: 0.875rem; + margin: 0; +} + +.ui-form-error { + color: var(--color-status-danger-foreground); + font-size: 0.875rem; + margin: 0; +} + +/* Source: internal/web/ui/modal.css */ +.ui-modal-backdrop { + align-items: center; + background: var(--overlay-backdrop-default); + display: flex; + inset: 0; + justify-content: center; + padding: 1rem; + position: fixed; + z-index: 40; +} + +.ui-modal-panel { + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 1rem; + box-shadow: var(--shadow-surface-lg); + 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 var(--color-border-default); + padding-bottom: 1rem; + padding-top: 1.25rem; +} + +.ui-modal-header h2 { + color: var(--color-text-primary); + 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 var(--color-border-default); + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding-bottom: 1rem; + padding-top: 1rem; +} + +/* Source: internal/web/ui/table.css */ +.ui-table-shell { + overflow-x: auto; + width: 100%; +} + +.ui-table { + border-collapse: collapse; + min-width: 100%; + width: 100%; +} + +/* Source: internal/web/ui/empty-state.css */ +.ui-empty-state { + align-items: center; + border: 1px dashed var(--color-border-subtle); + border-radius: 1rem; + color: var(--color-text-muted); + display: flex; + flex-direction: column; + gap: 0.75rem; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; +} + +.ui-empty-state-title { + color: var(--color-text-primary); + font-size: 1.125rem; + font-weight: 700; + margin: 0; +} + +.ui-empty-state-icon { + align-items: center; + background: var(--color-surface-muted); + border-radius: 999px; + color: var(--color-text-faint); + 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; +} + +/* Source: internal/web/ui/card.css */ +.ui-card { + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 1rem; + box-shadow: var(--shadow-surface-md); +} + +.ui-card-header, +.ui-card-body, +.ui-card-footer { + padding: 1.25rem 1.5rem; +} + +.ui-card-header, +.ui-card-footer { + border-color: var(--color-border-default); +} + +.ui-card-header { + border-bottom-style: solid; + border-bottom-width: 1px; +} + +.ui-card-footer { + border-top-style: solid; + border-top-width: 1px; +} + +/* Source: internal/web/ui/spacing.css */ +.ui-space-x { + display: inline-block; + flex-shrink: 0; +} + +.ui-space-y { + display: block; +} + +.ui-space-x-xs { + width: 0.25rem; +} + +.ui-space-x-sm { + width: 0.5rem; +} + +.ui-space-x-md { + width: 0.75rem; +} + +.ui-space-x-lg { + width: 1rem; +} + +.ui-space-x-xl { + width: 1.5rem; +} + +.ui-space-y-xs { + height: 0.25rem; +} + +.ui-space-y-sm { + height: 0.5rem; +} + +.ui-space-y-md { + height: 0.75rem; +} + +.ui-space-y-lg { + height: 1rem; +} + +.ui-space-y-xl { + height: 1.5rem; +} + +/* Source: internal/web/ui/app.css */ .app-shell, .login-screen { min-height: 100vh; @@ -74,10 +914,7 @@ input { .login-screen { align-items: center; - background: - linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%), - linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%), - linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04)); + background: var(--gradient-shell); display: flex; justify-content: center; overflow: hidden; @@ -170,7 +1007,7 @@ input { } .card-glow { - background: linear-gradient(to bottom right, rgba(30, 27, 46, 0.1), rgba(30, 27, 46, 0.05), rgba(128, 78, 236, 0.1)); + background: var(--gradient-card-glow); border-radius: 1rem; filter: blur(24px); inset: 0; @@ -183,7 +1020,7 @@ input { background: var(--card); border: 1px solid var(--border); border-radius: 1rem; - box-shadow: 0 20px 45px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-auth-card); padding: 1.25rem; position: relative; } @@ -271,7 +1108,7 @@ input { } .new-experience-link { - color: #804eec; + color: var(--color-text-brand); display: inline-flex; font-size: 0.875rem; font-weight: 500; @@ -280,7 +1117,7 @@ input { .new-experience-link:hover, .forgot-password-link:hover { - color: #6f3fd4; + color: var(--color-text-brand-hover); } .auth-body { @@ -334,7 +1171,7 @@ input { } .forgot-password-link { - color: #7c3aed; + color: var(--color-text-brand-strong); font-size: 0.875rem; transition: color 0.2s ease; } @@ -420,15 +1257,15 @@ input { } .status-success { - background: hsl(143 85% 96%); - border-color: hsl(145 92% 87%); - color: hsl(140 100% 27%); + background: var(--color-status-success-banner-bg); + border-color: var(--color-status-success-banner-border); + color: var(--color-status-success-banner-foreground); } .status-error { - background: hsl(359 100% 97%); - border-color: hsl(359 100% 94%); - color: hsl(360 100% 45%); + background: var(--color-status-danger-banner-bg); + border-color: var(--color-status-danger-banner-border); + color: var(--color-status-danger-banner-foreground); } .gsi-material-button { @@ -436,12 +1273,12 @@ input { -ms-user-select: none; -webkit-appearance: none; -webkit-user-select: none; - background-color: #fff; + background-color: var(--color-surface-default); background-image: none; - border: 1px solid #747775; + border: 1px solid var(--color-border-google); border-radius: 20px; box-sizing: border-box; - color: #1f1f1f; + color: var(--color-text-google); cursor: pointer; font-family: "Roboto", Arial, sans-serif; font-size: 14px; @@ -502,34 +1339,26 @@ input { .gsi-material-button:not(:disabled):active .gsi-material-button-state, .gsi-material-button:not(:disabled):focus .gsi-material-button-state { - background-color: #303030; + background-color: var(--overlay-google-state); opacity: 0.12; } .gsi-material-button:not(:disabled):hover { - box-shadow: - 0 1px 2px 0 rgba(60, 64, 67, 0.3), - 0 1px 3px 1px rgba(60, 64, 67, 0.15); + box-shadow: var(--shadow-google-button); } .gsi-material-button:not(:disabled):hover .gsi-material-button-state { - background-color: #303030; + background-color: var(--overlay-google-state); opacity: 0.08; } .home-shell { - background: - linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%), - linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%), - linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04)); + background: var(--gradient-shell); min-height: 100vh; } .dashboard-shell { - background: - linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%), - linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%), - linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04)); + background: var(--gradient-shell); color: var(--foreground); display: grid; grid-template-columns: minmax(16rem, 18rem) 1fr; @@ -541,9 +1370,9 @@ input { } .sidebar-nav-shell { - background: rgba(255, 255, 255, 0.92); - border-right: 1px solid rgba(30, 27, 46, 0.08); - box-shadow: 20px 0 45px rgba(30, 27, 46, 0.06); + background: var(--color-surface-elevated); + border-right: 1px solid var(--color-border-panel); + box-shadow: var(--shadow-sidebar); display: flex; flex-direction: column; height: 100vh; @@ -578,7 +1407,7 @@ input { } .sidebar-brand-title { - color: #1f2937; + color: var(--color-text-heading-alt); font-size: 1.125rem; font-weight: 700; margin: 0; @@ -586,11 +1415,11 @@ input { .sidebar-collapse-button { align-items: center; - background: rgba(255, 255, 255, 0.95); + background: var(--color-surface-elevated-strong); border: 0; border-radius: 999px; - box-shadow: 0 10px 24px rgba(30, 27, 46, 0.14); - color: #6b7280; + box-shadow: var(--shadow-floating-control); + color: var(--color-text-muted); cursor: pointer; display: inline-flex; height: 1.5rem; @@ -629,13 +1458,13 @@ input { .sidebar-divider hr, .sidebar-projects hr { border: 0; - border-top: 1px solid rgba(107, 114, 128, 0.22); + border-top: 1px solid var(--color-border-panel-muted); margin: 0; } .sidebar-nav-item { border-radius: 0.9rem; - color: #6b7280; + color: var(--color-text-muted); font-weight: 500; margin: 0 0.5rem; padding: 0.15rem 0; @@ -643,13 +1472,13 @@ input { } .sidebar-nav-item:hover { - background: rgba(30, 27, 46, 0.05); - color: #111827; + background: var(--overlay-dark-soft); + color: var(--color-surface-muted-inverse); } .sidebar-nav-item.is-active { - background: rgba(128, 78, 236, 0.14); - color: #804eec; + background: var(--overlay-brand-soft-strong); + color: var(--color-text-brand); font-weight: 600; } @@ -686,7 +1515,7 @@ input { } .sidebar-section-label { - color: #6b7280; + color: var(--color-text-muted); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.14em; @@ -706,7 +1535,7 @@ input { .sidebar-project-link { align-items: center; border-radius: 0.85rem; - color: #6b7280; + color: var(--color-text-muted); display: flex; gap: 0.65rem; padding: 0.48rem 0.5rem; @@ -714,13 +1543,13 @@ input { } .sidebar-project-link:hover { - background: rgba(30, 27, 46, 0.05); - color: #111827; + background: var(--overlay-dark-soft); + color: var(--color-surface-muted-inverse); } .sidebar-project-icon { align-items: center; - border: 1px solid rgba(107, 114, 128, 0.35); + border: 1px solid var(--color-border-panel-strong); border-radius: 999px; display: inline-flex; flex-shrink: 0; @@ -749,7 +1578,7 @@ input { } .sidebar-organization { - background: rgba(255, 255, 255, 0.92); + background: var(--color-surface-elevated); padding: 0 0.5rem 0.9rem; } @@ -768,7 +1597,7 @@ input { } .organization-button:hover { - background: rgba(30, 27, 46, 0.05); + background: var(--overlay-dark-soft); } .organization-avatar { @@ -794,7 +1623,7 @@ input { } .organization-name { - color: #374151; + color: var(--color-text-body-subtle); font-size: 0.95rem; font-weight: 600; overflow: hidden; @@ -803,7 +1632,7 @@ input { } .organization-meta { - color: #6b7280; + color: var(--color-text-muted); font-size: 0.75rem; } @@ -827,7 +1656,7 @@ input { } .overview-date { - color: #475467; + color: var(--color-text-secondary); font-size: 1rem; font-weight: 500; margin: 0 0 0.5rem; @@ -842,14 +1671,14 @@ input { } .overview-greeting { - color: #475467; + color: var(--color-text-secondary); font-size: clamp(1.4rem, 2vw, 1.75rem); font-weight: 500; margin: 0; } .overview-greeting span { - color: #111827; + color: var(--color-surface-muted-inverse); } .overview-header-actions { @@ -861,10 +1690,10 @@ input { .overview-badge { align-items: center; - background: linear-gradient(to right, #a855f7, #3b82f6); + background: var(--gradient-overview-badge); border: 0; border-radius: 999px; - color: #fff; + color: var(--color-text-inverse); display: inline-flex; font-size: 0.75rem; font-weight: 600; @@ -878,10 +1707,10 @@ input { } .overview-logout-button { - background: rgba(255, 255, 255, 0.92); - border: 1px solid rgba(234, 236, 240, 1); + background: var(--color-surface-elevated); + border: 1px solid var(--color-border-subtle); border-radius: 0.85rem; - color: #475467; + color: var(--color-text-secondary); cursor: pointer; font-weight: 600; min-height: 2.75rem; @@ -896,8 +1725,8 @@ input { .quick-action-card { align-items: center; - background: #fff; - border: 1px solid #eaecf0; + background: var(--color-surface-default); + border: 1px solid var(--color-border-subtle); border-radius: 1rem; cursor: pointer; display: flex; @@ -910,14 +1739,14 @@ input { .quick-action-card:hover, .project-card:hover { - box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); + box-shadow: var(--shadow-surface-hover); } .quick-action-icon { align-items: center; - background: #f4f3ff; + background: var(--color-surface-brand-muted); border-radius: 0.7rem; - color: #7f56d9; + color: var(--color-text-brand-accent); display: inline-flex; flex-shrink: 0; height: 2.5rem; @@ -936,14 +1765,14 @@ input { } .quick-action-title { - color: #111827; + color: var(--color-surface-muted-inverse); font-size: 1.05rem; font-weight: 600; line-height: 1.2; } .quick-action-copy p { - color: #6b7280; + color: var(--color-text-muted); font-size: 0.9rem; margin: 0.25rem 0 0; } @@ -961,7 +1790,7 @@ input { .overview-section-heading h3, .tasks-section-header h3 { - color: #111827; + color: var(--color-surface-muted-inverse); font-size: 1.6rem; font-weight: 600; margin: 0; @@ -974,8 +1803,8 @@ input { } .project-card { - background: #fff; - border: 1px solid #eaecf0; + background: var(--color-surface-default); + border: 1px solid var(--color-border-subtle); border-radius: 1rem; cursor: pointer; padding: 1rem; @@ -1000,677 +1829,34 @@ input { } .tone-warning { - background: #fffbeb; - border-color: #fde68a; - color: #ca8a04; + background: var(--color-status-warning-emphasis-bg); + border-color: var(--color-status-warning-emphasis-border); + color: var(--color-status-warning-emphasis-foreground); } .tone-info { - background: #eff6ff; - border-color: #bfdbfe; - color: #2563eb; + background: var(--color-status-info-soft-bg); + border-color: var(--color-status-info-soft-border); + color: var(--color-status-info-foreground); } .tone-success { - background: #ecfdf3; - border-color: #bbf7d0; - color: #16a34a; + background: var(--color-status-success-soft-bg); + border-color: var(--color-status-success-soft-border); + color: var(--color-status-success-foreground); } -.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.82rem 1.15rem; -} - -.ui-button-solid.ui-button-default { - background: var(--secondary); - color: #fff; -} - -.ui-button-solid.ui-button-default:hover { - background: #6d28d9; -} - -.ui-button-solid.ui-button-default:active { - background: #5b21b6; -} - -.ui-button-solid.ui-button-neutral { - background: #f3f4f6; - color: #111827; -} - -.ui-button-solid.ui-button-neutral:hover { - background: #e5e7eb; -} - -.ui-button-solid.ui-button-neutral:active { - background: #d1d5db; -} - -.ui-button-solid.ui-button-warning { - background: #db9729; - color: #fff; -} - -.ui-button-solid.ui-button-warning:hover { - background: #c37f12; -} - -.ui-button-solid.ui-button-warning:active { - background: #a9670c; -} - -.ui-button-solid.ui-button-success { - background: #16a34a; - color: #fff; -} - -.ui-button-solid.ui-button-success:hover { - background: #15803d; -} - -.ui-button-solid.ui-button-success:active { - background: #166534; -} - -.ui-button-solid.ui-button-danger { - background: #dc2626; - color: #fff; -} - -.ui-button-solid.ui-button-danger:hover { - background: #b91c1c; -} - -.ui-button-solid.ui-button-danger:active { - background: #991b1b; -} - -.ui-button-soft.ui-button-default { - background: #ede9fe; - color: #6d28d9; -} - -.ui-button-soft.ui-button-default:hover { - background: #ddd6fe; -} - -.ui-button-soft.ui-button-default:active { - background: #c4b5fd; -} - -.ui-button-soft.ui-button-warning { - background: #fff4e2; - color: #b86e00; -} - -.ui-button-soft.ui-button-warning:hover { - background: #fee6b7; -} - -.ui-button-soft.ui-button-warning:active { - background: #fdd58e; -} - -.ui-button-soft.ui-button-success { - background: #ecfdf3; - color: #15803d; -} - -.ui-button-soft.ui-button-success:hover { - background: #d1fadf; -} - -.ui-button-soft.ui-button-success:active { - background: #a6f4c5; -} - -.ui-button-soft.ui-button-danger { - background: #fef3f2; - color: #b42318; -} - -.ui-button-soft.ui-button-danger:hover { - background: #fee4e2; -} - -.ui-button-soft.ui-button-danger:active { - background: #fecdca; -} - -.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-spacing-row { - align-items: center; - display: flex; - gap: 0; -} - -.catalog-spacing-column { - display: flex; - flex-direction: column; - gap: 0; - width: 100%; -} - -.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; - 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-solid.ui-icon-button-neutral { - color: #6b7280; -} - -.ui-icon-button-solid.ui-icon-button-neutral:hover { - background: #f9fafb; - color: #111827; -} - -.ui-space-x { - display: inline-block; - flex-shrink: 0; -} - -.ui-space-y { - display: block; -} - -.ui-space-x-xs { - width: 0.25rem; -} - -.ui-space-x-sm { - width: 0.5rem; -} - -.ui-space-x-md { - width: 0.75rem; -} - -.ui-space-x-lg { - width: 1rem; -} - -.ui-space-x-xl { - width: 1.5rem; -} - -.ui-space-y-xs { - height: 0.25rem; -} - -.ui-space-y-sm { - height: 0.5rem; -} - -.ui-space-y-md { - height: 0.75rem; -} - -.ui-space-y-lg { - height: 1rem; -} - -.ui-space-y-xl { - height: 1.5rem; -} - -.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; - cursor: pointer; - outline: none; -} - -.ui-icon-button-ghost.ui-icon-button-neutral, -.ui-icon-button-ghost.ui-icon-button-danger { - color: #9ca3af; -} .project-card-top .borderless-icon-button { padding: 0; } .project-card-top .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-neutral:hover { - color: #111827; + color: var(--color-surface-muted-inverse); } .project-card-top .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger:hover { - color: #ef4444; + color: var(--color-status-danger-icon-hover); } .borderless-icon-button svg, @@ -1685,7 +1871,7 @@ input { td.text-right .borderless-icon-button { align-items: center; border-radius: 0.25rem; - color: #9ca3af; + color: var(--color-text-faint); display: inline-flex; justify-content: center; min-height: 44px; @@ -1695,11 +1881,11 @@ td.text-right .borderless-icon-button { } td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-neutral:hover { - color: #111827; + color: var(--color-surface-muted-inverse); } td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger:hover { - color: #ef4444; + color: var(--color-status-danger-icon-hover); } .project-card-title-row { @@ -1711,9 +1897,9 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .project-avatar { align-items: center; - background: var(--project-color, #3b82f6); + background: var(--project-color, var(--color-project-fallback)); border-radius: 0.85rem; - color: #fff; + color: var(--color-text-inverse); display: inline-flex; flex-shrink: 0; font-size: 1.1rem; @@ -1724,24 +1910,24 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .project-list-icon { - background: var(--project-color, #3b82f6); - color: #fff; + background: var(--project-color, var(--color-project-fallback)); + color: var(--color-text-inverse); } .project-accent-blue { - background: #3b82f6; + background: var(--color-project-fallback); } .project-accent-purple { - background: #a855f7; + background: var(--color-project-accent-purple); } .project-accent-red { - background: #ef4444; + background: var(--color-project-accent-red); } .project-card-title-row h4 { - color: #111827; + color: var(--color-surface-muted-inverse); flex: 1; font-size: 1rem; font-weight: 600; @@ -1752,7 +1938,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .project-date-row { align-items: center; - color: #6b7280; + color: var(--color-text-muted); display: flex; font-size: 0.875rem; gap: 0.5rem; @@ -1761,7 +1947,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .project-progress-label { align-items: center; - color: #6b7280; + color: var(--color-text-muted); display: flex; font-size: 0.875rem; justify-content: space-between; @@ -1769,18 +1955,18 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .project-progress-label strong { - color: #111827; + color: var(--color-surface-muted-inverse); } .project-progress-track { - background: #f3f4f6; + background: var(--color-surface-muted); border-radius: 999px; height: 0.5rem; overflow: hidden; } .project-progress-bar { - background: var(--project-color, #3b82f6); + background: var(--project-color, var(--color-project-fallback)); border-radius: 999px; height: 100%; } @@ -1801,7 +1987,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger align-items: center; background: transparent; border: 0; - color: #7c3aed; + color: var(--color-text-brand-strong); cursor: pointer; display: inline-flex; font-size: 0.875rem; @@ -1815,16 +2001,16 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .app-section-surface { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, #ffffff 100%); - border: 1px solid #eaecf0; + background: var(--gradient-app-surface); + border: 1px solid var(--color-border-subtle); border-radius: 1.5rem; - box-shadow: 0 24px 48px rgba(15, 23, 42, 0.08); + box-shadow: var(--shadow-surface-lg); max-width: 52rem; padding: 2rem; } .app-section-eyebrow { - color: #7c3aed; + color: var(--color-text-brand-strong); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; @@ -1833,14 +2019,14 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .app-section-surface h2 { - color: #111827; + color: var(--color-surface-muted-inverse); font-size: clamp(1.8rem, 4vw, 2.6rem); line-height: 1.05; margin: 0; } .app-section-surface p { - color: #475467; + color: var(--color-text-secondary); font-size: 1rem; line-height: 1.8; margin: 1rem 0 0; @@ -1849,9 +2035,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .not-found-page { align-items: center; - background: - radial-gradient(circle at top, rgba(124, 58, 237, 0.14), transparent 35%), - linear-gradient(180deg, #f8f7ff 0%, #f4f7fb 100%); + background: var(--gradient-not-found-bg); display: flex; justify-content: center; min-height: 100vh; @@ -1860,19 +2044,19 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .not-found-surface { backdrop-filter: blur(18px); - background: rgba(255, 255, 255, 0.88); - border: 1px solid rgba(148, 163, 184, 0.22); + background: var(--color-surface-overlay); + border: 1px solid var(--color-border-overlay); border-radius: 2rem; - box-shadow: 0 32px 70px rgba(15, 23, 42, 0.12); + box-shadow: var(--shadow-surface-xl); padding: 3rem; width: min(100%, 44rem); } .not-found-eyebrow { align-items: center; - background: rgba(124, 58, 237, 0.1); + background: var(--overlay-brand-soft); border-radius: 999px; - color: #7c3aed; + color: var(--color-text-brand-strong); display: inline-flex; font-size: 0.78rem; font-weight: 700; @@ -1882,7 +2066,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .not-found-code { - color: #111827; + color: var(--color-surface-muted-inverse); font-size: clamp(4.75rem, 11vw, 7.5rem); font-weight: 800; letter-spacing: -0.08em; @@ -1891,14 +2075,14 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .not-found-surface h2 { - color: #111827; + color: var(--color-surface-muted-inverse); font-size: clamp(1.9rem, 4vw, 2.8rem); line-height: 1.05; margin: 1rem 0 0; } .not-found-surface p { - color: #475467; + color: var(--color-text-secondary); font-size: 1rem; line-height: 1.75; margin: 1rem 0 0; @@ -1932,9 +2116,9 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .not-found-primary { - background: linear-gradient(135deg, #7c3aed 0%, #2563eb 100%); - box-shadow: 0 18px 35px rgba(124, 58, 237, 0.25); - color: #fff; + background: var(--gradient-not-found-primary); + box-shadow: var(--shadow-brand-action); + color: var(--color-text-inverse); } .not-found-primary:hover, @@ -1947,15 +2131,15 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .not-found-secondary { - background: rgba(255, 255, 255, 0.9); - border-color: rgba(148, 163, 184, 0.3); - color: #344054; + background: var(--color-surface-elevated-soft); + border-color: var(--color-border-overlay-strong); + color: var(--color-text-overlay); cursor: pointer; } .not-found-meta { align-items: center; - color: #667085; + color: var(--color-text-disabled); display: flex; font-size: 0.95rem; gap: 0.5rem; @@ -1963,19 +2147,19 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .not-found-meta strong { - color: #111827; + color: var(--color-surface-muted-inverse); } .tasks-section { - background: #fff; - border: 1px solid #eaecf0; + background: var(--color-surface-default); + border: 1px solid var(--color-border-subtle); border-radius: 1rem; overflow: hidden; } .tasks-section-header { align-items: center; - border-bottom: 1px solid #e5e7eb; + border-bottom: 1px solid var(--color-border-muted); display: flex; justify-content: space-between; padding: 1.2rem 1rem; @@ -1983,10 +2167,10 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .tasks-add-button { align-items: center; - background: #fff; - border: 1px solid #e5e7eb; + background: var(--color-surface-default); + border: 1px solid var(--color-border-muted); border-radius: 0.7rem; - color: #475467; + color: var(--color-text-secondary); cursor: pointer; display: inline-flex; font-weight: 500; @@ -2002,7 +2186,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .task-row { align-items: center; - border-bottom: 1px solid #e5e7eb; + border-bottom: 1px solid var(--color-border-muted); cursor: pointer; display: flex; gap: 0.75rem; @@ -2011,15 +2195,15 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .task-row:hover { - background: rgba(249, 250, 251, 0.9); + background: var(--color-surface-neutral-hover); } .task-check { align-items: center; - background: #fff; - border: 2px solid #d1d5db; + background: var(--color-surface-default); + border: 2px solid var(--color-border-strong); border-radius: 999px; - color: #fff; + color: var(--color-text-inverse); cursor: pointer; display: inline-flex; flex-shrink: 0; @@ -2029,8 +2213,8 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .task-check.is-complete { - background: #7c3aed; - border-color: #7c3aed; + background: var(--color-text-brand-strong); + border-color: var(--color-text-brand-strong); } .task-body { @@ -2039,7 +2223,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .task-body p { - color: #111827; + color: var(--color-surface-muted-inverse); font-size: 0.95rem; font-weight: 500; margin: 0; @@ -2049,13 +2233,13 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .task-row.is-complete .task-body p { - color: #9ca3af; + color: var(--color-text-faint); text-decoration: line-through; } .task-meta { align-items: center; - color: #6b7280; + color: var(--color-text-muted); display: flex; flex-wrap: wrap; font-size: 0.75rem; @@ -2066,7 +2250,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger .task-project-badge { align-items: center; border-radius: 0.35rem; - color: #fff; + color: var(--color-text-inverse); display: inline-flex; font-size: 0.5rem; font-weight: 700; @@ -2084,7 +2268,7 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger background: var(--card); border: 1px solid var(--border); border-radius: 1rem; - box-shadow: 0 20px 45px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-auth-card); max-width: 28rem; padding: 2rem; text-align: center; @@ -2123,9 +2307,9 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger } .sidebar-nav-shell { - border-bottom: 1px solid rgba(30, 27, 46, 0.08); + border-bottom: 1px solid var(--color-border-panel); border-right: 0; - box-shadow: 0 12px 30px rgba(30, 27, 46, 0.08); + box-shadow: var(--shadow-surface-hover); height: auto; position: static; } diff --git a/go-backend/static/tailwind.css b/go-backend/static/tailwind.css index 98b2ad0..69a665a 100644 --- a/go-backend/static/tailwind.css +++ b/go-backend/static/tailwind.css @@ -49,6 +49,9 @@ --radius-xl: 0.75rem; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --color-surface-muted: #f9fafb; + --color-text-muted: #6b7280; + --color-border-subtle: #eaecf0; } .pointer-events-none { pointer-events: none; -- 2.45.2 From c780dd162542d0a71b4245dbff0bc49344da8e97 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 13:53:23 +0200 Subject: [PATCH 029/546] Remove server-side search filtering and implement client-side filtering This commit moves project search filtering from the server to the client. Changes include: - Remove `Query` field from `ListTablosInput` and related handlers - Add French date formatting for project cards - Convert search form to client-side filter with data attributes - Add empty state message for no search results - Update button border-radius from 0 to 0.7rem - Increase air.toml build command to include Tailwind CSS generation --- go-backend/.air.toml | 4 +- go-backend/internal/db/repository.go | 1 - go-backend/internal/tablos/model.go | 1 - go-backend/internal/web/dates/french.go | 13 + go-backend/internal/web/handlers/tablos.go | 22 +- .../internal/web/handlers/tablos_test.go | 72 ++- go-backend/internal/web/ui/button.css | 2 +- .../web/views/dashboard_components_test.go | 3 + go-backend/internal/web/views/home.go | 3 +- go-backend/internal/web/views/tablos.templ | 87 ++- go-backend/internal/web/views/tablos_templ.go | 607 +++++++++--------- go-backend/internal/web/views/tablos_view.go | 28 +- go-backend/static/styles.css | 2 +- go-backend/static/tailwind.css | 28 + 14 files changed, 478 insertions(+), 395 deletions(-) create mode 100644 go-backend/internal/web/dates/french.go diff --git a/go-backend/.air.toml b/go-backend/.air.toml index 7d3d4e4..d8c1aee 100644 --- a/go-backend/.air.toml +++ b/go-backend/.air.toml @@ -4,11 +4,11 @@ root = "." tmp_dir = "tmp" [build] - cmd = "go run github.com/a-h/templ/cmd/templ@latest generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ." + cmd = "go run ./cmd/buildstyles && pnpm exec tailwindcss -i tailwind.input.css -o static/tailwind.css --cwd . && go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ." entrypoint = ["./tmp/main"] include_ext = ["go", "templ", "sql", "css", "html", "png", "svg", "webmanifest", "json"] exclude_dir = ["tmp", "vendor", ".git", "internal/db/sqlc"] - exclude_regex = ["_templ\\.go$"] + exclude_regex = ["_templ\\.go$", "static/(styles|tailwind)\\.css$"] delay = 200 stop_on_error = true send_interrupt = true diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go index ae50ac0..5c72c3b 100644 --- a/go-backend/internal/db/repository.go +++ b/go-backend/internal/db/repository.go @@ -164,7 +164,6 @@ func (r *PostgresAuthRepository) CreateTablo(ctx context.Context, input tablomod 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), } diff --git a/go-backend/internal/tablos/model.go b/go-backend/internal/tablos/model.go index c15bec6..35714d6 100644 --- a/go-backend/internal/tablos/model.go +++ b/go-backend/internal/tablos/model.go @@ -44,6 +44,5 @@ type UpdateInput struct { type ListInput struct { OwnerID uuid.UUID - Query string Status *Status } diff --git a/go-backend/internal/web/dates/french.go b/go-backend/internal/web/dates/french.go new file mode 100644 index 0000000..b8b748f --- /dev/null +++ b/go-backend/internal/web/dates/french.go @@ -0,0 +1,13 @@ +package dates + +import ( + "fmt" + "time" +) + +var frenchMonths = [...]string{"janv.", "fevr.", "mars", "avr.", "mai", "juin", "juil.", "aout", "sept.", "oct.", "nov.", "dec."} + +func FormatFrenchDate(value time.Time) string { + month := frenchMonths[int(value.Month())-1] + return fmt.Sprintf("%02d %s %d", value.Day(), month, value.Year()) +} diff --git a/go-backend/internal/web/handlers/tablos.go b/go-backend/internal/web/handlers/tablos.go index 9dc6884..a1cc9a4 100644 --- a/go-backend/internal/web/handlers/tablos.go +++ b/go-backend/internal/web/handlers/tablos.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" tablomodel "xtablo-backend/internal/tablos" + "xtablo-backend/internal/web/dates" "xtablo-backend/internal/web/views" ) @@ -37,16 +38,11 @@ type ListTablosInput = tablomodel.ListInput type TablosPageState struct { View string - Query string Status string ModalKind string EditingTabloID string } -func normalizeTabloQuery(query string) string { - return strings.ToLower(strings.TrimSpace(query)) -} - func parseTablosPageState(values interface { Get(string) string }) TablosPageState { @@ -64,7 +60,6 @@ func parseTablosPageState(values interface { return TablosPageState{ View: view, - Query: strings.TrimSpace(values.Get("q")), Status: status, ModalKind: normalizedModalKind(strings.TrimSpace(values.Get("modal"))), } @@ -329,7 +324,6 @@ func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloR return views.NewTablosPageViewModel( user.DisplayName, state.View, - state.Query, state.Status, state.ModalKind, state.EditingTabloID, @@ -343,7 +337,6 @@ func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloR func listTablosForState(ctx context.Context, repo AuthRepository, ownerID uuid.UUID, state TablosPageState) ([]TabloRecord, error) { return repo.ListTablos(ctx, ListTablosInput{ OwnerID: ownerID, - Query: state.Query, Status: state.statusFilter(), }) } @@ -396,7 +389,6 @@ func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosI r.mu.RLock() defer r.mu.RUnlock() - query := normalizeTabloQuery(input.Query) var tablos []TabloRecord for _, tablo := range r.tablos { @@ -409,9 +401,6 @@ func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosI if input.Status != nil && tablo.Status != *input.Status { continue } - if query != "" && !strings.Contains(strings.ToLower(tablo.Name), query) { - continue - } tablos = append(tablos, tablo) } @@ -505,9 +494,6 @@ func buildStatefulRequestURL(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 @@ -538,13 +524,11 @@ func tabloIconPresentation(name string) (string, string, string, string) { } 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()) + return dates.FormatFrenchDate(value) } func formatCardDate(value time.Time) string { - return value.Format("Jan 02, 2006") + return dates.FormatFrenchDate(value) } func projectInitial(name string) string { diff --git a/go-backend/internal/web/handlers/tablos_test.go b/go-backend/internal/web/handlers/tablos_test.go index 03fd88b..928d560 100644 --- a/go-backend/internal/web/handlers/tablos_test.go +++ b/go-backend/internal/web/handlers/tablos_test.go @@ -7,6 +7,7 @@ import ( "net/url" "strings" "testing" + "time" ) func TestInMemoryTablosListExcludesSoftDeletedRows(t *testing.T) { @@ -54,7 +55,7 @@ func TestInMemoryTablosListExcludesSoftDeletedRows(t *testing.T) { } } -func TestInMemoryTablosListFiltersBySearchAndStatus(t *testing.T) { +func TestInMemoryTablosListFiltersByStatus(t *testing.T) { repo := NewInMemoryAuthRepository() user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") if err != nil { @@ -81,15 +82,14 @@ func TestInMemoryTablosListFiltersBySearchAndStatus(t *testing.T) { 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) + t.Fatalf("list status-filtered tablos: %v", err) } if len(tablos) != 1 { - t.Fatalf("expected 1 filtered tablo, got %d", len(tablos)) + t.Fatalf("expected 1 status-filtered tablo, got %d", len(tablos)) } if tablos[0].ID != expectedTablo.ID { @@ -177,7 +177,7 @@ func TestGetTablosPageDefaultsToGridAndAllStatus(t *testing.T) { } } -func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) { +func TestGetTablosPageIgnoresSearchQueryParamAndLeavesFilteringToClient(t *testing.T) { repo := NewInMemoryAuthRepository() handler := NewAuthHandler(repo) sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") @@ -192,10 +192,10 @@ func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) { _, err := repo.CreateTablo(context.Background(), CreateTabloInput{ OwnerID: userID, Name: "Alpha Draft", - Status: TabloStatusTodo, + Status: TabloStatusInProgress, }) if err != nil { - t.Fatalf("create todo tablo: %v", err) + t.Fatalf("create first in-progress tablo: %v", err) } _, err = repo.CreateTablo(context.Background(), CreateTabloInput{ @@ -218,11 +218,54 @@ func TestGetTablosPageHonorsSearchAndStatus(t *testing.T) { } 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 stay visible for client filtering, got %q", body) } - if strings.Contains(body, "Alpha Draft") { - t.Fatalf("expected non-matching tablo to be filtered out, got %q", body) + if !strings.Contains(body, "Beta Delivery") { + t.Fatalf("expected matching tablo to be visible, got %q", body) + } +} + +func TestGetTablosPageRendersClientSideProjectFilterHooks(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: "Searchable Project", + Status: TabloStatusTodo, + }) + if err != nil { + t.Fatalf("create searchable tablo: %v", err) + } + + pageReq := httptest.NewRequest(http.MethodGet, "/tablos", nil) + pageReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTablosPage().ServeHTTP(rec, pageReq) + + body := rec.Body.String() + for _, want := range []string{ + `data-project-filter-root`, + `data-project-filter-input`, + `data-project-filter-item`, + `window.xtabloProjectFilterInitialized`, + } { + if !strings.Contains(body, want) { + t.Fatalf("expected client-side filter markup %q, got %q", want, body) + } + } + if strings.Contains(body, `name="q"`) { + t.Fatalf("expected search query field to be removed from markup, got %q", body) } } @@ -443,6 +486,13 @@ func TestGetTablosPageGridUsesProjectDateRowMarkup(t *testing.T) { } } +func TestFormatCardDateUsesFrenchMonthNames(t *testing.T) { + got := formatCardDate(time.Date(2026, time.May, 10, 9, 0, 0, 0, time.UTC)) + if got != "10 mai 2026" { + t.Fatalf("expected French card date label, got %q", got) + } +} + func TestGetTablosPageGridUsesProjectCardMarkup(t *testing.T) { repo := NewInMemoryAuthRepository() handler := NewAuthHandler(repo) diff --git a/go-backend/internal/web/ui/button.css b/go-backend/internal/web/ui/button.css index a7e912c..07f045b 100644 --- a/go-backend/internal/web/ui/button.css +++ b/go-backend/internal/web/ui/button.css @@ -1,7 +1,7 @@ .ui-button { align-items: center; border: 0; - border-radius: 0rem; + border-radius: 0.7rem; cursor: pointer; display: inline-flex; font-weight: 600; diff --git a/go-backend/internal/web/views/dashboard_components_test.go b/go-backend/internal/web/views/dashboard_components_test.go index 57bc770..3be1984 100644 --- a/go-backend/internal/web/views/dashboard_components_test.go +++ b/go-backend/internal/web/views/dashboard_components_test.go @@ -33,6 +33,9 @@ func TestOverviewProjectsFromTablosCarriesColorAndEditURL(t *testing.T) { if project.EditRequestURL != "/tablos/11111111-1111-1111-1111-111111111111/edit" { t.Fatalf("expected edit request url to be set, got %q", project.EditRequestURL) } + if project.CardDateLabel != "10 mai 2026" { + t.Fatalf("expected French card date label, got %q", project.CardDateLabel) + } } func TestOverviewProjectsSectionRendersColorAndEditAction(t *testing.T) { diff --git a/go-backend/internal/web/views/home.go b/go-backend/internal/web/views/home.go index de089b9..2818aa3 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -7,6 +7,7 @@ import ( "github.com/a-h/templ" tablomodel "xtablo-backend/internal/tablos" + "xtablo-backend/internal/web/dates" ) const overviewProjectsPreviewLimit = 6 @@ -119,7 +120,7 @@ func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView { StatusTone: statusTone, Initial: projectInitial(tablo.Name), Accent: overviewProjectAccent(tablo.Name), - CardDateLabel: tablo.CreatedAt.Format("Jan 02, 2006"), + CardDateLabel: dates.FormatFrenchDate(tablo.CreatedAt), Progress: progress, ProgressLabel: progressPercentLabel(progress), DeleteRequestURL: "/tablos/" + tablo.ID.String(), diff --git a/go-backend/internal/web/views/tablos.templ b/go-backend/internal/web/views/tablos.templ index 19eb1a3..589ea28 100644 --- a/go-backend/internal/web/views/tablos.templ +++ b/go-backend/internal/web/views/tablos.templ @@ -3,7 +3,7 @@ package views import "xtablo-backend/internal/web/ui" templ TablosPageContent(vm TablosPageViewModel) { -
+

Mes Projets

@ui.Button(ui.ButtonProps{ @@ -49,27 +49,18 @@ templ TablosPageContent(vm TablosPageViewModel) {
-
- - +
@ActionIcon("search") - +
@StatusPill(vm, "all", "Tous") @StatusPill(vm, "todo", "Pas commencé") @@ -79,19 +70,27 @@ templ TablosPageContent(vm TablosPageViewModel) {
if vm.HasTablos() { if vm.IsGridView() { -
+
for _, tablo := range vm.Tablos { @TabloGridCard(tablo) }
} else { -
+
@ui.Table(ui.TableProps{ Head: TabloListHead(), Body: TabloListBody(vm.Tablos), })
} + + @InitProjectFilterScript() } else { @ui.EmptyState(ui.EmptyStateProps{ Title: "Aucun projet trouvé", @@ -177,7 +176,7 @@ templ TabloGridCard(tablo TabloCardView) { } templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) { -
+
@ui.Badge(ui.BadgeProps{ Label: tablo.StatusLabel, @@ -211,7 +210,7 @@ templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) { } templ TabloListRow(tablo TabloCardView) { - +
@@ -256,6 +255,56 @@ templ CreateTabloModal(vm TablosPageViewModel) { }) } +templ InitProjectFilterScript() { + +} + templ TabloListHead() { Projet @@ -281,7 +330,6 @@ templ CreateTabloModalBody(vm TablosPageViewModel) { > - if vm.ErrorMessage != "" {
{ vm.ErrorMessage }
@@ -373,7 +421,6 @@ templ EditTabloModalBody(vm TablosPageViewModel) { > - if vm.ErrorMessage != "" {
{ vm.ErrorMessage }
} diff --git a/go-backend/internal/web/views/tablos_templ.go b/go-backend/internal/web/views/tablos_templ.go index e27ac9f..7598de5 100644 --- a/go-backend/internal/web/views/tablos_templ.go +++ b/go-backend/internal/web/views/tablos_templ.go @@ -31,7 +31,7 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Mes Projets

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

Mes Projets

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -163,46 +163,7 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " Vue en liste
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " Vue en liste
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -210,20 +171,7 @@ func TablosPageContent(vm TablosPageViewModel) 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, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -243,13 +191,13 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if vm.HasTablos() { if vm.IsGridView() { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -259,12 +207,12 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -275,11 +223,19 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { 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, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = InitProjectFilterScript().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } else { templ_7745c5c3_Err = ui.EmptyState(ui.EmptyStateProps{ Title: "Aucun projet trouvé", @@ -316,7 +272,7 @@ func TablosPageContent(vm TablosPageViewModel) templ.Component { } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -340,61 +296,61 @@ func StatusPill(vm TablosPageViewModel, status string, label string) templ.Compo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = 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...) + var templ_7745c5c3_Var11 = []any{statusPillClass(vm.Status == status)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...) 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, 24, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if status == "all" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -402,21 +358,21 @@ func StatusPill(vm TablosPageViewModel, status string, label string) templ.Compo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(label) + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, 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: 139, Col: 9} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 138, Col: 9} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, 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, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -440,9 +396,9 @@ func BorderlessDeleteButton(deleteRequestURL 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_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{ @@ -481,9 +437,9 @@ func EditTabloButton(editRequestURL string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var21 := templ.GetChildren(ctx) - if templ_7745c5c3_Var21 == nil { - templ_7745c5c3_Var21 = 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 = ui.IconButton(ui.IconButtonProps{ @@ -522,9 +478,9 @@ func TabloGridCard(tablo TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var22 := templ.GetChildren(ctx) - if templ_7745c5c3_Var22 == nil { - templ_7745c5c3_Var22 = 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 = TabloGridCardWithAttrs(tablo, nil).Render(ctx, templ_7745c5c3_Buffer) @@ -551,25 +507,38 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var23 := templ.GetChildren(ctx) - if templ_7745c5c3_Var23 == nil { - templ_7745c5c3_Var23 = 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -588,7 +557,7 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -600,33 +569,33 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Initial) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, 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: 193, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 192, Col: 25} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, 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, 36, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, 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: 195, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 194, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -634,46 +603,46 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.CardDateLabel) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, 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: 199, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 198, Col: 30} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, 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, 39, "
Progression: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
Progression: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.ProgressLabel) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, 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: 204, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 203, Col: 33} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + _, 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, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -697,25 +666,38 @@ func TabloListRow(tablo TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var30 := templ.GetChildren(ctx) - if templ_7745c5c3_Var30 == nil { - templ_7745c5c3_Var30 = templ.NopComponent + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
svg]:w-4 [&>svg]:h-4\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" data-project-filter-item data-project-name=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(tablo.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 213, Col: 237} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">
svg]:w-4 [&>svg]:h-4\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -723,20 +705,20 @@ func TabloListRow(tablo TabloCardView) templ.Component { 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, 43, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.Name) + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, 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: 220, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 219, Col: 84} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -747,7 +729,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -755,42 +737,42 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(tablo.CreatedAtLabel) + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, 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: 232, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 231, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + 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_Var33 string + templ_7745c5c3_Var33, 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: 239, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, 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: 240, Col: 109} - } - _, 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -802,7 +784,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { 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, 49, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -826,9 +808,9 @@ func CreateTabloModal(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var36 := templ.GetChildren(ctx) - if templ_7745c5c3_Var36 == nil { - templ_7745c5c3_Var36 = templ.NopComponent + templ_7745c5c3_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = ui.Modal(ui.ModalProps{ @@ -842,6 +824,35 @@ func CreateTabloModal(vm TablosPageViewModel) templ.Component { }) } +func InitProjectFilterScript() 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_Var35 := templ.GetChildren(ctx) + if templ_7745c5c3_Var35 == nil { + templ_7745c5c3_Var35 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + 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 @@ -858,9 +869,9 @@ func TabloListHead() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var37 := templ.GetChildren(ctx) - if templ_7745c5c3_Var37 == nil { - templ_7745c5c3_Var37 = templ.NopComponent + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "ProjetStatutCréé leProgression") @@ -887,9 +898,9 @@ func TabloListBody(tablos []TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var38 := templ.GetChildren(ctx) - if templ_7745c5c3_Var38 == nil { - templ_7745c5c3_Var38 = templ.NopComponent + templ_7745c5c3_Var37 := templ.GetChildren(ctx) + if templ_7745c5c3_Var37 == nil { + templ_7745c5c3_Var37 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, tablo := range tablos { @@ -918,21 +929,21 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var39 := templ.GetChildren(ctx) - if templ_7745c5c3_Var39 == nil { - templ_7745c5c3_Var39 = templ.NopComponent + templ_7745c5c3_Var38 := templ.GetChildren(ctx) + if templ_7745c5c3_Var38 == nil { + templ_7745c5c3_Var38 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + 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, 56, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ErrorMessage) + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, 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: 287, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 335, Col: 112} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + _, 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, 57, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1017,33 +1015,33 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
Annuler") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"ui-button ui-button-solid ui-button-neutral ui-button-md\">Annuler") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1056,7 +1054,7 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1080,9 +1078,9 @@ func EditTabloModal(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var46 := templ.GetChildren(ctx) - if templ_7745c5c3_Var46 == nil { - templ_7745c5c3_Var46 = templ.NopComponent + templ_7745c5c3_Var44 := templ.GetChildren(ctx) + if templ_7745c5c3_Var44 == nil { + templ_7745c5c3_Var44 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = ui.Modal(ui.ModalProps{ @@ -1112,12 +1110,12 @@ func EditTabloColorField(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var47 := templ.GetChildren(ctx) - if templ_7745c5c3_Var47 == nil { - templ_7745c5c3_Var47 = templ.NopComponent + templ_7745c5c3_Var45 := templ.GetChildren(ctx) + if templ_7745c5c3_Var45 == nil { + templ_7745c5c3_Var45 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1136,20 +1134,20 @@ func EditTabloColorField(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" class=\"ui-input tablo-color-picker\" oninput=\"document.getElementById('edit-tablo-color').value=this.value\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1173,82 +1171,69 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var49 := templ.GetChildren(ctx) - if templ_7745c5c3_Var49 == nil { - templ_7745c5c3_Var49 = templ.NopComponent + templ_7745c5c3_Var47 := templ.GetChildren(ctx) + if templ_7745c5c3_Var47 == nil { + templ_7745c5c3_Var47 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if vm.ErrorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var54 string - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ErrorMessage) + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, 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: 378, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 425, Col: 112} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + _, 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, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1276,33 +1261,33 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
Annuler") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"ui-button ui-button-solid ui-button-neutral ui-button-md\">Annuler") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1315,7 +1300,7 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + 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/tablos_view.go b/go-backend/internal/web/views/tablos_view.go index 6625902..f9fb8fa 100644 --- a/go-backend/internal/web/views/tablos_view.go +++ b/go-backend/internal/web/views/tablos_view.go @@ -1,7 +1,6 @@ package views import ( - "fmt" "net/url" "strings" @@ -34,7 +33,6 @@ type TabloCardView struct { type TablosPageViewModel struct { DisplayName string View string - Query string Status string ModalKind string EditingTabloID string @@ -44,11 +42,10 @@ type TablosPageViewModel struct { Tablos []TabloCardView } -func NewTablosPageViewModel(displayName string, view string, query string, status string, modalKind string, editingTabloID string, formName string, formColor string, errorMessage string, tablos []TabloCardView) TablosPageViewModel { +func NewTablosPageViewModel(displayName string, view string, status string, modalKind string, editingTabloID string, formName string, formColor string, errorMessage string, tablos []TabloCardView) TablosPageViewModel { return TablosPageViewModel{ DisplayName: displayName, View: normalizedView(view), - Query: strings.TrimSpace(query), Status: normalizedStatus(status), ModalKind: normalizedModalKind(modalKind), EditingTabloID: strings.TrimSpace(editingTabloID), @@ -91,22 +88,6 @@ func (vm TablosPageViewModel) ViewHref(view string) string { 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") @@ -127,10 +108,6 @@ func (vm TablosPageViewModel) EditSubmitHref() string { return "/tablos/" + vm.EditingTabloID } -func (vm TablosPageViewModel) HasSearch() bool { - return vm.Query != "" -} - func normalizedView(view string) string { if view == "list" { return "list" @@ -171,9 +148,6 @@ 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 } diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css index a6d490b..04a54ba 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -394,7 +394,7 @@ input { .ui-button { align-items: center; border: 0; - border-radius: 0rem; + border-radius: 0.7rem; cursor: pointer; display: inline-flex; font-weight: 600; diff --git a/go-backend/static/tailwind.css b/go-backend/static/tailwind.css index 69a665a..b3b7b89 100644 --- a/go-backend/static/tailwind.css +++ b/go-backend/static/tailwind.css @@ -264,6 +264,10 @@ border-bottom-style: var(--tw-border-style); border-bottom-width: 2px; } +.border-dashed { + --tw-border-style: dashed; + border-style: dashed; +} .border-\[\#DB9729\] { border-color: #DB9729; } @@ -321,6 +325,14 @@ .bg-white { background-color: var(--color-white); } +.bg-white\/80 { + background-color: color-mix(in srgb, #fff 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + & { + background-color: color-mix(in oklab, var(--color-white) 80%, transparent); + } + } +} .px-4 { padding-inline: calc(var(--spacing) * 4); } @@ -336,6 +348,9 @@ .py-4 { padding-block: calc(var(--spacing) * 4); } +.py-10 { + padding-block: calc(var(--spacing) * 10); +} .pt-8 { padding-top: calc(var(--spacing) * 8); } @@ -351,6 +366,9 @@ .pl-10 { padding-left: calc(var(--spacing) * 10); } +.text-center { + text-align: center; +} .text-left { text-align: left; } @@ -558,6 +576,16 @@ background-color: var(--color-gray-800); } } +.dark\:bg-gray-800\/60 { + &:is(.dark *) { + background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 60%, transparent); + @supports (color: color-mix(in lab, red, red)) { + & { + background-color: color-mix(in oklab, var(--color-gray-800) 60%, transparent); + } + } + } +} .dark\:bg-gray-800\/80 { &:is(.dark *) { background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 80%, transparent); -- 2.45.2 From 3232309388a15de5cd5cefd4943ce835801aa99c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 14:18:33 +0200 Subject: [PATCH 030/546] Make tablo icon selection dynamic based on color using nearest palette match Instead of selecting icons based on tablo name length, compute the closest matching icon from a predefined palette by comparing hex color values. This ensures consistent icon-color pairing and better visual harmony. --- go-backend/internal/web/handlers/tablos.go | 83 ++++++++++++++++--- .../internal/web/handlers/tablos_test.go | 27 ++++++ go-backend/internal/web/ui/button.css | 2 +- .../web/views/dashboard_components_test.go | 23 +++++ go-backend/internal/web/views/home.go | 3 +- go-backend/internal/web/views/icons.templ | 48 +++++++++++ go-backend/internal/web/views/icons_templ.go | 42 +++++++++- go-backend/static/styles.css | 2 +- go-backend/static/tailwind.css | 28 +++++++ 9 files changed, 244 insertions(+), 14 deletions(-) diff --git a/go-backend/internal/web/handlers/tablos.go b/go-backend/internal/web/handlers/tablos.go index a1cc9a4..3d1811c 100644 --- a/go-backend/internal/web/handlers/tablos.go +++ b/go-backend/internal/web/handlers/tablos.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "strings" "time" @@ -23,6 +24,29 @@ var tabloColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) const defaultTabloColor = "#3B82F6" const tabloColorValidationMessage = "La couleur du projet doit être un code hexadécimal au format #RRGGBB" +type tabloPaletteEntry struct { + icon string + bg string + fg string + accent string + r int + g int + b int +} + +var tabloIconPalette = []tabloPaletteEntry{ + {icon: "bolt", bg: "bg-blue-500", fg: "text-white", accent: "blue", r: 59, g: 130, b: 246}, + {icon: "leaf", bg: "bg-green-500", fg: "text-white", accent: "green", r: 34, g: 197, b: 94}, + {icon: "gem", bg: "bg-purple-500", fg: "text-white", accent: "purple", r: 168, g: 85, b: 247}, + {icon: "flame", bg: "bg-red-500", fg: "text-white", accent: "red", r: 239, g: 68, b: 68}, + {icon: "star", bg: "bg-yellow-500", fg: "text-gray-700", accent: "yellow", r: 234, g: 179, b: 8}, + {icon: "compass", bg: "bg-indigo-500", fg: "text-white", accent: "indigo", r: 99, g: 102, b: 241}, + {icon: "heart", bg: "bg-pink-500", fg: "text-white", accent: "pink", r: 236, g: 72, b: 153}, + {icon: "waves", bg: "bg-teal-500", fg: "text-white", accent: "teal", r: 20, g: 184, b: 166}, + {icon: "sun", bg: "bg-orange-500", fg: "text-white", accent: "orange", r: 249, g: 115, b: 22}, + {icon: "sparkles", bg: "bg-cyan-500", fg: "text-gray-700", accent: "cyan", r: 6, g: 182, b: 212}, +} + type TabloStatus = tablomodel.Status const ( @@ -455,12 +479,13 @@ func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.Ta 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) + color := storedTabloColor(tablo.Color) + iconKind, bgClass, fgClass, accent := tabloIconPresentation(color) items = append(items, views.TabloCardView{ ID: tablo.ID.String(), Name: tablo.Name, - Color: storedTabloColor(tablo.Color), + Color: color, Status: string(tablo.Status), StatusLabel: statusLabel, StatusClass: statusClass, @@ -512,15 +537,53 @@ func tabloStatusPresentation(status TabloStatus) (string, string, int, string) { } } -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 tabloIconPresentation(color string) (string, string, string, string) { + r, g, b, ok := parseHexColor(color) + if !ok { + fallback := tabloIconPalette[0] + return fallback.icon, fallback.bg, fallback.fg, fallback.accent } + + best := tabloIconPalette[0] + bestDistance := colorDistanceSquared(r, g, b, best.r, best.g, best.b) + for _, entry := range tabloIconPalette[1:] { + distance := colorDistanceSquared(r, g, b, entry.r, entry.g, entry.b) + if distance < bestDistance { + best = entry + bestDistance = distance + } + } + + return best.icon, best.bg, best.fg, best.accent +} + +func parseHexColor(color string) (int, int, int, bool) { + trimmed := strings.TrimSpace(color) + if len(trimmed) != 7 || trimmed[0] != '#' { + return 0, 0, 0, false + } + + r, err := strconv.ParseInt(trimmed[1:3], 16, 0) + if err != nil { + return 0, 0, 0, false + } + g, err := strconv.ParseInt(trimmed[3:5], 16, 0) + if err != nil { + return 0, 0, 0, false + } + b, err := strconv.ParseInt(trimmed[5:7], 16, 0) + if err != nil { + return 0, 0, 0, false + } + + return int(r), int(g), int(b), true +} + +func colorDistanceSquared(r1 int, g1 int, b1 int, r2 int, g2 int, b2 int) int { + dr := r1 - r2 + dg := g1 - g2 + db := b1 - b2 + return dr*dr + dg*dg + db*db } func formatFrenchDate(value time.Time) string { diff --git a/go-backend/internal/web/handlers/tablos_test.go b/go-backend/internal/web/handlers/tablos_test.go index 928d560..95e5cdf 100644 --- a/go-backend/internal/web/handlers/tablos_test.go +++ b/go-backend/internal/web/handlers/tablos_test.go @@ -493,6 +493,33 @@ func TestFormatCardDateUsesFrenchMonthNames(t *testing.T) { } } +func TestTabloIconPresentationUsesClosestPaletteColor(t *testing.T) { + for _, tt := range []struct { + name string + color string + icon string + }{ + {name: "blue maps to bolt", color: "#3B82F6", icon: "bolt"}, + {name: "green maps to leaf", color: "#22C55E", icon: "leaf"}, + {name: "purple maps to gem", color: "#A855F7", icon: "gem"}, + {name: "red maps to flame", color: "#EF4444", icon: "flame"}, + {name: "yellow maps to star", color: "#EAB308", icon: "star"}, + {name: "indigo maps to compass", color: "#6366F1", icon: "compass"}, + {name: "pink maps to heart", color: "#EC4899", icon: "heart"}, + {name: "teal maps to waves", color: "#14B8A6", icon: "waves"}, + {name: "orange maps to sun", color: "#F97316", icon: "sun"}, + {name: "cyan maps to sparkles", color: "#06B6D4", icon: "sparkles"}, + {name: "nearby blue still maps to bolt", color: "#4F86F7", icon: "bolt"}, + } { + t.Run(tt.name, func(t *testing.T) { + icon, _, _, _ := tabloIconPresentation(tt.color) + if icon != tt.icon { + t.Fatalf("expected icon %q for color %q, got %q", tt.icon, tt.color, icon) + } + }) + } +} + func TestGetTablosPageGridUsesProjectCardMarkup(t *testing.T) { repo := NewInMemoryAuthRepository() handler := NewAuthHandler(repo) diff --git a/go-backend/internal/web/ui/button.css b/go-backend/internal/web/ui/button.css index 07f045b..95fb45e 100644 --- a/go-backend/internal/web/ui/button.css +++ b/go-backend/internal/web/ui/button.css @@ -1,7 +1,7 @@ .ui-button { align-items: center; border: 0; - border-radius: 0.7rem; + border-radius: 0.35rem; cursor: pointer; display: inline-flex; font-weight: 600; diff --git a/go-backend/internal/web/views/dashboard_components_test.go b/go-backend/internal/web/views/dashboard_components_test.go index 3be1984..7d67bd5 100644 --- a/go-backend/internal/web/views/dashboard_components_test.go +++ b/go-backend/internal/web/views/dashboard_components_test.go @@ -60,6 +60,29 @@ func TestOverviewProjectsSectionRendersColorAndEditAction(t *testing.T) { } } +func TestTabloListRowRendersLeafIconKind(t *testing.T) { + component := TabloListRow(TabloCardView{ + ID: "11111111-1111-1111-1111-111111111111", + Name: "Palette", + Color: "#22C55E", + StatusLabel: "À faire", + StatusTone: "info", + Progress: 0, + ProgressLabel: "0%", + CreatedAtLabel: "10 mai 2026", + DeleteRequestURL: "/tablos/11111111-1111-1111-1111-111111111111", + EditRequestURL: "/tablos/11111111-1111-1111-1111-111111111111/edit", + IconKind: "leaf", + Initial: "P", + }) + + html := renderViewToString(t, component) + + if !strings.Contains(html, ``) { + t.Fatalf("expected leaf icon markup, got %q", html) + } +} + func TestTabloListRowDoesNotRenderSpacerBetweenEditAndDelete(t *testing.T) { component := TabloListRow(TabloCardView{ ID: "11111111-1111-1111-1111-111111111111", diff --git a/go-backend/internal/web/views/home.go b/go-backend/internal/web/views/home.go index 2818aa3..a7de2eb 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -5,9 +5,10 @@ import ( "strings" "time" - "github.com/a-h/templ" tablomodel "xtablo-backend/internal/tablos" "xtablo-backend/internal/web/dates" + + "github.com/a-h/templ" ) const overviewProjectsPreviewLimit = 6 diff --git a/go-backend/internal/web/views/icons.templ b/go-backend/internal/web/views/icons.templ index 19a2373..f5834c6 100644 --- a/go-backend/internal/web/views/icons.templ +++ b/go-backend/internal/web/views/icons.templ @@ -125,6 +125,54 @@ templ SidebarIcon(kind string) { + case "leaf": + + case "flame": + + case "star": + + case "compass": + + case "heart": + + case "waves": + + case "sun": + + case "sparkles": + default: @@ -20,7 +21,7 @@ templ DashboardPageWithMainClass(activePath string, mainClass string, content te
- @DashboardSidebar(activePath) + @DashboardSidebar(activePath, tablos) @DashboardMainContentWithClass(mainClass, content)
@@ -28,7 +29,7 @@ templ DashboardPageWithMainClass(activePath string, mainClass string, content te } templ DashboardNotFoundPage(displayName string, email string) { - @DashboardPage("", NotFoundContent(displayName)) + @DashboardPage("", nil, NotFoundContent(displayName)) } templ DashboardMainContent(content templ.Component) { @@ -41,16 +42,16 @@ templ DashboardMainContentWithClass(mainClass string, content templ.Component) { } -templ DashboardContentSwap(activePath string, content templ.Component) { - @DashboardContentSwapWithMainClass(activePath, "dashboard-main flex-1 overflow-auto", content) +templ DashboardContentSwap(activePath string, tablos []tablomodel.Record, content templ.Component) { + @DashboardContentSwapWithMainClass(activePath, tablos, "dashboard-main flex-1 overflow-auto", content) } -templ DashboardContentSwapWithMainClass(activePath string, mainClass string, content templ.Component) { +templ DashboardContentSwapWithMainClass(activePath string, tablos []tablomodel.Record, mainClass string, content templ.Component) { @DashboardMainContentWithClass(mainClass, content) - @DashboardNavOOB(activePath) + @DashboardNavOOB(activePath, tablos) } -templ DashboardSidebar(activePath string) { +templ DashboardSidebar(activePath string, tablos []tablomodel.Record) { } -templ DashboardNavOOB(activePath string) { +templ DashboardNavOOB(activePath string, tablos []tablomodel.Record) { for _, item := range sidebarPrimaryNavItems(activePath) { @SidebarNavItemOOB(item) } + @SidebarProjectsSectionOOB(tablos) for _, item := range sidebarFooterNavItems(activePath) { @SidebarNavItemOOB(item) } } +templ SidebarProjectsSection(tablos []tablomodel.Record) { + +} + +templ SidebarProjectsSectionOOB(tablos []tablomodel.Record) { + +} + templ SidebarOrganization() { ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -345,7 +336,7 @@ func DashboardSidebar(activePath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -353,7 +344,7 @@ func DashboardSidebar(activePath string) templ.Component { }) } -func DashboardNavOOB(activePath string) templ.Component { +func DashboardNavOOB(activePath string, tablos []tablomodel.Record) 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 { @@ -380,6 +371,10 @@ func DashboardNavOOB(activePath string) templ.Component { return templ_7745c5c3_Err } } + templ_7745c5c3_Err = SidebarProjectsSectionOOB(tablos).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } for _, item := range sidebarFooterNavItems(activePath) { templ_7745c5c3_Err = SidebarNavItemOOB(item).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { @@ -390,6 +385,100 @@ func DashboardNavOOB(activePath string) templ.Component { }) } +func SidebarProjectsSection(tablos []tablomodel.Record) 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_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, 16, "

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, item := range sidebarProjectItems(tablos) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = SidebarProjectItem(item).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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SidebarProjectsSectionOOB(tablos []tablomodel.Record) 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_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, 20, "

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, item := range sidebarProjectItems(tablos) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = SidebarProjectItem(item).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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + func SidebarOrganization() 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 @@ -406,12 +495,12 @@ func SidebarOrganization() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -435,12 +524,12 @@ func OverviewMainContent(displayName string, email string, tablos []TabloCardVie }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var13 := templ.GetChildren(ctx) - if templ_7745c5c3_Var13 == nil { - templ_7745c5c3_Var13 = 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -460,7 +549,7 @@ func OverviewMainContent(displayName string, email string, tablos []TabloCardVie if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -484,9 +573,9 @@ func TasksMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = 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("Tâches", "Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.").Render(ctx, templ_7745c5c3_Buffer) @@ -513,9 +602,9 @@ func TablosMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var15 := templ.GetChildren(ctx) - if templ_7745c5c3_Var15 == nil { - templ_7745c5c3_Var15 = 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("Projets", "Gardez une vue claire sur vos tablos, leur état d'avancement et les prochaines décisions à prendre.").Render(ctx, templ_7745c5c3_Buffer) @@ -542,9 +631,9 @@ func PlanningMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var16 := templ.GetChildren(ctx) - if templ_7745c5c3_Var16 == nil { - templ_7745c5c3_Var16 = 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("Planning", "Visualisez le rythme de l'équipe, les jalons à venir et les arbitrages de charge.").Render(ctx, templ_7745c5c3_Buffer) @@ -571,9 +660,9 @@ func ChatMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var17 := templ.GetChildren(ctx) - if templ_7745c5c3_Var17 == nil { - templ_7745c5c3_Var17 = 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("Discussions", "Retrouvez les conversations importantes, les décisions récentes et les échanges à relancer.").Render(ctx, templ_7745c5c3_Buffer) @@ -600,9 +689,9 @@ func FilesMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = 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 = AppSectionMainContent("Fichiers", "Centralisez les documents utiles, les pièces partagées et les ressources de travail.").Render(ctx, templ_7745c5c3_Buffer) @@ -629,9 +718,9 @@ func FeedbackMainContent() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var19 := templ.GetChildren(ctx) - if templ_7745c5c3_Var19 == nil { - templ_7745c5c3_Var19 = templ.NopComponent + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = 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) @@ -658,38 +747,38 @@ func AppSectionMainContent(title string, description 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_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
Espace de travail

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(title) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, 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: 162, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 182, Col: 14} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(description) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, 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: 163, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 183, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, 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, 23, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -713,25 +802,25 @@ func NotFoundContent(displayName string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var23 := templ.GetChildren(ctx) - if templ_7745c5c3_Var23 == nil { - templ_7745c5c3_Var23 = 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, 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 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
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_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(dashboardGreetingName(displayName)) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, 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: 183, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 203, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, 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, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -755,38 +844,38 @@ func OverviewHeader(displayName string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

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

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

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

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

Founder
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "!

Founder") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -800,7 +889,7 @@ func OverviewHeader(displayName string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -824,12 +913,12 @@ func OverviewActions(actions []quickAction) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var28 := templ.GetChildren(ctx) - if templ_7745c5c3_Var28 == nil { - templ_7745c5c3_Var28 = templ.NopComponent + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -839,7 +928,7 @@ func OverviewActions(actions []quickAction) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -863,12 +952,12 @@ func OverviewProjectsSection(projects []TabloCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var29 := templ.GetChildren(ctx) - if templ_7745c5c3_Var29 == nil { - templ_7745c5c3_Var29 = templ.NopComponent + 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, 32, "

Mes Projets

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

Mes Projets

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -887,7 +976,7 @@ func OverviewProjectsSection(projects []TabloCardView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -895,7 +984,7 @@ func OverviewProjectsSection(projects []TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -923,39 +1012,39 @@ func SeeMoreProjects(hiddenCount int) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var30 := templ.GetChildren(ctx) - if templ_7745c5c3_Var30 == nil { - templ_7745c5c3_Var30 = templ.NopComponent + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if hiddenCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " de plus
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -980,12 +1069,12 @@ func OverviewProjectsScript() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var33 := templ.GetChildren(ctx) - if templ_7745c5c3_Var33 == nil { - templ_7745c5c3_Var33 = templ.NopComponent + templ_7745c5c3_Var35 := templ.GetChildren(ctx) + if templ_7745c5c3_Var35 == nil { + templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1009,12 +1098,12 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var34 := templ.GetChildren(ctx) - if templ_7745c5c3_Var34 == nil { - templ_7745c5c3_Var34 = templ.NopComponent + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

Mes Tâches

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

Mes Tâches

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1024,7 +1113,7 @@ func OverviewTasks(tasks []dashboardTask) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1048,12 +1137,12 @@ func QuickActionCard(action quickAction) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var35 := templ.GetChildren(ctx) - if templ_7745c5c3_Var35 == nil { - templ_7745c5c3_Var35 = templ.NopComponent + templ_7745c5c3_Var37 := templ.GetChildren(ctx) + if templ_7745c5c3_Var37 == nil { + templ_7745c5c3_Var37 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1111,39 +1200,17 @@ func TaskRow(task dashboardTask) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var38 := templ.GetChildren(ctx) - if templ_7745c5c3_Var38 == nil { - templ_7745c5c3_Var38 = templ.NopComponent + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var39 = []any{taskRowClass(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, 45, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var41 = []any{taskCheckClass(task.Completed)} + var templ_7745c5c3_Var41 = []any{taskRowClass(task.Completed)} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var41...) 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_Var43 string - templ_7745c5c3_Var43, 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: 338, Col: 18} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var44 = []any{"task-project-badge " + projectAccentClass(task.ProjectHue)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...) - 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_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var44).String()) + templ_7745c5c3_Var45, 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: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 358, Col: 18} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) + _, 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, 52, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, 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: 341, Col: 28} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + var templ_7745c5c3_Var46 = []any{"task-project-badge " + projectAccentClass(task.ProjectHue)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var46...) 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, 57, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(task.Date) + templ_7745c5c3_Var48, 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: 344, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 361, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) 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, 59, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var49 = []any{"task-status " + toneClass(task.StatusTone)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var49...) + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, 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: 363, Col: 50} + } + _, 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, 56, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var49).String()) + templ_7745c5c3_Var50, 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: 364, Col: 39} + } + _, 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 = []any{"task-status " + toneClass(task.StatusTone)} + 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, 62, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(task.Status) + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, 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: 347, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 367, Col: 75} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) 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, 64, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1299,69 +1388,69 @@ func SidebarNavItem(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var52 := templ.GetChildren(ctx) - if templ_7745c5c3_Var52 == nil { - templ_7745c5c3_Var52 = templ.NopComponent + templ_7745c5c3_Var54 := templ.GetChildren(ctx) + if templ_7745c5c3_Var54 == nil { + templ_7745c5c3_Var54 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var53 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var53...) + var templ_7745c5c3_Var55 = []any{sidebarNavItemClass(item.Active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var55...) 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 } @@ -1406,69 +1495,69 @@ func SidebarNavItemOOB(item sidebarNavItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var59 := templ.GetChildren(ctx) - if templ_7745c5c3_Var59 == nil { - templ_7745c5c3_Var59 = templ.NopComponent + templ_7745c5c3_Var61 := templ.GetChildren(ctx) + if templ_7745c5c3_Var61 == nil { + templ_7745c5c3_Var61 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var60 = []any{sidebarNavItemClass(item.Active)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var60...) + var templ_7745c5c3_Var62 = []any{sidebarNavItemClass(item.Active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var62...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1513,25 +1602,25 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var66 := templ.GetChildren(ctx) - if templ_7745c5c3_Var66 == nil { - templ_7745c5c3_Var66 = templ.NopComponent + templ_7745c5c3_Var68 := templ.GetChildren(ctx) + if templ_7745c5c3_Var68 == nil { + templ_7745c5c3_Var68 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1539,20 +1628,20 @@ func SidebarProjectItem(item sidebarProjectItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var68 string - templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(item.Label) + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, 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: 382, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/dashboard_components.templ`, Line: 402, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/views/dashboard_components_test.go b/go-backend/internal/web/views/dashboard_components_test.go index 7d67bd5..4a9ce94 100644 --- a/go-backend/internal/web/views/dashboard_components_test.go +++ b/go-backend/internal/web/views/dashboard_components_test.go @@ -16,7 +16,7 @@ func TestOverviewProjectsFromTablosCarriesColorAndEditURL(t *testing.T) { record := tablomodel.Record{ ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Name: "Palette", - Color: "#3B82F6", + Color: "#22C55E", Status: tablomodel.StatusTodo, CreatedAt: time.Date(2026, time.May, 10, 9, 0, 0, 0, time.UTC), } @@ -27,7 +27,7 @@ func TestOverviewProjectsFromTablosCarriesColorAndEditURL(t *testing.T) { } project := projects[0] - if project.Color != "#3B82F6" { + if project.Color != "#22C55E" { t.Fatalf("expected color to be preserved, got %q", project.Color) } if project.EditRequestURL != "/tablos/11111111-1111-1111-1111-111111111111/edit" { @@ -36,13 +36,16 @@ func TestOverviewProjectsFromTablosCarriesColorAndEditURL(t *testing.T) { if project.CardDateLabel != "10 mai 2026" { t.Fatalf("expected French card date label, got %q", project.CardDateLabel) } + if project.IconKind != "leaf" { + t.Fatalf("expected color presentation icon to be preserved, got %q", project.IconKind) + } } func TestOverviewProjectsSectionRendersColorAndEditAction(t *testing.T) { record := tablomodel.Record{ ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Name: "Palette", - Color: "#3B82F6", + Color: "#22C55E", Status: tablomodel.StatusTodo, CreatedAt: time.Date(2026, time.May, 10, 9, 0, 0, 0, time.UTC), } @@ -50,9 +53,10 @@ func TestOverviewProjectsSectionRendersColorAndEditAction(t *testing.T) { html := renderViewToString(t, OverviewProjectsSection(OverviewProjectsFromTablos([]tablomodel.Record{record}))) for _, want := range []string{ - `style="--project-color:#3B82F6;"`, + `style="--project-color:#22C55E;"`, `aria-label="Modifier le projet"`, `hx-get="/tablos/11111111-1111-1111-1111-111111111111/edit"`, + ``, } { if !strings.Contains(html, want) { t.Fatalf("expected %q in %q", want, html) @@ -83,6 +87,36 @@ func TestTabloListRowRendersLeafIconKind(t *testing.T) { } } +func TestSidebarProjectItemsUsesFirstFourRealTablos(t *testing.T) { + tablos := []tablomodel.Record{ + {ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Name: "Alpha", Color: "#3B82F6"}, + {ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Name: "Beta", Color: "#22C55E"}, + {ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), Name: "Gamma", Color: "#A855F7"}, + {ID: uuid.MustParse("44444444-4444-4444-4444-444444444444"), Name: "Delta", Color: "#EF4444"}, + {ID: uuid.MustParse("55555555-5555-5555-5555-555555555555"), Name: "Epsilon", Color: "#EAB308"}, + } + + items := sidebarProjectItems(tablos) + if len(items) != 4 { + t.Fatalf("expected 4 sidebar items, got %d", len(items)) + } + + for i, want := range []struct { + href string + label string + icon string + }{ + {href: "/tablos/11111111-1111-1111-1111-111111111111", label: "Alpha", icon: "bolt"}, + {href: "/tablos/22222222-2222-2222-2222-222222222222", label: "Beta", icon: "leaf"}, + {href: "/tablos/33333333-3333-3333-3333-333333333333", label: "Gamma", icon: "gem"}, + {href: "/tablos/44444444-4444-4444-4444-444444444444", label: "Delta", icon: "flame"}, + } { + if items[i].Href != want.href || items[i].Label != want.label || items[i].Icon != want.icon { + t.Fatalf("item %d = %#v, want href=%q label=%q icon=%q", i, items[i], want.href, want.label, want.icon) + } + } +} + func TestTabloListRowDoesNotRenderSpacerBetweenEditAndDelete(t *testing.T) { component := TabloListRow(TabloCardView{ ID: "11111111-1111-1111-1111-111111111111", diff --git a/go-backend/internal/web/views/home.go b/go-backend/internal/web/views/home.go index a7de2eb..e190716 100644 --- a/go-backend/internal/web/views/home.go +++ b/go-backend/internal/web/views/home.go @@ -7,6 +7,7 @@ import ( tablomodel "xtablo-backend/internal/tablos" "xtablo-backend/internal/web/dates" + "xtablo-backend/internal/web/tabloicons" "github.com/a-h/templ" ) @@ -112,6 +113,7 @@ func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView { projects := make([]TabloCardView, 0, len(tablos)) for _, tablo := range tablos { statusLabel, statusTone, progress := overviewProjectStatus(tablo.Status) + presentation := tabloicons.ForColor(tablo.Color) projects = append(projects, TabloCardView{ ID: tablo.ID.String(), Name: tablo.Name, @@ -119,8 +121,9 @@ func OverviewProjectsFromTablos(tablos []tablomodel.Record) []TabloCardView { Status: string(tablo.Status), StatusLabel: statusLabel, StatusTone: statusTone, + IconKind: presentation.Icon, Initial: projectInitial(tablo.Name), - Accent: overviewProjectAccent(tablo.Name), + Accent: presentation.Accent, CardDateLabel: dates.FormatFrenchDate(tablo.CreatedAt), Progress: progress, ProgressLabel: progressPercentLabel(progress), @@ -163,13 +166,21 @@ func sidebarPrimaryNavItems(activePath string) []sidebarNavItem { } } -func sidebarProjectItems() []sidebarProjectItem { - return []sidebarProjectItem{ - {Href: "/tablos/hello", Label: "Hello", Icon: "bolt"}, - {Href: "/tablos/atelier", Label: "Atelier Produit", Icon: "gem"}, - {Href: "/tablos/arthur", Label: "Arthur Belleville", Icon: "bolt"}, - {Href: "/tablos/equipe", Label: "Equipe Design", Icon: "bolt"}, +func sidebarProjectItems(tablos []tablomodel.Record) []sidebarProjectItem { + limit := len(tablos) + if limit > 4 { + limit = 4 } + + items := make([]sidebarProjectItem, 0, limit) + for _, tablo := range tablos[:limit] { + items = append(items, sidebarProjectItem{ + Href: fmt.Sprintf("/tablos/%s", tablo.ID), + Label: tablo.Name, + Icon: tabloicons.ForColor(tablo.Color).Icon, + }) + } + return items } func sidebarFooterNavItems(activePath string) []sidebarNavItem { @@ -233,17 +244,6 @@ func overviewProjectStatus(status tablomodel.Status) (string, string, int) { } } -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 == "" { diff --git a/go-backend/internal/web/views/tablos.templ b/go-backend/internal/web/views/tablos.templ index 589ea28..c943e63 100644 --- a/go-backend/internal/web/views/tablos.templ +++ b/go-backend/internal/web/views/tablos.templ @@ -189,7 +189,11 @@ templ TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) {
- { tablo.Initial } + if tablo.IconKind != "" { + @ActionIcon(tablo.IconKind) + } else { + { tablo.Initial } + }

{ tablo.Name }

diff --git a/go-backend/internal/web/views/tablos_templ.go b/go-backend/internal/web/views/tablos_templ.go index 7598de5..68af53a 100644 --- a/go-backend/internal/web/views/tablos_templ.go +++ b/go-backend/internal/web/views/tablos_templ.go @@ -569,33 +569,48 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, 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: 192, Col: 25} + if tablo.IconKind != "" { + templ_7745c5c3_Err = ActionIcon(tablo.IconKind).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, 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: 195, Col: 26} + } + _, 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, 35, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, 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, 34, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, 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: 194, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 198, Col: 19} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -603,46 +618,46 @@ func TabloGridCardWithAttrs(tablo TabloCardView, attrs templ.Attributes) templ.C if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, 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: 198, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 202, Col: 30} } _, 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, 37, "
Progression: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
Progression: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, 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: 203, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 207, Col: 33} } _, 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, 38, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -671,33 +686,33 @@ func TabloListRow(tablo TabloCardView) templ.Component { templ_7745c5c3_Var27 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
svg]:w-4 [&>svg]:h-4\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">
svg]:w-4 [&>svg]:h-4\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -705,20 +720,20 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, 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: 219, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 223, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -729,7 +744,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -740,39 +755,39 @@ func TabloListRow(tablo TabloCardView) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, 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: 231, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 235, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string templ_7745c5c3_Var33, 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: 239, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 243, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -784,7 +799,7 @@ func TabloListRow(tablo TabloCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -845,7 +860,7 @@ func InitProjectFilterScript() templ.Component { templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -874,7 +889,7 @@ func TabloListHead() templ.Component { templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "ProjetStatutCréé leProgression") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "ProjetStatutCréé leProgression") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -934,51 +949,51 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { templ_7745c5c3_Var38 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if vm.ErrorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var41 string templ_7745c5c3_Var41, 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: 335, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 339, Col: 112} } _, 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, 56, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1015,33 +1030,33 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
Annuler") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"ui-button ui-button-solid ui-button-neutral ui-button-md\">Annuler") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1054,7 +1069,7 @@ func CreateTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1115,7 +1130,7 @@ func EditTabloColorField(vm TablosPageViewModel) templ.Component { templ_7745c5c3_Var45 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1134,20 +1149,20 @@ func EditTabloColorField(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"ui-input tablo-color-picker\" oninput=\"document.getElementById('edit-tablo-color').value=this.value\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1176,64 +1191,64 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { templ_7745c5c3_Var47 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if vm.ErrorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var51 string templ_7745c5c3_Var51, 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: 425, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tablos.templ`, Line: 429, Col: 112} } _, 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, 69, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1261,33 +1276,33 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
Annuler") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" hx-target=\"#app-main-content\" hx-swap=\"outerHTML\" hx-push-url=\"true\" class=\"ui-button ui-button-solid ui-button-neutral ui-button-md\">Annuler") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1300,7 +1315,7 @@ func EditTabloModalBody(vm TablosPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css index bc2a6a9..9b89ab0 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -1909,6 +1909,11 @@ td.text-right .borderless-icon-button.ui-icon-button-ghost.ui-icon-button-danger width: 3rem; } +.project-avatar > svg { + height: 1.25rem; + width: 1.25rem; +} + .project-list-icon { background: var(--project-color, var(--color-project-fallback)); color: var(--color-text-inverse); -- 2.45.2 From ef7ccd8c6f3412c44503f1304f58a58e7f3f6b83 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 18:25:18 +0200 Subject: [PATCH 032/546] Add go-backend tasks and etapes design spec --- ...26-05-10-go-backend-tasks-etapes-design.md | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md diff --git a/docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md b/docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md new file mode 100644 index 0000000..534b7e1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md @@ -0,0 +1,338 @@ +# Go Backend Tasks And Etapes Design + +**Date:** 2026-05-10 + +**Goal** + +Build the first real tasks vertical slice in `go-backend`: owner-scoped storage, SQL-backed CRUD for both tasks and etapes, and a server-rendered `/tasks` page that can create, list, update, and delete them. + +**Scope** + +- Work exclusively in `go-backend`. +- Add persistent SQL schema for tasks and etapes. +- Represent etapes inside the same table as tasks. +- Support owner-only CRUD for: + - regular tasks + - etapes +- Support updating a task or etape with: + - title + - description + - status + - due date + - parent etape +- Render a real `/tasks` page instead of placeholder content. +- Keep the page server-rendered with HTMX-driven form/modal flows. +- Build the runtime-only "Sans etape" grouping in the view layer rather than storing it in the database. + +**Out of Scope** + +- RBAC, collaborators, tablo sharing, or organization-level permissions +- Drag and drop +- Reordering tasks or etapes +- Assignees +- Comments, attachments, or activity history +- Separate API-only JSON endpoints +- Persisting a synthetic "Sans etape" record +- Single-task detail pages outside the `/tasks` page workflow + +**Architecture** + +The feature should live entirely inside the existing Go rewrite stack: + +- schema updates in `go-backend/internal/db/schema.sql` +- sqlc statements in `go-backend/internal/db/queries.sql` +- repository methods in `go-backend/internal/db/repository.go` +- task domain types in a new `go-backend/internal/tasks/` package +- HTTP handlers in `go-backend/internal/web/handlers/` +- `templ` views in `go-backend/internal/web/views/` +- route registration in `go-backend/router.go` + +The current repository abstraction is broader than auth now, so it should be expanded or renamed to cover task persistence cleanly instead of treating task CRUD as an auth concern. + +The `/tasks` page remains server-rendered. HTMX should be used to swap page content and modal fragments, not to introduce a separate SPA data layer. + +**Recommended Data Model** + +Use a single self-referential `public.tasks` table. + +Columns: + +- `id uuid primary key` +- `tablo_id uuid not null references public.tablos(id) on delete cascade` +- `owner_id uuid not null references public.users(id) on delete cascade` +- `title text not null` +- `description text not null default ''` +- `status text not null` +- `is_etape boolean not null default false` +- `parent_task_id uuid null references public.tasks(id) on delete set null` +- `due_date date null` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` +- `deleted_at timestamptz null` + +Suggested indexes: + +- active tablo task reads: + - `(owner_id, tablo_id, deleted_at)` +- etape grouping: + - `(tablo_id, is_etape, deleted_at)` +- child lookup: + - `(parent_task_id)` +- due date filtering/sorting: + - `(tablo_id, due_date)` with active rows preference if needed later + +This table models the product rules directly: + +- an etape is a row where `is_etape = true` +- a regular task is a row where `is_etape = false` +- a task without parent etape has `parent_task_id = null` +- "Sans etape" is built at runtime from those parentless regular tasks + +**Data Invariants** + +Allowed status values: + +- `todo` +- `in_progress` +- `in_review` +- `done` + +Enforce these invariants: + +- `status` must be one of the four allowed values +- an etape cannot itself belong to another etape +- `parent_task_id`, when set, must point to an active row with `is_etape = true` +- parent and child must share the same `tablo_id` +- parent and child must share the same `owner_id` +- normal list queries must exclude rows where `deleted_at is not null` + +Implementation notes: + +- keep `status` as plain text in Postgres for consistency with the current `go-backend` schema style +- enforce allowed status values with a `CHECK` +- enforce cross-row parent validation with a trigger, not a `CHECK`, because Postgres `CHECK` constraints should not query sibling rows + +The trigger should reject: + +- an etape with a non-null `parent_task_id` +- a task pointing to a parent in another tablo +- a task pointing to a parent owned by someone else +- a task pointing to a non-etape row +- a task pointing to a soft-deleted parent + +**Route Design** + +- `GET /tasks` + - Returns the full dashboard page for normal requests. + - Returns the swapped `/tasks` content fragment for HTMX requests. +- `POST /tasks` + - Creates either a regular task or an etape from form data. + - Owner is taken from the authenticated session, never from user input. + - On success, returns refreshed `/tasks` content. + - On validation failure, returns modal or inline form content with status `422`. +- `PATCH /tasks/{taskID}` + - Updates title, description, status, due date, and parent etape. + - Must be owner-scoped. + - On success, returns refreshed `/tasks` content or updated fragment. +- `DELETE /tasks/{taskID}` + - Soft-deletes a regular task or an etape. + - Must be owner-scoped. +- `GET /tasks/{taskID}/edit` + - Returns an edit form/modal fragment for HTMX. +- Optional helper fragments if the view composition benefits from them: + - `GET /tasks/new` + - `GET /tasks/new-etape` + +**Mutation Semantics** + +Create: + +- regular task: + - `is_etape = false` + - `parent_task_id` may be null +- etape: + - `is_etape = true` + - `parent_task_id = null` + +Update: + +- allowed editable fields: + - `title` + - `description` + - `status` + - `due_date` + - `parent_task_id` +- for etapes: + - `parent_task_id` must remain null +- for regular tasks: + - `parent_task_id` may be null or point to an etape + +Delete: + +- regular task: + - soft-delete the row +- etape: + - soft-delete the etape + - clear `parent_task_id` on its active child tasks so they fall back into runtime "Sans etape" + +That child-rehoming should happen inside the same repository operation or database transaction so the page never renders a broken intermediate state. + +**Repository Design** + +Add explicit repository methods for task work, parallel to the current tablo methods. + +Minimum methods: + +- create task +- update task +- soft delete task +- list tasks for owner across tablos +- list tasks for one owner-scoped tablo +- fetch one owner-scoped task +- clear children for a deleted etape + +List semantics: + +- always scope by `owner_id` +- always exclude `deleted_at is not null` +- return both etapes and regular tasks for the page +- preserve deterministic ordering + +Suggested ordering for per-tablo reads: + +- etapes first by `created_at` or a future explicit position +- then regular tasks by: + - grouped under their parent etape in Go + - or sorted by `parent_task_id nulls first, created_at desc` and regrouped in Go + +The repository should own transaction boundaries for etape deletion plus child-parent clearing. + +**Handler Design** + +Handlers should follow the current `tablos` pattern: + +- resolve the authenticated user first +- reject unauthenticated requests with redirect to `/login` +- scope every repo call by that owner +- re-render server HTML on success or validation failure + +Validation rules: + +- title required +- status required and must be one of the four allowed values +- malformed UUIDs return `400` +- etapes cannot have a parent +- parent, when present, must be a valid owner-scoped etape in the same tablo + +Error mapping: + +- `401` when no session +- `400` for malformed ids or invalid HTTP payload shape +- `422` for user-correctable validation failures +- `404` when the requested task does not exist for that owner +- `500` for storage or rendering failures + +**View And Page Behavior** + +Replace the placeholder `TasksMainContent()` with a real tasks page view model. + +The page should group rows by tablo. + +Inside each tablo: + +- render etape sections for rows where `is_etape = true` +- render child tasks beneath their parent etape +- render a synthetic "Sans etape" section only when parentless regular tasks exist + +Each displayed task row should support: + +- title +- optional description preview +- status indicator or control +- due date when present +- parent etape display when relevant +- edit and delete actions + +Each displayed etape row or section should support: + +- title +- optional description +- status +- optional due date +- edit and delete actions +- nested child tasks + +The page should remain intentionally simple for this slice. No drag-and-drop, no reordering, and no client-side state model beyond HTMX interactions. + +**HTMX Response Strategy** + +Normal requests return the full dashboard page. + +HTMX requests return only the relevant fragment: + +- `/tasks` main content swap for list refresh +- edit modal fragment +- create modal fragment +- validation-state fragment on `422` + +Fragment boundaries should stay explicit so later work can evolve the `/tasks` page without undoing the current server-rendered structure. + +**Testing Strategy** + +Use TDD across repository and handler layers. + +Minimum repository coverage: + +- create regular task +- create etape +- list excludes soft-deleted rows +- owner scoping is enforced +- parent etape must belong to same owner +- parent etape must belong to same tablo +- etape cannot point to a parent +- deleting a regular task soft-deletes it +- deleting an etape clears `parent_task_id` for active children + +Minimum handler coverage: + +- authenticated `GET /tasks` renders the real tasks page +- HTMX `GET /tasks` renders the content fragment +- `POST /tasks` creates a regular task +- `POST /tasks` creates an etape +- `POST /tasks` rejects invalid parent selection +- `PATCH /tasks/{taskID}` updates title +- `PATCH /tasks/{taskID}` updates description +- `PATCH /tasks/{taskID}` updates status +- `PATCH /tasks/{taskID}` updates due date +- `PATCH /tasks/{taskID}` updates parent etape +- `DELETE /tasks/{taskID}` soft-deletes a regular task +- `DELETE /tasks/{taskID}` soft-deletes an etape and moves children into runtime "Sans etape" + +Minimum rendering assertions: + +- etape sections appear +- tasks under etapes appear in the correct group +- parentless regular tasks appear under "Sans etape" +- owner can only see their own tablos and related tasks + +**Risks And Mitigations** + +- Self-referential task models can silently drift into invalid parent chains. + - Mitigation: enforce parent validity in the database with a dedicated trigger. +- Etape deletion can orphan children in surprising ways. + - Mitigation: make the repository clear `parent_task_id` inside the same transaction. +- The current repository interface is auth-oriented. + - Mitigation: expand or rename it now rather than leaking task persistence into auth-only abstractions. +- The `/tasks` page can become view-heavy quickly. + - Mitigation: keep grouping logic in small helpers and separate page view models from raw DB rows. + +**Acceptance Criteria** + +- `go-backend/internal/db/schema.sql` includes a persistent tasks table and required integrity rules. +- sqlc queries and repository methods exist for owner-scoped task and etape CRUD. +- `/tasks` renders real owner data instead of placeholder content. +- Owners can create, list, update, and delete both tasks and etapes. +- `PATCH /tasks/{taskID}` updates title, description, status, due date, and parent etape. +- Deleting an etape causes its active child tasks to appear in runtime "Sans etape". +- No collaborator or RBAC behavior is introduced. +- Targeted repository and handler tests cover the core flows and invariants. -- 2.45.2 From dd6e5b7d64a120c692b34956d5e6fea730f19b22 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 18:37:44 +0200 Subject: [PATCH 033/546] Update go-backend tasks docs for assignee support --- .../2026-05-10-go-backend-tasks-etapes.md | 701 ++++++++++++++++++ ...26-05-10-go-backend-tasks-etapes-design.md | 18 +- 2 files changed, 715 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-10-go-backend-tasks-etapes.md diff --git a/docs/superpowers/plans/2026-05-10-go-backend-tasks-etapes.md b/docs/superpowers/plans/2026-05-10-go-backend-tasks-etapes.md new file mode 100644 index 0000000..8878198 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-go-backend-tasks-etapes.md @@ -0,0 +1,701 @@ +# Go Backend Tasks And Etapes 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 `/tasks` vertical slice in `go-backend` with SQL-backed owner-only CRUD for tasks and etapes, optional `assignee_id`, runtime "Sans etape" grouping, and HTMX-driven server-rendered page flows. + +**Architecture:** Extend the existing Go app by adding a small `tasks` domain, a self-referential `public.tasks` table with optional `assignee_id`, sqlc-backed repository methods, owner-scoped handlers, and `templ` views for grouped task rendering. Keep all interactions server-rendered, with HTMX only refreshing the `/tasks` content or modal fragments, and keep "Sans etape" as a pure view concern derived from regular tasks with `parent_task_id = null`. + +**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.tasks` table, optional `assignee_id`, indexes, `CHECK` constraints, and trigger/function for parent validation. +- `go-backend/internal/db/queries.sql` + - Add sqlc queries for owner-scoped create, list, fetch, update, assignee persistence, child-clearing, and soft delete. +- `go-backend/internal/db/repository.go` + - Add Postgres-backed task methods and the transaction for deleting an etape and clearing child `parent_task_id`. +- `go-backend/internal/web/handlers/auth.go` + - Extend the repository interface with task methods and delegate `GetTasksPage()` to real task rendering. +- `go-backend/internal/web/handlers/in_memory_auth_repository.go` + - Add in-memory task storage and behavior for tests. +- `go-backend/internal/web/views/home.go` + - Remove or stop depending on hard-coded task placeholder data if `/tasks` and overview rendering share helpers. +- `go-backend/router.go` + - Register task mutation and fragment routes. +- `go-backend/router_test.go` + - Add full-router coverage for `/tasks` page and mutation flows where end-to-end routing matters. + +**New files to create** + +- `go-backend/internal/tasks/model.go` + - Task and etape domain types, status constants, validation helpers, and list/update inputs, including optional assignee support. +- `go-backend/internal/web/handlers/tasks.go` + - Query-state parsing, form parsing, owner checks, list rendering, and mutation handlers. +- `go-backend/internal/web/handlers/tasks_test.go` + - Focused handler and in-memory repository tests for tasks and etapes. +- `go-backend/internal/web/views/tasks.templ` + - `/tasks` page content, grouped sections, task rows, etape sections, assignee display, and modal/form fragments. +- `go-backend/internal/web/views/tasks_view.go` + - View models and grouping helpers, including runtime "Sans etape" construction. + +**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 'Tasks|InMemoryTasks'` +- `cd go-backend && go test ./...` +- `cd go-backend && just generate` +- `cd go-backend && just build` + +## Chunk 1: Task Domain And In-Memory Contract + +### Task 1: Add failing in-memory repository tests for task and etape behavior + +**Files:** +- Create: `go-backend/internal/web/handlers/tasks_test.go` +- Modify: `go-backend/internal/web/handlers/in_memory_auth_repository.go` + +- [ ] **Step 1: Write the failing tests for create/list/delete task behavior** + +Add focused tests that define the repository contract before production code exists: + +```go +func TestInMemoryTasksListExcludesSoftDeletedRows(t *testing.T) { + repo := NewInMemoryAuthRepository() + user, _ := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + + etape, err := repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: user.ID, + TabloID: mustCreateOwnedTablo(t, repo, user.ID).ID, + Title: "Etape 1", + IsEtape: true, + Status: tasks.StatusTodo, + }) + if err != nil { + t.Fatal(err) + } + + task, err := repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: user.ID, + TabloID: etape.TabloID, + Title: "Task 1", + Status: tasks.StatusTodo, + ParentTaskID: &etape.ID, + }) + if err != nil { + t.Fatal(err) + } + + if err := repo.SoftDeleteTask(context.Background(), task.ID, user.ID); err != nil { + t.Fatal(err) + } + + records, err := repo.ListTasksByTablo(context.Background(), ListTasksByTabloInput{ + OwnerID: user.ID, + TabloID: etape.TabloID, + }) + if err != nil { + t.Fatal(err) + } + + if len(records) != 1 || records[0].ID != etape.ID { + t.Fatalf("expected only etape to remain visible, got %#v", records) + } +} +``` + +- [ ] **Step 2: Write failing tests for etape-specific invariants** + +Add tests for: + +- etape cannot have a parent +- parent must be an etape +- assignee persists when set and clears to null when unset +- deleting an etape clears active child `parent_task_id` +- owner scoping rejects another user + +Example: + +```go +func TestInMemoryDeleteEtapeClearsChildParentID(t *testing.T) { + repo := NewInMemoryAuthRepository() + user, _ := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + tablo := mustCreateOwnedTablo(t, repo, user.ID) + etape := mustCreateEtape(t, repo, user.ID, tablo.ID, "Launch") + child := mustCreateTask(t, repo, user.ID, tablo.ID, etape.ID, "Ship copy") + + if err := repo.SoftDeleteTask(context.Background(), etape.ID, user.ID); err != nil { + t.Fatal(err) + } + + updated, err := repo.GetTaskByID(context.Background(), child.ID, user.ID) + if err != nil { + t.Fatal(err) + } + if updated.ParentTaskID != nil { + t.Fatalf("expected child task to move to Sans etape, got parent %v", *updated.ParentTaskID) + } +} +``` + +- [ ] **Step 3: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|InMemoryTasks'` + +Expected: FAIL with missing task types or missing repository methods. + +- [ ] **Step 4: Add the minimal task domain types and in-memory storage** + +Create `internal/tasks/model.go` with: + +```go +package tasks + +type Status string + +const ( + StatusTodo Status = "todo" + StatusInProgress Status = "in_progress" + StatusInReview Status = "in_review" + StatusDone Status = "done" +) +``` + +Add repository-facing types used by handlers: + +- `TaskRecord` +- `CreateTaskInput` +- `UpdateTaskInput` +- `ListTasksByTabloInput` +- validation helpers such as `ParseStatus` +- optional `AssigneeID *uuid.UUID` on the task record and mutation inputs + +Extend `InMemoryAuthRepository` with: + +- `tasks map[uuid.UUID]TaskRecord` +- `CreateTask` +- `ListTasksByTablo` +- `GetTaskByID` +- `UpdateTask` +- `SoftDeleteTask` + +- [ ] **Step 5: Re-run the focused tests to verify they pass** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|InMemoryTasks'` + +Expected: PASS for the new in-memory task and etape behavior tests. + +- [ ] **Step 6: Commit** + +```bash +git add go-backend/internal/tasks/model.go go-backend/internal/web/handlers/in_memory_auth_repository.go go-backend/internal/web/handlers/tasks_test.go +git commit -m "feat: add in-memory tasks and etapes support" +``` + +## Chunk 2: SQL Schema, sqlc, And Postgres Repository + +### Task 2: Add the SQL schema for tasks and etapes + +**Files:** +- Modify: `go-backend/internal/db/schema.sql` + +- [ ] **Step 1: Write a failing repository-shaped test comment block or focused TODO test to lock the SQL contract** + +If there is still no dedicated DB integration harness, add a small repository contract test stub in `tasks_test.go` that documents the needed shape: + +```go +func TestTaskRepositoryContractDocumentsOwnerScopedCRUD(t *testing.T) { + t.Skip("Enable once Postgres-backed repository tests exist for go-backend tasks") +} +``` + +The point is to define the target before editing SQL. + +- [ ] **Step 2: Run the focused tests to keep the failure visible** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|InMemoryTasks'` + +Expected: existing tests still pass, and the repository contract work remains unimplemented in Postgres. + +- [ ] **Step 3: Add the `public.tasks` table, indexes, and constraints** + +Update `schema.sql` with the minimal approved schema: + +```sql +CREATE TABLE IF NOT EXISTS public.tasks ( + id uuid PRIMARY KEY, + tablo_id uuid NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE, + owner_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + title text NOT NULL, + description text NOT NULL DEFAULT '', + status text NOT NULL CHECK (status IN ('todo', 'in_progress', 'in_review', 'done')), + assignee_id uuid NULL REFERENCES public.users(id) ON DELETE SET NULL, + is_etape boolean NOT NULL DEFAULT false, + parent_task_id uuid NULL REFERENCES public.tasks(id) ON DELETE SET NULL, + due_date date NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CHECK (NOT is_etape OR parent_task_id IS NULL) +); +``` + +Add indexes for: + +- `(owner_id, tablo_id, deleted_at)` +- `(assignee_id, deleted_at)` +- `(tablo_id, is_etape, deleted_at)` +- `(parent_task_id)` +- `(tablo_id, due_date)` + +- [ ] **Step 4: Add the parent validation function and trigger** + +Implement a trigger function in `schema.sql` that rejects: + +- etape rows with a parent +- parent rows outside the same owner +- parent rows outside the same tablo +- parent rows that are not etapes +- parent rows that are soft-deleted + +- [ ] **Step 5: Regenerate sqlc once the schema compiles** + +Run: `cd go-backend && just generate` + +Expected: PASS with updated generated database types. + +- [ ] **Step 6: Commit** + +```bash +git add go-backend/internal/db/schema.sql go-backend/internal/db/sqlc +git commit -m "feat: add tasks schema and integrity rules" +``` + +### Task 3: Add sqlc queries and Postgres repository methods + +**Files:** +- Modify: `go-backend/internal/db/queries.sql` +- Modify: `go-backend/internal/db/repository.go` +- Modify: `go-backend/internal/web/handlers/auth.go` +- Generated: `go-backend/internal/db/sqlc/*` + +- [ ] **Step 1: Add the failing task repository method signatures to the shared interface** + +Extend the repository interface in `auth.go` with: + +```go +CreateTask(ctx context.Context, input CreateTaskInput) (TaskRecord, error) +GetTaskByID(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) (TaskRecord, error) +ListTasksByTablo(ctx context.Context, input ListTasksByTabloInput) ([]TaskRecord, error) +ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]TaskRecord, error) +UpdateTask(ctx context.Context, input UpdateTaskInput) (TaskRecord, error) +SoftDeleteTask(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) error +``` + +- [ ] **Step 2: Run the focused tests to verify the Postgres implementation is still missing** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|InMemoryTasks'` + +Expected: FAIL or compile errors in the Postgres repository until real methods are added. + +- [ ] **Step 3: Add sqlc queries for owner-scoped CRUD** + +Add at minimum: + +- `CreateTask` +- `GetTaskByID` +- `ListTasksByOwner` +- `ListTasksByTablo` +- `UpdateTask` +- `ClearTaskChildrenParent` +- `SoftDeleteTask` + +Example update query shape: + +```sql +-- name: UpdateTask :one +UPDATE public.tasks +SET + title = $3, + description = $4, + status = $5, + due_date = $6, + assignee_id = $7, + parent_task_id = $8, + updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +RETURNING *; +``` + +- [ ] **Step 4: Regenerate sqlc** + +Run: `cd go-backend && just generate` + +Expected: PASS with generated query methods for the new statements. + +- [ ] **Step 5: Implement the Postgres repository methods** + +Add Go mapping code in `repository.go`: + +- trim titles +- default empty description to `""` +- map nullable `due_date`, `deleted_at`, `assignee_id`, and `parent_task_id` +- wrap etape delete in a transaction: + - fetch current row + - if etape, clear active child parents + - soft-delete the target row + +- [ ] **Step 6: Re-run the focused tests to verify compilation and behavior** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|InMemoryTasks'` + +Expected: PASS with repository interface and implementations in sync. + +- [ ] **Step 7: Commit** + +```bash +git add go-backend/internal/db/queries.sql go-backend/internal/db/repository.go go-backend/internal/db/sqlc go-backend/internal/web/handlers/auth.go +git commit -m "feat: add sqlc-backed task repository methods" +``` + +## Chunk 3: Task Page Rendering And Grouping + +### Task 4: Replace the placeholder `/tasks` page with real grouped task content + +**Files:** +- Create: `go-backend/internal/web/views/tasks_view.go` +- Create: `go-backend/internal/web/views/tasks.templ` +- Modify: `go-backend/internal/web/handlers/auth.go` +- Modify: `go-backend/internal/web/views/home.go` + +- [ ] **Step 1: Write a failing handler test for real `/tasks` page rendering** + +Add a test that proves the page is no longer placeholder-only: + +```go +func TestGetTasksPageRendersEtapesAndSansEtapeSections(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, _ := handler.currentUserID(req.Context(), req) + tablo := mustCreateOwnedTablo(t, repo, userID) + etape := mustCreateEtape(t, repo, userID, tablo.ID, "Production") + _ = mustCreateTask(t, repo, userID, tablo.ID, etape.ID, "Cut footage") + _ = mustCreateParentlessTask(t, repo, userID, tablo.ID, "Inbox task") + + pageReq := httptest.NewRequest(http.MethodGet, "/tasks", nil) + pageReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTasksPage().ServeHTTP(rec, pageReq) + + body := rec.Body.String() + for _, want := range []string{"Taches", "Production", "Sans etape", "Inbox task"} { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q, got %q", want, body) + } + } +} +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|GetTasksPage'` + +Expected: FAIL because `/tasks` still renders placeholder content. + +- [ ] **Step 3: Build the task page view model and grouping helpers** + +Implement `tasks_view.go` with helpers to: + +- group rows by tablo +- split etapes from regular tasks +- attach child tasks under their etape +- synthesize a "Sans etape" section from parentless regular tasks +- surface assignee labels in row view models when present +- format due dates and labels for the view + +- [ ] **Step 4: Add the real `templ` page content** + +Implement `tasks.templ` components for: + +- page shell +- tablo section +- etape section +- "Sans etape" section +- task row +- empty state + +- [ ] **Step 5: Update `GetTasksPage()` to load owner data and render the new view** + +Load: + +- current owner +- owner tablos for the sidebar +- owner tasks for grouped rendering + +Render the real task content for both full-page and HTMX requests. + +- [ ] **Step 6: Re-run the focused tests to verify the page rendering passes** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|GetTasksPage'` + +Expected: PASS with grouped task content visible. + +- [ ] **Step 7: Commit** + +```bash +git add go-backend/internal/web/views/tasks_view.go go-backend/internal/web/views/tasks.templ go-backend/internal/web/handlers/auth.go go-backend/internal/web/views/home.go go-backend/internal/web/views/*_templ.go go-backend/internal/web/handlers/tasks_test.go +git commit -m "feat: render grouped tasks page" +``` + +## Chunk 4: Create And Edit Flows + +### Task 5: Add create and edit handlers for tasks and etapes + +**Files:** +- Create: `go-backend/internal/web/handlers/tasks.go` +- Modify: `go-backend/router.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` +- Modify: `go-backend/internal/web/views/tasks.templ` + +- [ ] **Step 1: Write failing handler tests for `POST /tasks` create flows** + +Add tests for: + +- creating a regular task +- creating an etape +- rejecting empty title +- rejecting a non-etape parent +- persisting `assignee_id` when provided + +Example: + +```go +func TestPostTasksCreatesEtape(t *testing.T) { + // Arrange authenticated owner, tablo, form post with is_etape=true + // Assert 200 and page contains the new etape title. +} +``` + +- [ ] **Step 2: Write a failing handler test for `GET /tasks/{taskID}/edit`** + +Assert the returned fragment contains the current title, description, status, parent selector state, and assignee selector state. + +- [ ] **Step 3: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'PostTasks|EditTask|Tasks'` + +Expected: FAIL due to missing routes and handlers. + +- [ ] **Step 4: Implement form parsing and owner-scoped create logic** + +In `tasks.go`, parse: + +- `tablo_id` +- `title` +- `description` +- `status` +- `due_date` +- `assignee_id` +- `parent_task_id` +- `is_etape` + +On success, create the row through the repository and re-render `/tasks`. + +- [ ] **Step 5: Implement the edit fragment handler** + +Load the owner-scoped task and render a fragment with: + +- title field +- description field +- status control +- due date input +- parent etape select for regular tasks only + +- [ ] **Step 6: Re-run the focused tests to verify create and edit pass** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'PostTasks|EditTask|Tasks'` + +Expected: PASS for create and edit-fragment coverage. + +- [ ] **Step 7: Commit** + +```bash +git add go-backend/internal/web/handlers/tasks.go go-backend/router.go go-backend/internal/web/handlers/tasks_test.go go-backend/internal/web/views/tasks.templ go-backend/internal/web/views/*_templ.go +git commit -m "feat: add task and etape create flows" +``` + +### Task 6: Add `PATCH /tasks/{taskID}` for updates + +**Files:** +- Modify: `go-backend/internal/web/handlers/tasks.go` +- Modify: `go-backend/router.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` + +- [ ] **Step 1: Write failing handler tests for each editable field** + +Add coverage for: + +- updating title +- updating description +- updating status +- updating due date +- updating assignee +- updating parent etape + +Example: + +```go +func TestPatchTaskUpdatesParentEtape(t *testing.T) { + // Arrange a task with no parent and a valid etape in the same tablo. + // Submit PATCH /tasks/{id}. + // Assert the refreshed page or fragment shows the updated grouping. +} +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'PatchTask|Tasks'` + +Expected: FAIL because the PATCH route does not exist yet. + +- [ ] **Step 3: Implement `PATCH /tasks/{taskID}`** + +Add handler logic to: + +- parse owner session +- parse and validate target id +- read form payload +- allow `assignee_id` to be set or cleared +- keep etapes parentless +- update the task through the repository +- return refreshed page content or task fragment + +- [ ] **Step 4: Re-run the focused tests to verify the PATCH flow passes** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'PatchTask|Tasks'` + +Expected: PASS for all editable-field update cases. + +- [ ] **Step 5: Commit** + +```bash +git add go-backend/internal/web/handlers/tasks.go go-backend/router.go go-backend/internal/web/handlers/tasks_test.go +git commit -m "feat: add task patch update flow" +``` + +## Chunk 5: Delete Flow, Full-Router Coverage, And Verification + +### Task 7: Add owner-scoped delete behavior for tasks and etapes + +**Files:** +- Modify: `go-backend/internal/web/handlers/tasks.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` +- Modify: `go-backend/router.go` + +- [ ] **Step 1: Write failing delete tests** + +Add tests for: + +- deleting a regular task +- deleting an etape +- child tasks moving into "Sans etape" after etape deletion +- rejecting delete from another owner + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'DeleteTask|Tasks'` + +Expected: FAIL because delete behavior is still missing or incomplete. + +- [ ] **Step 3: Implement `DELETE /tasks/{taskID}`** + +Call the repository `SoftDeleteTask` method and re-render the `/tasks` page. Ensure etape child-parent clearing remains transaction-backed in the repository rather than reproduced in handlers. + +- [ ] **Step 4: Re-run the focused tests to verify they pass** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'DeleteTask|Tasks'` + +Expected: PASS for delete flows. + +- [ ] **Step 5: Commit** + +```bash +git add go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/tasks_test.go go-backend/router.go +git commit -m "feat: add task and etape delete flow" +``` + +### Task 8: Add full-router coverage and run full verification + +**Files:** +- Modify: `go-backend/router_test.go` +- Generated: `go-backend/internal/db/sqlc/*.go` +- Generated: `go-backend/internal/web/views/*_templ.go` + +- [ ] **Step 1: Add end-to-end router tests for `/tasks`** + +Cover: + +- authenticated `GET /tasks` +- HTMX `GET /tasks` +- at least one create flow through the router +- at least one patch flow through the router +- at least one delete flow through the router + +- [ ] **Step 2: Run the focused router tests** + +Run: `cd go-backend && go test ./... -run 'TasksPage|TasksRouter|PatchTask|DeleteTask'` + +Expected: PASS for router-level task coverage. + +- [ ] **Step 3: Regenerate assets and generated code** + +Run: `cd go-backend && just generate` + +Expected: PASS with fresh `templ` and `sqlc` outputs. + +- [ ] **Step 4: Format and run the full test suite** + +Run: `cd go-backend && gofmt -w . && go test ./...` + +Expected: PASS for the full Go suite. + +- [ ] **Step 5: Run build verification** + +Run: `cd go-backend && just build` + +Expected: PASS with a successful Go build and CSS generation. + +- [ ] **Step 6: Manual smoke check** + +Run: `cd go-backend && just run` + +Expected: the app starts, `/tasks` renders real grouped data, create/edit/delete interactions work for the demo owner, and assignee flows behave correctly. + +- [ ] **Step 7: Commit** + +```bash +git add go-backend/router_test.go go-backend/internal/db/sqlc go-backend/internal/web/views/*_templ.go +git commit -m "test: verify go-backend tasks and etapes flows" +``` + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-05-10-go-backend-tasks-etapes.md`. Ready to execute? diff --git a/docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md b/docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md index 534b7e1..bbdf705 100644 --- a/docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md +++ b/docs/superpowers/specs/2026-05-10-go-backend-tasks-etapes-design.md @@ -4,7 +4,7 @@ **Goal** -Build the first real tasks vertical slice in `go-backend`: owner-scoped storage, SQL-backed CRUD for both tasks and etapes, and a server-rendered `/tasks` page that can create, list, update, and delete them. +Build the first real tasks vertical slice in `go-backend`: owner-scoped storage, SQL-backed CRUD for both tasks and etapes, optional assignees via `assignee_id`, and a server-rendered `/tasks` page that can create, list, update, and delete them. **Scope** @@ -19,6 +19,7 @@ Build the first real tasks vertical slice in `go-backend`: owner-scoped storage, - description - status - due date + - assignee - parent etape - Render a real `/tasks` page instead of placeholder content. - Keep the page server-rendered with HTMX-driven form/modal flows. @@ -29,7 +30,6 @@ Build the first real tasks vertical slice in `go-backend`: owner-scoped storage, - RBAC, collaborators, tablo sharing, or organization-level permissions - Drag and drop - Reordering tasks or etapes -- Assignees - Comments, attachments, or activity history - Separate API-only JSON endpoints - Persisting a synthetic "Sans etape" record @@ -63,6 +63,7 @@ Columns: - `title text not null` - `description text not null default ''` - `status text not null` +- `assignee_id uuid null references public.users(id) on delete set null` - `is_etape boolean not null default false` - `parent_task_id uuid null references public.tasks(id) on delete set null` - `due_date date null` @@ -74,6 +75,8 @@ Suggested indexes: - active tablo task reads: - `(owner_id, tablo_id, deleted_at)` +- assignee lookups: + - `(assignee_id, deleted_at)` - etape grouping: - `(tablo_id, is_etape, deleted_at)` - child lookup: @@ -131,7 +134,7 @@ The trigger should reject: - On success, returns refreshed `/tasks` content. - On validation failure, returns modal or inline form content with status `422`. - `PATCH /tasks/{taskID}` - - Updates title, description, status, due date, and parent etape. + - Updates title, description, status, due date, assignee, and parent etape. - Must be owner-scoped. - On success, returns refreshed `/tasks` content or updated fragment. - `DELETE /tasks/{taskID}` @@ -161,6 +164,7 @@ Update: - `description` - `status` - `due_date` + - `assignee_id` - `parent_task_id` - for etapes: - `parent_task_id` must remain null @@ -221,6 +225,7 @@ Validation rules: - title required - status required and must be one of the four allowed values - malformed UUIDs return `400` +- assignee, when present, must reference an existing user row - etapes cannot have a parent - parent, when present, must be a valid owner-scoped etape in the same tablo @@ -250,6 +255,7 @@ Each displayed task row should support: - optional description preview - status indicator or control - due date when present +- assignee when present - parent etape display when relevant - edit and delete actions @@ -259,6 +265,7 @@ Each displayed etape row or section should support: - optional description - status - optional due date +- optional assignee - edit and delete actions - nested child tasks @@ -287,6 +294,7 @@ Minimum repository coverage: - create etape - list excludes soft-deleted rows - owner scoping is enforced +- assignee persists when set and clears to null when unset - parent etape must belong to same owner - parent etape must belong to same tablo - etape cannot point to a parent @@ -304,6 +312,7 @@ Minimum handler coverage: - `PATCH /tasks/{taskID}` updates description - `PATCH /tasks/{taskID}` updates status - `PATCH /tasks/{taskID}` updates due date +- `PATCH /tasks/{taskID}` updates assignee - `PATCH /tasks/{taskID}` updates parent etape - `DELETE /tasks/{taskID}` soft-deletes a regular task - `DELETE /tasks/{taskID}` soft-deletes an etape and moves children into runtime "Sans etape" @@ -332,7 +341,8 @@ Minimum rendering assertions: - sqlc queries and repository methods exist for owner-scoped task and etape CRUD. - `/tasks` renders real owner data instead of placeholder content. - Owners can create, list, update, and delete both tasks and etapes. -- `PATCH /tasks/{taskID}` updates title, description, status, due date, and parent etape. +- Tasks and etapes may have an optional `assignee_id`. +- `PATCH /tasks/{taskID}` updates title, description, status, due date, assignee, and parent etape. - Deleting an etape causes its active child tasks to appear in runtime "Sans etape". - No collaborator or RBAC behavior is introduced. - Targeted repository and handler tests cover the core flows and invariants. -- 2.45.2 From 1a00f843641ee2b0cea5fafe712b5f1a68ec4854 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 21:52:39 +0200 Subject: [PATCH 034/546] Add tasks multi-view dashboard design spec --- ...ckend-tasks-multi-view-dashboard-design.md | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-10-go-backend-tasks-multi-view-dashboard-design.md diff --git a/docs/superpowers/specs/2026-05-10-go-backend-tasks-multi-view-dashboard-design.md b/docs/superpowers/specs/2026-05-10-go-backend-tasks-multi-view-dashboard-design.md new file mode 100644 index 0000000..fcab2e7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-go-backend-tasks-multi-view-dashboard-design.md @@ -0,0 +1,374 @@ +# Go Backend Tasks Multi-View Dashboard Design + +**Date:** 2026-05-10 + +**Goal** + +Replace the current simple `/tasks` CRUD page in `go-backend` with a richer owner-level tasks dashboard that aggregates tasks across every tablo owned by the signed-in user and renders three server-side views: + +- `Tableau` +- `Liste` +- `Roadmap` + +The page should use query params as the source of truth for view state and filtering, while keeping task creation, editing, and deletion inside the existing server-rendered and HTMX-friendly backend architecture. + +**Context** + +The current tasks vertical slice already exists in `go-backend`: + +- owner-scoped task and etape persistence is implemented +- `/tasks` already supports create, edit, update, and delete +- the page is currently rendered as a simple grouped CRUD view + +This work does not redesign the persistence model. It redesigns the `/tasks` page contract, its server-side view shaping, and the related create/edit flows so the route behaves like a personal cross-tablo dashboard. + +**Scope** + +- Work exclusively in `go-backend` +- Replace the current `/tasks` page with a server-rendered dashboard +- Aggregate tasks across all tablos owned by the current user +- Support three server-rendered views on the same route: + - `kanban` + - `list` + - `roadmap` +- Support URL-driven state with query params +- Implement functional filtering for: + - `tablo` + - `assignee` + - `status` +- Extend create and edit forms to include: + - `tablo_id` + - `parent_task_id` + - `assignee_id` + - `due_date` + - `status` + - `title` + - `description` +- Constrain selectable etapes to the chosen tablo during create and edit +- Implement two roadmap sub-modes: + - `week` + - `month` + +**Out Of Scope** + +- Drag and drop +- Client-only tab switching +- Calendar view +- Reordering tasks or etapes +- Persisted synthetic "Sans étape" rows +- New JSON API endpoints +- Collaborator or RBAC expansion beyond owner-only access +- Comments, attachments, activity feeds, or task detail pages + +**Architecture** + +The feature should stay inside the current Go server-rendered stack: + +- HTTP handlers in `go-backend/internal/web/handlers/` +- page and component rendering in `go-backend/internal/web/views/` +- task shaping and task-related helpers in `go-backend/internal/tasks/` or closely related view-model code +- existing repository methods reused or extended only as needed + +The core architectural move is to split `/tasks` rendering into: + +1. one shared page-state parser +2. one shared filtered task dataset +3. three pure view-specific shaping functions + +Recommended shaping functions: + +- `buildKanbanView(...)` +- `buildListView(...)` +- `buildRoadmapView(...)` + +This keeps filtering and ownership logic centralized while isolating presentation-specific grouping rules. + +**Route Contract** + +The route remains `GET /tasks`, but the page state is now controlled by query params. + +Supported query params: + +- `view=kanban|list|roadmap` +- `roadmap_mode=week|month` +- `tablo=` optional, repeatable +- `assignee=` optional, repeatable +- `status=todo|in_progress|in_review|done` optional, repeatable + +Default behavior: + +- missing or invalid `view` falls back to `kanban` +- missing or invalid `roadmap_mode` falls back to `week` +- invalid filter values are ignored rather than breaking the page +- `roadmap_mode` is ignored when `view` is not `roadmap` + +The page should remain shareable and refresh-safe: + +- filter changes submit with `GET /tasks` +- tabs are links that preserve the current applicable filters +- roadmap mode toggles are links or GET forms that preserve filters + +**Data Scope** + +All three views consume the same owner-scoped task set: + +- every active task across every tablo owned by the signed-in user +- every active etape required to label or group those tasks + +This is intentionally not a single-tablo page. `/tasks` is a personal dashboard over the user’s full owned workload. + +**Shared Page Shell** + +The page shell should render: + +- page title +- `Nouvelle tâche` action +- view tabs: + - `Tableau` + - `Liste` + - `Roadmap` +- roadmap sub-mode toggle when `view=roadmap` +- filter controls for: + - tablo + - assignee + - status + +The shell also needs the option data required to render forms and filters: + +- all owner tablos +- all relevant assignee options +- status options +- tablo-scoped etape options for create and edit forms + +**Kanban View Design** + +The kanban view is status-driven and cross-tablo. + +Columns: + +- `À faire` +- `En cours` +- `Vérification` +- `Terminé` + +Each column contains task cards from all owned tablos matching that status. + +Each card should show enough metadata to make a cross-tablo dashboard usable: + +- title +- tablo name +- étape label, or `Sans étape` +- due date if present +- assignee if present + +First pass interaction is explicit-action based: + +- no drag and drop +- status changes happen through edit actions or other explicit server-backed controls + +**List View Design** + +The list view is grouped by status across all owned tablos. + +Groups: + +- `À faire` +- `En cours` +- `Vérification` +- `Terminé` + +Inside each group, tasks are rendered as flat rows rather than nested by tablo or étape. + +Each row should show: + +- title +- tablo +- étape or `Sans étape` +- assignee if present +- due date if present +- status actions and edit/delete access as appropriate + +This view is intentionally a personal workload list rather than a single-tablo breakdown. + +**Roadmap View Design** + +The roadmap view is grouped horizontally by étape lanes and bucketed by due date. + +Lane rules: + +- lanes represent `tablo + étape` +- the lane identity must include the tablo to avoid collisions between same-named etapes in different tablos +- tasks without a parent etape go into a synthetic per-tablo lane: + - `Tablo name / Sans étape` + +Bucket rules: + +- tasks are grouped inside each lane by due date +- tasks without a due date go into a final `Sans date` bucket + +Roadmap sub-modes: + +- `week` + - weekly buckets starting from today, May 10, 2026 +- `month` + - monthly buckets starting from the current month containing May 10, 2026 + +Past-due tasks: + +- tasks with due dates before the current starting point should still remain visible +- they should be folded into the first visible bucket rather than disappearing into an unrendered past section + +This preserves visibility without introducing a separate "past" axis in the first pass. + +**Filter Behavior** + +Filters must apply consistently across all three views. + +Required filters: + +- `tablo` +- `assignee` +- `status` + +Behavior: + +- filters are applied once to the shared task set before view-specific grouping +- switching between `kanban`, `list`, and `roadmap` preserves active filters where possible +- switching roadmap sub-modes preserves active filters + +Invalid filters: + +- unknown tablo IDs are dropped +- unknown assignee IDs are dropped +- unknown status values are dropped + +The page should always normalize back to a valid state instead of failing. + +**Create And Edit Flow** + +Because `/tasks` is now cross-tablo, create and edit forms must include tablo selection. + +Create form fields: + +- `tablo_id` +- `title` +- `description` +- `status` +- `due_date` +- `assignee_id` +- `parent_task_id` + +Edit form fields: + +- `tablo_id` +- `title` +- `description` +- `status` +- `due_date` +- `assignee_id` +- `parent_task_id` + +Tablo and étape coupling: + +- once a tablo is chosen, selectable etapes must be limited to active etapes from that tablo only +- `Sans étape` remains a runtime concept, represented by `parent_task_id = null` + +The chosen tablo and étape combination must be enforced on the server even if the browser submits an invalid combination. + +**Validation Rules** + +The dashboard should reuse the current task invariants and add page-specific validation. + +Required server-side rules: + +- `parent_task_id` must be null or point to an active etape +- `parent_task_id` must belong to the selected `tablo_id` +- a task cannot point to an etape from another tablo +- `assignee_id` must resolve to a valid selectable user +- invalid `view` and `roadmap_mode` values fall back to defaults + +When editing a task: + +- if the selected tablo changes, the selected parent etape must be revalidated against the new tablo +- if the current parent no longer belongs to the selected tablo, the mutation must be rejected + +Delete behavior remains unchanged functionally: + +- delete the task or etape +- re-render the page in the current normalized query state + +**Rendering Strategy** + +The page should keep the current server-driven model: + +- full-page render for normal requests +- content fragment render for HTMX requests where appropriate +- create and edit forms can continue to use modal or inline fragment responses + +The important change is not the transport mechanism. It is the introduction of explicit page-state modeling and isolated per-view presenters. + +Recommended internal page-state model: + +- selected `view` +- selected `roadmap_mode` +- selected tablo filters +- selected assignee filters +- selected status filters + +Recommended shared view-model layers: + +- page shell view model +- create/edit form view model +- `kanban` view model +- `list` view model +- `roadmap` view model + +**Testing** + +Testing should cover both state parsing and view grouping behavior. + +Handler tests: + +- default `/tasks` render +- `view=kanban` +- `view=list` +- `view=roadmap` +- `roadmap_mode=week` +- `roadmap_mode=month` +- filter combinations for: + - tablo + - assignee + - status +- create flow with `tablo_id` +- edit flow with `tablo_id` +- invalid cross-tablo parent étape rejection + +View-model tests where practical: + +- kanban grouping by status across multiple tablos +- list grouping by status across multiple tablos +- roadmap lane identity uses `tablo + étape` +- synthetic `Sans étape` lanes are created per tablo +- `Sans date` bucket is rendered +- overdue tasks are placed in the first visible roadmap bucket + +**Implementation Slices** + +1. Extend `/tasks` query parsing and normalized page-state handling. +2. Build shared filter-option loading for tablos, assignees, and statuses. +3. Introduce a shared owner-level filtered task dataset. +4. Split rendering into `kanban`, `list`, and `roadmap` view-model builders. +5. Replace the current simple `/tasks` view with a page shell and mode-specific renderers. +6. Extend create and edit forms with `tablo_id` and tablo-scoped etape options. +7. Add tests for filtering, grouping, roadmap bucketing, and invalid parent validation. + +**Recommendation** + +Use one `/tasks` route with shared filtering and separate presenter functions for each mode. + +This approach fits the current Go backend architecture best because it: + +- keeps the URL as the source of truth +- avoids duplicated repository and filter logic +- keeps each view’s grouping logic isolated +- preserves server-rendered behavior without introducing a browser-side state machine -- 2.45.2 From 9a92f358e8aeecfa6295a04dc98f6940238369c2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 21:58:48 +0200 Subject: [PATCH 035/546] Add task management features with database schema and handlers Create a new tasks module with full CRUD operations, supporting both regular tasks and etapes (phases). Implements task hierarchy with parent-child relationships, assignees, and due dates. Includes database schema with validation triggers, SQLC query generation, in-memory repository implementation, HTTP handlers, view templates, and comprehensive test coverage. --- go-backend/internal/db/queries.sql | 82 ++++ go-backend/internal/db/repository.go | 177 +++++++ go-backend/internal/db/schema.sql | 77 +++ go-backend/internal/db/seed.sql | 128 +++++ go-backend/internal/db/sqlc/models.go | 16 + go-backend/internal/db/sqlc/querier.go | 7 + go-backend/internal/db/sqlc/queries.sql.go | 300 ++++++++++++ go-backend/internal/tasks/model.go | 76 +++ go-backend/internal/web/handlers/auth.go | 6 +- .../web/handlers/in_memory_auth_repository.go | 174 +++++++ go-backend/internal/web/handlers/tasks.go | 401 ++++++++++++++++ .../internal/web/handlers/tasks_test.go | 447 ++++++++++++++++++ .../internal/web/ui/catalog/catalog_test.go | 2 + go-backend/internal/web/ui/ui_test.go | 67 +++ go-backend/internal/web/views/tasks_view.go | 284 +++++++++++ go-backend/router.go | 4 + go-backend/router_test.go | 98 ++++ 17 files changed, 2343 insertions(+), 3 deletions(-) create mode 100644 go-backend/internal/tasks/model.go create mode 100644 go-backend/internal/web/handlers/tasks.go create mode 100644 go-backend/internal/web/handlers/tasks_test.go create mode 100644 go-backend/internal/web/views/tasks_view.go diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql index 116aff7..ee7b305 100644 --- a/go-backend/internal/db/queries.sql +++ b/go-backend/internal/db/queries.sql @@ -75,6 +75,36 @@ INSERT INTO public.tablos ( ) RETURNING id, owner_id, name, color, status, created_at, updated_at, deleted_at; +-- name: CreateTask :one +INSERT INTO public.tasks ( + id, + tablo_id, + owner_id, + title, + description, + status, + assignee_id, + is_etape, + parent_task_id, + due_date, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + now(), + now() +) +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at; + -- name: ListTablos :many SELECT id, owner_id, name, color, status, created_at, updated_at, deleted_at FROM public.tablos @@ -88,6 +118,29 @@ WHERE owner_id = sqlc.arg(owner_id) ) ORDER BY created_at DESC; +-- name: ListTasksByOwner :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND deleted_at IS NULL +ORDER BY created_at ASC; + +-- name: ListTasksByTablo :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND tablo_id = $2 + AND deleted_at IS NULL +ORDER BY created_at ASC; + +-- name: GetTaskByID :one +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +LIMIT 1; + -- name: UpdateTablo :execrows UPDATE public.tablos SET name = $3, @@ -97,9 +150,38 @@ WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL; +-- name: UpdateTask :one +UPDATE public.tasks +SET + title = $3, + description = $4, + status = $5, + due_date = $6, + assignee_id = $7, + parent_task_id = $8, + updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at; + -- 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; + +-- name: ClearTaskChildrenParent :execrows +UPDATE public.tasks +SET parent_task_id = NULL, updated_at = now() +WHERE parent_task_id = $1 + AND owner_id = $2 + AND deleted_at IS NULL; + +-- name: SoftDeleteTask :execrows +UPDATE public.tasks +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 5c72c3b..fa159fa 100644 --- a/go-backend/internal/db/repository.go +++ b/go-backend/internal/db/repository.go @@ -16,6 +16,7 @@ import ( sqlcdb "xtablo-backend/internal/db/sqlc" tablomodel "xtablo-backend/internal/tablos" + taskmodel "xtablo-backend/internal/tasks" "xtablo-backend/internal/web/handlers" ) @@ -209,6 +210,131 @@ func (r *PostgresAuthRepository) SoftDeleteTablo(ctx context.Context, tabloID uu return nil } +func (r *PostgresAuthRepository) CreateTask(ctx context.Context, input handlers.CreateTaskInput) (handlers.TaskRecord, error) { + row, err := r.queries.CreateTask(ctx, sqlcdb.CreateTaskParams{ + ID: uuid.New(), + TabloID: input.TabloID, + OwnerID: input.OwnerID, + Title: strings.TrimSpace(input.Title), + Description: strings.TrimSpace(input.Description), + Status: string(input.Status), + AssigneeID: nullableUUID(input.AssigneeID), + IsEtape: input.IsEtape, + ParentTaskID: nullableUUID(input.ParentTaskID), + DueDate: nullableDate(input.DueDate), + }) + if err != nil { + return handlers.TaskRecord{}, err + } + return mapTaskRecord(row), nil +} + +func (r *PostgresAuthRepository) ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]handlers.TaskRecord, error) { + rows, err := r.queries.ListTasksByOwner(ctx, ownerID) + if err != nil { + return nil, err + } + + records := make([]handlers.TaskRecord, 0, len(rows)) + for _, row := range rows { + records = append(records, mapTaskRecord(row)) + } + return records, nil +} + +func (r *PostgresAuthRepository) ListTasksByTablo(ctx context.Context, input handlers.ListTasksByTabloInput) ([]handlers.TaskRecord, error) { + rows, err := r.queries.ListTasksByTablo(ctx, sqlcdb.ListTasksByTabloParams{ + OwnerID: input.OwnerID, + TabloID: input.TabloID, + }) + if err != nil { + return nil, err + } + + records := make([]handlers.TaskRecord, 0, len(rows)) + for _, row := range rows { + records = append(records, mapTaskRecord(row)) + } + return records, nil +} + +func (r *PostgresAuthRepository) GetTaskByID(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) (handlers.TaskRecord, error) { + row, err := r.queries.GetTaskByID(ctx, sqlcdb.GetTaskByIDParams{ + ID: taskID, + OwnerID: ownerID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.TaskRecord{}, taskmodel.ErrNotFound + } + return handlers.TaskRecord{}, err + } + return mapTaskRecord(row), nil +} + +func (r *PostgresAuthRepository) UpdateTask(ctx context.Context, input handlers.UpdateTaskInput) (handlers.TaskRecord, error) { + row, err := r.queries.UpdateTask(ctx, sqlcdb.UpdateTaskParams{ + ID: input.ID, + OwnerID: input.OwnerID, + Title: strings.TrimSpace(input.Title), + Description: strings.TrimSpace(input.Description), + Status: string(input.Status), + DueDate: nullableDate(input.DueDate), + AssigneeID: nullableUUID(input.AssigneeID), + ParentTaskID: nullableUUID(input.ParentTaskID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.TaskRecord{}, taskmodel.ErrNotFound + } + return handlers.TaskRecord{}, err + } + return mapTaskRecord(row), nil +} + +func (r *PostgresAuthRepository) SoftDeleteTask(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + queries := r.queries.WithTx(tx) + + task, err := queries.GetTaskByID(ctx, sqlcdb.GetTaskByIDParams{ + ID: taskID, + OwnerID: ownerID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return taskmodel.ErrNotFound + } + return err + } + + if task.IsEtape { + if _, err := queries.ClearTaskChildrenParent(ctx, sqlcdb.ClearTaskChildrenParentParams{ + ParentTaskID: pgtypeUUID(task.ID), + OwnerID: ownerID, + }); err != nil { + return err + } + } + + rows, err := queries.SoftDeleteTask(ctx, sqlcdb.SoftDeleteTaskParams{ + ID: taskID, + OwnerID: ownerID, + }) + if err != nil { + return err + } + if rows == 0 { + return taskmodel.ErrNotFound + } + + return tx.Commit(ctx) +} + func pgtypeTimestamptz(value time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: value, Valid: true} } @@ -235,6 +361,26 @@ func nullableStatus(value *tablomodel.Status) pgtype.Text { return nullableText(string(*value)) } +func nullableUUID(value *uuid.UUID) pgtype.UUID { + if value == nil { + return pgtype.UUID{} + } + return pgtypeUUID(*value) +} + +func pgtypeUUID(value uuid.UUID) pgtype.UUID { + var bytes [16]byte + copy(bytes[:], value[:]) + return pgtype.UUID{Bytes: bytes, Valid: true} +} + +func nullableDate(value *time.Time) pgtype.Date { + if value == nil { + return pgtype.Date{} + } + return pgtype.Date{Time: *value, Valid: true} +} + func mapTabloRecord(row sqlcdb.Tablo) tablomodel.Record { record := tablomodel.Record{ ID: row.ID, @@ -251,3 +397,34 @@ func mapTabloRecord(row sqlcdb.Tablo) tablomodel.Record { } return record } + +func mapTaskRecord(row sqlcdb.Task) handlers.TaskRecord { + record := handlers.TaskRecord{ + ID: row.ID, + OwnerID: row.OwnerID, + TabloID: row.TabloID, + Title: row.Title, + Description: row.Description, + Status: taskmodel.Status(row.Status), + IsEtape: row.IsEtape, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } + if row.AssigneeID.Valid { + assigneeID := uuid.UUID(row.AssigneeID.Bytes) + record.AssigneeID = &assigneeID + } + if row.ParentTaskID.Valid { + parentID := uuid.UUID(row.ParentTaskID.Bytes) + record.ParentTaskID = &parentID + } + if row.DueDate.Valid { + dueDate := row.DueDate.Time + record.DueDate = &dueDate + } + 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 58bc0f7..950f91d 100644 --- a/go-backend/internal/db/schema.sql +++ b/go-backend/internal/db/schema.sql @@ -43,6 +43,83 @@ CREATE INDEX IF NOT EXISTS tablos_owner_created_idx ON public.tablos (owner_id, created_at DESC) WHERE deleted_at IS NULL; +CREATE TABLE IF NOT EXISTS public.tasks ( + id uuid PRIMARY KEY, + tablo_id uuid NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE, + owner_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + title text NOT NULL, + description text NOT NULL DEFAULT '', + status text NOT NULL CHECK (status IN ('todo', 'in_progress', 'in_review', 'done')), + assignee_id uuid NULL REFERENCES public.users(id) ON DELETE SET NULL, + is_etape boolean NOT NULL DEFAULT false, + parent_task_id uuid NULL REFERENCES public.tasks(id) ON DELETE SET NULL, + due_date date NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CHECK (NOT is_etape OR parent_task_id IS NULL) +); + +CREATE INDEX IF NOT EXISTS tasks_owner_tablo_deleted_idx +ON public.tasks (owner_id, tablo_id, deleted_at); + +CREATE INDEX IF NOT EXISTS tasks_assignee_deleted_idx +ON public.tasks (assignee_id, deleted_at); + +CREATE INDEX IF NOT EXISTS tasks_tablo_is_etape_deleted_idx +ON public.tasks (tablo_id, is_etape, deleted_at); + +CREATE INDEX IF NOT EXISTS tasks_parent_task_id_idx +ON public.tasks (parent_task_id); + +CREATE INDEX IF NOT EXISTS tasks_tablo_due_date_idx +ON public.tasks (tablo_id, due_date); + +CREATE OR REPLACE FUNCTION public.validate_task_parent() RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + parent_record public.tasks%ROWTYPE; +BEGIN + IF NEW.parent_task_id IS NULL THEN + RETURN NEW; + END IF; + + IF NEW.is_etape THEN + RAISE EXCEPTION 'Etapes cannot have parent tasks'; + END IF; + + SELECT * + INTO parent_record + FROM public.tasks + WHERE id = NEW.parent_task_id; + + IF NOT FOUND OR parent_record.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'Parent task is invalid'; + END IF; + + IF parent_record.is_etape IS NOT TRUE THEN + RAISE EXCEPTION 'Parent task must be an etape'; + END IF; + + IF parent_record.owner_id <> NEW.owner_id THEN + RAISE EXCEPTION 'Parent task owner must match child owner'; + END IF; + + IF parent_record.tablo_id <> NEW.tablo_id THEN + RAISE EXCEPTION 'Parent task tablo must match child tablo'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS tasks_validate_parent_trigger ON public.tasks; +CREATE TRIGGER tasks_validate_parent_trigger +BEFORE INSERT OR UPDATE ON public.tasks +FOR EACH ROW +EXECUTE FUNCTION public.validate_task_parent(); + CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER diff --git a/go-backend/internal/db/seed.sql b/go-backend/internal/db/seed.sql index 0c59bd6..71417fd 100644 --- a/go-backend/internal/db/seed.sql +++ b/go-backend/internal/db/seed.sql @@ -14,3 +14,131 @@ INSERT INTO auth.users ( now() ) ON CONFLICT (email) DO NOTHING; + +INSERT INTO public.tablos ( + id, + owner_id, + name, + color, + status, + created_at, + updated_at, + deleted_at +) VALUES ( + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Démo produit', + '#3B82F6', + 'in_progress', + now(), + now(), + NULL +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.tasks ( + id, + tablo_id, + owner_id, + title, + description, + status, + assignee_id, + is_etape, + parent_task_id, + due_date, + created_at, + updated_at, + deleted_at +) VALUES +( + '33333333-3333-3333-3333-333333333331', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Préparation', + 'Étape de cadrage et de préparation de la démo.', + 'in_progress', + '11111111-1111-1111-1111-111111111111', + TRUE, + NULL, + '2026-05-20', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333332', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Livraison', + 'Étape finale avant mise en ligne de la démo.', + 'todo', + NULL, + TRUE, + NULL, + '2026-05-30', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333341', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Valider le périmètre', + 'Lister les fonctionnalités visibles dans la démo.', + 'done', + '11111111-1111-1111-1111-111111111111', + FALSE, + '33333333-3333-3333-3333-333333333331', + '2026-05-15', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333342', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Préparer les captures', + 'Assembler les écrans et les textes de présentation.', + 'in_progress', + '11111111-1111-1111-1111-111111111111', + FALSE, + '33333333-3333-3333-3333-333333333331', + '2026-05-18', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333343', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Relire le message d’annonce', + 'Vérifier le texte envoyé avec la démo.', + 'todo', + NULL, + FALSE, + NULL, + '2026-05-19', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333344', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Envoyer la démo', + 'Partager la version finale aux premiers retours.', + 'todo', + '11111111-1111-1111-1111-111111111111', + FALSE, + '33333333-3333-3333-3333-333333333332', + '2026-05-30', + now(), + now(), + NULL +) +ON CONFLICT (id) DO NOTHING; diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go index d8ea423..0fc8a7c 100644 --- a/go-backend/internal/db/sqlc/models.go +++ b/go-backend/internal/db/sqlc/models.go @@ -38,6 +38,22 @@ type Tablo struct { DeletedAt pgtype.Timestamptz `db:"deleted_at"` } +type Task struct { + ID uuid.UUID `db:"id"` + TabloID uuid.UUID `db:"tablo_id"` + OwnerID uuid.UUID `db:"owner_id"` + Title string `db:"title"` + Description string `db:"description"` + Status string `db:"status"` + AssigneeID pgtype.UUID `db:"assignee_id"` + IsEtape bool `db:"is_etape"` + ParentTaskID pgtype.UUID `db:"parent_task_id"` + DueDate pgtype.Date `db:"due_date"` + 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 c60deef..2492711 100644 --- a/go-backend/internal/db/sqlc/querier.go +++ b/go-backend/internal/db/sqlc/querier.go @@ -11,16 +11,23 @@ import ( ) type Querier interface { + ClearTaskChildrenParent(ctx context.Context, arg ClearTaskChildrenParentParams) (int64, error) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) CreateSession(ctx context.Context, arg CreateSessionParams) error CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo, error) + CreateTask(ctx context.Context, arg CreateTaskParams) (Task, 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) + GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo, error) + ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]Task, error) + ListTasksByTablo(ctx context.Context, arg ListTasksByTabloParams) ([]Task, error) SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams) (int64, error) + SoftDeleteTask(ctx context.Context, arg SoftDeleteTaskParams) (int64, error) UpdateTablo(ctx context.Context, arg UpdateTabloParams) (int64, error) + UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, 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 85adafd..61f6951 100644 --- a/go-backend/internal/db/sqlc/queries.sql.go +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -12,6 +12,27 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const clearTaskChildrenParent = `-- name: ClearTaskChildrenParent :execrows +UPDATE public.tasks +SET parent_task_id = NULL, updated_at = now() +WHERE parent_task_id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +` + +type ClearTaskChildrenParentParams struct { + ParentTaskID pgtype.UUID `db:"parent_task_id"` + OwnerID uuid.UUID `db:"owner_id"` +} + +func (q *Queries) ClearTaskChildrenParent(ctx context.Context, arg ClearTaskChildrenParentParams) (int64, error) { + result, err := q.db.Exec(ctx, clearTaskChildrenParent, arg.ParentTaskID, arg.OwnerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const createAuthUser = `-- name: CreateAuthUser :one INSERT INTO auth.users ( id, @@ -136,6 +157,82 @@ func (q *Queries) CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo return i, err } +const createTask = `-- name: CreateTask :one +INSERT INTO public.tasks ( + id, + tablo_id, + owner_id, + title, + description, + status, + assignee_id, + is_etape, + parent_task_id, + due_date, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + now(), + now() +) +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +` + +type CreateTaskParams struct { + ID uuid.UUID `db:"id"` + TabloID uuid.UUID `db:"tablo_id"` + OwnerID uuid.UUID `db:"owner_id"` + Title string `db:"title"` + Description string `db:"description"` + Status string `db:"status"` + AssigneeID pgtype.UUID `db:"assignee_id"` + IsEtape bool `db:"is_etape"` + ParentTaskID pgtype.UUID `db:"parent_task_id"` + DueDate pgtype.Date `db:"due_date"` +} + +func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, createTask, + arg.ID, + arg.TabloID, + arg.OwnerID, + arg.Title, + arg.Description, + arg.Status, + arg.AssigneeID, + arg.IsEtape, + arg.ParentTaskID, + arg.DueDate, + ) + var i Task + err := row.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + const deleteSessionByToken = `-- name: DeleteSessionByToken :execrows DELETE FROM auth.sessions WHERE session_token = $1 @@ -218,6 +315,41 @@ func (q *Queries) GetSessionByToken(ctx context.Context, sessionToken string) (A return i, err } +const getTaskByID = `-- name: GetTaskByID :one +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +LIMIT 1 +` + +type GetTaskByIDParams struct { + ID uuid.UUID `db:"id"` + OwnerID uuid.UUID `db:"owner_id"` +} + +func (q *Queries) GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) { + row := q.db.QueryRow(ctx, getTaskByID, arg.ID, arg.OwnerID) + var i Task + err := row.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + const listTablos = `-- name: ListTablos :many SELECT id, owner_id, name, color, status, created_at, updated_at, deleted_at FROM public.tablos @@ -267,6 +399,96 @@ func (q *Queries) ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo return items, nil } +const listTasksByOwner = `-- name: ListTasksByOwner :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND deleted_at IS NULL +ORDER BY created_at ASC +` + +func (q *Queries) ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]Task, error) { + rows, err := q.db.Query(ctx, listTasksByOwner, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Task + for rows.Next() { + var i Task + if err := rows.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &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 listTasksByTablo = `-- name: ListTasksByTablo :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND tablo_id = $2 + AND deleted_at IS NULL +ORDER BY created_at ASC +` + +type ListTasksByTabloParams struct { + OwnerID uuid.UUID `db:"owner_id"` + TabloID uuid.UUID `db:"tablo_id"` +} + +func (q *Queries) ListTasksByTablo(ctx context.Context, arg ListTasksByTabloParams) ([]Task, error) { + rows, err := q.db.Query(ctx, listTasksByTablo, arg.OwnerID, arg.TabloID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Task + for rows.Next() { + var i Task + if err := rows.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &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() @@ -288,6 +510,27 @@ func (q *Queries) SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams return result.RowsAffected(), nil } +const softDeleteTask = `-- name: SoftDeleteTask :execrows +UPDATE public.tasks +SET deleted_at = now(), updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +` + +type SoftDeleteTaskParams struct { + ID uuid.UUID `db:"id"` + OwnerID uuid.UUID `db:"owner_id"` +} + +func (q *Queries) SoftDeleteTask(ctx context.Context, arg SoftDeleteTaskParams) (int64, error) { + result, err := q.db.Exec(ctx, softDeleteTask, arg.ID, arg.OwnerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const updateTablo = `-- name: UpdateTablo :execrows UPDATE public.tablos SET name = $3, @@ -317,3 +560,60 @@ func (q *Queries) UpdateTablo(ctx context.Context, arg UpdateTabloParams) (int64 } return result.RowsAffected(), nil } + +const updateTask = `-- name: UpdateTask :one +UPDATE public.tasks +SET + title = $3, + description = $4, + status = $5, + due_date = $6, + assignee_id = $7, + parent_task_id = $8, + updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +` + +type UpdateTaskParams struct { + ID uuid.UUID `db:"id"` + OwnerID uuid.UUID `db:"owner_id"` + Title string `db:"title"` + Description string `db:"description"` + Status string `db:"status"` + DueDate pgtype.Date `db:"due_date"` + AssigneeID pgtype.UUID `db:"assignee_id"` + ParentTaskID pgtype.UUID `db:"parent_task_id"` +} + +func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, updateTask, + arg.ID, + arg.OwnerID, + arg.Title, + arg.Description, + arg.Status, + arg.DueDate, + arg.AssigneeID, + arg.ParentTaskID, + ) + var i Task + err := row.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} diff --git a/go-backend/internal/tasks/model.go b/go-backend/internal/tasks/model.go new file mode 100644 index 0000000..97606cb --- /dev/null +++ b/go-backend/internal/tasks/model.go @@ -0,0 +1,76 @@ +package tasks + +import ( + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +var ErrNotFound = errors.New("task not found") +var ErrInvalidParent = errors.New("invalid parent task") +var ErrInvalidAssignee = errors.New("invalid assignee") +var ErrInvalidStatus = errors.New("invalid task status") + +type Status string + +const ( + StatusTodo Status = "todo" + StatusInProgress Status = "in_progress" + StatusInReview Status = "in_review" + StatusDone Status = "done" +) + +type Record struct { + ID uuid.UUID + OwnerID uuid.UUID + TabloID uuid.UUID + Title string + Description string + Status Status + AssigneeID *uuid.UUID + IsEtape bool + ParentTaskID *uuid.UUID + DueDate *time.Time + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +type CreateInput struct { + OwnerID uuid.UUID + TabloID uuid.UUID + Title string + Description string + Status Status + AssigneeID *uuid.UUID + IsEtape bool + ParentTaskID *uuid.UUID + DueDate *time.Time +} + +type UpdateInput struct { + ID uuid.UUID + OwnerID uuid.UUID + Title string + Description string + Status Status + AssigneeID *uuid.UUID + ParentTaskID *uuid.UUID + DueDate *time.Time +} + +type ListByTabloInput struct { + OwnerID uuid.UUID + TabloID uuid.UUID +} + +func ParseStatus(raw string) (Status, error) { + switch Status(strings.TrimSpace(raw)) { + case StatusTodo, StatusInProgress, StatusInReview, StatusDone: + return Status(strings.TrimSpace(raw)), nil + default: + return "", ErrInvalidStatus + } +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go index 7b7df0c..291ef10 100644 --- a/go-backend/internal/web/handlers/auth.go +++ b/go-backend/internal/web/handlers/auth.go @@ -109,9 +109,9 @@ func (h *AuthHandler) GetHome() http.HandlerFunc { } func (h *AuthHandler) GetTasksPage() http.HandlerFunc { - return h.renderAppPage("/tasks", func(user PublicUser) templ.Component { - return views.TasksMainContent() - }) + return func(w http.ResponseWriter, r *http.Request) { + h.renderTasksPage(w, r) + } } func (h *AuthHandler) GetTablosPage() http.HandlerFunc { 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 94a8e7b..427065f 100644 --- a/go-backend/internal/web/handlers/in_memory_auth_repository.go +++ b/go-backend/internal/web/handlers/in_memory_auth_repository.go @@ -2,10 +2,12 @@ package handlers import ( "context" + "errors" "sync" "time" "github.com/google/uuid" + taskmodel "xtablo-backend/internal/tasks" ) // InMemoryAuthRepository exists only as test support. @@ -16,6 +18,7 @@ type InMemoryAuthRepository struct { publicUsers map[uuid.UUID]PublicUser sessions map[string]Session tablos map[uuid.UUID]TabloRecord + tasks map[uuid.UUID]TaskRecord } // NewInMemoryAuthRepository creates a testing-only auth repository. @@ -26,6 +29,7 @@ func NewInMemoryAuthRepository() *InMemoryAuthRepository { publicUsers: map[uuid.UUID]PublicUser{}, sessions: map[string]Session{}, tablos: map[uuid.UUID]TabloRecord{}, + tasks: map[uuid.UUID]TaskRecord{}, } demoHash, err := hashPassword("xtablo-demo") @@ -132,3 +136,173 @@ func (r *InMemoryAuthRepository) DeleteSessionByToken(_ context.Context, token s delete(r.sessions, token) return nil } + +func (r *InMemoryAuthRepository) CreateTask(_ context.Context, input CreateTaskInput) (TaskRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if err := r.validateTaskInputLocked(input.OwnerID, input.TabloID, input.Status, input.AssigneeID, input.IsEtape, input.ParentTaskID); err != nil { + return TaskRecord{}, err + } + + now := time.Now().UTC() + record := TaskRecord{ + ID: uuid.New(), + OwnerID: input.OwnerID, + TabloID: input.TabloID, + Title: input.Title, + Description: input.Description, + Status: input.Status, + AssigneeID: input.AssigneeID, + IsEtape: input.IsEtape, + ParentTaskID: input.ParentTaskID, + DueDate: input.DueDate, + CreatedAt: now, + UpdatedAt: now, + } + + r.tasks[record.ID] = record + return record, nil +} + +func (r *InMemoryAuthRepository) ListTasksByTablo(_ context.Context, input ListTasksByTabloInput) ([]TaskRecord, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + tasks := make([]TaskRecord, 0) + for _, record := range r.tasks { + if record.OwnerID != input.OwnerID || record.TabloID != input.TabloID || record.DeletedAt != nil { + continue + } + tasks = append(tasks, record) + } + + sortTasksByCreatedAt(tasks) + return tasks, nil +} + +func (r *InMemoryAuthRepository) ListTasksByOwner(_ context.Context, ownerID uuid.UUID) ([]TaskRecord, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + tasks := make([]TaskRecord, 0) + for _, record := range r.tasks { + if record.OwnerID != ownerID || record.DeletedAt != nil { + continue + } + tasks = append(tasks, record) + } + + sortTasksByCreatedAt(tasks) + return tasks, nil +} + +func (r *InMemoryAuthRepository) GetTaskByID(_ context.Context, taskID uuid.UUID, ownerID uuid.UUID) (TaskRecord, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + record, ok := r.tasks[taskID] + if !ok || record.OwnerID != ownerID || record.DeletedAt != nil { + return TaskRecord{}, taskmodel.ErrNotFound + } + return record, nil +} + +func (r *InMemoryAuthRepository) UpdateTask(_ context.Context, input UpdateTaskInput) (TaskRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + + record, ok := r.tasks[input.ID] + if !ok || record.OwnerID != input.OwnerID || record.DeletedAt != nil { + return TaskRecord{}, taskmodel.ErrNotFound + } + if err := r.validateTaskInputLocked(input.OwnerID, record.TabloID, input.Status, input.AssigneeID, record.IsEtape, input.ParentTaskID); err != nil { + return TaskRecord{}, err + } + + record.Title = input.Title + record.Description = input.Description + record.Status = input.Status + record.AssigneeID = input.AssigneeID + record.ParentTaskID = input.ParentTaskID + record.DueDate = input.DueDate + record.UpdatedAt = time.Now().UTC() + + r.tasks[input.ID] = record + return record, nil +} + +func (r *InMemoryAuthRepository) SoftDeleteTask(_ context.Context, taskID uuid.UUID, ownerID uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + + record, ok := r.tasks[taskID] + if !ok || record.OwnerID != ownerID || record.DeletedAt != nil { + return taskmodel.ErrNotFound + } + + now := time.Now().UTC() + record.DeletedAt = &now + record.UpdatedAt = now + r.tasks[taskID] = record + + if record.IsEtape { + for id, child := range r.tasks { + if child.DeletedAt != nil || child.ParentTaskID == nil { + continue + } + if *child.ParentTaskID != taskID { + continue + } + child.ParentTaskID = nil + child.UpdatedAt = now + r.tasks[id] = child + } + } + + return nil +} + +func (r *InMemoryAuthRepository) validateTaskInputLocked(ownerID uuid.UUID, tabloID uuid.UUID, status TaskStatus, assigneeID *uuid.UUID, isEtape bool, parentTaskID *uuid.UUID) error { + tablo, ok := r.tablos[tabloID] + if !ok || tablo.OwnerID != ownerID || tablo.DeletedAt != nil { + return errors.New("tablo not found") + } + + if _, err := taskmodel.ParseStatus(string(status)); err != nil { + return err + } + + if assigneeID != nil { + if _, ok := r.publicUsers[*assigneeID]; !ok { + return taskmodel.ErrInvalidAssignee + } + } + + if isEtape && parentTaskID != nil { + return taskmodel.ErrInvalidParent + } + + if parentTaskID == nil { + return nil + } + + parent, ok := r.tasks[*parentTaskID] + if !ok || parent.DeletedAt != nil { + return taskmodel.ErrInvalidParent + } + if parent.OwnerID != ownerID || parent.TabloID != tabloID || !parent.IsEtape { + return taskmodel.ErrInvalidParent + } + return nil +} + +func sortTasksByCreatedAt(tasks []TaskRecord) { + for i := 0; i < len(tasks); i++ { + for j := i + 1; j < len(tasks); j++ { + if tasks[j].CreatedAt.Before(tasks[i].CreatedAt) { + tasks[i], tasks[j] = tasks[j], tasks[i] + } + } + } +} diff --git a/go-backend/internal/web/handlers/tasks.go b/go-backend/internal/web/handlers/tasks.go new file mode 100644 index 0000000..53b6406 --- /dev/null +++ b/go-backend/internal/web/handlers/tasks.go @@ -0,0 +1,401 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "html" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + taskmodel "xtablo-backend/internal/tasks" + "xtablo-backend/internal/web/views" +) + +type TaskStatus = taskmodel.Status + +const ( + TaskStatusTodo = taskmodel.StatusTodo + TaskStatusInProgress = taskmodel.StatusInProgress + TaskStatusInReview = taskmodel.StatusInReview + TaskStatusDone = taskmodel.StatusDone +) + +type TaskRecord = taskmodel.Record +type CreateTaskInput = taskmodel.CreateInput +type UpdateTaskInput = taskmodel.UpdateInput +type ListTasksByTabloInput = taskmodel.ListByTabloInput + +type taskPageRepository interface { + ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]TaskRecord, error) +} + +type taskMutationRepository interface { + CreateTask(ctx context.Context, input CreateTaskInput) (TaskRecord, error) + GetTaskByID(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) (TaskRecord, error) + UpdateTask(ctx context.Context, input UpdateTaskInput) (TaskRecord, error) + SoftDeleteTask(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) error +} + +func (h *AuthHandler) renderTasksPage(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(r.Context(), ListTablosInput{ + OwnerID: user.ID, + }) + if err != nil { + http.Error(w, "failed to load projects", http.StatusInternalServerError) + return + } + + taskRepo, ok := h.repo.(taskPageRepository) + if !ok { + http.Error(w, "tasks repository not configured", http.StatusInternalServerError) + return + } + + tasks, err := taskRepo.ListTasksByOwner(r.Context(), user.ID) + if err != nil { + http.Error(w, "failed to load tasks", http.StatusInternalServerError) + return + } + + assigneeLabels := make(map[uuid.UUID]string) + for _, record := range tasks { + if record.AssigneeID == nil { + continue + } + if _, exists := assigneeLabels[*record.AssigneeID]; exists { + continue + } + publicUser, err := h.repo.GetPublicUserByID(r.Context(), *record.AssigneeID) + if err != nil { + continue + } + assigneeLabels[*record.AssigneeID] = publicUser.DisplayName + } + + vm := views.NewTasksPageViewModel(tablos, tasks, assigneeLabels) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + content := views.TasksPageContent(vm) + var renderErr error + if isHXRequest(r) { + renderErr = views.DashboardContentSwap("/tasks", tablos, content).Render(r.Context(), w) + } else { + renderErr = views.DashboardPage("/tasks", tablos, content).Render(r.Context(), w) + } + if renderErr != nil { + http.Error(w, "failed to render tasks page", http.StatusInternalServerError) + } +} + +func (h *AuthHandler) PostTasks() 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 + } + + repo, ok := h.repo.(taskMutationRepository) + if !ok { + http.Error(w, "tasks repository not configured", http.StatusInternalServerError) + return + } + + input, err := parseCreateTaskInput(r, user.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + if _, err := repo.CreateTask(r.Context(), input); err != nil { + if isTaskValidationError(err) { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + http.Error(w, "failed to create task", http.StatusInternalServerError) + return + } + + h.renderTasksPage(w, r) + } +} + +func (h *AuthHandler) GetEditTaskModal() 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 + } + + taskID, err := uuid.Parse(r.PathValue("taskID")) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + repo, ok := h.repo.(taskMutationRepository) + if !ok { + http.Error(w, "tasks repository not configured", http.StatusInternalServerError) + return + } + + record, err := repo.GetTaskByID(r.Context(), taskID, user.ID) + if err != nil { + if errors.Is(err, taskmodel.ErrNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } + http.Error(w, "failed to load task", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf( + w, + `
`, + html.EscapeString(record.ID.String()), + html.EscapeString(record.Title), + html.EscapeString(record.Description), + html.EscapeString(string(record.Status)), + html.EscapeString(optionalUUIDString(record.AssigneeID)), + ) + } +} + +func (h *AuthHandler) PatchTask() 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 + } + + taskID, err := uuid.Parse(r.PathValue("taskID")) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form payload", http.StatusBadRequest) + return + } + + repo, ok := h.repo.(taskMutationRepository) + if !ok { + http.Error(w, "tasks repository not configured", http.StatusInternalServerError) + return + } + + record, err := repo.GetTaskByID(r.Context(), taskID, user.ID) + if err != nil { + if errors.Is(err, taskmodel.ErrNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } + http.Error(w, "failed to load task", http.StatusInternalServerError) + return + } + + input, err := parseUpdateTaskInput(r, user.ID, record) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + if _, err := repo.UpdateTask(r.Context(), input); err != nil { + if isTaskValidationError(err) { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + http.Error(w, "failed to update task", http.StatusInternalServerError) + return + } + + h.renderTasksPage(w, r) + } +} + +func (h *AuthHandler) DeleteTask() 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 + } + + taskID, err := uuid.Parse(r.PathValue("taskID")) + if err != nil { + http.Error(w, "invalid task id", http.StatusBadRequest) + return + } + + repo, ok := h.repo.(taskMutationRepository) + if !ok { + http.Error(w, "tasks repository not configured", http.StatusInternalServerError) + return + } + + if err := repo.SoftDeleteTask(r.Context(), taskID, user.ID); err != nil { + if errors.Is(err, taskmodel.ErrNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } + http.Error(w, "failed to delete task", http.StatusInternalServerError) + return + } + + h.renderTasksPage(w, r) + } +} + +func parseCreateTaskInput(r *http.Request, ownerID uuid.UUID) (CreateTaskInput, error) { + tabloID, err := uuid.Parse(strings.TrimSpace(r.FormValue("tablo_id"))) + if err != nil { + return CreateTaskInput{}, errors.New("tablo_id invalide") + } + + title := strings.TrimSpace(r.FormValue("title")) + if title == "" { + return CreateTaskInput{}, errors.New("le titre est requis") + } + + status, err := parseTaskStatusFormValue(r.FormValue("status")) + if err != nil { + return CreateTaskInput{}, err + } + + parentTaskID, err := parseOptionalUUID(r.FormValue("parent_task_id")) + if err != nil { + return CreateTaskInput{}, errors.New("parent_task_id invalide") + } + + assigneeID, err := parseOptionalUUID(r.FormValue("assignee_id")) + if err != nil { + return CreateTaskInput{}, errors.New("assignee_id invalide") + } + + dueDate, err := parseOptionalDate(r.FormValue("due_date")) + if err != nil { + return CreateTaskInput{}, errors.New("due_date invalide") + } + + isEtape, _ := strconv.ParseBool(strings.TrimSpace(r.FormValue("is_etape"))) + + return CreateTaskInput{ + OwnerID: ownerID, + TabloID: tabloID, + Title: title, + Description: strings.TrimSpace(r.FormValue("description")), + Status: status, + AssigneeID: assigneeID, + IsEtape: isEtape, + ParentTaskID: parentTaskID, + DueDate: dueDate, + }, nil +} + +func parseUpdateTaskInput(r *http.Request, ownerID uuid.UUID, current TaskRecord) (UpdateTaskInput, error) { + title := strings.TrimSpace(r.FormValue("title")) + if title == "" { + return UpdateTaskInput{}, errors.New("le titre est requis") + } + + status, err := parseTaskStatusFormValue(r.FormValue("status")) + if err != nil { + return UpdateTaskInput{}, err + } + + parentTaskID, err := parseOptionalUUID(r.FormValue("parent_task_id")) + if err != nil { + return UpdateTaskInput{}, errors.New("parent_task_id invalide") + } + + assigneeID, err := parseOptionalUUID(r.FormValue("assignee_id")) + if err != nil { + return UpdateTaskInput{}, errors.New("assignee_id invalide") + } + + dueDate, err := parseOptionalDate(r.FormValue("due_date")) + if err != nil { + return UpdateTaskInput{}, errors.New("due_date invalide") + } + + if current.IsEtape { + parentTaskID = nil + } + + return UpdateTaskInput{ + ID: current.ID, + OwnerID: ownerID, + Title: title, + Description: strings.TrimSpace(r.FormValue("description")), + Status: status, + AssigneeID: assigneeID, + ParentTaskID: parentTaskID, + DueDate: dueDate, + }, nil +} + +func parseTaskStatusFormValue(raw string) (TaskStatus, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return TaskStatusTodo, nil + } + status, err := taskmodel.ParseStatus(raw) + if err != nil { + return "", errors.New("status invalide") + } + return status, nil +} + +func parseOptionalUUID(raw string) (*uuid.UUID, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil, err + } + return &id, nil +} + +func parseOptionalDate(raw string) (*time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + value, err := time.Parse("2006-01-02", raw) + if err != nil { + return nil, err + } + return &value, nil +} + +func optionalUUIDString(id *uuid.UUID) string { + if id == nil { + return "" + } + return id.String() +} + +func isTaskValidationError(err error) bool { + return errors.Is(err, taskmodel.ErrInvalidParent) || errors.Is(err, taskmodel.ErrInvalidAssignee) || errors.Is(err, taskmodel.ErrInvalidStatus) +} diff --git a/go-backend/internal/web/handlers/tasks_test.go b/go-backend/internal/web/handlers/tasks_test.go new file mode 100644 index 0000000..64c7e0a --- /dev/null +++ b/go-backend/internal/web/handlers/tasks_test.go @@ -0,0 +1,447 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + taskmodel "xtablo-backend/internal/tasks" +) + +func TestGetTasksPageRendersEtapesAndSansEtapeSections(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 authenticated user") + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + etape := mustCreateEtape(t, repo, userID, tablo.ID, "Production") + _ = mustCreateTask(t, repo, userID, tablo.ID, &etape.ID, "Cut footage") + _ = mustCreateTask(t, repo, userID, tablo.ID, nil, "Inbox task") + + pageReq := httptest.NewRequest(http.MethodGet, "/tasks", nil) + pageReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTasksPage().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{"Mes tâches", "Production", "Sans étape", "Inbox task", "Cut footage"} { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q, got %q", want, body) + } + } +} + +func TestPostTasksCreatesEtape(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 authenticated user") + } + tablo := mustCreateOwnedTablo(t, repo, userID) + + form := url.Values{} + form.Set("tablo_id", tablo.ID.String()) + form.Set("title", "Launch") + form.Set("status", string(taskmodel.StatusTodo)) + form.Set("is_etape", "true") + + postReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PostTasks().ServeHTTP(rec, postReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Launch") { + t.Fatalf("expected response to contain new etape, got %q", rec.Body.String()) + } +} + +func TestPostTasksRejectsNonEtapeParent(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 authenticated user") + } + tablo := mustCreateOwnedTablo(t, repo, userID) + parentTask := mustCreateTask(t, repo, userID, tablo.ID, nil, "Regular task") + + form := url.Values{} + form.Set("tablo_id", tablo.ID.String()) + form.Set("title", "Should fail") + form.Set("status", string(taskmodel.StatusTodo)) + form.Set("parent_task_id", parentTask.ID.String()) + + postReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PostTasks().ServeHTTP(rec, postReq) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected status 422, got %d", rec.Code) + } +} + +func TestGetEditTaskModalRendersCurrentValues(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 authenticated user") + } + + assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "modal-assignee@xtablo.com", + EncryptedPassword: "hash", + DisplayName: "modal assignee", + }) + if err != nil { + t.Fatalf("create assignee: %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + task := mustCreateTaskWithAssignee(t, repo, userID, tablo.ID, nil, "Editable", &assigneeID) + + editReq := httptest.NewRequest(http.MethodGet, "/tasks/"+task.ID.String()+"/edit", nil) + editReq.SetPathValue("taskID", task.ID.String()) + editReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetEditTaskModal().ServeHTTP(rec, editReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{"Editable", string(task.Status), assigneeID.String()} { + if !strings.Contains(body, want) { + t.Fatalf("expected edit modal to contain %q, got %q", want, body) + } + } +} + +func TestPatchTaskUpdatesEditableFields(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 authenticated user") + } + + assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "patch-assignee@xtablo.com", + EncryptedPassword: "hash", + DisplayName: "patch assignee", + }) + if err != nil { + t.Fatalf("create assignee: %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + etape := mustCreateEtape(t, repo, userID, tablo.ID, "Editing") + task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Old title") + + form := url.Values{} + form.Set("title", "New title") + form.Set("description", "New description") + form.Set("status", string(taskmodel.StatusInReview)) + form.Set("due_date", "2026-05-20") + form.Set("assignee_id", assigneeID.String()) + form.Set("parent_task_id", etape.ID.String()) + + patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String(), strings.NewReader(form.Encode())) + patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + patchReq.SetPathValue("taskID", task.ID.String()) + patchReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PatchTask().ServeHTTP(rec, patchReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + updated, err := repo.GetTaskByID(context.Background(), task.ID, userID) + if err != nil { + t.Fatalf("get updated task: %v", err) + } + if updated.Title != "New title" || updated.Description != "New description" { + t.Fatalf("expected title/description to update, got %#v", updated) + } + if updated.Status != taskmodel.StatusInReview { + t.Fatalf("expected status to update, got %q", updated.Status) + } + if updated.AssigneeID == nil || *updated.AssigneeID != assigneeID { + t.Fatalf("expected assignee to update, got %#v", updated.AssigneeID) + } + if updated.ParentTaskID == nil || *updated.ParentTaskID != etape.ID { + t.Fatalf("expected parent etape to update, got %#v", updated.ParentTaskID) + } + if updated.DueDate == nil || updated.DueDate.Format("2006-01-02") != "2026-05-20" { + t.Fatalf("expected due date to update, got %#v", updated.DueDate) + } +} + +func TestDeleteTaskSoftDeletesRegularTask(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 authenticated user") + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Delete me") + + deleteReq := httptest.NewRequest(http.MethodDelete, "/tasks/"+task.ID.String(), nil) + deleteReq.SetPathValue("taskID", task.ID.String()) + deleteReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.DeleteTask().ServeHTTP(rec, deleteReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if _, err := repo.GetTaskByID(context.Background(), task.ID, userID); err == nil { + t.Fatal("expected deleted task to become unavailable") + } +} + +func TestInMemoryTasksListExcludesSoftDeletedRows(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) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + etape := mustCreateEtape(t, repo, user.ID, tablo.ID, "Etape 1") + task := mustCreateTask(t, repo, user.ID, tablo.ID, &etape.ID, "Task 1") + + if err := repo.SoftDeleteTask(context.Background(), task.ID, user.ID); err != nil { + t.Fatalf("soft delete task: %v", err) + } + + records, err := repo.ListTasksByTablo(context.Background(), ListTasksByTabloInput{ + OwnerID: user.ID, + TabloID: tablo.ID, + }) + if err != nil { + t.Fatalf("list tasks: %v", err) + } + + if len(records) != 1 || records[0].ID != etape.ID { + t.Fatalf("expected only etape to remain visible, got %#v", records) + } +} + +func TestInMemoryDeleteEtapeClearsChildParentID(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) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + etape := mustCreateEtape(t, repo, user.ID, tablo.ID, "Launch") + child := mustCreateTask(t, repo, user.ID, tablo.ID, &etape.ID, "Ship copy") + + if err := repo.SoftDeleteTask(context.Background(), etape.ID, user.ID); err != nil { + t.Fatalf("delete etape: %v", err) + } + + updated, err := repo.GetTaskByID(context.Background(), child.ID, user.ID) + if err != nil { + t.Fatalf("get child task: %v", err) + } + if updated.ParentTaskID != nil { + t.Fatalf("expected child task to move to Sans etape, got parent %v", *updated.ParentTaskID) + } +} + +func TestInMemoryEtapeCannotHaveParent(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) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + parent := mustCreateEtape(t, repo, user.ID, tablo.ID, "Parent") + + _, err = repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: user.ID, + TabloID: tablo.ID, + Title: "Invalid child etape", + IsEtape: true, + Status: taskmodel.StatusTodo, + ParentTaskID: &parent.ID, + }) + if err == nil { + t.Fatal("expected etape with parent to fail") + } +} + +func TestInMemoryTaskParentMustBeEtape(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) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + taskParent := mustCreateTask(t, repo, user.ID, tablo.ID, nil, "Not an etape") + + _, err = repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: user.ID, + TabloID: tablo.ID, + Title: "Invalid child task", + Status: taskmodel.StatusTodo, + ParentTaskID: &taskParent.ID, + }) + if err == nil { + t.Fatal("expected task with non-etape parent to fail") + } +} + +func TestInMemoryTaskAssigneePersistsAndCanBeCleared(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) + } + + assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "assignee@xtablo.com", + EncryptedPassword: "hash", + DisplayName: "assignee", + }) + if err != nil { + t.Fatalf("create assignee: %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + task := mustCreateTaskWithAssignee(t, repo, user.ID, tablo.ID, nil, "Assigned", &assigneeID) + + if task.AssigneeID == nil || *task.AssigneeID != assigneeID { + t.Fatalf("expected assignee to persist, got %#v", task.AssigneeID) + } + + updated, err := repo.UpdateTask(context.Background(), UpdateTaskInput{ + ID: task.ID, + OwnerID: user.ID, + Title: task.Title, + Description: task.Description, + Status: task.Status, + AssigneeID: nil, + DueDate: task.DueDate, + ParentTaskID: task.ParentTaskID, + }) + if err != nil { + t.Fatalf("clear assignee: %v", err) + } + + if updated.AssigneeID != nil { + t.Fatalf("expected assignee to clear, got %#v", updated.AssigneeID) + } +} + +func mustCreateOwnedTablo(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID) TabloRecord { + t.Helper() + + tablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{ + OwnerID: ownerID, + Name: "Owned Tablo", + Color: "#3B82F6", + Status: TabloStatusTodo, + }) + if err != nil { + t.Fatalf("create tablo: %v", err) + } + return tablo +} + +func mustCreateEtape(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, title string) TaskRecord { + t.Helper() + + record, err := repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: ownerID, + TabloID: tabloID, + Title: title, + IsEtape: true, + Status: taskmodel.StatusTodo, + }) + if err != nil { + t.Fatalf("create etape: %v", err) + } + return record +} + +func mustCreateTask(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, parentTaskID *uuid.UUID, title string) TaskRecord { + t.Helper() + + return mustCreateTaskWithAssignee(t, repo, ownerID, tabloID, parentTaskID, title, nil) +} + +func mustCreateTaskWithAssignee(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, parentTaskID *uuid.UUID, title string, assigneeID *uuid.UUID) TaskRecord { + t.Helper() + + record, err := repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: ownerID, + TabloID: tabloID, + Title: title, + Status: taskmodel.StatusTodo, + ParentTaskID: parentTaskID, + AssigneeID: assigneeID, + }) + if err != nil { + t.Fatalf("create task: %v", err) + } + return record +} diff --git a/go-backend/internal/web/ui/catalog/catalog_test.go b/go-backend/internal/web/ui/catalog/catalog_test.go index d37a4b0..c6a2400 100644 --- a/go-backend/internal/web/ui/catalog/catalog_test.go +++ b/go-backend/internal/web/ui/catalog/catalog_test.go @@ -40,6 +40,7 @@ func TestPagesIncludePrimitiveCatalogCoverage(t *testing.T) { "badges", "icon-buttons", "inputs", + "selects", "form-fields", "modals", "spacing", @@ -134,6 +135,7 @@ func TestPrimitiveExamplesRenderRealMarkup(t *testing.T) { {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: "selects", want: []string{`class="ui-select"`, `class="ui-select-control"`}}, {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`}}, diff --git a/go-backend/internal/web/ui/ui_test.go b/go-backend/internal/web/ui/ui_test.go index 4d56475..27e6a46 100644 --- a/go-backend/internal/web/ui/ui_test.go +++ b/go-backend/internal/web/ui/ui_test.go @@ -105,6 +105,70 @@ func TestBadgeRendersSemanticStatusVariant(t *testing.T) { } } +func TestSelectRendersSingleSelectMarkup(t *testing.T) { + component := Select(SelectProps{ + ID: "project-status", + Name: "status", + Placeholder: "Select a status", + Value: "in-progress", + Options: []SelectOption{ + {Value: "todo", Label: "To do"}, + {Value: "in-progress", Label: "In progress"}, + {Value: "done", Label: "Done"}, + }, + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `class="ui-select"`, + `id="project-status"`, + `name="status"`, + `class="ui-select-native"`, + `class="ui-select-control"`, + `class="ui-select-value-wrapper"`, + `class="ui-select-arrow-zone"`, + `class="ui-select-arrow-icon"`, + `value="in-progress" selected`, + `data-ui-select-root`, + `data-ui-select-label`, + `Select a status`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestSelectRendersMultiSelectMarkup(t *testing.T) { + component := Select(SelectProps{ + Name: "assignee_ids", + Placeholder: "Select multiple values", + Multiple: true, + Values: []string{"u_1", "u_2"}, + Options: []SelectOption{ + {Value: "u_1", Label: "Alice"}, + {Value: "u_2", Label: "Bob"}, + {Value: "u_3", Label: "Charlie"}, + }, + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `multiple`, + `data-ui-select-multiple="true"`, + `data-placeholder="Select multiple values"`, + `data-selected-label="Alice, Bob"`, + `value="u_1" selected`, + `value="u_2" selected`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + func TestModalRendersShellStructure(t *testing.T) { component := Modal(ModalProps{ Title: "Nouveau projet", @@ -205,6 +269,9 @@ func TestSharedSemanticClassesExistInStylesheet(t *testing.T) { `.ui-space-x-md`, `.ui-space-y-md`, `.borderless-icon-button`, + `.ui-select-control`, + `.ui-select-native`, + `.ui-select-chip`, `.ui-icon-button-solid.ui-icon-button-neutral`, `.ui-icon-button-ghost.ui-icon-button-neutral`, `.ui-icon-button-ghost.ui-icon-button-danger`, diff --git a/go-backend/internal/web/views/tasks_view.go b/go-backend/internal/web/views/tasks_view.go new file mode 100644 index 0000000..f9ae029 --- /dev/null +++ b/go-backend/internal/web/views/tasks_view.go @@ -0,0 +1,284 @@ +package views + +import ( + "context" + "fmt" + "html" + "io" + "slices" + "strings" + "time" + + "github.com/a-h/templ" + "github.com/google/uuid" + tablomodel "xtablo-backend/internal/tablos" + taskmodel "xtablo-backend/internal/tasks" +) + +type TasksPageViewModel struct { + Tablos []TasksTabloGroupView +} + +type TasksTabloGroupView struct { + ID string + Name string + Color string + Sections []TasksSectionView +} + +type TasksSectionView struct { + ID string + Title string + Description string + IsEtape bool + Tasks []TaskRowView + Status string + StatusValue string + DueDate string + DueDateValue string + Assignee string + AssigneeID string +} + +type TaskRowView struct { + ID string + Title string + Description string + Status string + StatusValue string + DueDate string + DueDateValue string + Assignee string + AssigneeID string + ParentTaskID string +} + +func NewTasksPageViewModel(tablos []tablomodel.Record, tasks []taskmodel.Record, assigneeLabels map[uuid.UUID]string) TasksPageViewModel { + groups := make([]TasksTabloGroupView, 0, len(tablos)) + tasksByTablo := make(map[uuid.UUID][]taskmodel.Record) + for _, record := range tasks { + tasksByTablo[record.TabloID] = append(tasksByTablo[record.TabloID], record) + } + + for _, tablo := range tablos { + records := tasksByTablo[tablo.ID] + if len(records) == 0 { + continue + } + + etapes := make([]taskmodel.Record, 0) + childrenByParent := make(map[uuid.UUID][]taskmodel.Record) + parentless := make([]taskmodel.Record, 0) + + for _, record := range records { + if record.IsEtape { + etapes = append(etapes, record) + continue + } + if record.ParentTaskID == nil { + parentless = append(parentless, record) + continue + } + childrenByParent[*record.ParentTaskID] = append(childrenByParent[*record.ParentTaskID], record) + } + + sections := make([]TasksSectionView, 0, len(etapes)+1) + slices.SortFunc(etapes, func(a, b taskmodel.Record) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + for _, etape := range etapes { + children := childrenByParent[etape.ID] + slices.SortFunc(children, func(a, b taskmodel.Record) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + sections = append(sections, TasksSectionView{ + ID: etape.ID.String(), + Title: etape.Title, + Description: etape.Description, + IsEtape: true, + Status: taskStatusLabel(etape.Status), + StatusValue: string(etape.Status), + DueDate: formatOptionalDate(etape.DueDate), + DueDateValue: formatOptionalDateInput(etape.DueDate), + Assignee: assigneeName(etape.AssigneeID, assigneeLabels), + AssigneeID: optionalUUIDString(etape.AssigneeID), + Tasks: toTaskRows(children, assigneeLabels), + }) + } + + if len(parentless) > 0 { + slices.SortFunc(parentless, func(a, b taskmodel.Record) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + sections = append(sections, TasksSectionView{ + ID: "sans-etape-" + tablo.ID.String(), + Title: "Sans étape", + IsEtape: false, + Tasks: toTaskRows(parentless, assigneeLabels), + }) + } + + groups = append(groups, TasksTabloGroupView{ + ID: tablo.ID.String(), + Name: tablo.Name, + Color: tablo.Color, + Sections: sections, + }) + } + + return TasksPageViewModel{Tablos: groups} +} + +func TasksPageContent(vm TasksPageViewModel) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, `
Espace de travail
`) + _, _ = io.WriteString(w, `

Mes tâches

`) + _, _ = io.WriteString(w, `

Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.

`) + _, _ = io.WriteString(w, `
`) + + if len(vm.Tablos) == 0 { + _, _ = io.WriteString(w, `

Aucune tâche pour le moment.

`) + return nil + } + + for _, group := range vm.Tablos { + if err := renderTaskTabloGroup(w, group); err != nil { + return err + } + } + + _, _ = io.WriteString(w, ``) + return nil + }) +} + +func renderTaskTabloGroup(w io.Writer, group TasksTabloGroupView) error { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(group.ID), html.EscapeString(group.Name)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, `
`, html.EscapeString(group.ID), html.EscapeString(group.ID)); err != nil { + return err + } + for _, section := range group.Sections { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(section.ID), html.EscapeString(section.Title)); err != nil { + return err + } + if section.IsEtape { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(joinTaskMeta(section.Status, section.DueDate, section.Assignee))); err != nil { + return err + } + if _, err := fmt.Fprintf(w, `
`, html.EscapeString(section.ID), html.EscapeString(section.Title), html.EscapeString(section.Description), html.EscapeString(section.StatusValue), html.EscapeString(section.DueDateValue), html.EscapeString(section.AssigneeID), html.EscapeString(section.ID)); err != nil { + return err + } + } + if _, err := io.WriteString(w, `
`); err != nil { + return err + } + for _, task := range section.Tasks { + if err := renderTaskRow(w, task); err != nil { + return err + } + } + if _, err := io.WriteString(w, `
`); err != nil { + return err + } + } + _, err := io.WriteString(w, `
`) + return err +} + +func renderTaskRow(w io.Writer, task TaskRowView) error { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(task.ID), html.EscapeString(task.Title)); err != nil { + return err + } + if strings.TrimSpace(task.Description) != "" { + if _, err := fmt.Fprintf(w, `
%s
`, html.EscapeString(task.Description)); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, `
%s
`, html.EscapeString(joinTaskMeta(task.Status, task.DueDate, task.Assignee))); err != nil { + return err + } + if _, err := fmt.Fprintf(w, `
`, html.EscapeString(task.ID), html.EscapeString(task.Title), html.EscapeString(task.Description), html.EscapeString(task.StatusValue), html.EscapeString(task.DueDateValue), html.EscapeString(task.AssigneeID), html.EscapeString(task.ParentTaskID), html.EscapeString(task.ID)); err != nil { + return err + } + _, err := io.WriteString(w, `
`) + return err +} + +func toTaskRows(records []taskmodel.Record, assigneeLabels map[uuid.UUID]string) []TaskRowView { + rows := make([]TaskRowView, 0, len(records)) + for _, record := range records { + rows = append(rows, TaskRowView{ + ID: record.ID.String(), + Title: record.Title, + Description: record.Description, + Status: taskStatusLabel(record.Status), + StatusValue: string(record.Status), + DueDate: formatOptionalDate(record.DueDate), + DueDateValue: formatOptionalDateInput(record.DueDate), + Assignee: assigneeName(record.AssigneeID, assigneeLabels), + AssigneeID: optionalUUIDString(record.AssigneeID), + ParentTaskID: optionalUUIDString(record.ParentTaskID), + }) + } + return rows +} + +func taskStatusLabel(status taskmodel.Status) string { + switch status { + case taskmodel.StatusInProgress: + return "En cours" + case taskmodel.StatusInReview: + return "En revue" + case taskmodel.StatusDone: + return "Terminé" + default: + return "À faire" + } +} + +func formatOptionalDate(value *time.Time) string { + if value == nil { + return "" + } + return value.Format("02/01/2006") +} + +func formatOptionalDateInput(value *time.Time) string { + if value == nil { + return "" + } + return value.Format("2006-01-02") +} + +func assigneeName(id *uuid.UUID, labels map[uuid.UUID]string) string { + if id == nil { + return "" + } + if label, ok := labels[*id]; ok { + return label + } + return "" +} + +func optionalUUIDString(id *uuid.UUID) string { + if id == nil { + return "" + } + return id.String() +} + +func joinTaskMeta(parts ...string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + filtered = append(filtered, part) + } + return strings.Join(filtered, " · ") +} diff --git a/go-backend/router.go b/go-backend/router.go index 6038acb..9a47525 100644 --- a/go-backend/router.go +++ b/go-backend/router.go @@ -32,6 +32,10 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler { // Views mux.Get("/", authHandler.GetHome()) mux.Get("/tasks", authHandler.GetTasksPage()) + mux.Post("/tasks", authHandler.PostTasks()) + mux.Get("/tasks/{taskID}/edit", authHandler.GetEditTaskModal()) + mux.Patch("/tasks/{taskID}", authHandler.PatchTask()) + mux.Delete("/tasks/{taskID}", authHandler.DeleteTask()) mux.Get("/tablos", authHandler.GetTablosPage()) mux.Get("/planning", authHandler.GetPlanningPage()) mux.Get("/chat", authHandler.GetChatPage()) diff --git a/go-backend/router_test.go b/go-backend/router_test.go index 1126b76..fff745a 100644 --- a/go-backend/router_test.go +++ b/go-backend/router_test.go @@ -499,6 +499,85 @@ func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) { } } +func TestTasksRoutesCreatePatchAndDeleteThroughRouter(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) + } + + tablo, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{ + OwnerID: authUser.ID, + Name: "Router Tablo", + Color: "#3B82F6", + Status: handlers.TabloStatusTodo, + }) + if err != nil { + t.Fatalf("expected tablo creation to succeed, got error: %v", err) + } + + router := newRouterWithHandler(handlers.NewAuthHandler(repo)) + sessionCookie := loginCookieForRouter(t, router) + + createForm := url.Values{} + createForm.Set("tablo_id", tablo.ID.String()) + createForm.Set("title", "Route Task") + createForm.Set("status", "todo") + + createReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(createForm.Encode())) + createReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + createReq.AddCookie(sessionCookie) + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + + if createRec.Code != http.StatusOK { + t.Fatalf("expected task create status 200, got %d", createRec.Code) + } + if !strings.Contains(createRec.Body.String(), "Route Task") { + t.Fatalf("expected create response to contain task title, got %q", createRec.Body.String()) + } + + tasks, err := repo.ListTasksByTablo(context.Background(), handlers.ListTasksByTabloInput{ + OwnerID: authUser.ID, + TabloID: tablo.ID, + }) + if err != nil || len(tasks) != 1 { + t.Fatalf("expected one task after create, got %d / err=%v", len(tasks), err) + } + + patchForm := url.Values{} + patchForm.Set("title", "Updated Route Task") + patchForm.Set("description", "Updated from router test") + patchForm.Set("status", "done") + + patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+tasks[0].ID.String(), strings.NewReader(patchForm.Encode())) + patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + patchReq.SetPathValue("taskID", tasks[0].ID.String()) + patchReq.AddCookie(sessionCookie) + patchRec := httptest.NewRecorder() + router.ServeHTTP(patchRec, patchReq) + + if patchRec.Code != http.StatusOK { + t.Fatalf("expected task patch status 200, got %d", patchRec.Code) + } + if !strings.Contains(patchRec.Body.String(), "Updated Route Task") { + t.Fatalf("expected patch response to contain updated title, got %q", patchRec.Body.String()) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/tasks/"+tasks[0].ID.String(), nil) + deleteReq.SetPathValue("taskID", tasks[0].ID.String()) + deleteReq.AddCookie(sessionCookie) + deleteRec := httptest.NewRecorder() + router.ServeHTTP(deleteRec, deleteReq) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected task delete status 200, got %d", deleteRec.Code) + } + if strings.Contains(deleteRec.Body.String(), "Updated Route Task") { + t.Fatalf("expected delete response to remove updated task, got %q", deleteRec.Body.String()) + } +} + func TestTablosPageRendersFullDashboardPage(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") @@ -770,3 +849,22 @@ func findCookie(cookies []*http.Cookie, name string) *http.Cookie { } return nil } + +func loginCookieForRouter(t *testing.T, router http.Handler) *http.Cookie { + t.Helper() + + 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.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatal("expected session cookie to be set") + } + return sessionCookie +} -- 2.45.2 From c148ff9af3335939280e733873f4fb69d3f9ab55 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 22:04:09 +0200 Subject: [PATCH 036/546] Add select component with single and multi-value support --- docs/design-system/badges.html | 2 +- docs/design-system/buttons.html | 2 +- docs/design-system/cards.html | 2 +- docs/design-system/empty-states.html | 2 +- docs/design-system/form-fields.html | 2 +- docs/design-system/icon-buttons.html | 2 +- docs/design-system/index.html | 2 +- docs/design-system/inputs.html | 2 +- docs/design-system/modals.html | 2 +- docs/design-system/selects.html | 302 ++++++++++++ docs/design-system/spacing.html | 2 +- docs/design-system/tables.html | 2 +- docs/design-system/tokens.html | 2 +- go-backend/cmd/buildstyles/main.go | 1 + .../internal/web/ui/catalog/examples.go | 55 +++ go-backend/internal/web/ui/catalog/pages.go | 6 + go-backend/internal/web/ui/select.css | 154 +++++++ go-backend/internal/web/ui/select.templ | 251 ++++++++++ go-backend/internal/web/ui/select_helpers.go | 104 +++++ go-backend/internal/web/ui/select_templ.go | 429 ++++++++++++++++++ go-backend/static/styles.css | 156 +++++++ 21 files changed, 1470 insertions(+), 12 deletions(-) create mode 100644 docs/design-system/selects.html create mode 100644 go-backend/internal/web/ui/select.css create mode 100644 go-backend/internal/web/ui/select.templ create mode 100644 go-backend/internal/web/ui/select_helpers.go create mode 100644 go-backend/internal/web/ui/select_templ.go diff --git a/docs/design-system/badges.html b/docs/design-system/badges.html index 836f0a1..46b927e 100644 --- a/docs/design-system/badges.html +++ b/docs/design-system/badges.html @@ -8,6 +8,6 @@ -

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})
+

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 index 3ab955f..b6c36fb 100644 --- a/docs/design-system/buttons.html +++ b/docs/design-system/buttons.html @@ -8,7 +8,7 @@ -

Design System

Buttons

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

Default solid action

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

@ui.Button(ui.ButtonProps{
+

Design System

Buttons

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

Default solid action

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

@ui.Button(ui.ButtonProps{
 	Label:   "Nouveau projet",
 	Variant: ui.ButtonVariantDefault,
 	Size:    ui.SizeMD,
diff --git a/docs/design-system/cards.html b/docs/design-system/cards.html
index 4b7f232..a34f364 100644
--- a/docs/design-system/cards.html
+++ b/docs/design-system/cards.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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
index 1aa9494..e96bcc5 100644
--- a/docs/design-system/empty-states.html
+++ b/docs/design-system/empty-states.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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"),
diff --git a/docs/design-system/form-fields.html b/docs/design-system/form-fields.html
index 8efee19..3acfe7d 100644
--- a/docs/design-system/form-fields.html
+++ b/docs/design-system/form-fields.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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{
diff --git a/docs/design-system/icon-buttons.html b/docs/design-system/icon-buttons.html
index c2c632a..48b0a0f 100644
--- a/docs/design-system/icon-buttons.html
+++ b/docs/design-system/icon-buttons.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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.IconButtonVariantDanger,
diff --git a/docs/design-system/index.html b/docs/design-system/index.html
index ec86913..cbb2557 100644
--- a/docs/design-system/index.html
+++ b/docs/design-system/index.html
@@ -8,6 +8,6 @@
   
 
 
-

Design System

Component Catalog

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

+

Design System

Component Catalog

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

diff --git a/docs/design-system/inputs.html b/docs/design-system/inputs.html index 324f30a..7c72834 100644 --- a/docs/design-system/inputs.html +++ b/docs/design-system/inputs.html @@ -8,7 +8,7 @@ -

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

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",
diff --git a/docs/design-system/modals.html b/docs/design-system/modals.html
index a543d12..135b58e 100644
--- a/docs/design-system/modals.html
+++ b/docs/design-system/modals.html
@@ -8,7 +8,7 @@
   
 
 
-

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

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/selects.html b/docs/design-system/selects.html
new file mode 100644
index 0000000..13ad8eb
--- /dev/null
+++ b/docs/design-system/selects.html
@@ -0,0 +1,302 @@
+
+
+
+  
+  
+  Selects
+  
+  
+
+
+

Design System

Selects

Single and multi-value select controls with a shared server-rendered shell.

Single select

Single-choice dropdown with the shared input shell and custom chevron.

@ui.Select(ui.SelectProps{
+	Name:        "status",
+	Placeholder: "Select a status",
+	Value:       "in-progress",
+	Options: []ui.SelectOption{
+		{Value: "todo", Label: "To do"},
+		{Value: "in-progress", Label: "In progress"},
+		{Value: "done", Label: "Done"},
+	},
+})

Multiple select

Multi-value selection with inline pills that stay form-compatible.

@ui.Select(ui.SelectProps{
+	Name:        "assignee_ids",
+	Placeholder: "Select multiple values",
+	Multiple:    true,
+	Values:      []string{"alice", "bob"},
+	Options: []ui.SelectOption{
+		{Value: "alice", Label: "Alice"},
+		{Value: "bob", Label: "Bob"},
+		{Value: "charlie", Label: "Charlie"},
+	},
+})
+ + diff --git a/docs/design-system/spacing.html b/docs/design-system/spacing.html index c5d8028..6a79ae1 100644 --- a/docs/design-system/spacing.html +++ b/docs/design-system/spacing.html @@ -8,6 +8,6 @@ -

Design System

Spacing

Fixed horizontal and vertical spacer primitives for composing gaps between UI components.

Horizontal spacing

Use SpaceX to insert fixed horizontal gaps between inline or row-aligned components.

@ui.SpaceX(ui.SpaceProps{Size: ui.SpacingStepLG})

Vertical spacing

Use SpaceY to insert fixed vertical gaps between stacked blocks.

Bloc 1
Bloc 2
@ui.SpaceY(ui.SpaceProps{Size: ui.SpacingStepMD})
+

Design System

Spacing

Fixed horizontal and vertical spacer primitives for composing gaps between UI components.

Horizontal spacing

Use SpaceX to insert fixed horizontal gaps between inline or row-aligned components.

@ui.SpaceX(ui.SpaceProps{Size: ui.SpacingStepLG})

Vertical spacing

Use SpaceY to insert fixed vertical gaps between stacked blocks.

Bloc 1
Bloc 2
@ui.SpaceY(ui.SpaceProps{Size: ui.SpacingStepMD})
diff --git a/docs/design-system/tables.html b/docs/design-system/tables.html index ae87212..b0d8c54 100644 --- a/docs/design-system/tables.html +++ b/docs/design-system/tables.html @@ -8,7 +8,7 @@ -

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

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 index 1784085..dafba33 100644 --- a/docs/design-system/tokens.html +++ b/docs/design-system/tokens.html @@ -8,6 +8,6 @@ -

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})
+

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/go-backend/cmd/buildstyles/main.go b/go-backend/cmd/buildstyles/main.go index 4cdd36c..92ef6b5 100644 --- a/go-backend/cmd/buildstyles/main.go +++ b/go-backend/cmd/buildstyles/main.go @@ -15,6 +15,7 @@ var sourceOrder = []string{ filepath.Join("internal", "web", "ui", "badge.css"), filepath.Join("internal", "web", "ui", "icon-button.css"), filepath.Join("internal", "web", "ui", "input.css"), + filepath.Join("internal", "web", "ui", "select.css"), filepath.Join("internal", "web", "ui", "textarea.css"), filepath.Join("internal", "web", "ui", "form-field.css"), filepath.Join("internal", "web", "ui", "modal.css"), diff --git a/go-backend/internal/web/ui/catalog/examples.go b/go-backend/internal/web/ui/catalog/examples.go index 692767c..ae02a7d 100644 --- a/go-backend/internal/web/ui/catalog/examples.go +++ b/go-backend/internal/web/ui/catalog/examples.go @@ -193,6 +193,61 @@ func inputExamples() []Example { } } +func selectExamples() []Example { + return []Example{ + { + Title: "Single select", + Description: "Single-choice dropdown with the shared input shell and custom chevron.", + Preview: ui.Select(ui.SelectProps{ + Name: "status", + Placeholder: "Select a status", + Value: "in-progress", + Options: []ui.SelectOption{ + {Value: "todo", Label: "To do"}, + {Value: "in-progress", Label: "In progress"}, + {Value: "done", Label: "Done"}, + }, + }), + Snippet: `@ui.Select(ui.SelectProps{ + Name: "status", + Placeholder: "Select a status", + Value: "in-progress", + Options: []ui.SelectOption{ + {Value: "todo", Label: "To do"}, + {Value: "in-progress", Label: "In progress"}, + {Value: "done", Label: "Done"}, + }, +})`, + }, + { + Title: "Multiple select", + Description: "Multi-value selection with inline pills that stay form-compatible.", + Preview: ui.Select(ui.SelectProps{ + Name: "assignee_ids", + Placeholder: "Select multiple values", + Multiple: true, + Values: []string{"alice", "bob"}, + Options: []ui.SelectOption{ + {Value: "alice", Label: "Alice"}, + {Value: "bob", Label: "Bob"}, + {Value: "charlie", Label: "Charlie"}, + }, + }), + Snippet: `@ui.Select(ui.SelectProps{ + Name: "assignee_ids", + Placeholder: "Select multiple values", + Multiple: true, + Values: []string{"alice", "bob"}, + Options: []ui.SelectOption{ + {Value: "alice", Label: "Alice"}, + {Value: "bob", Label: "Bob"}, + {Value: "charlie", Label: "Charlie"}, + }, +})`, + }, + } +} + func formFieldExamples() []Example { return []Example{ { diff --git a/go-backend/internal/web/ui/catalog/pages.go b/go-backend/internal/web/ui/catalog/pages.go index 3d9f2bc..698dacf 100644 --- a/go-backend/internal/web/ui/catalog/pages.go +++ b/go-backend/internal/web/ui/catalog/pages.go @@ -48,6 +48,12 @@ func Pages() []Page { Description: "Shared single-line and multiline text controls.", Examples: inputExamples(), }, + { + Slug: "selects", + Title: "Selects", + Description: "Single and multi-value select controls with a shared server-rendered shell.", + Examples: selectExamples(), + }, { Slug: "form-fields", Title: "Form Fields", diff --git a/go-backend/internal/web/ui/select.css b/go-backend/internal/web/ui/select.css new file mode 100644 index 0000000..aa24802 --- /dev/null +++ b/go-backend/internal/web/ui/select.css @@ -0,0 +1,154 @@ +.ui-select { + position: relative; + width: 100%; +} + +.ui-select-native { + height: 0; + left: 0; + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 0; +} + +.ui-select-control { + align-items: center; + appearance: none; + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.75rem; + color: var(--color-text-primary); + cursor: pointer; + display: flex; + gap: 0.75rem; + justify-content: space-between; + min-height: 44px; + padding: 0.55rem 0.75rem 0.55rem 0.95rem; + text-align: left; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + width: 100%; +} + +.ui-select-control:focus-visible { + border-color: var(--color-brand-focus); + box-shadow: 0 0 0 3px var(--color-focus-ring-strong); + outline: none; +} + +.ui-select-control:disabled { + color: var(--color-text-faint); + cursor: not-allowed; +} + +.ui-select-value-wrapper { + align-items: center; + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + gap: 0.35rem; + min-width: 0; +} + +.ui-select-placeholder { + color: var(--color-text-faint); +} + +.ui-select-chip { + background: var(--color-surface-muted); + border-radius: 999px; + color: var(--color-text-primary); + display: inline-flex; + font-size: 0.875rem; + line-height: 1; + padding: 0.35rem 0.6rem; +} + +.ui-select-arrow-zone { + align-items: center; + color: var(--color-text-secondary); + display: inline-flex; + flex: 0 0 auto; + justify-content: center; +} + +.ui-select-arrow-icon { + height: 1rem; + transition: transform 0.2s ease; + width: 1rem; +} + +.ui-select.is-open .ui-select-arrow-icon { + transform: rotate(180deg); +} + +.ui-select-menu { + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.9rem; + box-shadow: var(--shadow-surface-md); + display: grid; + gap: 0.25rem; + left: 0; + margin-top: 0.45rem; + max-height: 16rem; + overflow-y: auto; + padding: 0.4rem; + position: absolute; + right: 0; + top: 100%; + z-index: 30; +} + +.ui-select-menu[hidden] { + display: none; +} + +.ui-select-option { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 0.7rem; + color: var(--color-text-primary); + cursor: pointer; + display: flex; + font: inherit; + gap: 0.75rem; + justify-content: space-between; + padding: 0.7rem 0.8rem; + text-align: left; + width: 100%; +} + +.ui-select-option:hover, +.ui-select-option:focus-visible { + background: var(--color-surface-muted); + outline: none; +} + +.ui-select-option.is-selected { + background: var(--color-status-info-soft-bg); + color: var(--color-text-brand); +} + +.ui-select-option:disabled { + color: var(--color-text-faint); + cursor: not-allowed; +} + +.ui-select-option-text { + flex: 1 1 auto; +} + +.ui-select-option-check { + color: currentColor; + opacity: 0; +} + +.ui-select-option.is-selected .ui-select-option-check { + opacity: 1; +} diff --git a/go-backend/internal/web/ui/select.templ b/go-backend/internal/web/ui/select.templ new file mode 100644 index 0000000..befd82e --- /dev/null +++ b/go-backend/internal/web/ui/select.templ @@ -0,0 +1,251 @@ +package ui + +type SelectOption struct { + Value string + Label string + Disabled bool +} + +type SelectProps struct { + ID string + Name string + Placeholder string + Value string + Values []string + Multiple bool + Options []SelectOption + Attrs templ.Attributes +} + +templ Select(props SelectProps) { +
+ + + + +
+} diff --git a/go-backend/internal/web/ui/select_helpers.go b/go-backend/internal/web/ui/select_helpers.go new file mode 100644 index 0000000..5cf941e --- /dev/null +++ b/go-backend/internal/web/ui/select_helpers.go @@ -0,0 +1,104 @@ +package ui + +import ( + "strings" + + "github.com/a-h/templ" +) + +func selectPlaceholder(props SelectProps) string { + if props.Placeholder != "" { + return props.Placeholder + } + if props.Multiple { + return "Select values" + } + return "Select an option" +} + +func selectNativeID(id string, name string) string { + baseID := inputID(id, name) + if baseID == "" { + return "ui-select-native" + } + return baseID + "-native" +} + +func selectMenuID(id string, name string) string { + baseID := inputID(id, name) + if baseID == "" { + return "ui-select-menu" + } + return baseID + "-menu" +} + +func selectBoolData(value bool) string { + if value { + return "true" + } + return "false" +} + +func selectSelectedValues(props SelectProps) []string { + if props.Multiple { + return props.Values + } + if props.Value == "" { + return nil + } + return []string{props.Value} +} + +func selectOptionSelected(props SelectProps, value string) bool { + for _, selected := range selectSelectedValues(props) { + if selected == value { + return true + } + } + return false +} + +func selectSelectedLabels(props SelectProps) []string { + var labels []string + for _, option := range props.Options { + if selectOptionSelected(props, option.Value) { + labels = append(labels, option.Label) + } + } + return labels +} + +func selectSelectedLabel(props SelectProps) string { + return strings.Join(selectSelectedLabels(props), ", ") +} + +func selectMenuOptionClass(selected bool, disabled bool) string { + className := "ui-select-option" + if selected { + className += " is-selected" + } + if disabled { + className += " is-disabled" + } + return className +} + +func selectIsDisabled(attrs templ.Attributes) bool { + if attrs == nil { + return false + } + + value, ok := attrs["disabled"] + if !ok { + return false + } + + switch typed := value.(type) { + case bool: + return typed + case string: + return typed != "" && typed != "false" + default: + return true + } +} diff --git a/go-backend/internal/web/ui/select_templ.go b/go-backend/internal/web/ui/select_templ.go new file mode 100644 index 0000000..abd197c --- /dev/null +++ b/go-backend/internal/web/ui/select_templ.go @@ -0,0 +1,429 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +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 SelectOption struct { + Value string + Label string + Disabled bool +} + +type SelectProps struct { + ID string + Name string + Placeholder string + Value string + Values []string + Multiple bool + Options []SelectOption + Attrs templ.Attributes +} + +func Select(props SelectProps) 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/static/styles.css b/go-backend/static/styles.css index 9b89ab0..667950e 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -665,6 +665,162 @@ input { outline: none; } +/* Source: internal/web/ui/select.css */ +.ui-select { + position: relative; + width: 100%; +} + +.ui-select-native { + height: 0; + left: 0; + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 0; +} + +.ui-select-control { + align-items: center; + appearance: none; + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.75rem; + color: var(--color-text-primary); + cursor: pointer; + display: flex; + gap: 0.75rem; + justify-content: space-between; + min-height: 44px; + padding: 0.55rem 0.75rem 0.55rem 0.95rem; + text-align: left; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + width: 100%; +} + +.ui-select-control:focus-visible { + border-color: var(--color-brand-focus); + box-shadow: 0 0 0 3px var(--color-focus-ring-strong); + outline: none; +} + +.ui-select-control:disabled { + color: var(--color-text-faint); + cursor: not-allowed; +} + +.ui-select-value-wrapper { + align-items: center; + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + gap: 0.35rem; + min-width: 0; +} + +.ui-select-placeholder { + color: var(--color-text-faint); +} + +.ui-select-chip { + background: var(--color-surface-muted); + border-radius: 999px; + color: var(--color-text-primary); + display: inline-flex; + font-size: 0.875rem; + line-height: 1; + padding: 0.35rem 0.6rem; +} + +.ui-select-arrow-zone { + align-items: center; + color: var(--color-text-secondary); + display: inline-flex; + flex: 0 0 auto; + justify-content: center; +} + +.ui-select-arrow-icon { + height: 1rem; + transition: transform 0.2s ease; + width: 1rem; +} + +.ui-select.is-open .ui-select-arrow-icon { + transform: rotate(180deg); +} + +.ui-select-menu { + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 0.9rem; + box-shadow: var(--shadow-surface-md); + display: grid; + gap: 0.25rem; + left: 0; + margin-top: 0.45rem; + max-height: 16rem; + overflow-y: auto; + padding: 0.4rem; + position: absolute; + right: 0; + top: 100%; + z-index: 30; +} + +.ui-select-menu[hidden] { + display: none; +} + +.ui-select-option { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 0.7rem; + color: var(--color-text-primary); + cursor: pointer; + display: flex; + font: inherit; + gap: 0.75rem; + justify-content: space-between; + padding: 0.7rem 0.8rem; + text-align: left; + width: 100%; +} + +.ui-select-option:hover, +.ui-select-option:focus-visible { + background: var(--color-surface-muted); + outline: none; +} + +.ui-select-option.is-selected { + background: var(--color-status-info-soft-bg); + color: var(--color-text-brand); +} + +.ui-select-option:disabled { + color: var(--color-text-faint); + cursor: not-allowed; +} + +.ui-select-option-text { + flex: 1 1 auto; +} + +.ui-select-option-check { + color: currentColor; + opacity: 0; +} + +.ui-select-option.is-selected .ui-select-option-check { + opacity: 1; +} + /* Source: internal/web/ui/textarea.css */ .ui-textarea { appearance: none; -- 2.45.2 From 71a56b72f1bc51c438b2484b4852a37f5813b063 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 22:06:37 +0200 Subject: [PATCH 037/546] Add tasks multi-view dashboard implementation plan --- ...0-go-backend-tasks-multi-view-dashboard.md | 832 ++++++++++++++++++ 1 file changed, 832 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md diff --git a/docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md b/docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md new file mode 100644 index 0000000..59a2807 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md @@ -0,0 +1,832 @@ +# Go Backend Tasks Multi-View Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the simple `go-backend` `/tasks` CRUD page with a server-rendered cross-tablo dashboard that supports `kanban`, `list`, and `roadmap` views, URL-driven filtering, and tablo-aware create/edit flows. + +**Architecture:** Keep `/tasks` as a single owner-scoped route whose state is driven by query params. Parse page state once in the handler, load one shared task dataset across all owned tablos, then shape that dataset into dedicated `kanban`, `list`, and `roadmap` view models. Reuse the existing task persistence layer, and extend the create/edit flow so `tablo_id` and tablo-scoped étape selection are enforced on the server. + +**Tech Stack:** Go, chi, templ components rendered from Go view models, HTMX, PostgreSQL, pgx, sqlc, Go standard `net/http` testing + +--- + +## File Structure + +**Existing files to modify** + +- `go-backend/internal/tasks/model.go` + - Add owner-level dashboard page-state types, filter types, and shared dashboard record shapes. +- `go-backend/internal/web/handlers/tasks.go` + - Parse query params, normalize page state, load shared option data, preserve query state through mutations, and validate `tablo_id` plus tablo-scoped `parent_task_id`. +- `go-backend/internal/web/handlers/tasks_test.go` + - Add handler tests for view switching, filter normalization, roadmap mode behavior, and cross-tablo form validation. +- `go-backend/internal/web/handlers/in_memory_auth_repository.go` + - Add any list/filter support needed by handler tests, while keeping the repository owner-scoped. +- `go-backend/internal/web/views/tasks_view.go` + - Replace the simple grouped CRUD view model with a shared page shell and dedicated `kanban`, `list`, and `roadmap` view builders/renderers. +- `go-backend/router.go` + - Keep `/tasks` on one route, but ensure the new page behavior continues to work through the existing handlers. +- `go-backend/router_test.go` + - Add end-to-end route coverage for query-param-driven rendering and current-state-preserving mutation responses. + +**Files that may be modified if needed** + +- `go-backend/internal/db/repository.go` + - Only if the dashboard needs a small repository helper for owner-scoped task loading or deterministic ordering not already exposed. +- `go-backend/internal/db/queries.sql` + - Only if owner-scoped loading needs a dedicated query shape not already present. +- `go-backend/internal/db/sqlc/queries.sql.go` + - Regenerated if SQL changes are needed. + +**No new persistence design files are required** + +- This plan assumes the task and etape storage added earlier remains the source of truth. + +**Verification commands** + +- `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` +- `cd go-backend && go test ./...` +- `cd go-backend && just build` + +## Task 1: Add Query-State Parsing And Handler Coverage + +**Files:** +- Modify: `go-backend/internal/web/handlers/tasks.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` +- Modify: `go-backend/internal/tasks/model.go` + +- [ ] **Step 1: Write the failing handler tests for view and filter state** + +Add focused tests to `go-backend/internal/web/handlers/tasks_test.go`: + +```go +func TestGetTasksPageDefaultsToKanbanView(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/tasks", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTasksPage().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, `href="/tasks?view=kanban"`) { + t.Fatalf("expected kanban tab in body, got %q", body) + } + if !strings.Contains(body, `data-current-view="kanban"`) { + t.Fatalf("expected default view to be kanban, got %q", body) + } +} + +func TestGetTasksPageIgnoresInvalidQueryState(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/tasks?view=nope&roadmap_mode=nope&status=bad", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTasksPage().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, `data-current-view="kanban"`) { + t.Fatalf("expected normalized kanban view, got %q", body) + } + if strings.Contains(body, `value="bad" selected`) { + t.Fatalf("expected invalid status filter to be dropped, got %q", body) + } +} +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: FAIL because the current `/tasks` page has no query-state model, no `data-current-view`, and no normalized view handling. + +- [ ] **Step 3: Add the dashboard page-state types** + +Extend `go-backend/internal/tasks/model.go` with a dedicated page-state model: + +```go +type DashboardView string + +const ( + DashboardViewKanban DashboardView = "kanban" + DashboardViewList DashboardView = "list" + DashboardViewRoadmap DashboardView = "roadmap" +) + +type RoadmapMode string + +const ( + RoadmapModeWeek RoadmapMode = "week" + RoadmapModeMonth RoadmapMode = "month" +) + +type DashboardPageState struct { + View DashboardView + RoadmapMode RoadmapMode + TabloIDs []uuid.UUID + AssigneeIDs []uuid.UUID + Statuses []Status +} +``` + +Add parsing helpers with explicit normalization: + +```go +func NormalizeDashboardView(raw string) DashboardView { + switch DashboardView(raw) { + case DashboardViewList: + return DashboardViewList + case DashboardViewRoadmap: + return DashboardViewRoadmap + default: + return DashboardViewKanban + } +} + +func NormalizeRoadmapMode(raw string) RoadmapMode { + if RoadmapMode(raw) == RoadmapModeMonth { + return RoadmapModeMonth + } + return RoadmapModeWeek +} +``` + +- [ ] **Step 4: Parse query state in the handler** + +In `go-backend/internal/web/handlers/tasks.go`, add a helper used by `renderTasksPage`: + +```go +func parseDashboardPageState(r *http.Request) taskmodel.DashboardPageState { + query := r.URL.Query() + state := taskmodel.DashboardPageState{ + View: taskmodel.NormalizeDashboardView(query.Get("view")), + RoadmapMode: taskmodel.NormalizeRoadmapMode(query.Get("roadmap_mode")), + TabloIDs: parseUUIDList(query["tablo"]), + AssigneeIDs: parseUUIDList(query["assignee"]), + Statuses: parseStatusList(query["status"]), + } + if state.View != taskmodel.DashboardViewRoadmap { + state.RoadmapMode = taskmodel.RoadmapModeWeek + } + return state +} +``` + +Add the parsing helpers in the same file: + +```go +func parseUUIDList(values []string) []uuid.UUID { + parsed := make([]uuid.UUID, 0, len(values)) + for _, value := range values { + id, err := uuid.Parse(strings.TrimSpace(value)) + if err == nil { + parsed = append(parsed, id) + } + } + return parsed +} + +func parseStatusList(values []string) []taskmodel.Status { + parsed := make([]taskmodel.Status, 0, len(values)) + for _, value := range values { + status, err := taskmodel.ParseStatus(strings.TrimSpace(value)) + if err == nil { + parsed = append(parsed, status) + } + } + return parsed +} +``` + +- [ ] **Step 5: Re-run the focused tests to verify the parsing and normalization contract** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: still FAIL, but now only on missing page rendering markers and missing multi-view content. Query parsing should compile cleanly. + +- [ ] **Step 6: Commit** + +```bash +git add go-backend/internal/tasks/model.go go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/tasks_test.go +git commit -m "feat: add tasks dashboard query state parsing" +``` + +## Task 2: Build Shared Dashboard Dataset And Grouping Logic + +**Files:** +- Modify: `go-backend/internal/tasks/model.go` +- Modify: `go-backend/internal/web/views/tasks_view.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` + +- [ ] **Step 1: Write the failing view-model tests for cross-tablo grouping** + +Add tests to `go-backend/internal/web/handlers/tasks_test.go` or a new focused view-model test section in the same file: + +```go +func TestTasksDashboardListGroupsByStatusAcrossTablos(t *testing.T) { + now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC) + tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha", Color: "#111111"} + tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta", Color: "#222222"} + + records := []taskmodel.Record{ + {ID: uuid.New(), TabloID: tabloA.ID, Title: "Write copy", Status: taskmodel.StatusTodo, CreatedAt: now}, + {ID: uuid.New(), TabloID: tabloB.ID, Title: "Ship docs", Status: taskmodel.StatusTodo, CreatedAt: now.Add(time.Minute)}, + {ID: uuid.New(), TabloID: tabloB.ID, Title: "Review launch", Status: taskmodel.StatusInReview, CreatedAt: now.Add(2 * time.Minute)}, + } + + vm := views.NewTasksDashboardPageViewModel( + []tablomodel.Record{tabloA, tabloB}, + records, + nil, + taskmodel.DashboardPageState{View: taskmodel.DashboardViewList, RoadmapMode: taskmodel.RoadmapModeWeek}, + now, + ) + + if len(vm.List.Groups) != 4 { + t.Fatalf("expected four status groups, got %d", len(vm.List.Groups)) + } + if got := len(vm.List.Groups[0].Tasks); got != 2 { + t.Fatalf("expected two todo tasks across tablos, got %d", got) + } +} +``` + +Add a roadmap test: + +```go +func TestTasksDashboardRoadmapCreatesPerTabloSansEtapeLanes(t *testing.T) { + now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC) + tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha"} + tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta"} + + due := now.AddDate(0, 0, 3) + records := []taskmodel.Record{ + {ID: uuid.New(), TabloID: tabloA.ID, Title: "A task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now}, + {ID: uuid.New(), TabloID: tabloB.ID, Title: "B task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now}, + } + + vm := views.NewTasksDashboardPageViewModel( + []tablomodel.Record{tabloA, tabloB}, + records, + nil, + taskmodel.DashboardPageState{View: taskmodel.DashboardViewRoadmap, RoadmapMode: taskmodel.RoadmapModeWeek}, + now, + ) + + if len(vm.Roadmap.Lanes) != 2 { + t.Fatalf("expected one Sans étape lane per tablo, got %d", len(vm.Roadmap.Lanes)) + } +} +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: FAIL because the current view model has no `List` or `Roadmap` structures and still groups by tablo sections. + +- [ ] **Step 3: Replace the old simple page model with dashboard view models** + +Reshape `go-backend/internal/web/views/tasks_view.go` around a new top-level model: + +```go +type TasksDashboardPageViewModel struct { + State taskmodel.DashboardPageState + Filters TasksDashboardFiltersView + Kanban TasksKanbanView + List TasksListView + Roadmap TasksRoadmapView + Form TasksFormOptionsView + HasTasks bool +} +``` + +Add focused sub-models: + +```go +type TasksKanbanView struct { + Columns []TasksKanbanColumnView +} + +type TasksListView struct { + Groups []TasksStatusGroupView +} + +type TasksRoadmapView struct { + Mode string + Buckets []TasksRoadmapBucketView + Lanes []TasksRoadmapLaneView +} +``` + +- [ ] **Step 4: Add pure grouping builders for kanban, list, and roadmap** + +Implement these builders in `go-backend/internal/web/views/tasks_view.go`: + +```go +func buildKanbanView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string) TasksKanbanView +func buildListView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string) TasksListView +func buildRoadmapView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string, mode taskmodel.RoadmapMode, now time.Time) TasksRoadmapView +``` + +Use deterministic ordering: + +```go +slices.SortFunc(records, func(a, b taskmodel.Record) int { + if diff := strings.Compare(string(a.Status), string(b.Status)); diff != 0 { + return diff + } + return a.CreatedAt.Compare(b.CreatedAt) +}) +``` + +For roadmap lanes, use stable lane IDs: + +```go +func roadmapLaneID(tabloID uuid.UUID, parentTaskID *uuid.UUID) string { + if parentTaskID == nil { + return tabloID.String() + ":sans-etape" + } + return tabloID.String() + ":" + parentTaskID.String() +} +``` + +- [ ] **Step 5: Re-run the focused tests to verify grouping passes** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: PASS for the new grouping tests, while handler rendering tests may still fail until the page shell is updated. + +- [ ] **Step 6: Commit** + +```bash +git add go-backend/internal/tasks/model.go go-backend/internal/web/views/tasks_view.go go-backend/internal/web/handlers/tasks_test.go +git commit -m "feat: add tasks dashboard grouping models" +``` + +## Task 3: Extend Create/Edit Flow With Tablo-Aware Options And Validation + +**Files:** +- Modify: `go-backend/internal/web/handlers/tasks.go` +- Modify: `go-backend/internal/web/handlers/in_memory_auth_repository.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` + +- [ ] **Step 1: Write the failing mutation tests for cross-tablo validation** + +Add tests to `go-backend/internal/web/handlers/tasks_test.go`: + +```go +func TestPatchTaskRejectsParentEtapeFromAnotherTablo(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 authenticated user") + } + + tabloA := mustCreateOwnedTablo(t, repo, userID) + tabloB := mustCreateOwnedTablo(t, repo, userID) + task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task") + foreignEtape := mustCreateEtape(t, repo, userID, tabloB.ID, "Beta stage") + + form := url.Values{} + form.Set("tablo_id", tabloA.ID.String()) + form.Set("title", task.Title) + form.Set("status", string(taskmodel.StatusTodo)) + form.Set("parent_task_id", foreignEtape.ID.String()) + + patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String(), strings.NewReader(form.Encode())) + patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + patchReq.SetPathValue("taskID", task.ID.String()) + patchReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PatchTask().ServeHTTP(rec, patchReq) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422 for cross-tablo parent, got %d", rec.Code) + } +} +``` + +Add a create-form scoping test: + +```go +func TestGetEditTaskModalShowsOnlyEtapesFromSelectedTablo(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 authenticated user") + } + + tabloA := mustCreateOwnedTablo(t, repo, userID) + tabloB := mustCreateOwnedTablo(t, repo, userID) + task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Editable") + etapeA := mustCreateEtape(t, repo, userID, tabloA.ID, "Stage A") + _ = mustCreateEtape(t, repo, userID, tabloB.ID, "Stage B") + + editReq := httptest.NewRequest(http.MethodGet, "/tasks/"+task.ID.String()+"/edit", nil) + editReq.SetPathValue("taskID", task.ID.String()) + editReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetEditTaskModal().ServeHTTP(rec, editReq) + + body := rec.Body.String() + if !strings.Contains(body, etapeA.ID.String()) { + t.Fatalf("expected same-tablo etape in edit form, got %q", body) + } + if strings.Contains(body, "Stage B") { + t.Fatalf("did not expect foreign tablo etape in edit form, got %q", body) + } +} +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: FAIL because the current create/edit flow neither requires `tablo_id` on update nor renders tablo-scoped étape options. + +- [ ] **Step 3: Require `tablo_id` and load owner-scoped option data for forms** + +Update `go-backend/internal/web/handlers/tasks.go` so create and edit forms both build a consistent options model: + +```go +type taskFormOptions struct { + Tablos []tablomodel.Record + EtapesByTablo map[uuid.UUID][]taskmodel.Record + Assignees map[uuid.UUID]string +} + +func (h *AuthHandler) loadTaskFormOptions(ctx context.Context, ownerID uuid.UUID) (taskFormOptions, error) { + tablos, err := h.repo.ListTablos(ctx, ListTablosInput{OwnerID: ownerID}) + if err != nil { + return taskFormOptions{}, err + } + records, err := h.repo.(taskPageRepository).ListTasksByOwner(ctx, ownerID) + if err != nil { + return taskFormOptions{}, err + } + return buildTaskFormOptions(tablos, records, h.repo), nil +} +``` + +Make `parseUpdateTaskInput` require `tablo_id`: + +```go +tabloID, err := uuid.Parse(strings.TrimSpace(r.FormValue("tablo_id"))) +if err != nil { + return UpdateTaskInput{}, errors.New("tablo_id is required") +} +``` + +- [ ] **Step 4: Enforce selected tablo and selected étape consistency** + +Add a dedicated validator in `go-backend/internal/web/handlers/tasks.go`: + +```go +func validateTaskTabloAndParent(records []TaskRecord, tabloID uuid.UUID, parentTaskID *uuid.UUID) error { + if parentTaskID == nil { + return nil + } + for _, record := range records { + if record.ID != *parentTaskID { + continue + } + if !record.IsEtape { + return errors.New("parent_task_id must reference an étape") + } + if record.TabloID != tabloID { + return errors.New("parent_task_id must belong to the selected tablo") + } + return nil + } + return errors.New("parent_task_id must reference an active étape") +} +``` + +Call this from both create and patch handlers after parsing input and before repository mutation. + +- [ ] **Step 5: Re-run the focused tests to verify the mutation flow passes** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: PASS for the new cross-tablo validation tests and edit-form scoping test. + +- [ ] **Step 6: Commit** + +```bash +git add go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/in_memory_auth_repository.go go-backend/internal/web/handlers/tasks_test.go +git commit -m "feat: add tablo-aware task form validation" +``` + +## Task 4: Replace The `/tasks` Page Rendering With Multi-View Dashboard UI + +**Files:** +- Modify: `go-backend/internal/web/views/tasks_view.go` +- Modify: `go-backend/internal/web/handlers/tasks.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` + +- [ ] **Step 1: Write the failing rendering tests for view-specific content** + +Add tests to `go-backend/internal/web/handlers/tasks_test.go`: + +```go +func TestGetTasksPageRendersListViewAcrossTablos(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 authenticated user") + } + + tabloA := mustCreateOwnedTablo(t, repo, userID) + tabloB := mustCreateOwnedTablo(t, repo, userID) + _ = mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task") + _ = mustCreateTask(t, repo, userID, tabloB.ID, nil, "Beta task") + + pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=list", nil) + pageReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTasksPage().ServeHTTP(rec, pageReq) + + body := rec.Body.String() + for _, want := range []string{"Alpha task", "Beta task", "Liste", "À faire"} { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q, got %q", want, body) + } + } +} + +func TestGetTasksPageRendersRoadmapWeekMode(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 authenticated user") + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + etape := mustCreateEtape(t, repo, userID, tablo.ID, "Préparation") + task := mustCreateTask(t, repo, userID, tablo.ID, &etape.ID, "Planifier") + due := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC) + _, _ = repo.UpdateTask(context.Background(), UpdateTaskInput{ + ID: task.ID, OwnerID: userID, TabloID: tablo.ID, Title: task.Title, Description: task.Description, + Status: task.Status, DueDate: &due, ParentTaskID: &etape.ID, + }) + + pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=roadmap&roadmap_mode=week", nil) + pageReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTasksPage().ServeHTTP(rec, pageReq) + + body := rec.Body.String() + for _, want := range []string{"Roadmap", "Préparation", "Planifier", "Semaine"} { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q, got %q", want, body) + } + } +} +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: FAIL because the current page still renders simple tablo sections and has no list/roadmap UI. + +- [ ] **Step 3: Build a shared dashboard page shell** + +Replace `TasksPageContent` in `go-backend/internal/web/views/tasks_view.go` with a richer shell: + +```go +func TasksPageContent(vm TasksDashboardPageViewModel) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, `

Mes tâches

`) + _, _ = io.WriteString(w, `Nouvelle tâche`) + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, renderTasksViewTabs(vm.State)) + _, _ = io.WriteString(w, renderTasksFilters(vm.Filters, vm.State)) + _, _ = io.WriteString(w, `
`) + return renderCurrentTasksView(w, vm) + }) +} +``` + +Add explicit renderers: + +```go +func renderCurrentTasksView(w io.Writer, vm TasksDashboardPageViewModel) error +func renderTasksViewTabs(state taskmodel.DashboardPageState) string +func renderTasksFilters(filters TasksDashboardFiltersView, state taskmodel.DashboardPageState) string +``` + +- [ ] **Step 4: Implement the view-specific renderers** + +Add one renderer per mode: + +```go +func renderKanbanView(w io.Writer, view TasksKanbanView) error +func renderListView(w io.Writer, view TasksListView) error +func renderRoadmapView(w io.Writer, view TasksRoadmapView) error +``` + +Use obvious metadata in task cards/rows: + +```go +type TaskCardView struct { + ID string + Title string + TabloName string + EtapeName string + Assignee string + DueDate string + StatusLabel string +} +``` + +For roadmap bucket labels, compute from `now`: + +```go +func roadmapBucketLabelWeek(start time.Time) string { + return start.Format("02 Jan") +} + +func roadmapBucketLabelMonth(start time.Time) string { + return start.Format("Jan 2006") +} +``` + +- [ ] **Step 5: Re-run the focused tests to verify all render modes pass** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: PASS for default, list, and roadmap rendering tests. + +- [ ] **Step 6: Commit** + +```bash +git add go-backend/internal/web/views/tasks_view.go go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/tasks_test.go +git commit -m "feat: render tasks multi-view dashboard" +``` + +## Task 5: Add Router-Level Coverage And Final Verification + +**Files:** +- Modify: `go-backend/router_test.go` +- Modify: `go-backend/internal/web/handlers/tasks_test.go` + +- [ ] **Step 1: Write the failing router tests for query-param-driven `/tasks`** + +Add router-level tests to `go-backend/router_test.go`: + +```go +func TestRouterServesTasksListView(t *testing.T) { + router := newTestRouter() + req := httptest.NewRequest(http.MethodGet, "/tasks?view=list", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther && rec.Code != http.StatusOK { + t.Fatalf("expected login redirect or page response, got %d", rec.Code) + } +} +``` + +If your existing router tests already log in first, use that pattern instead and assert the response contains `Liste`. + +Add a mutation-state preservation test in handler tests: + +```go +func TestPatchTaskRendersCurrentDashboardQueryState(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 authenticated user") + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Stateful") + + form := url.Values{} + form.Set("tablo_id", tablo.ID.String()) + form.Set("title", "Stateful") + form.Set("status", string(taskmodel.StatusDone)) + + patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String()+"?view=list&status=done", strings.NewReader(form.Encode())) + patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + patchReq.SetPathValue("taskID", task.ID.String()) + patchReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PatchTask().ServeHTTP(rec, patchReq) + + body := rec.Body.String() + if !strings.Contains(body, `data-current-view="list"`) { + t.Fatalf("expected response to preserve current list view, got %q", body) + } +} +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'` + +Expected: FAIL if mutation responses still drop back to default state or router-level coverage is missing. + +- [ ] **Step 3: Preserve query state through mutation responses** + +Make `renderTasksPage` fully query-driven so post-mutation handlers reuse the request URL: + +```go +func (h *AuthHandler) renderTasksPage(w http.ResponseWriter, r *http.Request) { + state := parseDashboardPageState(r) + // load tablos, tasks, assignees + vm := views.NewTasksDashboardPageViewModel(tablos, filteredTasks, assigneeLabels, state, time.Now().UTC()) + // existing DashboardPage / DashboardContentSwap render path +} +``` + +Ensure `PostTasks`, `PatchTask`, and `DeleteTask` call `h.renderTasksPage(w, r)` without rebuilding a new request so the current query state survives. + +- [ ] **Step 4: Run the full verification suite** + +Run: `cd go-backend && go test ./...` + +Expected: PASS across handlers, router, and existing task tests. + +Run: `cd go-backend && just build` + +Expected: PASS with a successful Go build for the full `go-backend`. + +- [ ] **Step 5: Commit** + +```bash +git add go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/tasks_test.go go-backend/router_test.go +git commit -m "test: verify tasks dashboard routing and state preservation" +``` + +## Self-Review + +**Spec coverage** + +- Cross-tablo aggregation is covered in Task 2 and Task 4. +- Query-param-driven `view` and `roadmap_mode` are covered in Task 1 and Task 5. +- `tablo`, `assignee`, and `status` filters are covered in Task 1, Task 2, and Task 4. +- `kanban`, `list`, and `roadmap` renderers are covered in Task 2 and Task 4. +- `week` and `month` roadmap modes are covered in Task 2 and Task 4. +- `tablo_id` plus tablo-scoped étape validation in create/edit flows are covered in Task 3. +- Current-state-preserving mutation responses are covered in Task 5. + +**Placeholder scan** + +- No `TODO`, `TBD`, or "implement later" placeholders remain. +- Every code-changing step includes explicit code targets and representative code blocks. +- Every verification step includes an exact command and expected result. + +**Type consistency** + +- View-state types use `DashboardView`, `RoadmapMode`, and `DashboardPageState` consistently. +- Renderer naming is consistent across `buildKanbanView`, `buildListView`, `buildRoadmapView`, and `renderKanbanView`, `renderListView`, `renderRoadmapView`. +- Mutation validation consistently uses `tablo_id` and `parent_task_id`. -- 2.45.2 From faf3199b716710db50332a28156a02a94b3c9cba Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 22:18:00 +0200 Subject: [PATCH 038/546] Rename tasks plan dashboard state types --- ...0-go-backend-tasks-multi-view-dashboard.md | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md b/docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md index 59a2807..2ad8f60 100644 --- a/docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md +++ b/docs/superpowers/plans/2026-05-10-go-backend-tasks-multi-view-dashboard.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Replace the simple `go-backend` `/tasks` CRUD page with a server-rendered cross-tablo dashboard that supports `kanban`, `list`, and `roadmap` views, URL-driven filtering, and tablo-aware create/edit flows. +**Goal:** Replace the simple `go-backend` `/tasks` CRUD page with a server-rendered cross-tablo tasks page that supports `kanban`, `list`, and `roadmap` views, URL-driven filtering, and tablo-aware create/edit flows. **Architecture:** Keep `/tasks` as a single owner-scoped route whose state is driven by query params. Parse page state once in the handler, load one shared task dataset across all owned tablos, then shape that dataset into dedicated `kanban`, `list`, and `roadmap` view models. Reuse the existing task persistence layer, and extend the create/edit flow so `tablo_id` and tablo-scoped étape selection are enforced on the server. @@ -15,7 +15,7 @@ **Existing files to modify** - `go-backend/internal/tasks/model.go` - - Add owner-level dashboard page-state types, filter types, and shared dashboard record shapes. + - Add owner-level task page-state types, filter types, and shared task page record shapes. - `go-backend/internal/web/handlers/tasks.go` - Parse query params, normalize page state, load shared option data, preserve query state through mutations, and validate `tablo_id` plus tablo-scoped `parent_task_id`. - `go-backend/internal/web/handlers/tasks_test.go` @@ -118,24 +118,24 @@ Expected: FAIL because the current `/tasks` page has no query-state model, no `d Extend `go-backend/internal/tasks/model.go` with a dedicated page-state model: ```go -type DashboardView string +type TaskView string const ( - DashboardViewKanban DashboardView = "kanban" - DashboardViewList DashboardView = "list" - DashboardViewRoadmap DashboardView = "roadmap" + TaskViewKanban TaskView = "kanban" + TaskViewList TaskView = "list" + TaskViewRoadmap TaskView = "roadmap" ) -type RoadmapMode string +type TaskRoadmapMode string const ( - RoadmapModeWeek RoadmapMode = "week" - RoadmapModeMonth RoadmapMode = "month" + TaskRoadmapModeWeek TaskRoadmapMode = "week" + TaskRoadmapModeMonth TaskRoadmapMode = "month" ) -type DashboardPageState struct { - View DashboardView - RoadmapMode RoadmapMode +type TaskPageState struct { + View TaskView + RoadmapMode TaskRoadmapMode TabloIDs []uuid.UUID AssigneeIDs []uuid.UUID Statuses []Status @@ -145,22 +145,22 @@ type DashboardPageState struct { Add parsing helpers with explicit normalization: ```go -func NormalizeDashboardView(raw string) DashboardView { - switch DashboardView(raw) { - case DashboardViewList: - return DashboardViewList - case DashboardViewRoadmap: - return DashboardViewRoadmap +func NormalizeTaskView(raw string) TaskView { + switch TaskView(raw) { + case TaskViewList: + return TaskViewList + case TaskViewRoadmap: + return TaskViewRoadmap default: - return DashboardViewKanban + return TaskViewKanban } } -func NormalizeRoadmapMode(raw string) RoadmapMode { - if RoadmapMode(raw) == RoadmapModeMonth { - return RoadmapModeMonth +func NormalizeTaskRoadmapMode(raw string) TaskRoadmapMode { + if TaskRoadmapMode(raw) == TaskRoadmapModeMonth { + return TaskRoadmapModeMonth } - return RoadmapModeWeek + return TaskRoadmapModeWeek } ``` @@ -169,17 +169,17 @@ func NormalizeRoadmapMode(raw string) RoadmapMode { In `go-backend/internal/web/handlers/tasks.go`, add a helper used by `renderTasksPage`: ```go -func parseDashboardPageState(r *http.Request) taskmodel.DashboardPageState { +func parseTaskPageState(r *http.Request) taskmodel.TaskPageState { query := r.URL.Query() - state := taskmodel.DashboardPageState{ - View: taskmodel.NormalizeDashboardView(query.Get("view")), - RoadmapMode: taskmodel.NormalizeRoadmapMode(query.Get("roadmap_mode")), + state := taskmodel.TaskPageState{ + View: taskmodel.NormalizeTaskView(query.Get("view")), + RoadmapMode: taskmodel.NormalizeTaskRoadmapMode(query.Get("roadmap_mode")), TabloIDs: parseUUIDList(query["tablo"]), AssigneeIDs: parseUUIDList(query["assignee"]), Statuses: parseStatusList(query["status"]), } - if state.View != taskmodel.DashboardViewRoadmap { - state.RoadmapMode = taskmodel.RoadmapModeWeek + if state.View != taskmodel.TaskViewRoadmap { + state.RoadmapMode = taskmodel.TaskRoadmapModeWeek } return state } @@ -224,7 +224,7 @@ git add go-backend/internal/tasks/model.go go-backend/internal/web/handlers/task git commit -m "feat: add tasks dashboard query state parsing" ``` -## Task 2: Build Shared Dashboard Dataset And Grouping Logic +## Task 2: Build Shared Task Dataset And Grouping Logic **Files:** - Modify: `go-backend/internal/tasks/model.go` @@ -236,7 +236,7 @@ git commit -m "feat: add tasks dashboard query state parsing" Add tests to `go-backend/internal/web/handlers/tasks_test.go` or a new focused view-model test section in the same file: ```go -func TestTasksDashboardListGroupsByStatusAcrossTablos(t *testing.T) { +func TestTasksListGroupsByStatusAcrossTablos(t *testing.T) { now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC) tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha", Color: "#111111"} tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta", Color: "#222222"} @@ -247,11 +247,11 @@ func TestTasksDashboardListGroupsByStatusAcrossTablos(t *testing.T) { {ID: uuid.New(), TabloID: tabloB.ID, Title: "Review launch", Status: taskmodel.StatusInReview, CreatedAt: now.Add(2 * time.Minute)}, } - vm := views.NewTasksDashboardPageViewModel( + vm := views.NewTasksPageViewModel( []tablomodel.Record{tabloA, tabloB}, records, nil, - taskmodel.DashboardPageState{View: taskmodel.DashboardViewList, RoadmapMode: taskmodel.RoadmapModeWeek}, + taskmodel.TaskPageState{View: taskmodel.TaskViewList, RoadmapMode: taskmodel.TaskRoadmapModeWeek}, now, ) @@ -267,7 +267,7 @@ func TestTasksDashboardListGroupsByStatusAcrossTablos(t *testing.T) { Add a roadmap test: ```go -func TestTasksDashboardRoadmapCreatesPerTabloSansEtapeLanes(t *testing.T) { +func TestTasksRoadmapCreatesPerTabloSansEtapeLanes(t *testing.T) { now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC) tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha"} tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta"} @@ -278,11 +278,11 @@ func TestTasksDashboardRoadmapCreatesPerTabloSansEtapeLanes(t *testing.T) { {ID: uuid.New(), TabloID: tabloB.ID, Title: "B task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now}, } - vm := views.NewTasksDashboardPageViewModel( + vm := views.NewTasksPageViewModel( []tablomodel.Record{tabloA, tabloB}, records, nil, - taskmodel.DashboardPageState{View: taskmodel.DashboardViewRoadmap, RoadmapMode: taskmodel.RoadmapModeWeek}, + taskmodel.TaskPageState{View: taskmodel.TaskViewRoadmap, RoadmapMode: taskmodel.TaskRoadmapModeWeek}, now, ) @@ -303,9 +303,9 @@ Expected: FAIL because the current view model has no `List` or `Roadmap` structu Reshape `go-backend/internal/web/views/tasks_view.go` around a new top-level model: ```go -type TasksDashboardPageViewModel struct { - State taskmodel.DashboardPageState - Filters TasksDashboardFiltersView +type TasksPageViewModel struct { + State taskmodel.TaskPageState + Filters TasksFiltersView Kanban TasksKanbanView List TasksListView Roadmap TasksRoadmapView @@ -339,7 +339,7 @@ Implement these builders in `go-backend/internal/web/views/tasks_view.go`: ```go func buildKanbanView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string) TasksKanbanView func buildListView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string) TasksListView -func buildRoadmapView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string, mode taskmodel.RoadmapMode, now time.Time) TasksRoadmapView +func buildRoadmapView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string, mode taskmodel.TaskRoadmapMode, now time.Time) TasksRoadmapView ``` Use deterministic ordering: @@ -543,7 +543,7 @@ git add go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handle git commit -m "feat: add tablo-aware task form validation" ``` -## Task 4: Replace The `/tasks` Page Rendering With Multi-View Dashboard UI +## Task 4: Replace The `/tasks` Page Rendering With Multi-View Task UI **Files:** - Modify: `go-backend/internal/web/views/tasks_view.go` @@ -633,7 +633,7 @@ Expected: FAIL because the current page still renders simple tablo sections and Replace `TasksPageContent` in `go-backend/internal/web/views/tasks_view.go` with a richer shell: ```go -func TasksPageContent(vm TasksDashboardPageViewModel) templ.Component { +func TasksPageContent(vm TasksPageViewModel) templ.Component { return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { _, _ = io.WriteString(w, `
`) _, _ = io.WriteString(w, `
`) @@ -651,9 +651,9 @@ func TasksPageContent(vm TasksDashboardPageViewModel) templ.Component { Add explicit renderers: ```go -func renderCurrentTasksView(w io.Writer, vm TasksDashboardPageViewModel) error -func renderTasksViewTabs(state taskmodel.DashboardPageState) string -func renderTasksFilters(filters TasksDashboardFiltersView, state taskmodel.DashboardPageState) string +func renderCurrentTasksView(w io.Writer, vm TasksPageViewModel) error +func renderTasksViewTabs(state taskmodel.TaskPageState) string +func renderTasksFilters(filters TasksFiltersView, state taskmodel.TaskPageState) string ``` - [ ] **Step 4: Implement the view-specific renderers** @@ -734,7 +734,7 @@ If your existing router tests already log in first, use that pattern instead and Add a mutation-state preservation test in handler tests: ```go -func TestPatchTaskRendersCurrentDashboardQueryState(t *testing.T) { +func TestPatchTaskRendersCurrentTaskQueryState(t *testing.T) { repo := NewInMemoryAuthRepository() handler := NewAuthHandler(repo) sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") @@ -781,9 +781,9 @@ Make `renderTasksPage` fully query-driven so post-mutation handlers reuse the re ```go func (h *AuthHandler) renderTasksPage(w http.ResponseWriter, r *http.Request) { - state := parseDashboardPageState(r) + state := parseTaskPageState(r) // load tablos, tasks, assignees - vm := views.NewTasksDashboardPageViewModel(tablos, filteredTasks, assigneeLabels, state, time.Now().UTC()) + vm := views.NewTasksPageViewModel(tablos, filteredTasks, assigneeLabels, state, time.Now().UTC()) // existing DashboardPage / DashboardContentSwap render path } ``` @@ -827,6 +827,6 @@ git commit -m "test: verify tasks dashboard routing and state preservation" **Type consistency** -- View-state types use `DashboardView`, `RoadmapMode`, and `DashboardPageState` consistently. +- View-state types use `TaskView`, `TaskRoadmapMode`, and `TaskPageState` consistently. - Renderer naming is consistent across `buildKanbanView`, `buildListView`, `buildRoadmapView`, and `renderKanbanView`, `renderListView`, `renderRoadmapView`. - Mutation validation consistently uses `tablo_id` and `parent_task_id`. -- 2.45.2 From c80a8a875e59f29d54f1e5f3d5cd2156fd7dd1cb Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 23:14:47 +0200 Subject: [PATCH 039/546] First pass on tasks frontend --- go-backend/internal/tasks/model.go | 44 + go-backend/internal/web/handlers/auth_test.go | 15 +- .../web/handlers/in_memory_auth_repository.go | 6 +- go-backend/internal/web/handlers/tasks.go | 211 ++- .../internal/web/handlers/tasks_test.go | 349 +++- go-backend/internal/web/views/tasks.templ | 326 ++++ go-backend/internal/web/views/tasks_templ.go | 1505 +++++++++++++++++ go-backend/internal/web/views/tasks_view.go | 763 +++++++-- go-backend/router_test.go | 13 +- go-backend/static/tailwind.css | 602 ++++++- go-backend/tailwind.input.css | 36 +- 11 files changed, 3563 insertions(+), 307 deletions(-) create mode 100644 go-backend/internal/web/views/tasks.templ create mode 100644 go-backend/internal/web/views/tasks_templ.go diff --git a/go-backend/internal/tasks/model.go b/go-backend/internal/tasks/model.go index 97606cb..0e9bbfd 100644 --- a/go-backend/internal/tasks/model.go +++ b/go-backend/internal/tasks/model.go @@ -53,6 +53,7 @@ type CreateInput struct { type UpdateInput struct { ID uuid.UUID OwnerID uuid.UUID + TabloID uuid.UUID Title string Description string Status Status @@ -66,6 +67,29 @@ type ListByTabloInput struct { TabloID uuid.UUID } +type TaskView string + +const ( + TaskViewKanban TaskView = "kanban" + TaskViewList TaskView = "list" + TaskViewRoadmap TaskView = "roadmap" +) + +type TaskRoadmapMode string + +const ( + TaskRoadmapModeWeek TaskRoadmapMode = "week" + TaskRoadmapModeMonth TaskRoadmapMode = "month" +) + +type TaskPageState struct { + View TaskView + RoadmapMode TaskRoadmapMode + TabloIDs []uuid.UUID + AssigneeIDs []uuid.UUID + Statuses []Status +} + func ParseStatus(raw string) (Status, error) { switch Status(strings.TrimSpace(raw)) { case StatusTodo, StatusInProgress, StatusInReview, StatusDone: @@ -74,3 +98,23 @@ func ParseStatus(raw string) (Status, error) { return "", ErrInvalidStatus } } + +func NormalizeTaskView(raw string) TaskView { + switch TaskView(strings.TrimSpace(raw)) { + case TaskViewList: + return TaskViewList + case TaskViewRoadmap: + return TaskViewRoadmap + default: + return TaskViewKanban + } +} + +func NormalizeTaskRoadmapMode(raw string) TaskRoadmapMode { + switch TaskRoadmapMode(strings.TrimSpace(raw)) { + case TaskRoadmapModeMonth: + return TaskRoadmapModeMonth + default: + return TaskRoadmapModeWeek + } +} diff --git a/go-backend/internal/web/handlers/auth_test.go b/go-backend/internal/web/handlers/auth_test.go index 43f5641..4937207 100644 --- a/go-backend/internal/web/handlers/auth_test.go +++ b/go-backend/internal/web/handlers/auth_test.go @@ -150,18 +150,25 @@ func TestTasksPageSidebarShowsRealTablos(t *testing.T) { } body := rec.Body.String() + projectSection := body + if index := strings.Index(body, `id="sidebar-projects-section"`); index >= 0 { + projectSection = body[index:] + if end := strings.Index(projectSection, `
+} + +templ TasksViewTabs(state taskmodel.TaskPageState) { +
+ + @TasksIcon("kanban", "w-4 h-4") + Tableau + + + @TasksIcon("list", "w-4 h-4") + Liste + + + @TasksIcon("map", "w-4 h-4") + Roadmap + + +
+ if state.View == taskmodel.TaskViewRoadmap { + + } +} + +templ TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) { +
+ for _, column := range view.Columns { +
+
+
+ @TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(column.ID)) +

{ column.Label }

+ { len(column.Tasks) } +
+ +
+
+ for _, task := range column.Tasks { + @TaskCard(task, state, false) + } +
+
+ } +
+} + +templ TasksListLayout(view TasksListView, state taskmodel.TaskPageState) { +
+ for _, group := range view.Groups { +
+
+
+ @TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(group.ID)) +

{ group.Label }

+ { len(group.Tasks) } +
+ +
+
+ for _, task := range group.Tasks { + @TasksListRow(task, state) + } +
+
+ } +
+} + +templ TasksRoadmapLayout(view TasksRoadmapView, state taskmodel.TaskPageState) { +
+ for _, lane := range view.Lanes { +
+
+
+

{ lane.Label }

+

Étape comme lane horizontale, avec bucketisation par date d'échéance.

+
+ +
+
+ for _, bucket := range lane.Buckets { +
+
+

{ bucket.Label }

+ { len(bucket.Tasks) } +
+
+ for _, task := range bucket.Tasks { + @TaskCard(task, state, false) + } +
+
+ } +
+
+ } +
+} + +templ TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) { +
+
+

{ task.Title }

+ + +
+ if task.DueDate != "" { +
+ @TasksIcon("calendar", "lucide lucide-calendar w-3.5 h-3.5 mr-1.5") + { task.DueDate } +
+ } +
+
+ @TasksIcon(etapeIconName(task), "w-3 h-3 text-white") +
+ { etapeLabel(task) } +
+
+
+
+ @TasksIcon("message-square", "lucide lucide-message-square w-3.5 h-3.5 mr-1") + 0 +
+
+ @TasksIcon("paperclip", "lucide lucide-paperclip w-3.5 h-3.5 mr-1") + 0 +
+
+ Tablo { task.TabloName } +
+
+
+ @TaskAssigneeAvatar(task) +
+
+
+} + +templ TasksListRow(task TaskCardView, state taskmodel.TaskPageState) { +
+
+ @TaskCard(task, state, true) +
+
+
+
Tablo
+
{ task.TabloName }
+
+
+
Étape
+
{ etapeLabel(task) }
+
+
+
Assignée
+
{ emptyFallback(task.Assignee, "Non assignée") }
+
+
+
Statut
+
{ task.StatusLabel }
+
+
+
+
+
+} + +templ TaskAssigneeAvatar(task TaskCardView) { + if taskHasAssignee(task) { +
+ { assigneeInitials(task.Assignee) } +
+ } else { +
+ @TasksIcon("user", "lucide lucide-user w-3 h-3 text-gray-400 dark:text-gray-300") +
+ } +} + +templ TasksIcon(kind string, className string) { + switch kind { + case "circle": + + + + case "plus": + + + + + case "kanban": + + + + + + case "list": + + + + + + + + + case "map": + + + + + + case "calendar": + + + + + + + case "settings2": + + + + + + + case "ellipsis-vertical": + + + + + + case "trash2": + + + + + + + + case "message-square": + + + + case "paperclip": + + + + case "user": + + + + + case "gem": + + + + + + case "flame": + + + + case "zap": + + + + default: + @TasksIcon("circle", className) + } +} diff --git a/go-backend/internal/web/views/tasks_templ.go b/go-backend/internal/web/views/tasks_templ.go new file mode 100644 index 0000000..4e10b0c --- /dev/null +++ b/go-backend/internal/web/views/tasks_templ.go @@ -0,0 +1,1505 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +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 taskmodel "xtablo-backend/internal/tasks" + +func TasksPageContent(vm TasksPageViewModel) 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 Tâches

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksViewTabs(vm.State).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 !vm.HasTasks { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Aucune tâche pour le moment.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if vm.State.View == taskmodel.TaskViewList { + templ_7745c5c3_Err = TasksListLayout(vm.List, vm.State).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if vm.State.View == taskmodel.TaskViewRoadmap { + templ_7745c5c3_Err = TasksRoadmapLayout(vm.Roadmap, vm.State).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = TasksKanbanLayout(vm.Kanban, vm.State).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 + } + return nil + }) +} + +func TasksViewTabs(state taskmodel.TaskPageState) 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_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 = []any{taskViewTabClass(state, taskmodel.TaskViewKanban)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...) + 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 = TasksIcon("kanban", "w-4 h-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Tableau ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 = []any{taskViewTabClass(state, taskmodel.TaskViewList)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, 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 + } + templ_7745c5c3_Err = TasksIcon("list", "w-4 h-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Liste ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 = []any{taskViewTabClass(state, taskmodel.TaskViewRoadmap)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksIcon("map", "w-4 h-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Roadmap
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if state.View == taskmodel.TaskViewRoadmap { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 = []any{taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeWeek)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Semaine ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 = []any{taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeMonth)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Mois
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) 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_Var19 := templ.GetChildren(ctx) + if templ_7745c5c3_Var19 == nil { + templ_7745c5c3_Var19 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, column := range view.Columns { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksIcon("circle", "lucide lucide-circle w-5 h-5 "+statusIconClass(column.ID)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(column.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 78, Col: 79} + } + _, 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, 39, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(len(column.Tasks)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 79, Col: 146} + } + _, 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, 40, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, task := range column.Tasks { + templ_7745c5c3_Err = TaskCard(task, state, false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + 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 + } + return nil + }) +} + +func TasksListLayout(view TasksListView, state taskmodel.TaskPageState) 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_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, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, group := range view.Groups { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksIcon("circle", "lucide lucide-circle w-5 h-5 "+statusIconClass(group.ID)).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_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(group.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 102, Col: 78} + } + _, 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, 48, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(len(group.Tasks)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 103, Col: 145} + } + _, 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, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, task := range group.Tasks { + templ_7745c5c3_Err = TasksListRow(task, state).Render(ctx, templ_7745c5c3_Buffer) + 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TasksRoadmapLayout(view TasksRoadmapView, state taskmodel.TaskPageState) 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_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, lane := range view.Lanes { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(lane.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 125, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "

Étape comme lane horizontale, avec bucketisation par date d'échéance.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, bucket := range lane.Buckets { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 136, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + 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_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(len(bucket.Tasks)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 137, Col: 152} + } + _, 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, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, task := range bucket.Tasks { + templ_7745c5c3_Err = TaskCard(task, state, false).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + 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 + } + return nil + }) +} + +func TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact 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 { + 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_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var34 = []any{taskCardClass(compact)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(task.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 155, Col: 116} + } + _, 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, 68, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if task.DueDate != "" { + var templ_7745c5c3_Var41 = []any{"flex items-center text-xs mb-3 " + dueDateToneClass(task.DueDateValue)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var41...) + 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 + } + templ_7745c5c3_Err = TasksIcon("calendar", "lucide lucide-calendar w-3.5 h-3.5 mr-1.5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(task.DueDate) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 166, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 = []any{"w-5 h-5 rounded-[5px] mr-2 flex items-center justify-center shrink-0 " + etapeBadgeClass(task)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksIcon(etapeIconName(task), "w-3 h-3 text-white").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(etapeLabel(task)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 173, Col: 85} + } + _, 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, 81, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksIcon("message-square", "lucide lucide-message-square w-3.5 h-3.5 mr-1").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "0
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksIcon("paperclip", "lucide lucide-paperclip w-3.5 h-3.5 mr-1").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "0
Tablo ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(task.TabloName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 186, Col: 98} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TaskAssigneeAvatar(task).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TasksListRow(task TaskCardView, state taskmodel.TaskPageState) 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_Var48 := templ.GetChildren(ctx) + if templ_7745c5c3_Var48 == nil { + templ_7745c5c3_Var48 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TaskCard(task, state, true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
Tablo
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(task.TabloName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 204, Col: 39} + } + _, 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, 89, "
Étape
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(etapeLabel(task)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 208, Col: 41} + } + _, 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, 90, "
Assignée
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(emptyFallback(task.Assignee, "Non assignée")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 212, Col: 70} + } + _, 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, 91, "
Statut
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(task.StatusLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 216, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TaskAssigneeAvatar(task TaskCardView) 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_Var54 := templ.GetChildren(ctx) + if templ_7745c5c3_Var54 == nil { + templ_7745c5c3_Var54 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if taskHasAssignee(task) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(assigneeInitials(task.Assignee)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 227, Col: 36} + } + _, 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, 94, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksIcon("user", "lucide lucide-user w-3 h-3 text-gray-400 dark:text-gray-300").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func TasksIcon(kind string, className 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_Var56 := templ.GetChildren(ctx) + if templ_7745c5c3_Var56 == nil { + templ_7745c5c3_Var56 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + switch kind { + case "circle": + var templ_7745c5c3_Var57 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var57...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "plus": + var templ_7745c5c3_Var59 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var59...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "kanban": + var templ_7745c5c3_Var61 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var61...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "list": + var templ_7745c5c3_Var63 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var63...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "map": + var templ_7745c5c3_Var65 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var65...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "calendar": + var templ_7745c5c3_Var67 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var67...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "settings2": + var templ_7745c5c3_Var69 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var69...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "ellipsis-vertical": + var templ_7745c5c3_Var71 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var71...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "trash2": + var templ_7745c5c3_Var73 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var73...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "message-square": + var templ_7745c5c3_Var75 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var75...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "paperclip": + var templ_7745c5c3_Var77 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "user": + var templ_7745c5c3_Var79 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "gem": + var templ_7745c5c3_Var81 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var81...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "flame": + var templ_7745c5c3_Var83 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var83...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "zap": + var templ_7745c5c3_Var85 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var85...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + default: + templ_7745c5c3_Err = TasksIcon("circle", className).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/tasks_view.go b/go-backend/internal/web/views/tasks_view.go index f9ae029..c277720 100644 --- a/go-backend/internal/web/views/tasks_view.go +++ b/go-backend/internal/web/views/tasks_view.go @@ -1,50 +1,90 @@ package views import ( - "context" - "fmt" - "html" - "io" + "net/url" "slices" "strings" "time" - "github.com/a-h/templ" "github.com/google/uuid" tablomodel "xtablo-backend/internal/tablos" taskmodel "xtablo-backend/internal/tasks" ) type TasksPageViewModel struct { - Tablos []TasksTabloGroupView + State taskmodel.TaskPageState + Filters TasksFiltersView + Form TasksFormOptionsView + Kanban TasksKanbanView + List TasksListView + Roadmap TasksRoadmapView + HasTasks bool } -type TasksTabloGroupView struct { - ID string - Name string - Color string - Sections []TasksSectionView +type TasksFiltersView struct { + Tablos []TasksOptionView + Assignees []TasksOptionView + Statuses []TasksOptionView } -type TasksSectionView struct { +type TasksFormOptionsView struct { + Tablos []TasksOptionView + Assignees []TasksOptionView + EtapesByTablo map[string][]TasksOptionView + DefaultTabloID string +} + +type TasksOptionView struct { + Value string + Label string + Selected bool +} + +type TasksKanbanView struct { + Columns []TasksKanbanColumnView +} + +type TasksKanbanColumnView struct { + ID string + Label string + Tasks []TaskCardView +} + +type TasksListView struct { + Groups []TasksStatusGroupView +} + +type TasksStatusGroupView struct { + ID string + Label string + Tasks []TaskCardView +} + +type TasksRoadmapView struct { + Mode string + Lanes []TasksRoadmapLaneView +} + +type TasksRoadmapLaneView struct { + ID string + Label string + Buckets []TasksRoadmapBucketTasksView +} + +type TasksRoadmapBucketTasksView struct { + ID string + Label string + Tasks []TaskCardView +} + +type TaskCardView struct { ID string Title string Description string - IsEtape bool - Tasks []TaskRowView - Status string - StatusValue string - DueDate string - DueDateValue string - Assignee string - AssigneeID string -} - -type TaskRowView struct { - ID string - Title string - Description string - Status string + TabloID string + TabloName string + EtapeName string + StatusLabel string StatusValue string DueDate string DueDateValue string @@ -53,178 +93,529 @@ type TaskRowView struct { ParentTaskID string } -func NewTasksPageViewModel(tablos []tablomodel.Record, tasks []taskmodel.Record, assigneeLabels map[uuid.UUID]string) TasksPageViewModel { - groups := make([]TasksTabloGroupView, 0, len(tablos)) - tasksByTablo := make(map[uuid.UUID][]taskmodel.Record) - for _, record := range tasks { - tasksByTablo[record.TabloID] = append(tasksByTablo[record.TabloID], record) +type etapeMeta struct { + ID uuid.UUID + TabloID uuid.UUID + Title string +} + +func NewTasksPageViewModel(tablos []tablomodel.Record, tasks []taskmodel.Record, assigneeLabels map[uuid.UUID]string, state taskmodel.TaskPageState, now time.Time) TasksPageViewModel { + tabloByID := make(map[uuid.UUID]tablomodel.Record, len(tablos)) + for _, tablo := range tablos { + tabloByID[tablo.ID] = tablo } - for _, tablo := range tablos { - records := tasksByTablo[tablo.ID] - if len(records) == 0 { + etapesByID := make(map[uuid.UUID]etapeMeta) + etapesByTablo := make(map[string][]TasksOptionView) + for _, record := range tasks { + if !record.IsEtape { continue } - - etapes := make([]taskmodel.Record, 0) - childrenByParent := make(map[uuid.UUID][]taskmodel.Record) - parentless := make([]taskmodel.Record, 0) - - for _, record := range records { - if record.IsEtape { - etapes = append(etapes, record) - continue - } - if record.ParentTaskID == nil { - parentless = append(parentless, record) - continue - } - childrenByParent[*record.ParentTaskID] = append(childrenByParent[*record.ParentTaskID], record) + etapesByID[record.ID] = etapeMeta{ + ID: record.ID, + TabloID: record.TabloID, + Title: record.Title, } - - sections := make([]TasksSectionView, 0, len(etapes)+1) - slices.SortFunc(etapes, func(a, b taskmodel.Record) int { - return a.CreatedAt.Compare(b.CreatedAt) + etapesByTablo[record.TabloID.String()] = append(etapesByTablo[record.TabloID.String()], TasksOptionView{ + Value: record.ID.String(), + Label: record.Title, }) - for _, etape := range etapes { - children := childrenByParent[etape.ID] - slices.SortFunc(children, func(a, b taskmodel.Record) int { - return a.CreatedAt.Compare(b.CreatedAt) - }) - sections = append(sections, TasksSectionView{ - ID: etape.ID.String(), - Title: etape.Title, - Description: etape.Description, - IsEtape: true, - Status: taskStatusLabel(etape.Status), - StatusValue: string(etape.Status), - DueDate: formatOptionalDate(etape.DueDate), - DueDateValue: formatOptionalDateInput(etape.DueDate), - Assignee: assigneeName(etape.AssigneeID, assigneeLabels), - AssigneeID: optionalUUIDString(etape.AssigneeID), - Tasks: toTaskRows(children, assigneeLabels), - }) - } - - if len(parentless) > 0 { - slices.SortFunc(parentless, func(a, b taskmodel.Record) int { - return a.CreatedAt.Compare(b.CreatedAt) - }) - sections = append(sections, TasksSectionView{ - ID: "sans-etape-" + tablo.ID.String(), - Title: "Sans étape", - IsEtape: false, - Tasks: toTaskRows(parentless, assigneeLabels), - }) - } - - groups = append(groups, TasksTabloGroupView{ - ID: tablo.ID.String(), - Name: tablo.Name, - Color: tablo.Color, - Sections: sections, + } + for key := range etapesByTablo { + slices.SortFunc(etapesByTablo[key], func(a, b TasksOptionView) int { + return strings.Compare(a.Label, b.Label) }) } - return TasksPageViewModel{Tablos: groups} + filteredTasks := filterTasks(tasks, state) + form := buildTasksFormOptions(tablos, assigneeLabels, etapesByTablo) + + return TasksPageViewModel{ + State: state, + Filters: buildTasksFilters(tablos, assigneeLabels, state), + Form: form, + Kanban: buildKanbanView(filteredTasks, tabloByID, etapesByID, assigneeLabels), + List: buildListView(filteredTasks, tabloByID, etapesByID, assigneeLabels), + Roadmap: buildRoadmapView(filteredTasks, tabloByID, etapesByID, assigneeLabels, state.RoadmapMode, now), + HasTasks: len(filteredTasks) > 0, + } } -func TasksPageContent(vm TasksPageViewModel) templ.Component { - return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { - _, _ = io.WriteString(w, `
`) - _, _ = io.WriteString(w, `
`) - _, _ = io.WriteString(w, `
Espace de travail
`) - _, _ = io.WriteString(w, `

Mes tâches

`) - _, _ = io.WriteString(w, `

Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.

`) - _, _ = io.WriteString(w, `
`) +func buildTasksFilters(tablos []tablomodel.Record, assigneeLabels map[uuid.UUID]string, state taskmodel.TaskPageState) TasksFiltersView { + sortedTablos := append([]tablomodel.Record(nil), tablos...) + view := TasksFiltersView{ + Tablos: make([]TasksOptionView, 0, len(sortedTablos)), + Assignees: make([]TasksOptionView, 0, len(assigneeLabels)), + Statuses: []TasksOptionView{ + {Value: string(taskmodel.StatusTodo), Label: "À faire", Selected: containsStatus(state.Statuses, taskmodel.StatusTodo)}, + {Value: string(taskmodel.StatusInProgress), Label: "En cours", Selected: containsStatus(state.Statuses, taskmodel.StatusInProgress)}, + {Value: string(taskmodel.StatusInReview), Label: "Vérification", Selected: containsStatus(state.Statuses, taskmodel.StatusInReview)}, + {Value: string(taskmodel.StatusDone), Label: "Terminé", Selected: containsStatus(state.Statuses, taskmodel.StatusDone)}, + }, + } - if len(vm.Tablos) == 0 { - _, _ = io.WriteString(w, `

Aucune tâche pour le moment.

`) - return nil - } - - for _, group := range vm.Tablos { - if err := renderTaskTabloGroup(w, group); err != nil { - return err - } - } - - _, _ = io.WriteString(w, `
`) - return nil + slices.SortFunc(sortedTablos, func(a, b tablomodel.Record) int { + return strings.Compare(a.Name, b.Name) }) -} - -func renderTaskTabloGroup(w io.Writer, group TasksTabloGroupView) error { - if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(group.ID), html.EscapeString(group.Name)); err != nil { - return err - } - if _, err := fmt.Fprintf(w, `
`, html.EscapeString(group.ID), html.EscapeString(group.ID)); err != nil { - return err - } - for _, section := range group.Sections { - if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(section.ID), html.EscapeString(section.Title)); err != nil { - return err - } - if section.IsEtape { - if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(joinTaskMeta(section.Status, section.DueDate, section.Assignee))); err != nil { - return err - } - if _, err := fmt.Fprintf(w, `
`, html.EscapeString(section.ID), html.EscapeString(section.Title), html.EscapeString(section.Description), html.EscapeString(section.StatusValue), html.EscapeString(section.DueDateValue), html.EscapeString(section.AssigneeID), html.EscapeString(section.ID)); err != nil { - return err - } - } - if _, err := io.WriteString(w, `
`); err != nil { - return err - } - for _, task := range section.Tasks { - if err := renderTaskRow(w, task); err != nil { - return err - } - } - if _, err := io.WriteString(w, `
`); err != nil { - return err - } - } - _, err := io.WriteString(w, `
`) - return err -} - -func renderTaskRow(w io.Writer, task TaskRowView) error { - if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(task.ID), html.EscapeString(task.Title)); err != nil { - return err - } - if strings.TrimSpace(task.Description) != "" { - if _, err := fmt.Fprintf(w, `
%s
`, html.EscapeString(task.Description)); err != nil { - return err - } - } - if _, err := fmt.Fprintf(w, `
%s
`, html.EscapeString(joinTaskMeta(task.Status, task.DueDate, task.Assignee))); err != nil { - return err - } - if _, err := fmt.Fprintf(w, `
`, html.EscapeString(task.ID), html.EscapeString(task.Title), html.EscapeString(task.Description), html.EscapeString(task.StatusValue), html.EscapeString(task.DueDateValue), html.EscapeString(task.AssigneeID), html.EscapeString(task.ParentTaskID), html.EscapeString(task.ID)); err != nil { - return err - } - _, err := io.WriteString(w, `
`) - return err -} - -func toTaskRows(records []taskmodel.Record, assigneeLabels map[uuid.UUID]string) []TaskRowView { - rows := make([]TaskRowView, 0, len(records)) - for _, record := range records { - rows = append(rows, TaskRowView{ - ID: record.ID.String(), - Title: record.Title, - Description: record.Description, - Status: taskStatusLabel(record.Status), - StatusValue: string(record.Status), - DueDate: formatOptionalDate(record.DueDate), - DueDateValue: formatOptionalDateInput(record.DueDate), - Assignee: assigneeName(record.AssigneeID, assigneeLabels), - AssigneeID: optionalUUIDString(record.AssigneeID), - ParentTaskID: optionalUUIDString(record.ParentTaskID), + for _, tablo := range sortedTablos { + view.Tablos = append(view.Tablos, TasksOptionView{ + Value: tablo.ID.String(), + Label: tablo.Name, + Selected: containsUUID(state.TabloIDs, tablo.ID), }) } - return rows + + assigneeIDs := make([]uuid.UUID, 0, len(assigneeLabels)) + for id := range assigneeLabels { + assigneeIDs = append(assigneeIDs, id) + } + slices.SortFunc(assigneeIDs, func(a, b uuid.UUID) int { + return strings.Compare(assigneeLabels[a], assigneeLabels[b]) + }) + for _, id := range assigneeIDs { + view.Assignees = append(view.Assignees, TasksOptionView{ + Value: id.String(), + Label: assigneeLabels[id], + Selected: containsUUID(state.AssigneeIDs, id), + }) + } + + return view +} + +func buildTasksFormOptions(tablos []tablomodel.Record, assigneeLabels map[uuid.UUID]string, etapesByTablo map[string][]TasksOptionView) TasksFormOptionsView { + sortedTablos := append([]tablomodel.Record(nil), tablos...) + form := TasksFormOptionsView{ + Tablos: make([]TasksOptionView, 0, len(sortedTablos)), + Assignees: make([]TasksOptionView, 0, len(assigneeLabels)), + EtapesByTablo: etapesByTablo, + } + slices.SortFunc(sortedTablos, func(a, b tablomodel.Record) int { + return strings.Compare(a.Name, b.Name) + }) + for index, tablo := range sortedTablos { + if index == 0 { + form.DefaultTabloID = tablo.ID.String() + } + form.Tablos = append(form.Tablos, TasksOptionView{ + Value: tablo.ID.String(), + Label: tablo.Name, + }) + } + + assigneeIDs := make([]uuid.UUID, 0, len(assigneeLabels)) + for id := range assigneeLabels { + assigneeIDs = append(assigneeIDs, id) + } + slices.SortFunc(assigneeIDs, func(a, b uuid.UUID) int { + return strings.Compare(assigneeLabels[a], assigneeLabels[b]) + }) + for _, id := range assigneeIDs { + form.Assignees = append(form.Assignees, TasksOptionView{ + Value: id.String(), + Label: assigneeLabels[id], + }) + } + + return form +} + +func filterTasks(tasks []taskmodel.Record, state taskmodel.TaskPageState) []taskmodel.Record { + filtered := make([]taskmodel.Record, 0, len(tasks)) + for _, record := range tasks { + if record.IsEtape { + continue + } + if len(state.TabloIDs) > 0 && !containsUUID(state.TabloIDs, record.TabloID) { + continue + } + if len(state.AssigneeIDs) > 0 { + if record.AssigneeID == nil || !containsUUID(state.AssigneeIDs, *record.AssigneeID) { + continue + } + } + if len(state.Statuses) > 0 && !containsStatus(state.Statuses, record.Status) { + continue + } + filtered = append(filtered, record) + } + return filtered +} + +func buildKanbanView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string) TasksKanbanView { + columns := []TasksKanbanColumnView{ + {ID: string(taskmodel.StatusTodo), Label: "À faire"}, + {ID: string(taskmodel.StatusInProgress), Label: "En cours"}, + {ID: string(taskmodel.StatusInReview), Label: "Vérification"}, + {ID: string(taskmodel.StatusDone), Label: "Terminé"}, + } + for _, record := range sortTaskRecords(records) { + card := taskCardFromRecord(record, tabloByID, etapesByID, assigneeLabels) + for index := range columns { + if columns[index].ID == string(record.Status) { + columns[index].Tasks = append(columns[index].Tasks, card) + break + } + } + } + return TasksKanbanView{Columns: columns} +} + +func buildListView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string) TasksListView { + groups := []TasksStatusGroupView{ + {ID: string(taskmodel.StatusTodo), Label: "À faire"}, + {ID: string(taskmodel.StatusInProgress), Label: "En cours"}, + {ID: string(taskmodel.StatusInReview), Label: "Vérification"}, + {ID: string(taskmodel.StatusDone), Label: "Terminé"}, + } + for _, record := range sortTaskRecords(records) { + card := taskCardFromRecord(record, tabloByID, etapesByID, assigneeLabels) + for index := range groups { + if groups[index].ID == string(record.Status) { + groups[index].Tasks = append(groups[index].Tasks, card) + break + } + } + } + return TasksListView{Groups: groups} +} + +func buildRoadmapView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string, mode taskmodel.TaskRoadmapMode, now time.Time) TasksRoadmapView { + if now.IsZero() { + now = time.Now().UTC() + } + + bucketIDs, bucketLabels := roadmapBuckets(mode, now) + lanesByID := map[string]*TasksRoadmapLaneView{} + laneOrder := make([]string, 0) + + for _, record := range sortTaskRecords(records) { + laneID, laneLabel := roadmapLane(record, tabloByID, etapesByID) + lane, ok := lanesByID[laneID] + if !ok { + lane = &TasksRoadmapLaneView{ + ID: laneID, + Label: laneLabel, + Buckets: make([]TasksRoadmapBucketTasksView, 0, len(bucketIDs)+1), + } + for index, bucketID := range bucketIDs { + lane.Buckets = append(lane.Buckets, TasksRoadmapBucketTasksView{ + ID: bucketID, + Label: bucketLabels[index], + }) + } + lane.Buckets = append(lane.Buckets, TasksRoadmapBucketTasksView{ + ID: "sans-date", + Label: "Sans date", + }) + lanesByID[laneID] = lane + laneOrder = append(laneOrder, laneID) + } + + card := taskCardFromRecord(record, tabloByID, etapesByID, assigneeLabels) + bucketIndex := roadmapBucketIndex(record.DueDate, mode, now, len(bucketIDs)) + lane.Buckets[bucketIndex].Tasks = append(lane.Buckets[bucketIndex].Tasks, card) + } + + lanes := make([]TasksRoadmapLaneView, 0, len(laneOrder)) + for _, laneID := range laneOrder { + lanes = append(lanes, *lanesByID[laneID]) + } + + return TasksRoadmapView{ + Mode: map[taskmodel.TaskRoadmapMode]string{ + taskmodel.TaskRoadmapModeMonth: "Mois", + }[mode], + Lanes: lanes, + } +} + +func taskCardFromRecord(record taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta, assigneeLabels map[uuid.UUID]string) TaskCardView { + tabloName := record.TabloID.String() + if tablo, ok := tabloByID[record.TabloID]; ok { + tabloName = tablo.Name + } + + etapeName := "Sans étape" + if record.ParentTaskID != nil { + if etape, ok := etapesByID[*record.ParentTaskID]; ok { + etapeName = etape.Title + } + } + + return TaskCardView{ + ID: record.ID.String(), + Title: record.Title, + Description: record.Description, + TabloID: record.TabloID.String(), + TabloName: tabloName, + EtapeName: etapeName, + StatusLabel: taskStatusLabel(record.Status), + StatusValue: string(record.Status), + DueDate: formatOptionalDate(record.DueDate), + DueDateValue: formatOptionalDateInput(record.DueDate), + Assignee: assigneeName(record.AssigneeID, assigneeLabels), + AssigneeID: optionalUUIDString(record.AssigneeID), + ParentTaskID: optionalUUIDString(record.ParentTaskID), + } +} + +func taskViewHref(state taskmodel.TaskPageState, view taskmodel.TaskView) string { + nextState := state + nextState.View = view + if view != taskmodel.TaskViewRoadmap { + nextState.RoadmapMode = taskmodel.TaskRoadmapModeWeek + } + return stateAction("/tasks", nextState) +} + +func taskRoadmapModeHref(state taskmodel.TaskPageState, mode taskmodel.TaskRoadmapMode) string { + nextState := state + nextState.RoadmapMode = mode + return stateAction("/tasks", nextState) +} + +func taskViewTabClass(state taskmodel.TaskPageState, view taskmodel.TaskView) string { + base := "flex items-center gap-2 pb-3 pt-1 px-2 text-sm font-semibold transition-colors border-b-2 min-h-[44px] " + if state.View == view { + return base + "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400" + } + return base + "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100" +} + +func taskRoadmapModeClass(state taskmodel.TaskPageState, mode taskmodel.TaskRoadmapMode) string { + base := "inline-flex items-center rounded-full px-3 py-1.5 text-xs font-semibold " + if state.RoadmapMode == mode { + return base + "bg-purple-50 text-purple-700 border border-purple-200 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900" + } + return base + "bg-gray-100 text-gray-600 border border-transparent hover:text-gray-900 dark:bg-gray-800 dark:text-gray-300" +} + +func taskCardClass(compact bool) string { + if compact { + return "bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700" + } + return "bg-white dark:bg-gray-800 rounded-lg p-4 mb-3 shadow-sm hover:shadow-md transition-shadow border border-gray-100 dark:border-gray-700 cursor-pointer" +} + +func taskEditHref(task TaskCardView, state taskmodel.TaskPageState) string { + return stateAction("/tasks/"+task.ID+"/edit", state) +} + +func taskDeleteHref(task TaskCardView, state taskmodel.TaskPageState) string { + return stateAction("/tasks/"+task.ID, state) +} + +func taskDeleteAriaLabel(task TaskCardView) string { + return "Supprimer la tâche " + task.Title +} + +func taskHasAssignee(task TaskCardView) bool { + return strings.TrimSpace(task.Assignee) != "" +} + +func statusIconClass(statusID string) string { + switch statusID { + case string(taskmodel.StatusInProgress): + return "text-yellow-500" + case string(taskmodel.StatusInReview): + return "text-blue-500" + case string(taskmodel.StatusDone): + return "text-green-500" + default: + return "text-gray-400" + } +} + +func dueDateToneClass(raw string) string { + if raw == "" { + return "text-gray-500 dark:text-gray-400" + } + due, err := time.Parse("2006-01-02", raw) + if err != nil { + return "text-gray-500 dark:text-gray-400" + } + if due.Before(time.Now().UTC().Add(24 * time.Hour)) { + return "text-red-500" + } + return "text-gray-500 dark:text-gray-400" +} + +func etapeLabel(task TaskCardView) string { + if task.EtapeName == "" { + return "Sans étape" + } + return task.EtapeName +} + +func etapeBadgeClass(task TaskCardView) string { + if task.EtapeName == "" || task.EtapeName == "Sans étape" { + return "bg-blue-500" + } + if strings.Contains(strings.ToLower(task.EtapeName), "pré") || strings.Contains(strings.ToLower(task.EtapeName), "commenc") { + return "bg-blue-500" + } + if strings.Contains(strings.ToLower(task.EtapeName), "livr") || strings.Contains(strings.ToLower(task.EtapeName), "review") { + return "bg-purple-500" + } + return "bg-red-500" +} + +func etapeIconName(task TaskCardView) string { + if task.EtapeName == "" || task.EtapeName == "Sans étape" { + return "zap" + } + if strings.Contains(strings.ToLower(task.EtapeName), "livr") || strings.Contains(strings.ToLower(task.EtapeName), "review") { + return "gem" + } + if strings.Contains(strings.ToLower(task.EtapeName), "margot") { + return "flame" + } + return "zap" +} + +func assigneeInitials(name string) string { + parts := strings.Fields(strings.TrimSpace(name)) + if len(parts) == 0 { + return "?" + } + if len(parts) == 1 { + runes := []rune(parts[0]) + if len(runes) == 1 { + return strings.ToUpper(string(runes[0])) + } + return strings.ToUpper(string(runes[0:2])) + } + return strings.ToUpper(string([]rune(parts[0])[0]) + string([]rune(parts[len(parts)-1])[0])) +} + +func emptyFallback(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func stateAction(path string, state taskmodel.TaskPageState) string { + values := url.Values{} + if state.View != "" { + values.Set("view", string(state.View)) + } + if state.View == taskmodel.TaskViewRoadmap { + values.Set("roadmap_mode", string(state.RoadmapMode)) + } + for _, id := range state.TabloIDs { + values.Add("tablo", id.String()) + } + for _, id := range state.AssigneeIDs { + values.Add("assignee", id.String()) + } + for _, status := range state.Statuses { + values.Add("status", string(status)) + } + encoded := values.Encode() + if encoded == "" { + return path + } + return path + "?" + encoded +} + +func roadmapBuckets(mode taskmodel.TaskRoadmapMode, now time.Time) ([]string, []string) { + switch mode { + case taskmodel.TaskRoadmapModeMonth: + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + ids := make([]string, 0, 6) + labels := make([]string, 0, 6) + for i := 0; i < 6; i++ { + bucket := start.AddDate(0, i, 0) + ids = append(ids, bucket.Format("2006-01")) + labels = append(labels, bucket.Format("Jan 2006")) + } + return ids, labels + default: + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + ids := make([]string, 0, 6) + labels := make([]string, 0, 6) + for i := 0; i < 6; i++ { + bucket := start.AddDate(0, 0, i*7) + ids = append(ids, bucket.Format("2006-01-02")) + labels = append(labels, bucket.Format("02 Jan")) + } + return ids, labels + } +} + +func roadmapBucketIndex(dueDate *time.Time, mode taskmodel.TaskRoadmapMode, now time.Time, bucketCount int) int { + if dueDate == nil { + return bucketCount + } + + if mode == taskmodel.TaskRoadmapModeMonth { + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + target := time.Date(dueDate.Year(), dueDate.Month(), 1, 0, 0, 0, 0, time.UTC) + months := int(target.Month()) - int(start.Month()) + (target.Year()-start.Year())*12 + if months <= 0 { + return 0 + } + if months >= bucketCount { + return bucketCount - 1 + } + return months + } + + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + days := int(dueDate.Sub(start).Hours() / 24) + if days <= 0 { + return 0 + } + weeks := days / 7 + if weeks >= bucketCount { + return bucketCount - 1 + } + return weeks +} + +func roadmapLane(record taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, etapesByID map[uuid.UUID]etapeMeta) (string, string) { + tabloName := record.TabloID.String() + if tablo, ok := tabloByID[record.TabloID]; ok { + tabloName = tablo.Name + } + if record.ParentTaskID == nil { + return record.TabloID.String() + ":sans-etape", tabloName + " / Sans étape" + } + if etape, ok := etapesByID[*record.ParentTaskID]; ok { + return record.TabloID.String() + ":" + etape.ID.String(), tabloName + " / " + etape.Title + } + return record.TabloID.String() + ":sans-etape", tabloName + " / Sans étape" +} + +func sortTaskRecords(records []taskmodel.Record) []taskmodel.Record { + cloned := append([]taskmodel.Record(nil), records...) + slices.SortFunc(cloned, func(a, b taskmodel.Record) int { + if diff := strings.Compare(string(a.Status), string(b.Status)); diff != 0 { + return diff + } + return a.CreatedAt.Compare(b.CreatedAt) + }) + return cloned +} + +func containsUUID(ids []uuid.UUID, want uuid.UUID) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +func containsStatus(statuses []taskmodel.Status, want taskmodel.Status) bool { + for _, status := range statuses { + if status == want { + return true + } + } + return false } func taskStatusLabel(status taskmodel.Status) string { @@ -232,7 +623,7 @@ func taskStatusLabel(status taskmodel.Status) string { case taskmodel.StatusInProgress: return "En cours" case taskmodel.StatusInReview: - return "En revue" + return "Vérification" case taskmodel.StatusDone: return "Terminé" default: @@ -244,7 +635,7 @@ func formatOptionalDate(value *time.Time) string { if value == nil { return "" } - return value.Format("02/01/2006") + return value.Format("02 Jan") } func formatOptionalDateInput(value *time.Time) string { diff --git a/go-backend/router_test.go b/go-backend/router_test.go index fff745a..044927a 100644 --- a/go-backend/router_test.go +++ b/go-backend/router_test.go @@ -257,8 +257,11 @@ func TestTasksPageRendersFullDashboardPage(t *testing.T) { for _, want := range []string{ `class="sidebar-nav-shell"`, `id="app-main-content"`, - "Tâches", - "Suivez les tâches de votre équipe", + `data-current-view="kanban"`, + "Mes Tâches", + "Nouvelle tâche", + "Tableau", + "Filtrer", } { if !strings.Contains(body, want) { t.Fatalf("expected tasks page to contain %q", want) @@ -487,8 +490,10 @@ func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) { `id="app-main-content"`, `hx-swap-oob="outerHTML"`, `id="sidebar-nav-tasks"`, - "Tâches", - "Suivez les tâches de votre équipe", + `data-current-view="kanban"`, + "Mes Tâches", + "Nouvelle tâche", + "Tableau", } { if !strings.Contains(body, want) { t.Fatalf("expected HTMX tasks response to contain %q", want) diff --git a/go-backend/static/tailwind.css b/go-backend/static/tailwind.css index 4575e76..34a1a05 100644 --- a/go-backend/static/tailwind.css +++ b/go-backend/static/tailwind.css @@ -25,9 +25,13 @@ --color-blue-950: oklch(28.2% 0.091 267.935); --color-indigo-500: oklch(58.5% 0.233 277.117); --color-purple-50: oklch(97.7% 0.014 308.299); + --color-purple-200: oklch(90.2% 0.063 306.703); + --color-purple-300: oklch(82.7% 0.119 306.383); --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-700: oklch(49.6% 0.265 301.924); + --color-purple-900: oklch(38.1% 0.176 304.987); --color-purple-950: oklch(29.1% 0.149 302.717); --color-pink-500: oklch(65.6% 0.241 354.308); --color-gray-50: oklch(98.5% 0.002 247.839); @@ -51,7 +55,10 @@ --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; + --tracking-wide: 0.025em; --tracking-wider: 0.05em; + --leading-tight: 1.25; + --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; --default-transition-duration: 150ms; @@ -87,18 +94,48 @@ .isolate { isolation: isolate; } +.-m-1 { + margin: calc(var(--spacing) * -1); +} .-mx-4 { margin-inline: calc(var(--spacing) * -4); } +.mt-1 { + margin-top: calc(var(--spacing) * 1); +} +.mr-1 { + margin-right: calc(var(--spacing) * 1); +} +.mr-1\.5 { + margin-right: calc(var(--spacing) * 1.5); +} +.mr-2 { + margin-right: calc(var(--spacing) * 2); +} .mb-1 { margin-bottom: calc(var(--spacing) * 1); } +.mb-2 { + margin-bottom: calc(var(--spacing) * 2); +} +.mb-3 { + margin-bottom: calc(var(--spacing) * 3); +} +.mb-4 { + margin-bottom: calc(var(--spacing) * 4); +} .mb-6 { margin-bottom: calc(var(--spacing) * 6); } .mb-8 { margin-bottom: calc(var(--spacing) * 8); } +.line-clamp-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} .flex { display: flex; } @@ -111,6 +148,9 @@ .inline { display: inline; } +.inline-flex { + display: inline-flex; +} .table { display: table; } @@ -153,30 +193,75 @@ .h-2 { height: calc(var(--spacing) * 2); } +.h-3 { + height: calc(var(--spacing) * 3); +} +.h-3\.5 { + height: calc(var(--spacing) * 3.5); +} .h-4 { height: calc(var(--spacing) * 4); } .h-5 { height: calc(var(--spacing) * 5); } +.h-6 { + height: calc(var(--spacing) * 6); +} .h-8 { height: calc(var(--spacing) * 8); } +.h-9 { + height: calc(var(--spacing) * 9); +} +.h-\[18px\] { + height: 18px; +} +.h-fit { + height: fit-content; +} +.min-h-\[44px\] { + min-height: 44px; +} +.min-h-\[56px\] { + min-height: 56px; +} +.min-h-\[80px\] { + min-height: 80px; +} +.min-h-screen { + min-height: 100vh; +} +.w-3 { + width: calc(var(--spacing) * 3); +} +.w-3\.5 { + width: calc(var(--spacing) * 3.5); +} .w-4 { width: calc(var(--spacing) * 4); } .w-5 { width: calc(var(--spacing) * 5); } +.w-6 { + width: calc(var(--spacing) * 6); +} .w-8 { width: calc(var(--spacing) * 8); } .w-12 { width: calc(var(--spacing) * 12); } +.w-\[18px\] { + width: 18px; +} .w-full { width: 100%; } +.min-w-\[44px\] { + min-width: 44px; +} .min-w-\[80px\] { min-width: 80px; } @@ -190,6 +275,9 @@ --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } +.cursor-not-allowed { + cursor: not-allowed; +} .cursor-pointer { cursor: pointer; } @@ -205,6 +293,12 @@ .items-center { align-items: center; } +.items-start { + align-items: flex-start; +} +.justify-between { + justify-content: space-between; +} .justify-center { justify-content: center; } @@ -229,6 +323,41 @@ .gap-6 { gap: calc(var(--spacing) * 6); } +.space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } +} +.space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } +} +.-space-x-2 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse))); + } +} +.space-x-2 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } +} +.space-x-3 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); + } +} .truncate { overflow: hidden; text-overflow: ellipsis; @@ -243,15 +372,27 @@ .overflow-x-auto { overflow-x: auto; } +.rounded { + border-radius: 0.25rem; +} +.rounded-\[5px\] { + border-radius: 5px; +} .rounded-\[8px\] { border-radius: 8px; } +.rounded-\[12px\] { + border-radius: 12px; +} .rounded-full { border-radius: calc(infinity * 1px); } .rounded-lg { border-radius: var(--radius-lg); } +.rounded-md { + border-radius: var(--radius-md); +} .rounded-xl { border-radius: var(--radius-xl); } @@ -259,6 +400,10 @@ border-style: var(--tw-border-style); border-width: 1px; } +.border-2 { + border-style: var(--tw-border-style); + border-width: 2px; +} .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; @@ -275,6 +420,9 @@ --tw-border-style: dashed; border-style: dashed; } +.border-\[\#D0D5DD\] { + border-color: #D0D5DD; +} .border-\[\#DB9729\] { border-color: #DB9729; } @@ -284,9 +432,15 @@ .border-blue-200 { border-color: var(--color-blue-200); } +.border-gray-100 { + border-color: var(--color-gray-100); +} .border-green-200 { border-color: var(--color-green-200); } +.border-purple-200 { + border-color: var(--color-purple-200); +} .border-purple-600 { border-color: var(--color-purple-600); } @@ -296,6 +450,12 @@ .border-transparent { border-color: transparent; } +.border-white { + border-color: var(--color-white); +} +.bg-\[\#F9FAFB\] { + background-color: #F9FAFB; +} .bg-\[\#FFF4E2\] { background-color: #FFF4E2; } @@ -311,6 +471,9 @@ .bg-gray-50 { background-color: var(--color-gray-50); } +.bg-gray-100 { + background-color: var(--color-gray-100); +} .bg-gray-200 { background-color: var(--color-gray-200); } @@ -347,6 +510,9 @@ .bg-teal-500 { background-color: var(--color-teal-500); } +.bg-transparent { + background-color: transparent; +} .bg-white { background-color: var(--color-white); } @@ -361,12 +527,42 @@ .bg-yellow-500 { background-color: var(--color-yellow-500); } +.p-2 { + padding: calc(var(--spacing) * 2); +} +.p-3 { + padding: calc(var(--spacing) * 3); +} +.p-4 { + padding: calc(var(--spacing) * 4); +} +.p-8 { + padding: calc(var(--spacing) * 8); +} +.px-1\.5 { + padding-inline: calc(var(--spacing) * 1.5); +} +.px-2 { + padding-inline: calc(var(--spacing) * 2); +} +.px-3 { + padding-inline: calc(var(--spacing) * 3); +} .px-4 { padding-inline: calc(var(--spacing) * 4); } .px-6 { padding-inline: calc(var(--spacing) * 6); } +.py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); +} +.py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); +} +.py-2 { + padding-block: calc(var(--spacing) * 2); +} .py-2\.5 { padding-block: calc(var(--spacing) * 2.5); } @@ -379,15 +575,27 @@ .py-10 { padding-block: calc(var(--spacing) * 10); } +.pt-1 { + padding-top: calc(var(--spacing) * 1); +} +.pt-6 { + padding-top: calc(var(--spacing) * 6); +} .pt-8 { padding-top: calc(var(--spacing) * 8); } +.pr-1 { + padding-right: calc(var(--spacing) * 1); +} .pr-4 { padding-right: calc(var(--spacing) * 4); } .pb-3 { padding-bottom: calc(var(--spacing) * 3); } +.pb-5 { + padding-bottom: calc(var(--spacing) * 5); +} .pb-6 { padding-bottom: calc(var(--spacing) * 6); } @@ -415,6 +623,20 @@ font-size: var(--text-xs); line-height: var(--tw-leading, var(--text-xs--line-height)); } +.text-\[10px\] { + font-size: 10px; +} +.text-\[11px\] { + font-size: 11px; +} +.leading-none { + --tw-leading: 1; + line-height: 1; +} +.leading-tight { + --tw-leading: var(--leading-tight); + line-height: var(--leading-tight); +} .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); @@ -427,6 +649,10 @@ --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } +.tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); +} .tracking-wider { --tw-tracking: var(--tracking-wider); letter-spacing: var(--tracking-wider); @@ -434,9 +660,15 @@ .whitespace-nowrap { white-space: nowrap; } +.text-\[\#667085\] { + color: #667085; +} .text-\[\#DB9729\] { color: #DB9729; } +.text-blue-500 { + color: var(--color-blue-500); +} .text-blue-600 { color: var(--color-blue-600); } @@ -452,21 +684,36 @@ .text-gray-700 { color: var(--color-gray-700); } +.text-gray-800 { + color: var(--color-gray-800); +} .text-gray-900 { color: var(--color-gray-900); } +.text-green-500 { + color: var(--color-green-500); +} .text-green-600 { color: var(--color-green-600); } .text-purple-600 { color: var(--color-purple-600); } +.text-purple-700 { + color: var(--color-purple-700); +} +.text-red-500 { + color: var(--color-red-500); +} .text-red-700 { color: var(--color-red-700); } .text-white { color: var(--color-white); } +.text-yellow-500 { + color: var(--color-yellow-500); +} .uppercase { text-transform: uppercase; } @@ -475,6 +722,17 @@ color: var(--color-gray-400); } } +.opacity-40 { + opacity: 40%; +} +.shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} +.shadow-xs { + --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} .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,); } @@ -488,6 +746,11 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } +.transition-shadow { + transition-property: box-shadow; + 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) { @@ -495,6 +758,27 @@ } } } +.hover\:bg-gray-200 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-200); + } + } +} +.hover\:bg-purple-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-purple-700); + } + } +} +.hover\:text-gray-600 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-600); + } + } +} .hover\:text-gray-700 { &:hover { @media (hover: hover) { @@ -502,6 +786,28 @@ } } } +.hover\:text-gray-900 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-900); + } + } +} +.hover\:text-red-500 { + &:hover { + @media (hover: hover) { + color: var(--color-red-500); + } + } +} +.hover\:shadow-md { + &:hover { + @media (hover: hover) { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + 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-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); @@ -519,6 +825,39 @@ outline-style: none; } } +.focus-visible\:ring-2 { + &:focus-visible { + --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-visible\:ring-offset-2 { + &:focus-visible { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } +} +.focus-visible\:outline-none { + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } +} +.disabled\:pointer-events-none { + &:disabled { + pointer-events: none; + } +} +.disabled\:opacity-50 { + &:disabled { + opacity: 50%; + } +} +.has-\[\>svg\]\:px-3 { + &:has(>svg) { + padding-inline: calc(var(--spacing) * 3); + } +} .sm\:mx-0 { @media (width >= 40rem) { margin-inline: calc(var(--spacing) * 0); @@ -539,6 +878,16 @@ width: 350px; } } +.md\:w-auto { + @media (width >= 48rem) { + width: auto; + } +} +.md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} .md\:flex-row { @media (width >= 48rem) { flex-direction: row; @@ -554,11 +903,41 @@ justify-content: space-between; } } +.md\:justify-end { + @media (width >= 48rem) { + justify-content: flex-end; + } +} +.md\:gap-6 { + @media (width >= 48rem) { + gap: calc(var(--spacing) * 6); + } +} +.md\:px-6 { + @media (width >= 48rem) { + padding-inline: calc(var(--spacing) * 6); + } +} +.md\:pt-10 { + @media (width >= 48rem) { + padding-top: calc(var(--spacing) * 10); + } +} .lg\:grid-cols-3 { @media (width >= 64rem) { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +.lg\:grid-cols-4 { + @media (width >= 64rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} +.lg\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,1fr\)\] { + @media (width >= 64rem) { + grid-template-columns: minmax(0,2fr) minmax(0,1fr); + } +} .xl\:grid-cols-4 { @media (width >= 80rem) { grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -569,11 +948,21 @@ border-color: var(--color-blue-800); } } +.dark\:border-gray-600 { + &:is(.dark *) { + border-color: var(--color-gray-600); + } +} .dark\:border-gray-700 { &:is(.dark *) { border-color: var(--color-gray-700); } } +.dark\:border-gray-800 { + &:is(.dark *) { + border-color: var(--color-gray-800); + } +} .dark\:border-green-800 { &:is(.dark *) { border-color: var(--color-green-800); @@ -584,6 +973,11 @@ border-color: var(--color-purple-400); } } +.dark\:border-purple-900 { + &:is(.dark *) { + border-color: var(--color-purple-900); + } +} .dark\:bg-blue-950\/30 { &:is(.dark *) { background-color: color-mix(in srgb, oklch(28.2% 0.091 267.935) 30%, transparent); @@ -594,6 +988,11 @@ } } } +.dark\:bg-gray-600 { + &:is(.dark *) { + background-color: var(--color-gray-600); + } +} .dark\:bg-gray-700 { &:is(.dark *) { background-color: var(--color-gray-700); @@ -624,6 +1023,11 @@ } } } +.dark\:bg-gray-900 { + &:is(.dark *) { + background-color: var(--color-gray-900); + } +} .dark\:bg-green-950\/30 { &:is(.dark *) { background-color: color-mix(in srgb, oklch(26.6% 0.065 152.934) 30%, transparent); @@ -644,6 +1048,16 @@ } } } +.dark\:bg-purple-950\/40 { + &:is(.dark *) { + background-color: color-mix(in srgb, oklch(29.1% 0.149 302.717) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + & { + background-color: color-mix(in oklab, var(--color-purple-950) 40%, transparent); + } + } + } +} .dark\:text-blue-400 { &:is(.dark *) { color: var(--color-blue-400); @@ -669,11 +1083,25 @@ color: var(--color-green-400); } } +.dark\:text-purple-300 { + &:is(.dark *) { + color: var(--color-purple-300); + } +} .dark\:text-purple-400 { &:is(.dark *) { color: var(--color-purple-400); } } +.dark\:hover\:bg-gray-700 { + &:is(.dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-700); + } + } + } +} .dark\:hover\:bg-gray-800 { &:is(.dark *) { &:hover { @@ -683,6 +1111,15 @@ } } } +.dark\:hover\:text-gray-100 { + &:is(.dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-gray-100); + } + } + } +} .dark\:hover\:text-gray-200 { &:is(.dark *) { &:hover { @@ -692,6 +1129,22 @@ } } } +.\[\&_svg\]\:pointer-events-none { + & svg { + pointer-events: none; + } +} +.\[\&_svg\]\:size-4 { + & svg { + width: calc(var(--spacing) * 4); + height: calc(var(--spacing) * 4); + } +} +.\[\&_svg\]\:shrink-0 { + & svg { + flex-shrink: 0; + } +} .\[\&\>svg\]\:h-4 { &>svg { height: calc(var(--spacing) * 4); @@ -722,11 +1175,25 @@ inherits: false; initial-value: 0; } +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } +@property --tw-leading { + syntax: "*"; + inherits: false; +} @property --tw-font-weight { syntax: "*"; inherits: false; @@ -735,59 +1202,6 @@ 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; @@ -853,28 +1267,71 @@ inherits: false; initial-value: 0 0 #0000; } +@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; +} @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-space-y-reverse: 0; + --tw-space-x-reverse: 0; --tw-border-style: solid; + --tw-leading: initial; --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%; @@ -889,6 +1346,19 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --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; } } } diff --git a/go-backend/tailwind.input.css b/go-backend/tailwind.input.css index 00077a6..dc2d805 100644 --- a/go-backend/tailwind.input.css +++ b/go-backend/tailwind.input.css @@ -4,24 +4,24 @@ @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; + --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"; -- 2.45.2 From d9bf94583be0adf57144d0f0204c74c3cb8972c3 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 23:38:19 +0200 Subject: [PATCH 040/546] Refactor tasks UI to use reusable button and select components Replace inline button markup with `ui.Button` component calls for consistency and maintainability. Add filter menu component with dropdown functionality. Convert roadmap mode toggle from link-based to select dropdown. Include filter counter badge and clear filter actions. --- go-backend/internal/web/ui/button.css | 2 +- go-backend/internal/web/views/tasks.templ | 264 +++- go-backend/internal/web/views/tasks_templ.go | 1176 +++++++++++------- go-backend/internal/web/views/tasks_view.go | 71 ++ go-backend/static/styles.css | 2 +- go-backend/static/tailwind.css | 187 ++- 6 files changed, 1115 insertions(+), 587 deletions(-) diff --git a/go-backend/internal/web/ui/button.css b/go-backend/internal/web/ui/button.css index 95fb45e..e4ee750 100644 --- a/go-backend/internal/web/ui/button.css +++ b/go-backend/internal/web/ui/button.css @@ -36,7 +36,7 @@ .ui-button-md { font-size: 0.95rem; - padding: 0.75rem 1.1rem; + padding: 0.7rem 1rem; } .ui-button-lg { diff --git a/go-backend/internal/web/views/tasks.templ b/go-backend/internal/web/views/tasks.templ index 8d12c7f..bfd46b0 100644 --- a/go-backend/internal/web/views/tasks.templ +++ b/go-backend/internal/web/views/tasks.templ @@ -1,24 +1,38 @@ package views import taskmodel "xtablo-backend/internal/tasks" +import "xtablo-backend/internal/web/ui" templ TasksPageContent(vm TasksPageViewModel) {

Mes Tâches

- + @ui.Button(ui.ButtonProps{ + Label: "Nouvelle tâche", + Variant: ui.ButtonVariantDefault, + Size: ui.SizeMD, + Type: "button", + Icon: "plus", + })
@TasksViewTabs(vm.State)
- + @ui.Button(ui.ButtonProps{ + Label: tasksFilterSummaryLabel(vm.Filters), + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + Icon: "filter", + Attrs: templ.Attributes{ + "data-tasks-filter-trigger": "", + "aria-haspopup": "menu", + "aria-expanded": "false", + }, + })
+ @TasksFilterMenu(vm)
if !vm.HasTasks { @@ -36,17 +50,168 @@ templ TasksPageContent(vm TasksPageViewModel) {
} +templ TasksFilterMenu(vm TasksPageViewModel) { +
+ + +
+} + templ TasksViewTabs(state taskmodel.TaskPageState) { if state.View == taskmodel.TaskViewRoadmap { -
- - Semaine - - - Mois - +
+ @ui.Select(taskRoadmapModeSelectProps(state))
} } @@ -74,13 +234,17 @@ templ TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) {
- @TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(column.ID)) + @TasksIcon("circle", "lucide lucide-circle w-5 h-5 "+statusIconClass(column.ID))

{ column.Label }

{ len(column.Tasks) }
- + @ui.IconButton(ui.IconButtonProps{ + Label: "Nouvelle tâche dans " + column.Label, + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + })
for _, task := range column.Tasks { @@ -98,13 +262,17 @@ templ TasksListLayout(view TasksListView, state taskmodel.TaskPageState) {
- @TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(group.ID)) + @TasksIcon("circle", "lucide lucide-circle w-5 h-5 "+statusIconClass(group.ID))

{ group.Label }

{ len(group.Tasks) }
- + @ui.IconButton(ui.IconButtonProps{ + Label: "Nouvelle tâche dans " + group.Label, + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + })
for _, task := range group.Tasks { @@ -125,9 +293,13 @@ templ TasksRoadmapLayout(view TasksRoadmapView, state taskmodel.TaskPageState) {

{ lane.Label }

Étape comme lane horizontale, avec bucketisation par date d'échéance.

- + @ui.IconButton(ui.IconButtonProps{ + Label: "Nouvelle tâche dans " + lane.Label, + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + })
for _, bucket := range lane.Buckets { @@ -153,12 +325,30 @@ templ TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) {

{ task.Title }

- - + @ui.IconButton(ui.IconButtonProps{ + Label: "Modifier la tâche " + task.Title, + Icon: "pencil", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": taskEditHref(task, state), + "hx-target": "#app-main-content", + "hx-swap": "beforeend", + }, + }) + @ui.IconButton(ui.IconButtonProps{ + Label: taskDeleteAriaLabel(task), + Icon: "trash", + Variant: ui.IconButtonVariantDanger, + Tone: ui.IconButtonToneGhost, + Type: "button", + Attrs: templ.Attributes{ + "hx-delete": taskDeleteHref(task, state), + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + }, + })
if task.DueDate != "" {
@@ -320,6 +510,10 @@ templ TasksIcon(kind string, className string) { + case "check": + + + default: @TasksIcon("circle", className) } diff --git a/go-backend/internal/web/views/tasks_templ.go b/go-backend/internal/web/views/tasks_templ.go index 4e10b0c..c03539f 100644 --- a/go-backend/internal/web/views/tasks_templ.go +++ b/go-backend/internal/web/views/tasks_templ.go @@ -9,6 +9,7 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import taskmodel "xtablo-backend/internal/tasks" +import "xtablo-backend/internal/web/ui" func TasksPageContent(vm TasksPageViewModel) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { @@ -38,21 +39,27 @@ func TasksPageContent(vm TasksPageViewModel) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(vm.State.View)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 6, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 7, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">

Mes Tâches

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -60,20 +67,40 @@ func TasksPageContent(vm TasksPageViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TasksFilterMenu(vm).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 } if !vm.HasTasks { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Aucune tâche pour le moment.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Aucune tâche pour le moment.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -93,7 +120,322 @@ func TasksPageContent(vm TasksPageViewModel) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TasksFilterMenu(vm TasksPageViewModel) 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_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.State.View == taskmodel.TaskViewRoadmap { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Projet
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if tasksFilterGroupAllSelected(vm.Filters.Tablos) { + templ_7745c5c3_Err = TasksIcon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " Tous les projets ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, option := range vm.Filters.Tablos { + 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, 22, "
Statut
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if tasksFilterGroupAllSelected(vm.Filters.Statuses) { + templ_7745c5c3_Err = TasksIcon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " Tous ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, option := range vm.Filters.Statuses { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
Assigné
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if tasksFilterGroupAllSelected(vm.Filters.Assignees) { + templ_7745c5c3_Err = TasksIcon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " Tous ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, option := range vm.Filters.Assignees { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + 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 } @@ -117,57 +459,57 @@ func TasksViewTabs(state taskmodel.TaskPageState) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var4 = []any{taskViewTabClass(state, taskmodel.TaskViewKanban)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...) + var templ_7745c5c3_Var17 = []any{taskViewTabClass(state, taskmodel.TaskViewKanban)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -175,52 +517,52 @@ func TasksViewTabs(state taskmodel.TaskPageState) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Tableau ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "Tableau ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 = []any{taskViewTabClass(state, taskmodel.TaskViewList)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + var templ_7745c5c3_Var20 = []any{taskViewTabClass(state, taskmodel.TaskViewList)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -228,52 +570,52 @@ func TasksViewTabs(state taskmodel.TaskPageState) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Liste ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "Liste ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 = []any{taskViewTabClass(state, taskmodel.TaskViewRoadmap)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) + var templ_7745c5c3_Var23 = []any{taskViewTabClass(state, taskmodel.TaskViewRoadmap)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) 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, 59, ">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -281,7 +623,7 @@ func TasksViewTabs(state taskmodel.TaskPageState) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Roadmap
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Calendrier Bientôt
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if state.View == taskmodel.TaskViewRoadmap { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 = []any{taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeWeek)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...) + templ_7745c5c3_Err = ui.Select(taskRoadmapModeSelectProps(state)).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Semaine ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var16 = []any{taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeMonth)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Mois
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -389,30 +669,30 @@ func TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) temp }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var19 := templ.GetChildren(ctx) - if templ_7745c5c3_Var19 == nil { - templ_7745c5c3_Var19 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, column := range view.Columns { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -420,41 +700,47 @@ func TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) temp if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(column.Label) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(column.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 78, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 238, Col: 79} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(len(column.Tasks)) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(len(column.Tasks)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 79, Col: 146} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 239, Col: 146} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{ + Label: "Nouvelle tâche dans " + column.Label, + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -464,12 +750,12 @@ func TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) temp return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -493,30 +779,30 @@ func TasksListLayout(view TasksListView, state taskmodel.TaskPageState) templ.Co }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var23 := templ.GetChildren(ctx) - if templ_7745c5c3_Var23 == nil { - templ_7745c5c3_Var23 = templ.NopComponent + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, group := range view.Groups { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -524,41 +810,47 @@ func TasksListLayout(view TasksListView, state taskmodel.TaskPageState) templ.Co if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(group.Label) + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(group.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 102, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 266, Col: 78} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, 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, 48, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(len(group.Tasks)) + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(len(group.Tasks)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 103, Col: 145} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 267, Col: 145} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{ + Label: "Nouvelle tâche dans " + group.Label, + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + }).Render(ctx, templ_7745c5c3_Buffer) 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, 79, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -568,12 +860,12 @@ func TasksListLayout(view TasksListView, state taskmodel.TaskPageState) templ.Co return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -597,95 +889,101 @@ func TasksRoadmapLayout(view TasksRoadmapView, state taskmodel.TaskPageState) te }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var27 := templ.GetChildren(ctx) - if templ_7745c5c3_Var27 == nil { - templ_7745c5c3_Var27 = templ.NopComponent + templ_7745c5c3_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, lane := range view.Lanes { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(lane.Label) + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(lane.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 125, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 293, Col: 77} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + _, 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, 56, "

Étape comme lane horizontale, avec bucketisation par date d'échéance.

Étape comme lane horizontale, avec bucketisation par date d'échéance.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.IconButton(ui.IconButtonProps{ + Label: "Nouvelle tâche dans " + lane.Label, + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, bucket := range lane.Buckets { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Label) + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 136, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 308, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + _, 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, 60, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(len(bucket.Tasks)) + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(len(bucket.Tasks)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 137, Col: 152} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 309, Col: 152} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -695,17 +993,17 @@ func TasksRoadmapLayout(view TasksRoadmapView, state taskmodel.TaskPageState) te return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -729,134 +1027,113 @@ func TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) te }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var33 := templ.GetChildren(ctx) - if templ_7745c5c3_Var33 == nil { - templ_7745c5c3_Var33 = templ.NopComponent + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var34 = []any{taskCardClass(compact)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) + var templ_7745c5c3_Var41 = []any{taskCardClass(compact)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var41...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(task.Title) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(task.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 155, Col: 116} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 327, Col: 116} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + _, 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, 68, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if task.DueDate != "" { - var templ_7745c5c3_Var41 = []any{"flex items-center text-xs mb-3 " + dueDateToneClass(task.DueDateValue)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var41...) + var templ_7745c5c3_Var45 = []any{"flex items-center text-xs mb-3 " + dueDateToneClass(task.DueDateValue)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var45...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -864,43 +1141,43 @@ func TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(task.DueDate) + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(task.DueDate) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 166, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 356, Col: 18} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var44 = []any{"w-5 h-5 rounded-[5px] mr-2 flex items-center justify-center shrink-0 " + etapeBadgeClass(task)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...) + var templ_7745c5c3_Var48 = []any{"w-5 h-5 rounded-[5px] mr-2 flex items-center justify-center shrink-0 " + etapeBadgeClass(task)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var48...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -908,20 +1185,20 @@ func TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(etapeLabel(task)) + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(etapeLabel(task)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 173, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 363, Col: 85} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, 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, 81, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -929,7 +1206,7 @@ func TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "0
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "0
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -937,20 +1214,20 @@ func TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "0
Tablo ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "0
Tablo ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(task.TabloName) + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(task.TabloName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 186, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 376, Col: 98} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + _, 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, 84, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -958,7 +1235,7 @@ func TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -982,25 +1259,25 @@ func TasksListRow(task TaskCardView, state taskmodel.TaskPageState) templ.Compon }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var48 := templ.GetChildren(ctx) - if templ_7745c5c3_Var48 == nil { - templ_7745c5c3_Var48 = templ.NopComponent + templ_7745c5c3_Var52 := templ.GetChildren(ctx) + if templ_7745c5c3_Var52 == nil { + templ_7745c5c3_Var52 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1008,59 +1285,59 @@ func TasksListRow(task TaskCardView, state taskmodel.TaskPageState) templ.Compon if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
Tablo
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "
Tablo
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(task.TabloName) + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(task.TabloName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 204, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 394, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
Étape
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
Étape
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(etapeLabel(task)) + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(etapeLabel(task)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 208, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 398, Col: 41} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + _, 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, 90, "
Assignée
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "
Assignée
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(emptyFallback(task.Assignee, "Non assignée")) + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(emptyFallback(task.Assignee, "Non assignée")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 212, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 402, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
Statut
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
Statut
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var53 string - templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(task.StatusLabel) + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(task.StatusLabel) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 216, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 406, Col: 41} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1084,31 +1361,31 @@ func TaskAssigneeAvatar(task TaskCardView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var54 := templ.GetChildren(ctx) - if templ_7745c5c3_Var54 == nil { - templ_7745c5c3_Var54 = templ.NopComponent + templ_7745c5c3_Var58 := templ.GetChildren(ctx) + if templ_7745c5c3_Var58 == nil { + templ_7745c5c3_Var58 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if taskHasAssignee(task) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(assigneeInitials(task.Assignee)) + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(assigneeInitials(task.Assignee)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 227, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/tasks.templ`, Line: 417, Col: 36} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1116,7 +1393,7 @@ func TaskAssigneeAvatar(task TaskCardView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1141,65 +1418,19 @@ func TasksIcon(kind string, className string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var56 := templ.GetChildren(ctx) - if templ_7745c5c3_Var56 == nil { - templ_7745c5c3_Var56 = templ.NopComponent + templ_7745c5c3_Var60 := templ.GetChildren(ctx) + if templ_7745c5c3_Var60 == nil { + templ_7745c5c3_Var60 = templ.NopComponent } ctx = templ.ClearChildren(ctx) switch kind { case "circle": - var templ_7745c5c3_Var57 = []any{className} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var57...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - case "plus": - var templ_7745c5c3_Var59 = []any{className} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var59...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - case "kanban": var templ_7745c5c3_Var61 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var61...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "list": + case "plus": var templ_7745c5c3_Var63 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var63...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "map": + case "kanban": var templ_7745c5c3_Var65 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var65...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "calendar": + case "list": var templ_7745c5c3_Var67 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var67...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "settings2": + case "map": var templ_7745c5c3_Var69 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var69...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "ellipsis-vertical": + case "calendar": var templ_7745c5c3_Var71 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var71...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "trash2": + case "settings2": var templ_7745c5c3_Var73 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var73...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "message-square": + case "ellipsis-vertical": var templ_7745c5c3_Var75 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var75...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "paperclip": + case "trash2": var templ_7745c5c3_Var77 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "user": + case "message-square": var templ_7745c5c3_Var79 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "gem": + case "paperclip": var templ_7745c5c3_Var81 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var81...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "flame": + case "user": var templ_7745c5c3_Var83 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var83...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - case "zap": + case "gem": var templ_7745c5c3_Var85 = []any{className} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var85...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "flame": + var templ_7745c5c3_Var87 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var87...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "zap": + var templ_7745c5c3_Var89 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var89...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "check": + var templ_7745c5c3_Var91 = []any{className} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var91...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go-backend/internal/web/views/tasks_view.go b/go-backend/internal/web/views/tasks_view.go index c277720..e3548fb 100644 --- a/go-backend/internal/web/views/tasks_view.go +++ b/go-backend/internal/web/views/tasks_view.go @@ -3,12 +3,14 @@ package views import ( "net/url" "slices" + "strconv" "strings" "time" "github.com/google/uuid" tablomodel "xtablo-backend/internal/tablos" taskmodel "xtablo-backend/internal/tasks" + "xtablo-backend/internal/web/ui" ) type TasksPageViewModel struct { @@ -391,6 +393,75 @@ func taskRoadmapModeClass(state taskmodel.TaskPageState, mode taskmodel.TaskRoad return base + "bg-gray-100 text-gray-600 border border-transparent hover:text-gray-900 dark:bg-gray-800 dark:text-gray-300" } +func taskRoadmapModeSelectProps(state taskmodel.TaskPageState) ui.SelectProps { + return ui.SelectProps{ + ID: "tasks-roadmap-mode", + Name: "roadmap_mode_nav", + Value: taskRoadmapModeHref(state, state.RoadmapMode), + Options: []ui.SelectOption{ + {Value: taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeWeek), Label: "Semaine"}, + {Value: taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeMonth), Label: "Mois"}, + }, + Attrs: map[string]any{ + "onchange": "if (this.value) window.location.href=this.value", + }, + } +} + +func tasksFilterSummaryLabel(filters TasksFiltersView) string { + count := 0 + for _, option := range filters.Tablos { + if option.Selected { + count++ + } + } + for _, option := range filters.Statuses { + if option.Selected { + count++ + } + } + for _, option := range filters.Assignees { + if option.Selected { + count++ + } + } + if count == 0 { + return "Filtrer" + } + return "Filtrer (" + strconv.Itoa(count) + ")" +} + +func tasksFilterGroupAllSelected(options []TasksOptionView) bool { + for _, option := range options { + if option.Selected { + return false + } + } + return true +} + +func tasksFilterGroupHasChoices(options []TasksOptionView) bool { + return len(options) > 0 +} + +func tasksClearTabloFiltersHref(state taskmodel.TaskPageState) string { + nextState := state + nextState.TabloIDs = nil + return stateAction("/tasks", nextState) +} + +func tasksClearStatusFiltersHref(state taskmodel.TaskPageState) string { + nextState := state + nextState.Statuses = nil + return stateAction("/tasks", nextState) +} + +func tasksClearAssigneeFiltersHref(state taskmodel.TaskPageState) string { + nextState := state + nextState.AssigneeIDs = nil + return stateAction("/tasks", nextState) +} + func taskCardClass(compact bool) string { if compact { return "bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700" diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css index 667950e..7ba0145 100644 --- a/go-backend/static/styles.css +++ b/go-backend/static/styles.css @@ -429,7 +429,7 @@ input { .ui-button-md { font-size: 0.95rem; - padding: 0.75rem 1.1rem; + padding: 0.7rem 1rem; } .ui-button-lg { diff --git a/go-backend/static/tailwind.css b/go-backend/static/tailwind.css index 34a1a05..76ce62f 100644 --- a/go-backend/static/tailwind.css +++ b/go-backend/static/tailwind.css @@ -58,6 +58,7 @@ --tracking-wide: 0.025em; --tracking-wider: 0.05em; --leading-tight: 1.25; + --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; @@ -73,6 +74,17 @@ .visible { visibility: visible; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; +} .absolute { position: absolute; } @@ -88,18 +100,33 @@ .top-1\/2 { top: calc(1/2 * 100%); } +.top-2 { + top: calc(var(--spacing) * 2); +} +.right-0 { + right: calc(var(--spacing) * 0); +} +.left-2 { + left: calc(var(--spacing) * 2); +} .left-3 { left: calc(var(--spacing) * 3); } .isolate { isolation: isolate; } -.-m-1 { - margin: calc(var(--spacing) * -1); +.z-50 { + z-index: 50; +} +.-mx-1 { + margin-inline: calc(var(--spacing) * -1); } .-mx-4 { margin-inline: calc(var(--spacing) * -4); } +.my-1 { + margin-block: calc(var(--spacing) * 1); +} .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -136,6 +163,9 @@ -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.block { + display: block; +} .flex { display: flex; } @@ -211,15 +241,15 @@ .h-8 { height: calc(var(--spacing) * 8); } -.h-9 { - height: calc(var(--spacing) * 9); -} -.h-\[18px\] { - height: 18px; -} .h-fit { height: fit-content; } +.h-px { + height: 1px; +} +.max-h-\[28rem\] { + max-height: 28rem; +} .min-h-\[44px\] { min-height: 44px; } @@ -232,6 +262,9 @@ .min-h-screen { min-height: 100vh; } +.w-2 { + width: calc(var(--spacing) * 2); +} .w-3 { width: calc(var(--spacing) * 3); } @@ -253,14 +286,14 @@ .w-12 { width: calc(var(--spacing) * 12); } -.w-\[18px\] { - width: 18px; +.w-56 { + width: calc(var(--spacing) * 56); } .w-full { width: 100%; } -.min-w-\[44px\] { - min-width: 44px; +.max-w-\[220px\] { + max-width: 220px; } .min-w-\[80px\] { min-width: 80px; @@ -372,8 +405,11 @@ .overflow-x-auto { overflow-x: auto; } -.rounded { - border-radius: 0.25rem; +.overflow-x-hidden { + overflow-x: hidden; +} +.overflow-y-auto { + overflow-y: auto; } .rounded-\[5px\] { border-radius: 5px; @@ -393,6 +429,9 @@ .rounded-md { border-radius: var(--radius-md); } +.rounded-sm { + border-radius: var(--radius-sm); +} .rounded-xl { border-radius: var(--radius-xl); } @@ -510,9 +549,6 @@ .bg-teal-500 { background-color: var(--color-teal-500); } -.bg-transparent { - background-color: transparent; -} .bg-white { background-color: var(--color-white); } @@ -527,8 +563,8 @@ .bg-yellow-500 { background-color: var(--color-yellow-500); } -.p-2 { - padding: calc(var(--spacing) * 2); +.p-1 { + padding: calc(var(--spacing) * 1); } .p-3 { padding: calc(var(--spacing) * 3); @@ -560,9 +596,6 @@ .py-1\.5 { padding-block: calc(var(--spacing) * 1.5); } -.py-2 { - padding-block: calc(var(--spacing) * 2); -} .py-2\.5 { padding-block: calc(var(--spacing) * 2.5); } @@ -587,6 +620,9 @@ .pr-1 { padding-right: calc(var(--spacing) * 1); } +.pr-2 { + padding-right: calc(var(--spacing) * 2); +} .pr-4 { padding-right: calc(var(--spacing) * 4); } @@ -599,6 +635,9 @@ .pb-6 { padding-bottom: calc(var(--spacing) * 6); } +.pl-8 { + padding-left: calc(var(--spacing) * 8); +} .pl-10 { padding-left: calc(var(--spacing) * 10); } @@ -725,12 +764,12 @@ .opacity-40 { opacity: 40%; } -.shadow-sm { - --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); +.shadow-md { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } -.shadow-xs { - --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); +.shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .filter { @@ -751,6 +790,14 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } +.outline-none { + --tw-outline-style: none; + outline-style: none; +} +.select-none { + -webkit-user-select: none; + user-select: none; +} .hover\:bg-gray-50 { &:hover { @media (hover: hover) { @@ -758,24 +805,10 @@ } } } -.hover\:bg-gray-200 { +.hover\:bg-gray-100 { &:hover { @media (hover: hover) { - background-color: var(--color-gray-200); - } - } -} -.hover\:bg-purple-700 { - &:hover { - @media (hover: hover) { - background-color: var(--color-purple-700); - } - } -} -.hover\:text-gray-600 { - &:hover { - @media (hover: hover) { - color: var(--color-gray-600); + background-color: var(--color-gray-100); } } } @@ -793,13 +826,6 @@ } } } -.hover\:text-red-500 { - &:hover { - @media (hover: hover) { - color: var(--color-red-500); - } - } -} .hover\:shadow-md { &:hover { @media (hover: hover) { @@ -825,39 +851,6 @@ outline-style: none; } } -.focus-visible\:ring-2 { - &:focus-visible { - --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-visible\:ring-offset-2 { - &:focus-visible { - --tw-ring-offset-width: 2px; - --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); - } -} -.focus-visible\:outline-none { - &:focus-visible { - --tw-outline-style: none; - outline-style: none; - } -} -.disabled\:pointer-events-none { - &:disabled { - pointer-events: none; - } -} -.disabled\:opacity-50 { - &:disabled { - opacity: 50%; - } -} -.has-\[\>svg\]\:px-3 { - &:has(>svg) { - padding-inline: calc(var(--spacing) * 3); - } -} .sm\:mx-0 { @media (width >= 40rem) { margin-inline: calc(var(--spacing) * 0); @@ -878,11 +871,6 @@ width: 350px; } } -.md\:w-auto { - @media (width >= 48rem) { - width: auto; - } -} .md\:grid-cols-2 { @media (width >= 48rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1093,15 +1081,6 @@ color: var(--color-purple-400); } } -.dark\:hover\:bg-gray-700 { - &:is(.dark *) { - &:hover { - @media (hover: hover) { - background-color: var(--color-gray-700); - } - } - } -} .dark\:hover\:bg-gray-800 { &:is(.dark *) { &:hover { @@ -1129,22 +1108,6 @@ } } } -.\[\&_svg\]\:pointer-events-none { - & svg { - pointer-events: none; - } -} -.\[\&_svg\]\:size-4 { - & svg { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - } -} -.\[\&_svg\]\:shrink-0 { - & svg { - flex-shrink: 0; - } -} .\[\&\>svg\]\:h-4 { &>svg { height: calc(var(--spacing) * 4); -- 2.45.2 From 2c23906716641df5f792679abf62922169e55c41 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:01:31 +0200 Subject: [PATCH 041/546] docs: map existing codebase --- .planning/codebase/ARCHITECTURE.md | 162 ++++++++++++++++ .planning/codebase/CONCERNS.md | 126 ++++++++++++ .planning/codebase/CONVENTIONS.md | 161 ++++++++++++++++ .planning/codebase/INTEGRATIONS.md | 140 ++++++++++++++ .planning/codebase/STACK.md | 142 ++++++++++++++ .planning/codebase/STRUCTURE.md | 296 +++++++++++++++++++++++++++++ .planning/codebase/TESTING.md | 141 ++++++++++++++ 7 files changed, 1168 insertions(+) create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..d237a89 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,162 @@ +# Architecture + +_Last updated: 2026-05-14_ + +This document describes the high-level architecture of the `xtablo-source` monorepo: how apps, packages, and external services fit together, the dominant data-flow patterns, and where the key abstractions live. + +## High-Level Diagram + +``` + +-----------------------------------------------------+ + | Frontend Apps | + | | + | apps/main apps/external apps/clients | + | (dashboard) (booking widget) (client portal)| + | apps/admin | + | (internal admin) | + +------+----------------+--------------------+--------+ + | | | + | shared packages (source-only) | + | @xtablo/shared @xtablo/ui | + | @xtablo/shared-types | + | @xtablo/auth-ui @xtablo/chat-ui | + | @xtablo/tablo-views | + | | + v v + +-----------------+ +---------------------+ + | apps/api |<------------>| apps/chat-worker | + | (Hono REST) | | (CF Durable Obj) | + +--------+--------+ +----------+----------+ + | | + +----------------+----------------+-----------------+ + | | | | + v v v v + Supabase Stripe Cloudflare R2 Stream Chat + (Postgres+Auth) (payments) (file storage) (messaging) + | + + Datadog (RUM/APM) + + Google Secret Manager +``` + +## Application Layers + +- **Frontend dashboard** (`apps/main`): primary authenticated SPA. Tablos, planning, events, chat, notes, billing. Entry: `apps/main/src/main.tsx`, root component `apps/main/src/App.tsx`. +- **Public booking widget** (`apps/external`): embeddable / floating booking widget. Entry: `apps/external/src/main.tsx`. Query params drive mode (`?mode=embed&eventTypeId=...`). +- **Client portal** (`apps/clients`): public-facing client portal experience. Entry: `apps/clients/src/main.tsx`, routes in `apps/clients/src/routes.tsx`. +- **Admin app** (`apps/admin`): internal admin tools. Entry: `apps/admin/src/main.tsx`, routes in `apps/admin/src/routes.tsx`. +- **API** (`apps/api`): Hono-based REST API serving all frontends. Entry: `apps/api/src/index.ts` (compiled to Node, deployed to Google Cloud Run). +- **Chat worker** (`apps/chat-worker`): Cloudflare Worker with Durable Objects for real-time chat presence. Entry: `apps/chat-worker/src/index.ts`. + +## Data Flow Patterns + +### React Query (TanStack Query v5) — primary server-state tool + +All server state flows through React Query. Default cache time is 5 minutes. Query keys follow a hierarchical convention so that mutations can invalidate just the affected sub-tree: + +```ts +["tablos"] // list +["tablos", tabloId] // single +["tablo-files", tabloId] // related collection +``` + +Hooks live in: +- `apps/main/src/hooks/` — feature hooks (`tablos.ts`, `events.ts`, `tasks.ts`, `availabilities.ts`, `stripe.ts`, `notes.ts`, ...). +- `packages/shared/src/hooks/` — cross-app hooks (`auth.ts`, `book.ts`, `public.ts`). + +### Zustand — global client state + +Used sparingly, primarily for the current user. The user is fetched via React Query then mirrored into a Zustand store so any component can read it synchronously: +- `useUser()` — throws if no session (use inside protected routes). +- `useMaybeUser()` — returns null if unauthenticated (use in route guards / public-aware components). + +Provider: `apps/main/src/providers/UserStoreProvider.tsx` (mirrored in `apps/external/src/UserStoreProvider.tsx`). + +### Direct Supabase queries vs API calls + +Two parallel data-access patterns coexist: + +1. **Direct Supabase** (`supabase.from("table").select()...`) — used from frontend hooks when row-level security is sufficient and no server-side logic is required. Client lives in `packages/shared/src/lib/supabase.ts` (re-exported via `apps/main/src/lib/supabase.ts`). +2. **API calls** (`api.get("/api/v1/...")`) — used when the operation needs the service role key, must run server-side logic (Stripe, file ops, email, multi-tenant integrity), or aggregates data. The HTTP client wrapper lives in `packages/shared/src/lib/api.ts` (re-exported via `apps/main/src/lib/api.ts`) and attaches the Supabase JWT as a Bearer token. + +File operations use specialized mutation hooks (e.g. `useUploadTabloFile`, `useDeleteTabloFile`) that invalidate `["tablo-files", tabloId]` automatically. + +## Authentication Flow + +- **Supabase Auth** issues JWTs on login / passwordless flows. +- **SessionContext** (`packages/shared/src/contexts/SessionContext.tsx`) subscribes to `supabase.auth.onAuthStateChange()` and exposes the current session. +- Frontend HTTP client reads the session token and sends `Authorization: Bearer ` on every API call. +- The API's `supabase` middleware validates the JWT and attaches the resolved user / supabase clients to the Hono context. +- **Passwordless onboarding** generates temporary accounts flagged with `is_temporary: true`. +- **Protected routes** check `useMaybeUser()` and redirect to landing when null. +- **Client portal** has a parallel auth path: magic-link based, signed cookies issued by `apps/api/src/routers/clientAuth.ts`. Configurable TTLs, cookie domain, JWT secret are passed into the router factory. + +## API Architecture (Hono) + +Entry: `apps/api/src/index.ts`. Flow: + +1. `loadSecrets()` pulls secrets (locally from env, in staging/prod from Google Secret Manager) — `apps/api/src/secrets.ts`. +2. `createConfig(secrets)` produces the typed `AppConfig` — `apps/api/src/config.ts`. +3. `MiddlewareManager.initialize(config)` constructs the middleware singleton — `apps/api/src/middlewares/middleware.ts`. +4. The root Hono app applies `logger()` and a CORS middleware that only accepts `*.xtablo.com` and `localhost` origins. +5. All routes mount under `/api/v1` via `getMainRouter(config)` (`apps/api/src/routers/index.ts`). + +### Middleware Manager (singleton pattern) + +`MiddlewareManager` builds each piece of middleware once on init and exposes them as instance properties. The main router pulls them via `MiddlewareManager.getInstance()` and chains them in this fixed order: + +``` +supabase -> r2 -> transporter -> stripe -> stripeSync +``` + +Auxiliary middleware modules: +- `apps/api/src/middlewares/middleware.ts` — central singleton and supabase / r2 / email / stripe middlewares. +- `apps/api/src/middlewares/stripeSync.ts` — bidirectional Supabase <-> Stripe sync engine. +- `apps/api/src/middlewares/transporter.ts` — email transporter. + +### Router ordering + +Public-first, then authenticated. From `apps/api/src/routers/index.ts`: + +1. `/public` — unauthenticated (`public.ts`). +2. `/tasks` — task router (`tasks.ts`). +3. `/revenuecat-webhook`, `/stripe-webhook` — webhook receivers. +4. `/admin` — admin-only routes (`admin.ts`, `adminAuth.ts`, ...). +5. `/client-auth`, `/client-portal`, `/client-invites` — client portal stack. +6. `/` — `maybeAuthRouter.ts` (optional auth — must come before authed to allow public booking). +7. `/` — `authRouter.ts` (requires JWT). + +The exported `ApiRoutes` type (`ReturnType`) enables Hono RPC clients to consume the API in a type-safe way. + +## Key Abstractions + +- **`packages/shared`** is the central runtime sharing point. Re-exports cover contexts (`SessionContext`, `ThemeContext`), cross-app hooks, the API client, the Supabase client, toast helpers, and shared types. Public surface in `packages/shared/src/index.ts`. +- **`packages/ui`** — Radix + Tailwind component library. Source-only. Components in `packages/ui/src/components/` (`button.tsx`, `dialog.tsx`, `select.tsx`, ...). +- **`packages/shared-types`** — zero-runtime-dependency TypeScript types. Auto-generated `database.types.ts` + hand-written domain layers (`tablos.types.ts`, `tablo-data.types.ts`, `events.types.ts`, `kanban.types.ts`, `stripe.types.ts`, `admin.types.ts`). +- **`packages/auth-ui`, `packages/chat-ui`, `packages/tablo-views`** — feature-scoped UI packages, also source-only. +- **Query keys** — convention enforced by colocation in `apps/main/src/hooks/` and feature naming (`["tablos", id]`, `["tablo-files", id]`, etc.). + +## Source-Only Package Pattern + +`@xtablo/shared`, `@xtablo/ui`, `@xtablo/shared-types`, `@xtablo/auth-ui`, `@xtablo/chat-ui`, and `@xtablo/tablo-views` export TypeScript directly with no build step. Consumers import source files; Vite handles transpilation and HMR. Benefits: instant updates, no watch processes, simpler dependency graph. Constraint: no circular dependencies between packages, and the API can only depend on `@xtablo/shared-types` (pure types, no React). + +## Entry Points + +| App / package | Entry file | +|----------------------|---------------------------------------------------------| +| `apps/main` | `apps/main/src/main.tsx` -> `App.tsx` | +| `apps/external` | `apps/external/src/main.tsx` -> `routes.tsx` | +| `apps/clients` | `apps/clients/src/main.tsx` -> `App.tsx` / `routes.tsx` | +| `apps/admin` | `apps/admin/src/main.tsx` -> `routes.tsx` | +| `apps/api` | `apps/api/src/index.ts` -> `routers/index.ts` | +| `apps/chat-worker` | `apps/chat-worker/src/index.ts` | +| `@xtablo/shared` | `packages/shared/src/index.ts` | +| `@xtablo/ui` | `packages/ui/src/components/index.ts` | +| `@xtablo/shared-types` | `packages/shared-types/src/index.ts` | + +## Build & Deployment Notes + +- **Turborepo** orchestrates tasks with caching (`turbo.json` at repo root). +- `apps/main` and other Vite apps deploy to **Cloudflare Workers** via `wrangler.toml` and the bundled `worker/` folder. +- `apps/api` compiles TypeScript and deploys to **Google Cloud Run**; secrets resolved via Google Secret Manager. +- `apps/chat-worker` deploys as a **Cloudflare Worker with Durable Objects**. +- Observability: Datadog RUM on frontends (`apps/main/src/lib/rum.ts`), `dd-trace` on the API (initialized at the top of `apps/api/src/index.ts`). diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..6b0b97a --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,126 @@ +# Codebase Concerns Map + +> Generated 2026-05-14. Catalogs tech debt, security considerations, fragile zones, and known gotchas across the `xtablo-source` Turborepo. Pointers use repo-relative paths; line numbers are accurate as of this scan. + +## TODOs / FIXMEs / HACKs / XXX + +Grep of `apps/` + `packages/` (excluding `node_modules`, `dist`) — TODO markers are sparse, suggesting either healthy hygiene or undocumented debt. + +- `apps/api/src/index.ts:67` — `// TODO: Add health check endpoint`. No `/healthz` for Cloud Run, which complicates liveness/readiness probes. +- `apps/api/src/routers/invite.ts:26` — `// TODO: Verify that the owner_id is correct`. Authorization gap on invite creation. +- `apps/api/src/routers/invite.ts:128` — `// TODO: Verify that the event start and end correspond to a slot`. Booking integrity not enforced server-side. +- `apps/main/src/components/WebcalModal.tsx:125` — `{/* TODO: Add webcal URL */}` — feature placeholder shipping incomplete. +- `apps/main/src/lib/rum.ts:25,31` — Datadog RUM session sampling rates are commented `// TODO: Uncomment when we have enough data` — observability runs in a partial state. +- `apps/api/src/routers/user.ts:54` — `// Deprecated: name field is deprecated, use first_name and last_name instead` — dual fields still flowing through APIs. + +No `FIXME`, `HACK`, or `XXX` markers found in `apps/` or `packages/` source — but absence is not assurance; many concerns live under euphemisms (see `@deprecated` below). + +`@deprecated` markers: +- `apps/main/src/hooks/user.ts:16` — `useUser` hook deprecated in favor of `useSession` from `SessionContext`. Likely still has callers. + +## Known issues (from docs) + +The `docs/` directory contains 35+ retrospective `*_FIX.md`, `*_SETUP.md`, and migration notes. No single `TROUBLESHOOTING.md` exists; institutional knowledge is scattered. Notable items: + +- `docs/MIDDLEWARE_INITIALIZATION_FIX.md` — November 2025 incident: routers called `MiddlewareManager.getInstance()` at module-load time before `initialize()` ran. Fixed by passing the manager into router factories, but the singleton's eager-throw API (`getInstance()` throws if not initialized) remains a footgun for any new router that forgets the pattern. +- `docs/STRIPE_SECURITY_FIX.md` — Migration 37: `public.active_subscriptions` view exposed all users' subscription data without RLS. Replaced with `SECURITY DEFINER` function `get_my_active_subscription()`. Future views need similar audit. +- `docs/STRIPE_MIGRATION_36.md`, `STRIPE_WITH_SYNC_ENGINE.md`, `STRIPE_FINAL_SETUP.md`, `STRIPE_IMPLEMENTATION_SUMMARY.md`, `STRIPE_CLEANUP_*` — eleven Stripe documents indicate repeated rework on the billing surface. +- `docs/TEST_FIXES.md`, `docs/TEST_ROUTER_REFACTOR.md`, `docs/API_TESTS.md`, `docs/MIDDLEWARE_TESTS.md` — test setup has needed periodic refactoring; mocks are tightly coupled to the singleton. +- `SECURITY_NOTICE.md` (repo root) — `.env` files were previously committed to git history. The checklist of credentials to rotate (Supabase service role, Stripe, Stream, Google OAuth, R2) is in the file; verification that rotation occurred is not tracked here. + +## Security considerations + +### JWT handling +- Validation occurs in `apps/api/src/helpers/auth.ts` via `validateAuthHeader` + Supabase `auth.getUser(token)`. Wired into `authMiddleware` / `maybeAuthenticatedMiddleware` in `apps/api/src/middlewares/middleware.ts`. +- `apps/main/src` uses `jwt-decode` (^4.0.0) to inspect tokens client-side — purely decode, no verification (correct), but any code path that *trusts* decoded claims for authorization would be a bug. +- Client portal sessions use a separate JWT (`CLIENT_AUTH_JWT_SECRET`, `apps/api/src/helpers/clientSessions.ts`) with cookie storage (`CLIENT_AUTH_COOKIE_NAME`). Two independent token systems = two attack surfaces. +- Admin sessions live in `apps/api/src/helpers/adminTokens.ts` signed by `ADMIN_TOKEN_SIGNING_SECRET` — a third token surface. + +### Service role key usage (RLS bypass) +The Supabase service role key is created exactly once in `apps/api/src/middlewares/middleware.ts:164` and injected into every authenticated request via `supabaseMiddleware`. This means **every API handler operates with full RLS bypass**. Authorization logic therefore must live in handler code; any forgotten ownership check is a data-exposure bug. Notable areas relying on handler-side checks: +- `apps/api/src/routers/tablo.ts`, `tablo_data.ts` +- `apps/api/src/routers/admin*.ts` (six admin routers) +- `apps/api/src/routers/clientPortal.ts` +- The two `TODO: Verify ... owner_id`/`slot` comments in `invite.ts` are direct evidence of this risk. + +### Stripe webhook verification +- `apps/api/src/routers/stripe.ts:167-191` — verification is delegated to `@supabase/stripe-sync-engine`'s `processWebhook(rawBody, signature)`. The route does retrieve raw body via `c.req.text()` (correct — verification needs unmodified bytes). Note: webhook router is wired pre-auth in `routers/index.ts`, which is required by Stripe — verify any future restructuring preserves this ordering. +- Webhook secret comes from `STRIPE_WEBHOOK_SECRET` in config; rotation procedure not documented in repo. + +### Secrets / env vars +- Production/staging: loaded from Google Secret Manager via `apps/api/src/secrets.ts` and assembled in `apps/api/src/config.ts`. +- Dev: `.env` via `dotenv`. `.env*` is now gitignored (see `SECURITY_NOTICE.md`). +- `apps/api/src/config.ts:32` requires a long list of secrets — `validateEnvVar` throws on missing; good fail-fast, but means a single missing env aborts boot with no partial-feature degradation. +- Frontend env: `apps/main` reads `VITE_*` env at build time per environment (`build:staging`, `build:prod`). Anything `VITE_*` is bundled into the public JS — only public keys belong here. + +## Performance considerations + +- **React Query defaults**: `packages/shared/src/lib/api.ts:18` sets a global `staleTime` of 5 minutes. Aggressive caching is appropriate for dashboard data but can hide write-after-read bugs; mutations must explicitly invalidate hierarchical keys (`["tablos", id]`, etc.). +- **Custom stale times**: `apps/main/src/hooks/stripe.ts:114` (5 min) and `:221` (10 min) — billing data caching for 10 minutes risks displaying a stale subscription state after a webhook arrives. UI should also listen to mutation success or refresh on Stripe-portal-return paths. +- **Pagination**: ~63 hits for `.select("*")` / `.range(` / `.limit(` across `apps/main/src` + `apps/api/src` (excluding tests). Many list endpoints (`apps/api/src/routers/tablo.ts`, `admin*.ts`) appear to return full tables; no shared cursor/offset helper exists. AG-Grid in main app loads client-side which exacerbates this for orgs with large datasets. +- **Source-only packages**: `@xtablo/shared`, `@xtablo/ui`, `@xtablo/chat-ui`, `@xtablo/auth-ui`, `@xtablo/tablo-views`, `@xtablo/shared-types` export TS directly. Pros: instant HMR. Cons: every app re-typechecks and re-bundles them; tree-shaking depends on each app's bundler being able to drop unused exports (Vite generally handles this, but barrel files in `packages/shared/src/index.ts`-style modules can defeat it — worth auditing if bundle size matters). +- **Bundle size**: `apps/main` has `rollup-plugin-visualizer` available for analysis but no tracked size budgets. Heavy deps: `@blocknote/*` (rich text editor), `ag-grid-community` + `ag-grid-react`, `jspdf`, `@datadog/browser-rum*` — all in `dependencies` of `apps/main/package.json`. +- **Datadog dd-trace** (`apps/api`) is initialized in `apps/api/src/index.ts:13` before everything else; misconfigured tracing has measurable cold-start cost on Cloud Run. + +## Fragile areas + +### Stripe sync engine (Supabase ↔ Stripe) +- `apps/api/src/middlewares/stripeSync.ts` instantiates `@supabase/stripe-sync-engine@^0.45.0` with a direct Postgres connection string (`SUPABASE_CONNECTION_STRING`) and base64-encoded CA cert (`SUPABASE_CA_CERT`). Bypasses Supabase API entirely. +- `revalidateObjectsViaStripeApi: ["subscription", "customer"]` — explicit workaround for stale-data bugs in the sync engine. +- Schema is `"stripe"` (separate from `public`); migrations must be applied carefully; eleven separate Stripe docs in `docs/` evidence repeated breakage. +- Sync is *eventually consistent*: UI hooks with 5–10 min `staleTime` (above) can show pre-webhook state. + +### Auth flow (passwordless + temporary accounts) +- Passwordless flow creates accounts flagged `is_temporary: true`. Lifecycle (cleanup of abandoned temp accounts, upgrade path to permanent) is not documented in `docs/`. +- Three independent token systems (user JWT, client-portal JWT, admin token) live side by side — see Security section. Test reference: `apps/api/src/__tests__/README.md:28` documents a `test_temp@example.com` fixture. +- `SessionContext` (main app) listens to `supabase.auth.onAuthStateChange()`. Deprecated `useUser` hook at `apps/main/src/hooks/user.ts:16` still exists and likely has stragglers. + +### Middleware singleton initialization order +- `MiddlewareManager` in `apps/api/src/middlewares/middleware.ts` is a singleton with throw-on-misuse semantics. The November 2025 fix (see `docs/MIDDLEWARE_INITIALIZATION_FIX.md`) is a pattern convention, not a structural guarantee. Any new router that calls `MiddlewareManager.getInstance()` at module top-level reintroduces the bug. +- The `index.ts` router order (`apps/api/src/routers/index.ts`) is load-bearing: public routes → stripe webhook (no auth, raw body) → auth-applied routes. Refactors that reorder these break either auth or signature verification. + +## Build / deploy concerns + +- **Cloudflare Workers** (`apps/main`, possibly `apps/clients`, `apps/external`, `apps/admin`): `apps/main/wrangler.toml` sets `compatibility_date = "2025-07-09"` and no `compatibility_flags`. Notably **no `nodejs_compat`** — any dep pulling Node built-ins at runtime will fail at the edge. Watch for incidental Node imports in shared packages. +- **Type generation**: per `CLAUDE.md`, run `npx supabase gen types typescript > packages/shared-types/src/database.types.ts` after schema changes. There's no CI guard that types are up to date; drift between DB and types is silent. +- **Cache invalidation gotchas** (from `CLAUDE.md` "Important Notes"): stale builds resolved only by `pnpm clean && rm -rf node_modules/.cache && pnpm install && pnpm build`. Indicates Turborepo cache occasionally misses dependency changes — possibly because source-only packages don't declare outputs. +- **API deploy** uses Cloud Run with Cloud Build (`docs/CLOUD_BUILD_*.md`, `docs/DOCKER_*.md`). Multi-stage pnpm Docker build is documented and has needed multiple optimization passes (`DOCKER_BUILD_PERFORMANCE.md`, `DOCKER_PNPM_OPTIMIZATION.md`, `DOCKER_FIX_SUMMARY.md`). +- **API uses `tsc` only** (`apps/api/package.json` `build: tsc`) — no bundling, ships `dist/` + `node_modules`. Large image surface; dependency vulnerabilities are deploy-time concerns. +- **Pre-commit**: `.pre-commit-config.yaml` exists at root; behavior not audited here. + +## Dependencies of concern + +Pinned/notable in `apps/main/package.json`: +- `@typescript/native-preview: 7.0.0-dev.20251010.1` — a *preview/nightly* TypeScript native compiler pinned to a dated dev build. High churn risk; may break unexpectedly. +- `@types/react: 19.0.10`, `@types/react-dom: 19.0.4` — exact-pinned (no caret). `react: 19.0.0` itself also exact-pinned. Upgrades will require coordinated change. +- `vitest: ^3.2.4` (in `apps/main`) vs `vitest: ^4.0.8` (in `apps/api`) — different major versions across the monorepo; shared test utilities will be incompatible. +- `@types/react-router-dom: ^5.3.3` listed alongside `react-router-dom: ^7.9.4` — the v5 type stubs are wrong for v7; either unused or actively misleading. +- `eslint: ^9.22.0` + `@typescript-eslint/*: ^7.0.2` — typescript-eslint v7 predates flat-config-stable ESLint 9; potential plugin compat issues. Note also Biome is the primary linter (`biome.json`), so ESLint may be vestigial. +- `jest` + `jest-environment-jsdom` + `@types/jest` in `apps/main` devDeps despite using Vitest — dead dependencies inflating install. +- `pnpm.overrides`: `form-data: ^4.0.4`, `linkifyjs: ^4.3.2` — root-level overrides usually indicate working around a transitive vulnerability or bug; reason isn't documented in repo. + +In `apps/api/package.json`: +- `stripe: ^20.0.0` — Stripe SDK is currently on v17+ as of cutoff; v20 is a future major. Verify lockfile matches expected runtime version. +- `ts-node: ^10.9.2` listed in `dependencies` (not devDeps) — likely unused at runtime; should be a devDep. +- `multer: ^2.0.2` — major version 2.x is recent; ensure middleware patterns aren't using legacy 1.x APIs. + +`pnpm-lock.yaml` is ~763 KB indicating substantial dependency graph; `pnpm audit` not run as part of this scan. + +## Documentation gaps + +- **No `TROUBLESHOOTING.md`** despite 35+ retrospective fix docs — there's no central index. +- **`docs/`** is fix-log heavy and architecture-light. `STRIPE_ARCHITECTURE.md` and `MIDDLEWARE_TESTS.md` exist but most flows lack an "as-built" diagram. +- **Three token systems** (user JWT, client session JWT, admin token) — no unified auth doc; you must read `apps/api/src/helpers/{auth,clientSessions,adminTokens}.ts` separately. +- **Temporary accounts** lifecycle (creation, retention, cleanup, upgrade to permanent) — undocumented. +- **Six admin routers** (`adminActions`, `adminAuth`, `adminDatasets`, `adminOverview`, `adminTables`, `admin.ts`) — admin surface is large and the only docs are `ADMIN_APP_ACCESS_SETUP.md` for access setup, not authorization model. +- **Frontend bundle budgets** — none defined; `rollup-plugin-visualizer` available but not enforced. +- **Type-generation workflow** — only mentioned in `CLAUDE.md`; no CI check. +- **`apps/chat-worker`, `apps/clients`, `apps/admin`, `apps/external`** — minimal architectural docs; main app dominates `CLAUDE.md`. +- **Legacy directories** at repo root (`backend/` Python, `go-backend/` Go, `frontend_v2/`, `xtablo-expo/`) are present but unmentioned in `CLAUDE.md`. Status (active? abandoned?) is unclear — this itself is a debt signal. +- **`SECURITY_NOTICE.md`** lists credentials to rotate after the `.env`-in-git incident, but completion status of that rotation checklist is not tracked. + +## Tracked-but-unaddressed observations + +- 43 `console.error`/`console.warn` calls in `apps/api/src/routers/` — direct logging instead of going through `pino` (which is a devDep but not wired to the routers). +- 21 explicit `: any` annotations in `apps/api/src` (excluding tests). Two visible examples: `apps/api/src/routers/clientInvites.ts:192` (`(candidate: any) =>`) and `apps/api/src/helpers/helpers.ts:374` (`(u: any) =>`). +- Only 2 `@ts-ignore`/`@ts-expect-error` comments across `apps/` + `packages/` — TypeScript discipline appears solid where types exist. diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..7c06e8c --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,161 @@ +# Code Conventions + +**Last updated:** 2026-05-14 +**Scope:** Turborepo monorepo at `/Users/arthur.belleville/Documents/perso/projects/xtablo-source` +**Apps:** `apps/main`, `apps/external`, `apps/api`, `apps/admin`, `apps/chat-worker`, `apps/clients` +**Packages:** `packages/shared`, `packages/ui`, `packages/shared-types`, `packages/auth-ui`, `packages/chat-ui`, `packages/tablo-views` + +## Linter & Formatter — Biome + +The repo uses a single root [Biome](https://biomejs.dev) config: `biome.json` (Biome 2.2.5, pinned in root `package.json` devDependencies). + +Formatting rules (from `biome.json`): +- `indentStyle: "space"`, `indentWidth: 2` +- `lineEnding: "lf"`, `lineWidth: 100` +- JS: `quoteStyle: "double"`, `jsxQuoteStyle: "double"`, `semicolons: "always"`, `trailingCommas: "es5"`, `arrowParentheses: "always"`, `bracketSpacing: true`, `bracketSameLine: false` +- JSON formatter enabled with same indent settings; parser allows comments but not trailing commas + +Key lint rules turned on (Biome `recommended: false` — rules are explicit): +- `suspicious.noExplicitAny: "error"` (overridden to `off` for some files via overrides; warn in `xtablo-expo`) +- `correctness.noUnusedVariables`, `noUnusedImports`, `noUndeclaredVariables` — all `error` +- `style.useConst`, `useTemplate`, `noNamespace`, `noCommonJs` — all `error` +- `complexity.noBannedTypes`, `suspicious.noDebugger`, `suspicious.noEmptyBlockStatements` — all `error` + +Per-package scripts wrap Biome: +- `pnpm lint` → `turbo lint` → each package runs `biome check .` +- `pnpm lint:fix` → `biome check --write .` +- `pnpm format` → `biome format --write .` + +## TypeScript Conventions + +Every package/app ships its own `tsconfig.json`; there is no root TS config. + +Strictness — every config sets `strict: true`. Additional flags consistent across configs: +- `noUnusedLocals: true`, `noUnusedParameters: true`, `noFallthroughCasesInSwitch: true` +- `noUncheckedIndexedAccess: true` for `packages/shared-types`, `packages/shared` +- `isolatedModules: true`, `moduleDetection: "force"`, `skipLibCheck: true` +- API uses `verbatimModuleSyntax: true` (`apps/api/tsconfig.json`) — type-only imports must be explicit + +Module systems: +- Frontend apps use `module: ESNext` + `moduleResolution: "bundler"` (e.g. `apps/main/tsconfig.app.json`) +- API uses `module: NodeNext` (TypeScript compiled output via `tsc`) +- All packages declare `"type": "module"` in package.json + +Path aliases (defined per app, resolved by Vite via `vite-tsconfig-paths`): +- `apps/main`: `@ui/*` → `./src/*`, `@xtablo/auth-ui` → `../../packages/auth-ui/src`, `@xtablo/ui/*` → `../../packages/ui/src/*` (see `apps/main/tsconfig.app.json`) +- `apps/main/tsconfig.json`: also `@external/*` → `src/external/*` + +Package import discipline (per `CLAUDE.md`): +- No circular dependencies between packages +- `apps/api` may only import from `@xtablo/shared-types` +- Frontend apps may import from all shared packages +- `@xtablo/shared` and `@xtablo/ui` are **source-only** — TypeScript is consumed directly via `vite-tsconfig-paths`; no build step + +Types-first workflow: +- Database types are auto-generated into `packages/shared-types/src/database.types.ts` via `npx supabase gen types typescript` +- Domain types in `@xtablo/shared-types` are derived from `database.types.ts` (nulls removed, refined shapes) +- API response types live in the same package so frontends and the API agree + +## React Component Conventions + +- Functional components only (no class components observed) +- TSX files use named exports for components, e.g. `export function CustomModal(...)` (see `apps/main/src/components/CustomModal.tsx`) +- Co-located unit tests: `Foo.tsx` + `Foo.test.tsx` +- Naming suffix conventions (per `CLAUDE.md`): + - Dialogs / modals → `*Modal.tsx` (e.g. `CustomModal.tsx`) + - Page sections → `*Section.tsx` (e.g. `TabloEventsSection.tsx`) + - Card surfaces → `*Card.tsx` (e.g. `EventTypeCard.tsx`, `AvailabilityCard.tsx`, `EventTypeCard.test.tsx`) +- Shared primitives (Radix + Tailwind) live in `packages/ui/src` +- Cross-app business hooks/contexts live in `packages/shared/src` + +## Hook Patterns + +React Query (TanStack Query v5) is the primary server-state tool. Standard return shapes: + +```ts +// Queries +const { data, isLoading, error } = useMyQuery(); + +// Mutations +const { mutate, isPending } = useMyMutation(); +``` + +- Default cache time: 5 minutes (configured in `packages/shared`'s QueryClient) +- Mutations invalidate targeted keys explicitly rather than blowing away the whole cache + +Zustand handles global client state (notably the authenticated user store): +- `useUser()` — throws if no session (use inside protected routes) +- `useMaybeUser()` — returns null if unauthenticated (use in route guards / public surfaces) + +## Query Key Conventions + +Hierarchical keys, with the resource name first and identifiers cascading deeper: + +```ts +["tablos"] // list +["tablos", tabloId] // single +["tablo-files", tabloId] // related collection +``` + +Invalidations should match the deepest key that needs to be refreshed. + +## Error Handling + +- User-facing: `toast.add({ ... })` (toast system in `packages/shared` / `packages/ui`) — messages should be friendly and actionable +- Technical: `console.error` for developer-only context (stack traces, raw API errors) +- API errors are caught at the React Query hook layer and surfaced via `error` from `useQuery`/`useMutation`; UI components branch on `isError` +- Server side (`apps/api`): Hono routers return `c.json({ error: ... }, statusCode)`; middleware handles auth failures with 401s (see `docs/MIDDLEWARE_TESTS.md`) + +## Loading States — three levels + +1. **Route level** — `ProtectedRoute` shows a full-page spinner while the session resolves +2. **Feature level** — React Query `isLoading` / `isPending` drives section-level skeletons or spinners +3. **Action level** — Buttons set `disabled` during the related mutation's `isPending` + +Empty / error states are explicit branches (no silent fallbacks). + +## Import / Export Patterns + +- ESM throughout (`"type": "module"` in every package.json; Biome enforces `noCommonJs`) +- Source-only packages (`@xtablo/shared`, `@xtablo/ui`) export from `src/index.ts` and are consumed via TS path aliases — no `dist/` involved +- Compiled packages (`@xtablo/shared-types` and `apps/api`) emit `dist/` via `tsc`; `shared-types` includes `declaration: true` and `declarationMap: true` +- Type-only imports preferred where supported (`verbatimModuleSyntax` is on for the API) +- Biome's `noUnusedImports` rule will flag dead imports at lint time + +## File Organization + +``` +apps//src/ + components/ # UI components, *.tsx + *.test.tsx + contexts/ # React contexts (e.g. UpgradeBlockContext.tsx) + providers/ # Store / provider wrappers (e.g. UserStoreProvider.tsx) + hooks/ # App-specific hooks + pages/ or routes/ # Route entry points + lib/ # Utilities, route table, api client setup + utils/testHelpers # Render-with-providers wrappers for tests +packages//src/ + index.ts # Single barrel export + ... # Domain folders mirror app layout +``` + +API layout (`apps/api/src`): +- `routers/` — Hono routers grouped by concern (`public.ts`, `authRouter.ts`, `tablo.ts`, `stripe.ts`, etc.) +- `middlewares/` — Auth, Supabase, R2, Stream, email injection +- `helpers/` — Pure logic (testable in isolation, e.g. `helpers/orgIcons.ts` + `orgIcons.test.ts`) +- `__tests__/` — Test-only fixtures, setup, globalSetup, route + middleware suites + +## Type Safety + +- `noExplicitAny` is on as `error` in the root config (relaxed to `warn` in `xtablo-expo` and `off` for select API/legacy file overrides) +- Prefer derived types from `@xtablo/shared-types` over inline shape literals +- `Database` table types are generated; domain types should narrow them rather than redeclare from scratch +- `noUncheckedIndexedAccess: true` in shared packages — index access returns `T | undefined`; handle the undefined branch explicitly +- API enforces `verbatimModuleSyntax`, so `import type { ... }` is required for type-only imports — relevant for compiled API code + +## Reference files + +- `biome.json` — single source for lint + format +- `apps/main/tsconfig.app.json` — canonical frontend TS config +- `apps/api/tsconfig.json` — canonical backend TS config +- `packages/shared-types/tsconfig.json` — type-only package config +- `CLAUDE.md` — high-level conventions (this doc expands it with verified specifics) diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..211e4a6 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,140 @@ +# INTEGRATIONS + +External systems wired into the xtablo monorepo. Last updated 2026-05-14. + +## Database — Supabase Postgres + +- **Hosted Postgres** managed by Supabase. The frontend talks to it via the JS SDK; the API talks to it via the service-role key and (for `stripe-sync-engine`) a direct `postgres://` connection string. +- **Service-role client** is constructed per-request inside the API in `apps/api/src/middlewares/middleware.ts` (`supabaseMiddleware`): + ```ts + createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY) + ``` + Mounted globally for every route in `apps/api/src/routers/index.ts`. +- **Browser client** factory at `packages/shared/src/lib/supabase.ts` re-exports `createClient`; instances are produced inside `@xtablo/shared` consumers and used directly (`supabase.from("table")...`). +- **RLS** — bypassed by the API using the service-role key when needed (see `CLAUDE.md` notes); browser clients use the anon key plus the user's JWT and rely on RLS policies. +- **Direct Postgres** — `SUPABASE_CONNECTION_STRING` (with base64-encoded `SUPABASE_CA_CERT`) is used by the Stripe sync engine `poolConfig` in `apps/api/src/middlewares/stripeSync.ts`. +- **Schema, migrations, snippets, tests** — `supabase/config.toml`, `supabase/migrations/`, `supabase/snippets/`, `supabase/tests/`. +- **Type codegen** — regenerate `packages/shared-types/src/database.types.ts` with: + ```bash + npx supabase gen types typescript > packages/shared-types/src/database.types.ts + ``` + These types are then refined into domain/API types inside `@xtablo/shared-types` (`tablos.types.ts`, `tablo-data.types.ts`, `events.types.ts`, `stripe.types.ts`, `kanban.types.ts`). + +## Authentication + +### Supabase Auth (primary users) +- JWT-based, with sessions managed by `SessionContext` (`packages/shared/src/contexts/SessionContext.tsx`) listening to `supabase.auth.onAuthStateChange()`. +- API extracts and validates the bearer token via `authenticateFromHeader` in `apps/api/src/helpers/auth.ts`, wired in `authMiddleware` (`apps/api/src/middlewares/middleware.ts`). +- `maybeAuthenticatedMiddleware` (same file) sets `c.var.user = null` when the header is missing — used by booking and other "maybe authed" endpoints in `apps/api/src/routers/maybeAuthRouter.ts`. +- Passwordless temporary accounts (`is_temporary: true`) created via the public booking flow. +- Front-end auth UI lives in `@xtablo/auth-ui`. + +### Admin auth (internal) +- Custom JWTs signed with `ADMIN_TOKEN_SIGNING_SECRET` (Google Secret Manager). +- Helpers in `apps/api/src/helpers/adminTokens.ts`, verified by `adminAuthMiddleware` (Bearer scheme) in `apps/api/src/middlewares/middleware.ts`. +- Routes: `apps/api/src/routers/admin.ts`, `adminActions.ts`, `adminAuth.ts`, `adminDatasets.ts`, `adminOverview.ts`, `adminTables.ts`. + +### Client portal auth (read-only client users) +- Magic-link → cookie-based session JWT, stored in cookie `xtablo_client_session` on domain `clients.xtablo.com`. +- Issuance: `apps/api/src/helpers/clientMagicLinks.ts`, `apps/api/src/helpers/clientSessions.ts`. +- Middlewares `clientAuthMiddleware` / `maybeClientAuthMiddleware` (`apps/api/src/middlewares/middleware.ts`) verify cookies using `CLIENT_AUTH_JWT_SECRET`. +- Routers: `apps/api/src/routers/clientAuth.ts`, `clientInvites.ts`, `clientPortal.ts`. + +### Task auth (cron / job runners) +- HTTP Basic with `TASKS_SECRET`, enforced by `basicAuthMiddleware` for `apps/api/src/routers/tasks.ts`. + +### Chat worker auth +- Independent JWT verification using `jose` against `JWT_SECRET` Wrangler secret. WebSocket connections receive the token via `?token=` query string; REST via Authorization header (`apps/chat-worker/src/index.ts`, `apps/chat-worker/src/lib/auth.ts`). + +## Payments — Stripe + +- **Server SDK** — `stripe ^20.0.0` instantiated per-request inside `stripeMiddleware` with `apiVersion: "2025-11-17.clover"` (`apps/api/src/middlewares/middleware.ts`). +- **Browser SDK** — `@stripe/stripe-js` in `apps/main`. +- **Webhook router** — `apps/api/src/routers/stripe.ts`, mounted at `/api/v1/stripe-webhook` in `apps/api/src/routers/index.ts`. Plan tiers (`solo`, `team`, `founder`) keyed off `STRIPE_*_PRICE_ID` env vars. +- **Sync engine** — `@supabase/stripe-sync-engine` writes Stripe objects directly into the `stripe` schema of Supabase via a direct Postgres pool (`apps/api/src/middlewares/stripeSync.ts`). Refetches `subscription` and `customer` objects to avoid stale state. +- **Secrets** — `stripe-secret-key`, `stripe-webhook-secret` (prod) and `…-staging` variants pulled from Google Secret Manager (`apps/api/src/secrets.ts`). +- **Billing helpers** — `apps/api/src/helpers/billing.ts`. Active-plan enforcement via `activePlanAccessMiddleware`; only the org's billing owner can manage billing. +- **Docs** — `docs/STRIPE_ARCHITECTURE.md`, `docs/STRIPE_README.md`, `docs/STRIPE_WITH_SYNC_ENGINE.md`, `docs/STRIPE_INTEGRATION_COMPLETE.md` plus several setup/testing guides. + +## Mobile Payments — RevenueCat + +- Webhook router `apps/api/src/routers/revenuecat.ts`, mounted at `/api/v1/revenuecat-webhook`. +- Maps Apple in-app product IDs to plans via `apps/api/src/helpers/appleBilling.ts`. +- Env: `REVENUECAT_WEBHOOK_AUTH_HEADER`, `REVENUECAT_SOLO_PRODUCT_ID`, `REVENUECAT_ANNUAL_PRODUCT_ID`. + +## Storage — Cloudflare R2 (S3 SDK) + +- `@aws-sdk/client-s3` pointed at `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com` with `region: "auto"`. +- Instantiated per-request in `r2Middleware` (`apps/api/src/middlewares/middleware.ts`) and exposed as `c.get("s3_client")`. +- Credentials: `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` from Secret Manager. +- Upload pipelines use `multer` + `sharp` for image processing (declared in `apps/api/package.json`). + +## Email — Gmail OAuth2 (Nodemailer) + +- SMTP through `smtp.gmail.com:465` using Google OAuth2 tokens. +- Built in `apps/api/src/middlewares/transporter.ts` via `nodemailer.createTransport` with `EMAIL_USER`, `EMAIL_CLIENT_ID`, `EMAIL_CLIENT_SECRET`, `EMAIL_REFRESH_TOKEN`. +- Exposed as `c.get("transporter")` everywhere `transporterMiddleware` is applied (mounted globally in `apps/api/src/routers/index.ts`). + +## Chat — Custom Cloudflare Durable Objects + +The CLAUDE.md mentions Stream Chat historically, but the current implementation is a **custom WebSocket chat** running on Cloudflare Workers + Durable Objects (no `stream-chat` dependency found anywhere in the repo). + +- Worker entry: `apps/chat-worker/src/index.ts` (Hono). +- Durable Object class: `apps/chat-worker/src/durable-objects/ChatRoom.ts`. Declared in `apps/chat-worker/wrangler.toml`: + ```toml + [durable_objects] + bindings = [{ name = "CHAT_ROOM", class_name = "ChatRoom" }] + [[migrations]] + tag = "v1" + new_sqlite_classes = ["ChatRoom"] + ``` +- Membership enforced by querying Supabase via PostgREST (`apps/chat-worker/src/lib/supabase.ts`). +- UI consumed via `@xtablo/chat-ui` (used by `apps/main`, `apps/clients`, `@xtablo/tablo-views`). +- Custom domain: `chat.xtablo.com`. + +## Observability + +### Frontend — Datadog RUM +- `apps/main/src/lib/rum.ts` initialises `@datadog/browser-rum` with `applicationId: "8e268e1a-1be0-44c6-b12a-978530d497c7"`, `service: "xtablo-ui"`, `sessionSampleRate: 100`, `sessionReplaySampleRate: 80`, `defaultPrivacyLevel: "mask-user-input"`, and the React plugin with router instrumentation. +- `apps/clients/src/lib/rum.ts` does the same for the clients app. +- User is set in `apps/main/src/providers/UserStoreProvider.tsx` (`datadogRum.setUser` on login, `clearUser` on logout). +- Manual view names via `apps/main/src/hooks/useDatadogRumViewName.tsx`. + +### Backend — dd-trace +- `tracer.init({ logInjection: true })` is the very first call in `apps/api/src/index.ts`. +- Datadog CI tooling (`@datadog/datadog-ci`, `@datadog/datadog-ci-plugin-cloud-run`) is wired for Cloud Run instrumentation uploads. +- Static analysis is configured at the repo root in `static-analysis.datadog.yml`. + +## Secrets Management — Google Secret Manager + +- `apps/api/src/secrets.ts` fetches every sensitive value from `projects/xtablo/secrets/{name}/versions/latest` using `@google-cloud/secret-manager`. +- Secrets loaded: `supabase-service-role-key`, `supabase-connection-string`, `supabase-ca-cert`, `admin-token-signing-secret`, `client-auth-jwt-secret`, `email-client-secret`, `email-refresh-token`, `r2-access-key-id`, `r2-secret-access-key`, `stripe-secret-key`, `stripe-webhook-secret`, plus their `…-staging` variants. +- Setup guide: `docs/GOOGLE_SECRET_MANAGER_SETUP.md`. + +## Deployment Platforms + +### Cloudflare Workers (web apps + chat) +- All Vite-built frontends are deployed to Workers via Wrangler (see `apps/*/wrangler.toml`). +- `apps/main/wrangler.toml` binds `app.xtablo.com` (prod) and `app-staging.xtablo.com` (staging), uses `not_found_handling = "single-page-application"`. +- `apps/external/wrangler.toml`, `apps/admin/wrangler.toml`, `apps/clients/wrangler.toml` each have their own custom domain. +- `apps/chat-worker/wrangler.toml` declares the `ChatRoom` Durable Object SQLite class and binds `chat.xtablo.com`. +- Worker scripts include observability blocks (`[observability] enabled = true`). + +### Google Cloud Run (API) +- Containerised by `apps/api/Dockerfile` (multi-stage Node 20 Alpine, pnpm-driven). +- CI/CD via Google Cloud Build using `apps/api/cloudbuild.yaml`. +- Runtime configuration uses Cloud Run env vars + Secret Manager (no env files inside the image). +- Deployment guides: `docs/CLOUD_BUILD_SETUP.md`, `docs/CLOUD_BUILD_ENV_CONFIG.md`, `docs/DOCKER_BUILD.md`, `docs/DOCKER_PNPM_OPTIMIZATION.md`, `docs/DOCKER_BUILD_PERFORMANCE.md`. + +### Go backend (separate) +- `go-backend/` deploys via its own `compose.yaml` / `justfile` and is independent of the TypeScript build pipeline (uses `chi`, `templ`, `sqlc`, `pgx`). + +### Infra utilities +- `infra/docker-compose.yaml`, `infra/docker-compose.traefik.yaml`, `infra/Dockerfile` — generic infra/traefik setup. `infra/app/main.py` is a small Python helper. + +## Other Integrations / Tooling + +- **Google APIs** (`googleapis ^161.0.0`) — currently used to mint OAuth2 access tokens for the Gmail SMTP transport (`apps/api/src/middlewares/transporter.ts`). +- **PWA** — `vite-plugin-pwa` in `apps/main` generates the service worker; `workbox-window` registers it. +- **Sourcemaps to Datadog** — `@datadog/datadog-ci` is available at the workspace root for `datadog-ci sourcemaps upload`. +- **Chromatic** — `chromatic ^11.5.0` listed in `apps/main` dev deps for visual regression (no CI workflow inspected here). diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..61b523d --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,142 @@ +# STACK + +Technology stack reference for the xtablo monorepo. Last updated 2026-05-14. + +## Languages & Runtimes + +- **TypeScript** — pinned at `^5.7.0` across most workspaces (api uses `^5.8.3`). Root devDependency in `package.json`. +- **Node.js** — `>=20.0.0` enforced via the `engines` field in the root `package.json`. The API Dockerfile builds on `node:20-alpine` (`apps/api/Dockerfile`). +- **Package manager** — pnpm `10.19.0` (declared as `packageManager` in the root `package.json`). Cloudflare Workers builds and the API container use `corepack` to install pnpm. +- **Go** — a parallel `go-backend/` service exists alongside the TS monorepo (`go-backend/go.mod`, `go 1.26.0`) using `chi`, `templ`, `pgx/v5`, and `sqlc`. It is included in the pnpm workspace but is otherwise its own toolchain. +- **Python** — a small infra utility at `infra/app/main.py` with `infra/requirements.txt` (not part of the runtime stack of the apps). + +## Frameworks + +### Frontend (React) +- **React 19.0.0** + **React DOM 19.0.0** — used by `apps/main`, `apps/external`, `apps/admin`, `apps/clients`, and all React packages. +- **React Router** — `react-router-dom ^7.9.4`. +- **Vite 6.2** — bundler/dev server for every web app (`apps/main/vite.config.ts`, `apps/external/vite.config.ts`, `apps/admin/vite.config.ts`, `apps/clients/vite.config.ts`). +- **TailwindCSS 4.1** — utility CSS via `@tailwindcss/vite`, with `tw-animate-css` and `tailwind-merge`. +- **Radix UI** + **React Aria** — primitives composed in `@xtablo/ui` (see `packages/ui/package.json`). +- **BlockNote** — rich-text editor (`@blocknote/core`, `@blocknote/mantine`, `@blocknote/react`) used in `apps/main`. +- **AG Grid Community** — data grid in `apps/main` (`ag-grid-community`, `ag-grid-react`). +- **React Hook Form** + **Zod** — forms and validation, wired through `@hookform/resolvers`. +- **i18next** + `react-i18next` — translations (`apps/main/src/i18n.ts`, plus `external`, `clients`, `tablo-views`). +- **react-day-picker** — calendar UI. +- **PWA** — `vite-plugin-pwa` + `workbox-window` in `apps/main`. + +### Backend (Hono) +- **Hono ^4.7.7** — HTTP framework for both `apps/api` and `apps/chat-worker`. +- **@hono/node-server** — Node adapter that drives `apps/api` (`apps/api/src/index.ts`). +- **hono-sessions** — session helpers in the API. +- **Cloudflare Workers + Durable Objects** — `apps/chat-worker` uses Hono with a `ChatRoom` SQLite-backed DO class (`apps/chat-worker/wrangler.toml`, `apps/chat-worker/src/durable-objects/ChatRoom.ts`). + +## Core Dependencies + +### Server State / Data Fetching +- `@tanstack/react-query ^5.69.0` — primary server-state cache. Hierarchical query keys; 5-minute default cache. Used in `apps/main`, `apps/clients`, `apps/admin`, `apps/external`, and `@xtablo/tablo-views`. +- `axios ^1.12.2` — HTTP client wrapper at `packages/shared/src/lib/api.ts`. + +### Client State +- `zustand ^5.0.5` — global stores (notably user). Lives in `@xtablo/shared` and is consumed via `useUser` / `useMaybeUser`. + +### Auth & JWT +- `@supabase/supabase-js ^2.49.x` — front-end and API client. +- `jwt-decode ^4.0.0` — decode access tokens on the client. +- `jose ^6.0.0` — JWT verification in the chat worker (`apps/chat-worker/src/lib/auth.ts`). + +### UI Primitives +- Radix: `react-avatar`, `react-checkbox`, `react-collapsible`, `react-dialog`, `react-dropdown-menu`, `react-label`, `react-popover`, `react-select`, `react-separator`, `react-slider`, `react-slot`, `react-switch`, `react-tabs`, `react-tooltip`, `react-radio-group` (`packages/ui/package.json`). +- `react-aria` / `react-aria-components ^1.7.0`, `@react-stately/*`, `@react-aria/*`. +- `lucide-react ^0.460.0` — iconography. +- `class-variance-authority`, `clsx`, `tailwind-merge` — class composition. +- `sonner ^2.0.7` — toast notifications (re-exported via `packages/shared/src/lib/toast.ts`). + +### Dates, IDs, Utilities +- `date-fns ^4.1.0`, `luxon ^3.7.2` (API only), `@internationalized/date`. +- `uuid ^11.1.0`, `pluralize ^8.0.0`, `ts-pattern ^5.6.2`. +- `jspdf ^3.0.3` — PDF export (main + shared). + +### Payments / Billing +- `stripe ^20.0.0` — server SDK (`apps/api`). +- `@stripe/stripe-js ^8.2.0` — browser SDK (`apps/main`). +- `@supabase/stripe-sync-engine ^0.45.0` — Stripe ↔ Supabase sync (`apps/api/src/middlewares/stripeSync.ts`). + +### Storage / Email +- `@aws-sdk/client-s3 ^3.850.0` — used against Cloudflare R2 in `apps/api/src/middlewares/middleware.ts` (`r2Middleware`). +- `multer ^2.0.2`, `sharp ^0.34.5` — file upload handling and image processing. +- `nodemailer ^7.0.4` + `googleapis ^161.0.0` — Gmail OAuth2 SMTP (`apps/api/src/middlewares/transporter.ts`). + +### Observability +- `@datadog/browser-rum ^6.13.0` + `@datadog/browser-rum-react ^6.13.0` — initialised in `apps/main/src/lib/rum.ts` and `apps/clients/src/lib/rum.ts`. +- `dd-trace ^5.74.0` — APM tracer started at the top of `apps/api/src/index.ts`. +- `@datadog/datadog-ci`, `@datadog/datadog-ci-base`, `@datadog/datadog-ci-plugin-cloud-run` — CI source-map upload and Cloud Run integration. +- `static-analysis.datadog.yml` — repo-level Datadog static analysis config. + +## Build / Dev Tooling + +- **Turborepo `^2.5.8`** — pipeline orchestration (`turbo.json`). Tasks: `build`, `dev`, `deploy(:staging|:prod)`, `build:staging`, `build:prod`, `lint`, `lint:fix`, `typecheck`, `test`, `test:watch`, `format`, `clean`. Caches `dist/**` and `tsconfig.tsbuildinfo`. +- **Biome `2.2.5`** — formatter + linter, config at `biome.json` with explicit per-package `files.includes`. +- **Vite `^6.2.2`** with `@vitejs/plugin-react ^4.3.4`, `vite-tsconfig-paths`, `@tailwindcss/vite`, `@cloudflare/vite-plugin`, `rollup-plugin-visualizer`, `vite-plugin-pwa`. +- **Vitest** — `^3.2.4` in frontend apps, `^4.0.8` in `apps/api`. Browser env via `happy-dom` (main, admin) or `jsdom` (clients). +- **Testing Library** — `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. +- **tsc** — every package has its own `tsconfig.json` and runs `tsc -b` or `tsc --noEmit` for typecheck. +- **Wrangler `^4.24.3`** — Cloudflare Workers CLI used by `main`, `external`, `admin`, `clients`, `chat-worker`. +- **tsx `^4.7.1`** — dev runner for the API (`pnpm dev` invokes `tsx watch src/index.ts`). + +## Configuration + +### Environment loading +- API: `dotenv.config({ path: `.env.${NODE_ENV}` })` in `apps/api/src/config.ts`. `createConfig(secrets)` synthesizes a typed `AppConfig` from env + Google Secret Manager values. +- Frontend: Vite `import.meta.env.*`; modes `dev`, `staging`, `production` selected via `vite build --mode`. + +### Secret loading +- `apps/api/src/secrets.ts` pulls all sensitive values from `projects/xtablo/secrets/*/versions/latest` using `@google-cloud/secret-manager`. +- Test mode bypasses Secret Manager and uses raw env vars. + +### Build targets per app +| App | Bundler | Output | Deploy target | +| --- | --- | --- | --- | +| `apps/main` | Vite + `@cloudflare/vite-plugin` | `dist/` + worker | Cloudflare Workers (`apps/main/wrangler.toml`, routes `app.xtablo.com`, `app-staging.xtablo.com`) | +| `apps/external` | Vite + Cloudflare plugin | `dist/` | Cloudflare Workers (`apps/external/wrangler.toml`) | +| `apps/admin` | Vite + Cloudflare plugin | `dist/` | Cloudflare Workers (`apps/admin/wrangler.toml`) | +| `apps/clients` | Vite + Cloudflare plugin | `dist/` | Cloudflare Workers (`apps/clients/wrangler.toml`) | +| `apps/chat-worker` | Wrangler-native | Worker bundle | Cloudflare Workers + Durable Objects, `chat.xtablo.com` | +| `apps/api` | `tsc` → `dist/` | Node 20 container | Google Cloud Run (`apps/api/Dockerfile`, `apps/api/cloudbuild.yaml`) | +| `go-backend` | `go build` | Binary | Separate (see `go-backend/justfile`) | + +### Static configs +- `biome.json` — single source of truth for formatting/linting scope. +- `turbo.json` — task graph; `globalDependencies` include `**/.env.*local`. +- `tsconfig.json` per workspace; project references across packages. + +## Workspace Structure + +`pnpm-workspace.yaml` defines: +```yaml +packages: + - 'apps/*' + - 'go-backend' + - 'packages/*' +``` + +### Apps (`apps/`) +- `@xtablo/main` — authenticated dashboard (port 5173). +- `@xtablo/external` — embeddable public booking widget (port 5174). +- `@xtablo/clients` — read-only client portal (port 5175, `clients.xtablo.com`). +- `@xtablo/admin` — internal admin app (port 5176). +- `@xtablo/api` — Hono REST API (port 8080). +- `@xtablo/chat-worker` — Cloudflare Worker hosting Durable-Object chat (`chat.xtablo.com`). + +### Packages (`packages/`) +- `@xtablo/shared` — React contexts, hooks, supabase wrapper, axios client, toast helper. **Source-only** (no build step; consumers import TS directly — see `packages/shared/package.json` `"main": "./src/index.ts"`). +- `@xtablo/ui` — Radix + Tailwind + react-aria component library. **Source-only**. +- `@xtablo/shared-types` — pure TS types including Supabase-generated `database.types.ts`. **Source-only**, zero runtime deps. +- `@xtablo/auth-ui` — shared auth screens. **Source-only**. +- `@xtablo/chat-ui` — chat UI components consumed by main, clients, tablo-views. **Source-only**. +- `@xtablo/tablo-views` — tablo view components shared between main and clients. **Source-only**. + +All shared packages export `./src/*` directly, so Vite HMR / tsc project references pick up changes instantly without a build step. + +### pnpm overrides +Root `package.json` pins `form-data ^4.0.4` and `linkifyjs ^4.3.2`, plus a `packageExtensions` entry adding `zod` as a peer of `@hookform/resolvers`. diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..938553d --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,296 @@ +# Structure + +_Last updated: 2026-05-14_ + +Directory layout, conventions, and where to look for things in the `xtablo-source` monorepo. + +## Top-Level Layout + +``` +xtablo-source/ +├── apps/ # Deployable applications +│ ├── main/ # Authenticated dashboard SPA (Vite + CF Workers) +│ ├── external/ # Public booking widget (Vite) +│ ├── clients/ # Client portal (Vite + CF Workers) +│ ├── admin/ # Internal admin tools (Vite + CF Workers) +│ ├── api/ # Hono REST API (Node, GCP Cloud Run) +│ └── chat-worker/ # Cloudflare Durable Object worker for chat +├── packages/ # Shared workspace packages (source-only) +│ ├── shared/ # Contexts, hooks, API client, toast, supabase client +│ ├── ui/ # Radix + Tailwind component library +│ ├── shared-types/ # Pure TypeScript types (zero runtime deps) +│ ├── auth-ui/ # Auth screens (login/register shells) +│ ├── chat-ui/ # Chat-specific UI components +│ └── tablo-views/ # Tablo detail / sections components +├── backend/ # Legacy / auxiliary backend code +├── go-backend/ # Go services (separate stack) +├── frontend_v2/ # Frontend v2 prototype +├── xtablo-expo/ # React Native (Expo) app +├── supabase/ # Supabase project (migrations, config) +├── infra/ # Infrastructure-as-code +├── docs/ # Architecture, deployment, integration docs +├── prompts/ # Prompt templates +├── scripts/ # Repo scripts +├── CLAUDE.md # Claude Code guidance (root) +├── DEVELOPMENT.md # Dev guide +├── turbo.json # Turborepo pipeline config +├── pnpm-workspace.yaml +└── package.json +``` + +## Apps + +### `apps/main` (`@xtablo/main`) + +Primary dashboard. Internal layout: + +``` +apps/main/src/ +├── main.tsx # Vite entry +├── App.tsx # Root component, providers +├── i18n.ts # i18next setup +├── main.css # Tailwind entry +├── components/ # UI components (Modals, Sections, Cards, ...) +├── contexts/ # App-local React contexts (UpgradeBlockContext, ...) +├── hooks/ # Feature hooks (tablos, events, tasks, stripe, ...) +├── lib/ +│ ├── api.ts # API client wrapper +│ ├── supabase.ts # Supabase client re-export +│ ├── routes.tsx # Protected routes +│ ├── publicRoutes.tsx # Public/auth routes +│ ├── billing.ts # Stripe-related helpers +│ ├── env.ts # Env validation +│ └── rum.ts # Datadog RUM init +├── pages/ # Route page components +├── providers/ +│ └── UserStoreProvider.tsx # Zustand user store +├── locales/ # i18n JSON +├── utils/ +└── assets/ +``` + +### `apps/external` (`@xtablo/external`) + +Embeddable booking widget. Internal layout: + +``` +apps/external/src/ +├── main.tsx +├── routes.tsx +├── EmbeddedBookingPage.tsx +├── FloatingBookingWidget.tsx +├── CustomModal.tsx +├── UserStoreProvider.tsx +├── lib/ +└── locales/ +``` + +Mode is driven by query string: `?mode=embed&eventTypeId=...` or `?mode=floating`. + +### `apps/clients` (`@xtablo/clients`) + +Public-facing client portal. + +``` +apps/clients/src/ +├── main.tsx +├── App.tsx +├── routes.tsx +├── components/ +├── hooks/ +├── lib/ +├── pages/ +├── locales/ +└── test/ +``` + +### `apps/admin` (`@xtablo/admin`) + +Internal admin app. + +``` +apps/admin/src/ +├── main.tsx +├── App.tsx +├── routes.tsx +├── components/ +├── hooks/ +├── lib/ +├── pages/ +└── registry/ +``` + +### `apps/api` (`@xtablo/api`) + +Hono REST API. Internal layout: + +``` +apps/api/src/ +├── index.ts # Entry: tracer, secrets, server start +├── config.ts # createConfig(secrets) -> AppConfig +├── secrets.ts # loadSecrets() (env or GCP Secret Manager) +├── client.ts # Supabase admin client factory +├── middlewares/ +│ ├── middleware.ts # MiddlewareManager singleton + supabase/r2/stripe +│ ├── stripeSync.ts # Stripe<->Supabase sync engine +│ └── transporter.ts # Email transport middleware +├── routers/ +│ ├── index.ts # getMainRouter — composition + ordering +│ ├── public.ts # Unauthenticated endpoints +│ ├── authRouter.ts # Requires JWT +│ ├── maybeAuthRouter.ts # Optional auth (booking-aware) +│ ├── tablo.ts # Tablo CRUD +│ ├── tablo_data.ts # Tablo content blocks +│ ├── tasks.ts # Tasks +│ ├── notes.ts # Notes +│ ├── events.ts (in user.ts/tablo_data.ts) +│ ├── stripe.ts # Stripe webhook + ops +│ ├── revenuecat.ts # RevenueCat webhook +│ ├── invite.ts # Tablo invites +│ ├── user.ts # User profile / settings +│ ├── admin*.ts # admin.ts, adminActions.ts, adminAuth.ts, +│ │ # adminDatasets.ts, adminOverview.ts, adminTables.ts +│ ├── clientAuth.ts # Client portal magic-link auth +│ ├── clientPortal.ts # Client portal endpoints +│ └── clientInvites.ts # Public client invites +├── helpers/ +├── types/ # BaseEnv, app.types.ts +└── __tests__/ +``` + +### `apps/chat-worker` + +``` +apps/chat-worker/src/ +├── index.ts # Worker entry +├── durable-objects/ # Durable Object classes +└── lib/ +``` + +## Packages + +### `packages/shared` (`@xtablo/shared`) + +Public surface via `packages/shared/src/index.ts`. + +``` +packages/shared/src/ +├── index.ts # Barrel +├── contexts/ +│ ├── SessionContext.tsx # Supabase session listener +│ └── ThemeContext.tsx +├── hooks/ +│ ├── auth.ts # useSignIn / useSignOut / useSession ... +│ ├── book.ts # Booking hooks +│ ├── public.ts # Public data hooks +│ └── useClickOutside.ts +├── lib/ +│ ├── api.ts # HTTP client w/ Bearer token +│ ├── supabase.ts # Supabase JS client +│ ├── toast.ts # toast.add() wrapper (sonner) +│ └── cn.ts # clsx + tailwind-merge +├── types/ # Re-exported domain types +└── utils/ + └── helpers.ts +``` + +### `packages/ui` (`@xtablo/ui`) + +Radix UI + Tailwind component library. Source-only. + +``` +packages/ui/src/ +├── components/ # button.tsx, dialog.tsx, select.tsx, +│ # popover.tsx, tabs.tsx, dropdown-menu.tsx, ... +├── hooks/ +└── styles/ +``` + +### `packages/shared-types` (`@xtablo/shared-types`) + +Zero-dependency TypeScript types — safe to import from API and frontend. + +``` +packages/shared-types/src/ +├── index.ts +├── database.types.ts # Auto-generated by supabase gen types +├── tablos.types.ts +├── tablo-data.types.ts +├── events.types.ts +├── kanban.types.ts +├── stripe.types.ts +├── admin.types.ts +└── utils.ts +``` + +### `packages/auth-ui` (`@xtablo/auth-ui`) + +Auth screens shared between apps: `AuthCardShell.tsx`, `AuthEmailPasswordForm.tsx`, `AuthInfoBanner.tsx`. + +### `packages/chat-ui` (`@xtablo/chat-ui`) + +Chat-specific UI (`components/`, `hooks.ts`, `security.ts`, `types.ts`, `chat-ui.css`). + +### `packages/tablo-views` (`@xtablo/tablo-views`) + +Tablo detail sections and shell: `TabloDetailsShell.tsx`, `TabloEventsSection.tsx`, `TabloFilesSection.tsx`, `TabloTasksSection.tsx`, `TabloDiscussionSection.tsx`, `TabloHeaderActions.tsx`, `EtapesSection.tsx`, `RoadmapSection.tsx`, plus `single-tablo/`, `components/`, `hooks/`, `styles/`. + +## Naming Conventions + +- **Modals**: `*Modal.tsx` — e.g. `apps/main/src/components/CreateTabloModal.tsx`, `EventDetailsModal.tsx`. +- **Sections**: `*Section.tsx` — e.g. `packages/tablo-views/src/TabloFilesSection.tsx`. +- **Cards**: `*Card.tsx` — e.g. `apps/main/src/components/EventTypeCard.tsx`, `AvailabilityCard.tsx`. +- **Pages**: live in `apps//src/pages/`, lowercased file names matching the route (`planning.tsx`, `events.tsx`, `login.tsx`). +- **Tests**: co-located as `*.test.tsx` / `*.test.ts` next to the source (`AvailabilityCard.test.tsx`, `auth.signup.test.ts`). +- **Hooks**: lowercase domain file in `hooks/` (`tablos.ts`, `events.ts`, `tasks.ts`); each exports multiple named hooks. +- **UI primitives**: lowercase in `packages/ui/src/components/` (`button.tsx`, `select.tsx`). + +## Key File Locations (Cheatsheet) + +| Concern | Path | +|-------------------------------|---------------------------------------------------------| +| Main app routes (protected) | `apps/main/src/lib/routes.tsx` | +| Main app public routes | `apps/main/src/lib/publicRoutes.tsx` | +| External app routes | `apps/external/src/routes.tsx` | +| Clients app routes | `apps/clients/src/routes.tsx` | +| Admin app routes | `apps/admin/src/routes.tsx` | +| Session context | `packages/shared/src/contexts/SessionContext.tsx` | +| User store (Zustand) | `apps/main/src/providers/UserStoreProvider.tsx` | +| HTTP API client | `packages/shared/src/lib/api.ts` | +| Supabase client (browser) | `packages/shared/src/lib/supabase.ts` | +| API entry | `apps/api/src/index.ts` | +| API router composition | `apps/api/src/routers/index.ts` | +| API middleware singleton | `apps/api/src/middlewares/middleware.ts` | +| API config | `apps/api/src/config.ts` | +| API secrets loader | `apps/api/src/secrets.ts` | +| Auto-generated DB types | `packages/shared-types/src/database.types.ts` | +| Shared barrel export | `packages/shared/src/index.ts` | +| UI component barrel | `packages/ui/src/components/index.ts` | +| Turborepo pipeline | `turbo.json` | +| Workspace definition | `pnpm-workspace.yaml` | +| Root Claude guidance | `CLAUDE.md` | + +## Adding New Features (5-Step Flow) + +From `CLAUDE.md`, the canonical workflow for a new feature is: + +1. **Define types** in `packages/shared-types/src/` (or run `npx supabase gen types typescript > packages/shared-types/src/database.types.ts` if you changed the DB schema). +2. **Add API endpoint** in `apps/api/src/routers/` — pick the right router (`public.ts`, `maybeAuthRouter.ts`, `authRouter.ts`, or a domain router like `tasks.ts`). Mount it in `apps/api/src/routers/index.ts` if it's new. +3. **Create a React Query hook** in either `packages/shared/src/hooks/` (cross-app) or `apps/main/src/hooks/` (app-local). Use the hierarchical query-key convention so mutations can invalidate predictably. +4. **Build the UI** using primitives from `@xtablo/ui` and patterns from `@xtablo/tablo-views` / `@xtablo/chat-ui` where applicable. Follow the `*Modal.tsx` / `*Section.tsx` / `*Card.tsx` naming convention and co-locate a `*.test.tsx`. +5. **Wire the route** in `apps/main/src/lib/routes.tsx` (or the equivalent in the relevant app) so the new page is reachable. + +## Testing Locations + +- **API tests**: `apps/api/src/__tests__/` (Vitest, mock Supabase). +- **Frontend tests**: co-located `*.test.tsx` next to source, run via `pnpm test` per app (`apps/main/src/setupTests.ts` configures happy-dom + Testing Library). +- **Docs on testing**: `docs/API_TESTS.md`, `docs/MIDDLEWARE_TESTS.md`. + +## Documentation Index (in `docs/`) + +- `DEVELOPMENT.md` — broad dev guide. +- `API_*.md` — API testing and integration. +- `STRIPE_*.md` — Stripe integration deep-dives. +- `AUTH_*.md` — Authentication patterns. +- `DOCKER_*.md` — Docker build optimization. +- `CLOUD_BUILD_*.md` — GCP Cloud Build setup. diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..22a6602 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,141 @@ +# Testing + +**Last updated:** 2026-05-14 +**Scope:** Turborepo monorepo at `/Users/arthur.belleville/Documents/perso/projects/xtablo-source` + +## Frameworks + +- **Vitest** is the only JS/TS test runner across every app and package (no Jest) +- **React Testing Library** (`@testing-library/react`) for component tests; `@testing-library/jest-dom` matchers loaded via setup files +- **happy-dom / jsdom** for DOM emulation in frontend tests (`apps/main/vite.config.ts` configures `environment: "jsdom"`) +- **Vitest with mocked Supabase** for `apps/api` — node environment, real Hono router exercised, Supabase client stubbed (`apps/api/src/__tests__/setup.ts` + `globalSetup.ts`) +- **pgTAP** SQL tests under `supabase/tests/database/*.test.sql` for schema/RLS/trigger correctness (run via the Supabase CLI, separate from Vitest) + +## Test Commands + +From the repo root (all dispatched by Turborepo via `turbo.json`): + +```bash +pnpm test # Run every package's `test` task +pnpm test:watch # Watch mode across the workspace +pnpm test:api # Run only @xtablo/api (turbo --filter) +cd apps/main && pnpm test # Run a single package directly +cd apps/api && pnpm test:watch +``` + +Per-package script conventions (see e.g. `apps/api/package.json`): +- `"test": "NODE_ENV=test vitest run"` +- `"test:watch": "NODE_ENV=test vitest"` +- Frontend apps similarly use `vitest run` / `vitest` (no `NODE_ENV=test` prefix required) + +## Test File Location + +Tests are **co-located** with the source they exercise: + +- React components: `Foo.tsx` + `Foo.test.tsx` in the same folder + - e.g. `apps/main/src/components/CustomModal.test.tsx`, `apps/main/src/components/NavigationBar.test.tsx` +- Hooks / contexts: `useFoo.ts` + `useFoo.test.ts(x)` + - e.g. `apps/main/src/contexts/UpgradeBlockContext.test.tsx` +- API: tests under `apps/api/src/__tests__//.test.ts` (grouped by router/concern), plus co-located helper tests like `apps/api/src/helpers/orgIcons.test.ts` +- Expo app sometimes uses `__tests__/` folders: `xtablo-expo/components/__tests__/BillingPaywall.test.tsx` + +## Vitest Configs + +Two distinct flavors live in the repo: + +1. **Frontend (Vite + Vitest)** — config embedded inside `vite.config.ts`. Example, `apps/main/vite.config.ts`: + ```ts + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + } + ``` + The Cloudflare plugin is skipped when `process.env.VITEST === "true"`. `VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY` are stubbed via `define` when running under Vitest. + + Other apps with the same pattern: + - `apps/admin/vite.config.ts` + `apps/admin/src/setupTests.ts` + - `apps/external/vite.config.ts` + `apps/external/src/setupTests.ts` + - `apps/clients/vite.config.ts` + `apps/clients/src/setupTests.ts` + +2. **API (standalone Vitest)** — dedicated `apps/api/vitest.config.ts`: + ```ts + test: { + globals: true, + environment: "node", + setupFiles: ["./src/__tests__/setup.ts"], + globalSetup: ["./src/__tests__/globalSetup.ts"], + testTimeout: 30000, + hookTimeout: 60000, + include: ["src/__tests__/**/*.test.ts", "src/**/*.test.ts"], + pool: "forks", + fileParallelism: false, + } + ``` + `fileParallelism: false` is deliberate — tests share initialized middleware state and can't safely run concurrently across files. + +## Setup Files + +- `apps/main/src/setupTests.ts` — imports `@testing-library/jest-dom`, registers an `afterEach(cleanup)`, mocks `ResizeObserver`, `Element.prototype.scrollIntoView`, `Element.prototype.scrollTo`, and `window.matchMedia`. Also imports `./i18n.test` to bootstrap i18next for tests. +- `apps/admin/src/setupTests.ts`, `apps/external/src/setupTests.ts`, `apps/clients/src/setupTests.ts` — analogous per-app setup +- `apps/api/src/__tests__/setup.ts` — minimal per-test-file setup (DB init handled by `globalSetup.ts`) +- `apps/api/src/__tests__/globalSetup.ts` — boots the test middleware manager via `createConfig()` reading `.env.test` + +## Mocking Patterns + +- **Frontend component tests** import the component and render it with a `renderWithProviders` helper (see `apps/main/src/utils/testHelpers.tsx` and `apps/clients/src/test/testHelpers.test.tsx`) that wires QueryClient, router, i18n, and Zustand stores. +- **React Query mocking**: integration tests mock the hook return values directly with `vi.mock(...)` and inject `{ data, isLoading, error }` shapes. +- **Supabase client**: API tests stub `supabase.from(...)` chains via `vi.fn()` — fixtures live in `apps/api/src/__tests__/fixtures/`. +- **Auth**: API middleware tests bypass real Supabase auth by overriding the Bearer-token validator and asserting on 401 paths (see `docs/MIDDLEWARE_TESTS.md`). +- **Toast / window APIs**: stubbed globally in `setupTests.ts` so individual tests don't have to. +- **i18n**: each frontend test setup imports `./i18n.test` so `useTranslation()` works without network. + +## API Test Patterns + +Documented in `docs/API_TESTS.md` and `docs/MIDDLEWARE_TESTS.md`. Key conventions: + +- Tests live under `apps/api/src/__tests__//.test.ts` (e.g. `notes/notes.test.ts`, `tasks/tasks.test.ts`) +- Each router has at minimum a smoke test that verifies the endpoint is reachable and returns the right shape +- Middleware is tested independently in `apps/api/src/__tests__/middlewares/middlewares.test.ts` — auth header validation, Supabase client injection, R2/Stream/email middleware behavior +- Test mode is detected via `NODE_ENV=test` in `createConfig()` so secrets load from `.env.test` instead of Google Secret Manager — no GCP credentials required to run the suite +- Fixtures (sample DB rows, sample Stripe payloads) live in `apps/api/src/__tests__/fixtures/` +- Helpers (token mocking, app builders) live in `apps/api/src/__tests__/helpers/` +- The `__tests__/README.md` documents the suite layout in-tree + +Snapshot from `docs/MIDDLEWARE_TESTS.md`: 116 passing tests across the API suite at last documented run (2025-11-10), with explicit coverage for the Supabase, Auth, Stream, R2, and email middlewares. + +## Coverage + +- **No coverage threshold is enforced** in CI today — `vitest.config.ts` files do not declare `coverage` blocks and `package.json` has no `test:coverage` script at the root. +- Coverage can still be produced ad-hoc with `vitest run --coverage` in any individual package, but it's not part of the normal workflow. +- The repo relies on (a) Biome lint errors blocking CI, (b) `pnpm typecheck` (`tsc -b`) blocking CI, and (c) `pnpm test` blocking CI — coverage % is currently advisory only. + +## Existing Test Inventory + +Total Vitest/RTL test files (`*.test.ts(x)`, excluding `node_modules`, `dist`): **147** + +Breakdown by area (approximate): +- `apps/main/src/**/*.test.tsx` — ~64 (components, contexts, providers, hooks) +- `apps/api/src/**/*.test.ts` — ~31 (routers, middlewares, helpers) +- `apps/admin/src/**/*.test.tsx` — pages, components, lib (e.g. `AnalyticsStudioPage.test.tsx`, `PrivilegedGate.test.tsx`) +- `apps/clients/src/**/*.test.ts(x)` — env + Vite config sanity checks plus page tests +- `apps/external/src/viteConfig.test.ts`, `apps/main/src/viteConfig.test.ts` — Vite build config smoke tests +- `xtablo-expo/**/*.test.ts(x)` — React Native (Expo) tests (e.g. `auth.test.ts`, `BillingPaywall.test.tsx`) +- `supabase/tests/database/*.test.sql` — 13 pgTAP files covering schema, RLS, triggers, indexes, Stripe + Apple billing functions + +Representative examples worth reading first: +- `apps/main/src/components/CustomModal.test.tsx` — minimal RTL test +- `apps/main/src/components/NavigationBar.test.tsx` — uses `renderWithProviders` +- `apps/main/src/providers/UserStoreProvider.test.tsx` — Zustand + React Query store test +- `apps/main/src/contexts/UpgradeBlockContext.test.tsx` — context provider test +- `apps/api/src/helpers/orgIcons.test.ts` — pure-helper unit test +- `apps/api/src/__tests__/middlewares/middlewares.test.ts` — middleware integration suite +- `supabase/tests/database/02_rls_policies_core.test.sql` — pgTAP RLS coverage + +## Reference Documents + +- `docs/API_TESTS.md` — API router test catalog, env setup, known limitations +- `docs/MIDDLEWARE_TESTS.md` — middleware-by-middleware coverage notes +- `docs/ENV_TEST_SETUP.md` — `.env.test` structure +- `docs/TEST_FIXES.md`, `docs/TEST_ROUTER_REFACTOR.md` — historical notes on test infrastructure changes +- `docs/TESTING_WITH_FAKE_ACCOUNTS.md` — temporary-account flow for manual + integration testing -- 2.45.2 From 3e3d02e244bed528ea3ead5b172a97c283795735 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:24:26 +0200 Subject: [PATCH 042/546] docs: initialize project (go+htmx rewrite) --- .planning/PROJECT.md | 94 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .planning/PROJECT.md diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..b29bbdf --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,94 @@ +# Xtablo — Go + HTMX Rewrite + +## What This Is + +A full rewrite of the Xtablo product from a JS/Turbo monorepo (`apps/main`, `apps/external`, `apps/api`, …) into a single Go server with HTMX-driven UI. v1 focuses on the authenticated Tablos workflow only; other surfaces (booking, client portal, admin, chat, billing) come in later milestones. Built for a developer who wants a simpler, more durable stack and is using this rewrite as a deliberate pivot. + +## Core Value + +**A user can sign in and run the Tablos workflow — create tablos, manage their tasks (kanban), and attach files — without a JS framework.** + +If everything else fails, this must work end-to-end on a single Go binary backed by Postgres and an S3-compatible bucket. + +## Requirements + +### Validated + + + +(None yet — ship to validate) + +### Active + + + +- [ ] Built-in email/password auth with server-managed sessions (no third-party auth) +- [ ] Tablo CRUD (list, create, view, edit, delete) scoped to the owning user +- [ ] Kanban tasks attached to a tablo (columns, ordering, CRUD) +- [ ] File attachments on a tablo via S3-compatible storage (R2) +- [ ] Background worker for async jobs (e.g. signed URL refresh, future emails) +- [ ] Deployable as a single container to a single VPS/Cloud Run-style host + +### Out of Scope + + + +- **Chat / messaging** — Stream Chat + Durable Object worker dropped; not load-bearing for v1 +- **Stripe / billing** — defer monetization until product loop is validated +- **Public booking widget** — `apps/external` rewrite not in v1 (may return in a later milestone) +- **Client portal** — `apps/clients` magic-link experience deferred +- **Admin app** — `apps/admin` internal tooling deferred +- **Notes / Etapes / Events sub-features** inside a Tablo — only Tasks + Files in v1 +- **Third-party auth providers** — explicitly built-in only; no Clerk/Auth0/Lucia +- **Mobile / Expo app** — out of scope for this rewrite +- **Supabase** as a runtime dependency — Postgres only; Supabase Auth / RLS replaced by Go-side authz +- **The existing `go-backend/` directory** — treated as scratch; new code lives in a fresh `backend/` Go package + +## Context + +- The JS monorepo (this repository) is the source of truth for product behavior and is fully mapped in `.planning/codebase/` (ARCHITECTURE, STACK, STRUCTURE, INTEGRATIONS, CONVENTIONS, CONCERNS, TESTING). Use these to derive expected behavior — but the rewrite is free to simplify both schema and visuals. +- The DB schema **will change** during the rewrite — the JS version's Supabase schema is a reference, not a constraint. The user wants to be in the loop on schema decisions for each domain (users/sessions, tablos, tasks, files). +- Visuals will also change — no requirement to mirror the existing UI; Tailwind + HTMX patterns drive the new look. +- `go-backend/` already contains real scaffolding (router, sqlc, air, tailwind input). It is *not* the foundation — a fresh `backend/` package will be created. +- Developer is comfortable in Go and wants a low-dependency, server-rendered stack going forward. + +## Constraints + +- **Tech stack**: Go (server + templates) + HTMX + Tailwind + Postgres + sqlc — no third-party auth, no JS framework, no managed BaaS +- **Auth**: Server-managed sessions only (signed/HTTP-only cookies), no JWTs from external providers +- **Storage**: Files in S3-compatible object storage (Cloudflare R2 to start) +- **Architecture**: One web server binary + one background worker (same repo, possibly same binary with subcommand) +- **Deploy target**: Single VPS / container — no Kubernetes +- **Scope discipline**: v1 ships the Tablo workflow only; resist scope creep from JS feature inventory + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Rewrite in Go + HTMX (no SPA) | Simpler stack, developer preference, product pivot | — Pending | +| Built-in sessions, no 3rd-party auth | Avoid vendor coupling; sessions are well-trodden ground | — Pending | +| Drop Supabase (keep Postgres) | Owning the auth + RLS story in Go is simpler than maintaining the boundary | — Pending | +| Fresh `backend/` Go package, set `go-backend/` aside | Existing scaffold has decisions to revisit; cleaner to start over | — Pending | +| v1 = Tablos workflow only (Tasks + Files), defer chat/billing/booking/portal/admin | Focus the rewrite around the load-bearing user loop first | — Pending | +| Single binary + background worker, single VPS deploy | Matches the "simpler stack" thesis; avoid orchestration cost early | — Pending | +| User-in-the-loop on Postgres schema for each domain | Schema is changing from JS version; explicit review before sqlc generation | — Pending | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd-transition`): +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone** (via `/gsd-complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-05-14 after initialization* -- 2.45.2 From 39c76088517db7768690e10845c676dac6c92056 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:26:25 +0200 Subject: [PATCH 043/546] chore: add project config --- .planning/config.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .planning/config.json diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..97f1109 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,42 @@ +{ + "model_profile": "quality", + "commit_docs": true, + "parallelization": false, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "git": { + "branching_strategy": "none", + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "auto_advance": true, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "text_mode": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false, + "code_review": true, + "code_review_depth": "standard" + }, + "hooks": { + "context_warnings": true + }, + "project_code": null, + "phase_naming": "sequential", + "agent_skills": {}, + "features": {}, + "resolve_model_ids": "omit", + "mode": "yolo", + "granularity": "standard" +} -- 2.45.2 From b46d314a359c310611941067d949906b14b6581f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:27:20 +0200 Subject: [PATCH 044/546] docs: define v1 requirements --- .planning/REQUIREMENTS.md | 133 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 .planning/REQUIREMENTS.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..123d5f3 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,133 @@ +# Requirements: Xtablo Go+HTMX Rewrite + +**Defined:** 2026-05-14 +**Core Value:** A user can sign in and run the Tablos workflow — create tablos, manage their tasks (kanban), and attach files — without a JS framework. + +## v1 Requirements + +Requirements for the initial Go+HTMX milestone. Each maps to exactly one roadmap phase. + +### Foundation + +- [ ] **FOUND-01**: Fresh `backend/` Go package with module init, `cmd/web` and `cmd/worker` entrypoints, and a runnable HTTP server returning `/healthz` +- [ ] **FOUND-02**: Postgres connection pool with env-driven config and a versioned migration tool (e.g. `goose` or `migrate`) wired into a `justfile`/`Makefile` +- [ ] **FOUND-03**: HTMX + Tailwind + templ rendering pipeline producing a base layout with a working dev loop (template hot-reload, CSS rebuild) +- [ ] **FOUND-04**: Structured logging, request ID middleware, and graceful shutdown on the web server +- [ ] **FOUND-05**: `.env.example`, local Postgres via `compose.yaml`, and a `justfile` documenting `dev`, `migrate`, `test`, `lint` + +### Authentication + +- [ ] **AUTH-01**: User can sign up with email and password (server-side validation, bcrypt/argon2 hash) +- [ ] **AUTH-02**: User can log in with email and password and receives a server-managed session +- [ ] **AUTH-03**: Sessions persist via HTTP-only, signed cookies (Secure + SameSite=Lax) and survive browser refresh +- [ ] **AUTH-04**: User can log out from any authenticated page (server invalidates session) +- [ ] **AUTH-05**: Protected routes redirect unauthenticated requests to the login page; authenticated users on auth pages are sent to the dashboard +- [ ] **AUTH-06**: CSRF protection on all state-changing requests +- [ ] **AUTH-07**: Rate-limited login attempts per email + IP to discourage credential stuffing + +### Tablos + +- [ ] **TABLO-01**: Authenticated user can list their tablos on the dashboard (newest first) +- [ ] **TABLO-02**: User can create a tablo with at minimum a title (and optional description) +- [ ] **TABLO-03**: User can view a single tablo's detail page (only owners can view in v1) +- [ ] **TABLO-04**: User can edit a tablo's title and description +- [ ] **TABLO-05**: User can delete a tablo (soft delete or hard delete; user-in-loop on schema decision) +- [ ] **TABLO-06**: All tablo mutations are HTMX-driven (no full page reloads for CRUD actions) + +### Tasks (Kanban) + +- [ ] **TASK-01**: Each tablo has a kanban board with named columns (e.g. To do / Doing / Done; configurable in v1 or fixed — user-in-loop on schema) +- [ ] **TASK-02**: User can create a task inside a column with a title +- [ ] **TASK-03**: User can edit a task's title and description +- [ ] **TASK-04**: User can move a task between columns (HTMX + drag-and-drop or button-based reorder, decided in plan-phase) +- [ ] **TASK-05**: User can reorder tasks within a column +- [ ] **TASK-06**: User can delete a task +- [ ] **TASK-07**: Task ordering persists across refreshes + +### Files + +- [ ] **FILE-01**: User can upload a file (with size limit) to a tablo; metadata stored in Postgres, bytes stored in S3-compatible storage (R2) +- [ ] **FILE-02**: Uploads use direct-to-S3 presigned PUT URLs OR server-proxied upload (decided in plan-phase) +- [ ] **FILE-03**: User can list files attached to a tablo with original filename and size +- [ ] **FILE-04**: User can download a file via a signed time-limited URL +- [ ] **FILE-05**: User can delete an attached file (removes both DB row and S3 object) +- [ ] **FILE-06**: Authorization checks ensure only the tablo owner can upload/list/download/delete + +### Worker + +- [ ] **WORK-01**: `cmd/worker` binary connects to the same Postgres and runs a job queue (e.g. `river`, `asynq`, or a hand-rolled `pg_notify` queue — decided in plan-phase) +- [ ] **WORK-02**: At least one real job runs end-to-end (e.g. periodic signed-URL prewarm OR scheduled file-orphan cleanup) to prove the wiring +- [ ] **WORK-03**: Worker has structured logging and graceful shutdown matching the web binary +- [ ] **WORK-04**: Failed jobs are retried with backoff and visible in a simple admin/CLI surface + +### Deploy + +- [ ] **DEPLOY-01**: Both binaries build into a single multi-stage Docker image (or two stages from one Dockerfile) +- [ ] **DEPLOY-02**: Image runs on a single VPS / Cloud Run-style host with env-injected config (no Supabase, no GCP Secret Manager required for v1) +- [ ] **DEPLOY-03**: Migrations run on deploy (entrypoint or pre-deploy step) without manual intervention +- [ ] **DEPLOY-04**: Health checks (`/healthz`, `/readyz`) and structured logs that a basic uptime monitor can consume +- [ ] **DEPLOY-05**: A documented runbook in `backend/README.md` covers local dev, deploy, and rollback + +## v2 Requirements + +Acknowledged, not in current roadmap. + +### Booking + +- **BOOK-01**: Public embeddable booking widget (port of `apps/external`) +- **BOOK-02**: Event types + availabilities backend + +### Client Portal + +- **CLIENT-01**: Magic-link client portal (port of `apps/clients`) + +### Admin + +- **ADMIN-01**: Internal admin tooling (port of `apps/admin`) + +### Tablo Sub-features + +- **TABLO-NOTES**: Rich notes inside a tablo +- **TABLO-ETAPES**: Etapes / roadmap sections inside a tablo +- **TABLO-EVENTS**: Calendar events tied to a tablo + +### Communication & Billing + +- **CHAT-01**: Real-time discussion inside a tablo (replacement for Stream Chat) +- **BILL-01**: Stripe subscriptions + entitlement gating + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| Third-party auth (Clerk, Auth0, Lucia) | Explicit "no 3rd party" decision — built-in sessions only | +| Supabase as runtime dependency | Drop the BaaS; Postgres only | +| React / SPA frontend | Whole point of the rewrite is HTMX + server templates | +| Stream Chat / Durable Object chat | Complexity not justified for v1; possibly revisited as a Go-native version | +| Mobile / Expo app | Web rewrite first; mobile not in scope | +| Existing `go-backend/` directory as foundation | Set aside; fresh `backend/` package | +| Full feature parity with JS app before cutover | Leaner-by-design — v2 milestones add back what's worth keeping | +| Kubernetes / multi-node deployment | Single VPS/container target only | + +## Traceability + +Populated during roadmap creation in Step 8. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| FOUND-01..05 | Phase 1 | Pending | +| AUTH-01..07 | Phase 2 | Pending | +| TABLO-01..06 | Phase 3 | Pending | +| TASK-01..07 | Phase 4 | Pending | +| FILE-01..06 | Phase 5 | Pending | +| WORK-01..04 | Phase 6 | Pending | +| DEPLOY-01..05 | Phase 7 | Pending | + +**Coverage:** +- v1 requirements: 40 total +- Mapped to phases: 40 +- Unmapped: 0 ✓ + +--- +*Requirements defined: 2026-05-14* +*Last updated: 2026-05-14 after initial definition* -- 2.45.2 From f58596e496be3afd1fcf4fa87221eb1748368044 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:28:40 +0200 Subject: [PATCH 045/546] docs: create roadmap (7 phases) --- .planning/ROADMAP.md | 139 ++++++++++++++++++++ .planning/STATE.md | 39 ++++++ CLAUDE.md | 297 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 475 insertions(+) create mode 100644 .planning/ROADMAP.md create mode 100644 .planning/STATE.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..63c7aae --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,139 @@ +# Roadmap: Xtablo Go+HTMX Rewrite + +**Created:** 2026-05-14 +**Project mode:** Vertical MVP (each phase delivers an end-to-end user-visible slice where possible) +**Milestone:** v1 — Tablos workflow on Go+HTMX + +7 phases, sequential. Earlier phases are foundational; later phases build atop them. Phase boundaries are deliberate review points — especially for DB schema decisions (Phases 2, 3, 4, 5). + +--- + +## Phase Summary + +| # | Phase | Goal | Requirements | +|---|-------|------|--------------| +| 1 | Foundation | Fresh `backend/` Go package boots, renders HTMX, talks to Postgres | FOUND-01..05 | +| 2 | Auth | A user can sign up, log in, and stay logged in | AUTH-01..07 | +| 3 | Tablos CRUD | An authenticated user can manage their tablos end-to-end | TABLO-01..06 | +| 4 | Tasks (Kanban) | A user can run a kanban board inside a tablo | TASK-01..07 | +| 5 | Files | A user can attach, list, download, delete files on a tablo | FILE-01..06 | +| 6 | Background Worker | A second binary runs jobs against the same Postgres | WORK-01..04 | +| 7 | Deploy v1 | The product runs in production on a single host | DEPLOY-01..05 | + +--- + +## Phase Details + +### Phase 1: Foundation +**Goal:** A fresh `backend/` Go package boots a web server, renders an HTMX-driven base layout, and connects to a local Postgres with migrations. +**Mode:** mvp +**Requirements:** FOUND-01, FOUND-02, FOUND-03, FOUND-04, FOUND-05 +**Success Criteria:** +1. `just dev` starts the web server on a local port and live-reloads on `.go` and template changes +2. `GET /healthz` returns 200 with a JSON `{status:"ok", db:"ok"}` only when the DB is reachable +3. The root route renders a Tailwind-styled HTMX page that loads without console errors and includes a working `hx-get` example +4. `just migrate up` applies migrations from `backend/migrations/` against the local Postgres +5. A new dev can clone the repo, run `compose up -d` + `just dev`, and see the page within ~5 minutes following `backend/README.md` + +**User-in-loop:** Approve directory layout (`backend/cmd/web`, `backend/cmd/worker`, `backend/internal/...`) and pick the migration tool (`goose` vs `golang-migrate`). + +### Phase 2: Authentication +**Goal:** A new user can sign up, log in with email + password, and stay logged in across requests using server-managed sessions. +**Mode:** mvp +**Requirements:** AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06, AUTH-07 +**Success Criteria:** +1. Signing up creates a user row with hashed password (argon2id or bcrypt) and starts a session +2. Logging in with valid credentials issues a signed HTTP-only cookie; invalid credentials show a clear error +3. Hitting any protected route while unauthenticated redirects to `/login`; logged-in users on `/login` go to `/` +4. Logout invalidates the session server-side (cookie cleared + DB session row deleted) +5. All POST routes require a valid CSRF token; missing/invalid tokens return 403 +6. >5 failed logins per email/IP per minute triggers rate-limiting + +**User-in-loop:** Approve the `users` and `sessions` table schemas (columns, indexes, deletion semantics) before sqlc generation. Approve hash algorithm choice. + +### Phase 3: Tablos CRUD +**Goal:** A logged-in user can list, create, view, edit, and delete their tablos end-to-end through HTMX-driven flows. +**Mode:** mvp +**Requirements:** TABLO-01, TABLO-02, TABLO-03, TABLO-04, TABLO-05, TABLO-06 +**Success Criteria:** +1. Dashboard lists the current user's tablos newest-first; empty state shows a "Create your first tablo" CTA +2. Creating a tablo via the create form inserts a row, dismisses the modal/inline form, and prepends the new tablo via HTMX swap +3. Tablo detail page renders title and description; non-owners (or unauthenticated) get a 404 +4. Editing title/description updates the row and re-renders the affected fragments without a full page reload +5. Deleting a tablo removes it from the list (with a confirmation step) and is irreversible via the UI +6. All actions work without JS errors and degrade gracefully if HTMX is unavailable (forms still submit) + +**User-in-loop:** Approve the `tablos` table schema (ownership model, soft-delete vs hard-delete, slug strategy). + +### Phase 4: Tasks (Kanban) +**Goal:** Inside a tablo, a user can run a kanban board — create, edit, move, reorder, and delete tasks across columns. +**Mode:** mvp +**Requirements:** TASK-01, TASK-02, TASK-03, TASK-04, TASK-05, TASK-06, TASK-07 +**Success Criteria:** +1. A tablo detail page shows a kanban board with at least three columns +2. Creating a task inserts it into the target column and renders without full reload +3. Editing a task updates title/description in place +4. Moving a task between columns persists the new column and refreshes the source + target columns +5. Reordering within a column persists and survives reload +6. Deleting a task removes it from the board with a confirmation +7. Two concurrent edits don't corrupt order (last-write-wins is acceptable for v1, documented) + +**User-in-loop:** Approve the `task_columns` (or fixed-column) schema and the ordering strategy (fractional indices, gaps-of-100, linked list — to be decided with research). Approve whether reorder is drag-and-drop or button-driven. + +### Phase 5: Files +**Goal:** A user can attach files to a tablo, list them, download them via signed URLs, and delete them — backed by S3-compatible storage. +**Mode:** mvp +**Requirements:** FILE-01, FILE-02, FILE-03, FILE-04, FILE-05, FILE-06 +**Success Criteria:** +1. Uploading a file from a tablo detail page creates a `tablo_files` row and stores bytes in the configured S3 bucket +2. The files list shows original filename, size, and uploaded-at; sorted newest first +3. Downloads use signed URLs with a short TTL (e.g. 5 minutes) generated server-side +4. Deleting a file removes both the DB row and the S3 object; failures are surfaced and logged +5. Only the tablo owner can upload/list/download/delete files for a given tablo (verified by tests) +6. Configurable max upload size enforced server-side, with a friendly error message above the form + +**User-in-loop:** Approve the `tablo_files` schema (key strategy, content-type handling, dedup). Approve upload method (direct PUT vs server-proxied). + +### Phase 6: Background Worker +**Goal:** A second binary (`cmd/worker`) runs against the same Postgres, processes jobs from a queue, and proves end-to-end with at least one real job. +**Mode:** mvp +**Requirements:** WORK-01, WORK-02, WORK-03, WORK-04 +**Success Criteria:** +1. `cmd/worker` starts, connects to Postgres, and registers handlers; logs are structured and graceful shutdown works +2. At least one real job (chosen during plan-phase — e.g. periodic orphan-file cleanup) runs on a schedule and is observable in logs +3. A failing job is retried with backoff and visible via a simple CLI surface (`backend list-failed-jobs` or admin route) +4. Web binary can enqueue a job; worker picks it up within a few seconds +5. README documents how to run the worker locally alongside the web binary + +**User-in-loop:** Approve the queue library/approach (`river` vs `asynq` vs hand-rolled `pg_notify`) and pick the proof-of-life job. + +### Phase 7: Deploy v1 +**Goal:** The product runs in production on a single host, behind a documented deploy + rollback workflow. +**Mode:** mvp +**Requirements:** DEPLOY-01, DEPLOY-02, DEPLOY-03, DEPLOY-04, DEPLOY-05 +**Success Criteria:** +1. A multi-stage Dockerfile builds both `web` and `worker` and the image starts either via a subcommand +2. The container runs on the chosen single-host target (e.g. Hetzner VM / Fly.io / Cloud Run) with env-injected config +3. Deploy step runs migrations against the production database before traffic is shifted +4. `/healthz` and `/readyz` return appropriate status codes during startup, steady state, and shutdown +5. `backend/README.md` documents: first-time deploy, routine deploy, rollback, and incident triage basics + +**User-in-loop:** Approve the deploy target choice (Hetzner / Fly / Cloud Run) and the secret-management strategy (env vars vs `.env` file vs SOPS). + +--- + +## Coverage + +- v1 requirements: 40 +- Mapped to phases: 40 +- Unmapped: 0 ✓ + +## Notes + +- Sequential execution: each phase depends on the previous. Auth must work before Tablos; Tablos must exist before Tasks/Files can attach to them. +- The user is in the loop on **DB schema decisions** at the start of Phases 2, 3, 4, and 5. Treat schema approval as a hard gate inside `plan-phase` — sqlc generation does not run until the schema is approved. +- Visual design is intentionally undefined per phase; UI plans choose Tailwind patterns inline. A `gsd-ui-phase` step can be invoked for any phase if a more deliberate UI contract is desired (Phases 3, 4, 5 are the strongest candidates). +- The legacy `apps/*` JS app is the behavioral reference — `.planning/codebase/` is the source of truth for "what does the JS version do today?". + +--- +*Roadmap created: 2026-05-14* diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..07ca114 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,39 @@ +# STATE + +**Project:** Xtablo Go+HTMX Rewrite +**Milestone:** v1 — Tablos workflow +**Created:** 2026-05-14 + +## Project Reference + +See: `.planning/PROJECT.md` (updated 2026-05-14) + +**Core value:** A user can sign in and run the Tablos workflow — create tablos, manage their tasks (kanban), and attach files — without a JS framework. +**Current focus:** Phase 1 — Foundation + +## Phase Status + +| # | Phase | Status | +|---|-------|--------| +| 1 | Foundation | ○ Pending | +| 2 | Authentication | ○ Pending | +| 3 | Tablos CRUD | ○ Pending | +| 4 | Tasks (Kanban) | ○ Pending | +| 5 | Files | ○ Pending | +| 6 | Background Worker | ○ Pending | +| 7 | Deploy v1 | ○ Pending | + +## Active Phase + +**Phase 1: Foundation** — not started. + +Next: `/gsd-discuss-phase 1` to gather context, or `/gsd-plan-phase 1` to plan directly. + +## Notes + +- Existing `go-backend/` is set aside; new code lives in a fresh `backend/` Go package. +- DB schema is changing from the JS/Supabase version — user is in the loop on every schema decision (Phases 2–5). +- GSD subagents are not installed in this repo; downstream commands may fail until `npx get-shit-done-cc@latest --global` is run. + +--- +*Last updated: 2026-05-14 after project initialization* diff --git a/CLAUDE.md b/CLAUDE.md index 4e7e331..4fb2cd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,3 +251,300 @@ Extensive documentation available in `/docs`: - `CLOUD_BUILD_*.md`: GCP Cloud Build setup For questions about architecture decisions or detailed implementation notes, check the docs folder first. + + +## Project + +**Xtablo — Go + HTMX Rewrite** + +A full rewrite of the Xtablo product from a JS/Turbo monorepo (`apps/main`, `apps/external`, `apps/api`, …) into a single Go server with HTMX-driven UI. v1 focuses on the authenticated Tablos workflow only; other surfaces (booking, client portal, admin, chat, billing) come in later milestones. Built for a developer who wants a simpler, more durable stack and is using this rewrite as a deliberate pivot. + +**Core Value:** **A user can sign in and run the Tablos workflow — create tablos, manage their tasks (kanban), and attach files — without a JS framework.** + +If everything else fails, this must work end-to-end on a single Go binary backed by Postgres and an S3-compatible bucket. + +### Constraints + +- **Tech stack**: Go (server + templates) + HTMX + Tailwind + Postgres + sqlc — no third-party auth, no JS framework, no managed BaaS +- **Auth**: Server-managed sessions only (signed/HTTP-only cookies), no JWTs from external providers +- **Storage**: Files in S3-compatible object storage (Cloudflare R2 to start) +- **Architecture**: One web server binary + one background worker (same repo, possibly same binary with subcommand) +- **Deploy target**: Single VPS / container — no Kubernetes +- **Scope discipline**: v1 ships the Tablo workflow only; resist scope creep from JS feature inventory + + + +## Technology Stack + +## Languages & Runtimes +- **TypeScript** — pinned at `^5.7.0` across most workspaces (api uses `^5.8.3`). Root devDependency in `package.json`. +- **Node.js** — `>=20.0.0` enforced via the `engines` field in the root `package.json`. The API Dockerfile builds on `node:20-alpine` (`apps/api/Dockerfile`). +- **Package manager** — pnpm `10.19.0` (declared as `packageManager` in the root `package.json`). Cloudflare Workers builds and the API container use `corepack` to install pnpm. +- **Go** — a parallel `go-backend/` service exists alongside the TS monorepo (`go-backend/go.mod`, `go 1.26.0`) using `chi`, `templ`, `pgx/v5`, and `sqlc`. It is included in the pnpm workspace but is otherwise its own toolchain. +- **Python** — a small infra utility at `infra/app/main.py` with `infra/requirements.txt` (not part of the runtime stack of the apps). +## Frameworks +### Frontend (React) +- **React 19.0.0** + **React DOM 19.0.0** — used by `apps/main`, `apps/external`, `apps/admin`, `apps/clients`, and all React packages. +- **React Router** — `react-router-dom ^7.9.4`. +- **Vite 6.2** — bundler/dev server for every web app (`apps/main/vite.config.ts`, `apps/external/vite.config.ts`, `apps/admin/vite.config.ts`, `apps/clients/vite.config.ts`). +- **TailwindCSS 4.1** — utility CSS via `@tailwindcss/vite`, with `tw-animate-css` and `tailwind-merge`. +- **Radix UI** + **React Aria** — primitives composed in `@xtablo/ui` (see `packages/ui/package.json`). +- **BlockNote** — rich-text editor (`@blocknote/core`, `@blocknote/mantine`, `@blocknote/react`) used in `apps/main`. +- **AG Grid Community** — data grid in `apps/main` (`ag-grid-community`, `ag-grid-react`). +- **React Hook Form** + **Zod** — forms and validation, wired through `@hookform/resolvers`. +- **i18next** + `react-i18next` — translations (`apps/main/src/i18n.ts`, plus `external`, `clients`, `tablo-views`). +- **react-day-picker** — calendar UI. +- **PWA** — `vite-plugin-pwa` + `workbox-window` in `apps/main`. +### Backend (Hono) +- **Hono ^4.7.7** — HTTP framework for both `apps/api` and `apps/chat-worker`. +- **@hono/node-server** — Node adapter that drives `apps/api` (`apps/api/src/index.ts`). +- **hono-sessions** — session helpers in the API. +- **Cloudflare Workers + Durable Objects** — `apps/chat-worker` uses Hono with a `ChatRoom` SQLite-backed DO class (`apps/chat-worker/wrangler.toml`, `apps/chat-worker/src/durable-objects/ChatRoom.ts`). +## Core Dependencies +### Server State / Data Fetching +- `@tanstack/react-query ^5.69.0` — primary server-state cache. Hierarchical query keys; 5-minute default cache. Used in `apps/main`, `apps/clients`, `apps/admin`, `apps/external`, and `@xtablo/tablo-views`. +- `axios ^1.12.2` — HTTP client wrapper at `packages/shared/src/lib/api.ts`. +### Client State +- `zustand ^5.0.5` — global stores (notably user). Lives in `@xtablo/shared` and is consumed via `useUser` / `useMaybeUser`. +### Auth & JWT +- `@supabase/supabase-js ^2.49.x` — front-end and API client. +- `jwt-decode ^4.0.0` — decode access tokens on the client. +- `jose ^6.0.0` — JWT verification in the chat worker (`apps/chat-worker/src/lib/auth.ts`). +### UI Primitives +- Radix: `react-avatar`, `react-checkbox`, `react-collapsible`, `react-dialog`, `react-dropdown-menu`, `react-label`, `react-popover`, `react-select`, `react-separator`, `react-slider`, `react-slot`, `react-switch`, `react-tabs`, `react-tooltip`, `react-radio-group` (`packages/ui/package.json`). +- `react-aria` / `react-aria-components ^1.7.0`, `@react-stately/*`, `@react-aria/*`. +- `lucide-react ^0.460.0` — iconography. +- `class-variance-authority`, `clsx`, `tailwind-merge` — class composition. +- `sonner ^2.0.7` — toast notifications (re-exported via `packages/shared/src/lib/toast.ts`). +### Dates, IDs, Utilities +- `date-fns ^4.1.0`, `luxon ^3.7.2` (API only), `@internationalized/date`. +- `uuid ^11.1.0`, `pluralize ^8.0.0`, `ts-pattern ^5.6.2`. +- `jspdf ^3.0.3` — PDF export (main + shared). +### Payments / Billing +- `stripe ^20.0.0` — server SDK (`apps/api`). +- `@stripe/stripe-js ^8.2.0` — browser SDK (`apps/main`). +- `@supabase/stripe-sync-engine ^0.45.0` — Stripe ↔ Supabase sync (`apps/api/src/middlewares/stripeSync.ts`). +### Storage / Email +- `@aws-sdk/client-s3 ^3.850.0` — used against Cloudflare R2 in `apps/api/src/middlewares/middleware.ts` (`r2Middleware`). +- `multer ^2.0.2`, `sharp ^0.34.5` — file upload handling and image processing. +- `nodemailer ^7.0.4` + `googleapis ^161.0.0` — Gmail OAuth2 SMTP (`apps/api/src/middlewares/transporter.ts`). +### Observability +- `@datadog/browser-rum ^6.13.0` + `@datadog/browser-rum-react ^6.13.0` — initialised in `apps/main/src/lib/rum.ts` and `apps/clients/src/lib/rum.ts`. +- `dd-trace ^5.74.0` — APM tracer started at the top of `apps/api/src/index.ts`. +- `@datadog/datadog-ci`, `@datadog/datadog-ci-base`, `@datadog/datadog-ci-plugin-cloud-run` — CI source-map upload and Cloud Run integration. +- `static-analysis.datadog.yml` — repo-level Datadog static analysis config. +## Build / Dev Tooling +- **Turborepo `^2.5.8`** — pipeline orchestration (`turbo.json`). Tasks: `build`, `dev`, `deploy(:staging|:prod)`, `build:staging`, `build:prod`, `lint`, `lint:fix`, `typecheck`, `test`, `test:watch`, `format`, `clean`. Caches `dist/**` and `tsconfig.tsbuildinfo`. +- **Biome `2.2.5`** — formatter + linter, config at `biome.json` with explicit per-package `files.includes`. +- **Vite `^6.2.2`** with `@vitejs/plugin-react ^4.3.4`, `vite-tsconfig-paths`, `@tailwindcss/vite`, `@cloudflare/vite-plugin`, `rollup-plugin-visualizer`, `vite-plugin-pwa`. +- **Vitest** — `^3.2.4` in frontend apps, `^4.0.8` in `apps/api`. Browser env via `happy-dom` (main, admin) or `jsdom` (clients). +- **Testing Library** — `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. +- **tsc** — every package has its own `tsconfig.json` and runs `tsc -b` or `tsc --noEmit` for typecheck. +- **Wrangler `^4.24.3`** — Cloudflare Workers CLI used by `main`, `external`, `admin`, `clients`, `chat-worker`. +- **tsx `^4.7.1`** — dev runner for the API (`pnpm dev` invokes `tsx watch src/index.ts`). +## Configuration +### Environment loading +- API: `dotenv.config({ path: `.env.${NODE_ENV}` })` in `apps/api/src/config.ts`. `createConfig(secrets)` synthesizes a typed `AppConfig` from env + Google Secret Manager values. +- Frontend: Vite `import.meta.env.*`; modes `dev`, `staging`, `production` selected via `vite build --mode`. +### Secret loading +- `apps/api/src/secrets.ts` pulls all sensitive values from `projects/xtablo/secrets/*/versions/latest` using `@google-cloud/secret-manager`. +- Test mode bypasses Secret Manager and uses raw env vars. +### Build targets per app +| App | Bundler | Output | Deploy target | +| --- | --- | --- | --- | +| `apps/main` | Vite + `@cloudflare/vite-plugin` | `dist/` + worker | Cloudflare Workers (`apps/main/wrangler.toml`, routes `app.xtablo.com`, `app-staging.xtablo.com`) | +| `apps/external` | Vite + Cloudflare plugin | `dist/` | Cloudflare Workers (`apps/external/wrangler.toml`) | +| `apps/admin` | Vite + Cloudflare plugin | `dist/` | Cloudflare Workers (`apps/admin/wrangler.toml`) | +| `apps/clients` | Vite + Cloudflare plugin | `dist/` | Cloudflare Workers (`apps/clients/wrangler.toml`) | +| `apps/chat-worker` | Wrangler-native | Worker bundle | Cloudflare Workers + Durable Objects, `chat.xtablo.com` | +| `apps/api` | `tsc` → `dist/` | Node 20 container | Google Cloud Run (`apps/api/Dockerfile`, `apps/api/cloudbuild.yaml`) | +| `go-backend` | `go build` | Binary | Separate (see `go-backend/justfile`) | +### Static configs +- `biome.json` — single source of truth for formatting/linting scope. +- `turbo.json` — task graph; `globalDependencies` include `**/.env.*local`. +- `tsconfig.json` per workspace; project references across packages. +## Workspace Structure +### Apps (`apps/`) +- `@xtablo/main` — authenticated dashboard (port 5173). +- `@xtablo/external` — embeddable public booking widget (port 5174). +- `@xtablo/clients` — read-only client portal (port 5175, `clients.xtablo.com`). +- `@xtablo/admin` — internal admin app (port 5176). +- `@xtablo/api` — Hono REST API (port 8080). +- `@xtablo/chat-worker` — Cloudflare Worker hosting Durable-Object chat (`chat.xtablo.com`). +### Packages (`packages/`) +- `@xtablo/shared` — React contexts, hooks, supabase wrapper, axios client, toast helper. **Source-only** (no build step; consumers import TS directly — see `packages/shared/package.json` `"main": "./src/index.ts"`). +- `@xtablo/ui` — Radix + Tailwind + react-aria component library. **Source-only**. +- `@xtablo/shared-types` — pure TS types including Supabase-generated `database.types.ts`. **Source-only**, zero runtime deps. +- `@xtablo/auth-ui` — shared auth screens. **Source-only**. +- `@xtablo/chat-ui` — chat UI components consumed by main, clients, tablo-views. **Source-only**. +- `@xtablo/tablo-views` — tablo view components shared between main and clients. **Source-only**. +### pnpm overrides + + + +## Conventions + +## Linter & Formatter — Biome +- `indentStyle: "space"`, `indentWidth: 2` +- `lineEnding: "lf"`, `lineWidth: 100` +- JS: `quoteStyle: "double"`, `jsxQuoteStyle: "double"`, `semicolons: "always"`, `trailingCommas: "es5"`, `arrowParentheses: "always"`, `bracketSpacing: true`, `bracketSameLine: false` +- JSON formatter enabled with same indent settings; parser allows comments but not trailing commas +- `suspicious.noExplicitAny: "error"` (overridden to `off` for some files via overrides; warn in `xtablo-expo`) +- `correctness.noUnusedVariables`, `noUnusedImports`, `noUndeclaredVariables` — all `error` +- `style.useConst`, `useTemplate`, `noNamespace`, `noCommonJs` — all `error` +- `complexity.noBannedTypes`, `suspicious.noDebugger`, `suspicious.noEmptyBlockStatements` — all `error` +- `pnpm lint` → `turbo lint` → each package runs `biome check .` +- `pnpm lint:fix` → `biome check --write .` +- `pnpm format` → `biome format --write .` +## TypeScript Conventions +- `noUnusedLocals: true`, `noUnusedParameters: true`, `noFallthroughCasesInSwitch: true` +- `noUncheckedIndexedAccess: true` for `packages/shared-types`, `packages/shared` +- `isolatedModules: true`, `moduleDetection: "force"`, `skipLibCheck: true` +- API uses `verbatimModuleSyntax: true` (`apps/api/tsconfig.json`) — type-only imports must be explicit +- Frontend apps use `module: ESNext` + `moduleResolution: "bundler"` (e.g. `apps/main/tsconfig.app.json`) +- API uses `module: NodeNext` (TypeScript compiled output via `tsc`) +- All packages declare `"type": "module"` in package.json +- `apps/main`: `@ui/*` → `./src/*`, `@xtablo/auth-ui` → `../../packages/auth-ui/src`, `@xtablo/ui/*` → `../../packages/ui/src/*` (see `apps/main/tsconfig.app.json`) +- `apps/main/tsconfig.json`: also `@external/*` → `src/external/*` +- No circular dependencies between packages +- `apps/api` may only import from `@xtablo/shared-types` +- Frontend apps may import from all shared packages +- `@xtablo/shared` and `@xtablo/ui` are **source-only** — TypeScript is consumed directly via `vite-tsconfig-paths`; no build step +- Database types are auto-generated into `packages/shared-types/src/database.types.ts` via `npx supabase gen types typescript` +- Domain types in `@xtablo/shared-types` are derived from `database.types.ts` (nulls removed, refined shapes) +- API response types live in the same package so frontends and the API agree +## React Component Conventions +- Functional components only (no class components observed) +- TSX files use named exports for components, e.g. `export function CustomModal(...)` (see `apps/main/src/components/CustomModal.tsx`) +- Co-located unit tests: `Foo.tsx` + `Foo.test.tsx` +- Naming suffix conventions (per `CLAUDE.md`): +- Shared primitives (Radix + Tailwind) live in `packages/ui/src` +- Cross-app business hooks/contexts live in `packages/shared/src` +## Hook Patterns +- Default cache time: 5 minutes (configured in `packages/shared`'s QueryClient) +- Mutations invalidate targeted keys explicitly rather than blowing away the whole cache +- `useUser()` — throws if no session (use inside protected routes) +- `useMaybeUser()` — returns null if unauthenticated (use in route guards / public surfaces) +## Query Key Conventions +## Error Handling +- User-facing: `toast.add({ ... })` (toast system in `packages/shared` / `packages/ui`) — messages should be friendly and actionable +- Technical: `console.error` for developer-only context (stack traces, raw API errors) +- API errors are caught at the React Query hook layer and surfaced via `error` from `useQuery`/`useMutation`; UI components branch on `isError` +- Server side (`apps/api`): Hono routers return `c.json({ error: ... }, statusCode)`; middleware handles auth failures with 401s (see `docs/MIDDLEWARE_TESTS.md`) +## Loading States — three levels +## Import / Export Patterns +- ESM throughout (`"type": "module"` in every package.json; Biome enforces `noCommonJs`) +- Source-only packages (`@xtablo/shared`, `@xtablo/ui`) export from `src/index.ts` and are consumed via TS path aliases — no `dist/` involved +- Compiled packages (`@xtablo/shared-types` and `apps/api`) emit `dist/` via `tsc`; `shared-types` includes `declaration: true` and `declarationMap: true` +- Type-only imports preferred where supported (`verbatimModuleSyntax` is on for the API) +- Biome's `noUnusedImports` rule will flag dead imports at lint time +## File Organization +- `routers/` — Hono routers grouped by concern (`public.ts`, `authRouter.ts`, `tablo.ts`, `stripe.ts`, etc.) +- `middlewares/` — Auth, Supabase, R2, Stream, email injection +- `helpers/` — Pure logic (testable in isolation, e.g. `helpers/orgIcons.ts` + `orgIcons.test.ts`) +- `__tests__/` — Test-only fixtures, setup, globalSetup, route + middleware suites +## Type Safety +- `noExplicitAny` is on as `error` in the root config (relaxed to `warn` in `xtablo-expo` and `off` for select API/legacy file overrides) +- Prefer derived types from `@xtablo/shared-types` over inline shape literals +- `Database` table types are generated; domain types should narrow them rather than redeclare from scratch +- `noUncheckedIndexedAccess: true` in shared packages — index access returns `T | undefined`; handle the undefined branch explicitly +- API enforces `verbatimModuleSyntax`, so `import type { ... }` is required for type-only imports — relevant for compiled API code +## Reference files +- `biome.json` — single source for lint + format +- `apps/main/tsconfig.app.json` — canonical frontend TS config +- `apps/api/tsconfig.json` — canonical backend TS config +- `packages/shared-types/tsconfig.json` — type-only package config +- `CLAUDE.md` — high-level conventions (this doc expands it with verified specifics) + + + +## Architecture + +## High-Level Diagram +``` +``` +## Application Layers +- **Frontend dashboard** (`apps/main`): primary authenticated SPA. Tablos, planning, events, chat, notes, billing. Entry: `apps/main/src/main.tsx`, root component `apps/main/src/App.tsx`. +- **Public booking widget** (`apps/external`): embeddable / floating booking widget. Entry: `apps/external/src/main.tsx`. Query params drive mode (`?mode=embed&eventTypeId=...`). +- **Client portal** (`apps/clients`): public-facing client portal experience. Entry: `apps/clients/src/main.tsx`, routes in `apps/clients/src/routes.tsx`. +- **Admin app** (`apps/admin`): internal admin tools. Entry: `apps/admin/src/main.tsx`, routes in `apps/admin/src/routes.tsx`. +- **API** (`apps/api`): Hono-based REST API serving all frontends. Entry: `apps/api/src/index.ts` (compiled to Node, deployed to Google Cloud Run). +- **Chat worker** (`apps/chat-worker`): Cloudflare Worker with Durable Objects for real-time chat presence. Entry: `apps/chat-worker/src/index.ts`. +## Data Flow Patterns +### React Query (TanStack Query v5) — primary server-state tool +```ts +``` +- `apps/main/src/hooks/` — feature hooks (`tablos.ts`, `events.ts`, `tasks.ts`, `availabilities.ts`, `stripe.ts`, `notes.ts`, ...). +- `packages/shared/src/hooks/` — cross-app hooks (`auth.ts`, `book.ts`, `public.ts`). +### Zustand — global client state +- `useUser()` — throws if no session (use inside protected routes). +- `useMaybeUser()` — returns null if unauthenticated (use in route guards / public-aware components). +### Direct Supabase queries vs API calls +## Authentication Flow +- **Supabase Auth** issues JWTs on login / passwordless flows. +- **SessionContext** (`packages/shared/src/contexts/SessionContext.tsx`) subscribes to `supabase.auth.onAuthStateChange()` and exposes the current session. +- Frontend HTTP client reads the session token and sends `Authorization: Bearer ` on every API call. +- The API's `supabase` middleware validates the JWT and attaches the resolved user / supabase clients to the Hono context. +- **Passwordless onboarding** generates temporary accounts flagged with `is_temporary: true`. +- **Protected routes** check `useMaybeUser()` and redirect to landing when null. +- **Client portal** has a parallel auth path: magic-link based, signed cookies issued by `apps/api/src/routers/clientAuth.ts`. Configurable TTLs, cookie domain, JWT secret are passed into the router factory. +## API Architecture (Hono) +### Middleware Manager (singleton pattern) +``` +``` +- `apps/api/src/middlewares/middleware.ts` — central singleton and supabase / r2 / email / stripe middlewares. +- `apps/api/src/middlewares/stripeSync.ts` — bidirectional Supabase <-> Stripe sync engine. +- `apps/api/src/middlewares/transporter.ts` — email transporter. +### Router ordering +## Key Abstractions +- **`packages/shared`** is the central runtime sharing point. Re-exports cover contexts (`SessionContext`, `ThemeContext`), cross-app hooks, the API client, the Supabase client, toast helpers, and shared types. Public surface in `packages/shared/src/index.ts`. +- **`packages/ui`** — Radix + Tailwind component library. Source-only. Components in `packages/ui/src/components/` (`button.tsx`, `dialog.tsx`, `select.tsx`, ...). +- **`packages/shared-types`** — zero-runtime-dependency TypeScript types. Auto-generated `database.types.ts` + hand-written domain layers (`tablos.types.ts`, `tablo-data.types.ts`, `events.types.ts`, `kanban.types.ts`, `stripe.types.ts`, `admin.types.ts`). +- **`packages/auth-ui`, `packages/chat-ui`, `packages/tablo-views`** — feature-scoped UI packages, also source-only. +- **Query keys** — convention enforced by colocation in `apps/main/src/hooks/` and feature naming (`["tablos", id]`, `["tablo-files", id]`, etc.). +## Source-Only Package Pattern +## Entry Points +| App / package | Entry file | +|----------------------|---------------------------------------------------------| +| `apps/main` | `apps/main/src/main.tsx` -> `App.tsx` | +| `apps/external` | `apps/external/src/main.tsx` -> `routes.tsx` | +| `apps/clients` | `apps/clients/src/main.tsx` -> `App.tsx` / `routes.tsx` | +| `apps/admin` | `apps/admin/src/main.tsx` -> `routes.tsx` | +| `apps/api` | `apps/api/src/index.ts` -> `routers/index.ts` | +| `apps/chat-worker` | `apps/chat-worker/src/index.ts` | +| `@xtablo/shared` | `packages/shared/src/index.ts` | +| `@xtablo/ui` | `packages/ui/src/components/index.ts` | +| `@xtablo/shared-types` | `packages/shared-types/src/index.ts` | +## Build & Deployment Notes +- **Turborepo** orchestrates tasks with caching (`turbo.json` at repo root). +- `apps/main` and other Vite apps deploy to **Cloudflare Workers** via `wrangler.toml` and the bundled `worker/` folder. +- `apps/api` compiles TypeScript and deploys to **Google Cloud Run**; secrets resolved via Google Secret Manager. +- `apps/chat-worker` deploys as a **Cloudflare Worker with Durable Objects**. +- Observability: Datadog RUM on frontends (`apps/main/src/lib/rum.ts`), `dd-trace` on the API (initialized at the top of `apps/api/src/index.ts`). + + + +## Project Skills + +No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file. + + + +## GSD Workflow Enforcement + +Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync. + +Use these entry points: +- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks +- `/gsd-debug` for investigation and bug fixing +- `/gsd-execute-phase` for planned phase work + +Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it. + + + +## Developer Profile + +> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile. +> This section is managed by `generate-claude-profile` -- do not edit manually. + -- 2.45.2 From 6373834b9f872e1cc63ca9d8e9bab7441cf0dee9 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:42:33 +0200 Subject: [PATCH 046/546] docs(01): capture phase context --- .planning/phases/01-foundation/01-CONTEXT.md | 152 ++++++++++++++++++ .../phases/01-foundation/01-DISCUSSION-LOG.md | 79 +++++++++ 2 files changed, 231 insertions(+) create mode 100644 .planning/phases/01-foundation/01-CONTEXT.md create mode 100644 .planning/phases/01-foundation/01-DISCUSSION-LOG.md diff --git a/.planning/phases/01-foundation/01-CONTEXT.md b/.planning/phases/01-foundation/01-CONTEXT.md new file mode 100644 index 0000000..ba497e8 --- /dev/null +++ b/.planning/phases/01-foundation/01-CONTEXT.md @@ -0,0 +1,152 @@ +# Phase 1: Foundation - Context + +**Gathered:** 2026-05-14 +**Status:** Ready for planning + + +## Phase Boundary + +A fresh `backend/` Go package boots a web server, renders an HTMX-driven base layout styled with Tailwind, and connects to a local Postgres with migrations applied. A new dev can clone the repo, run `compose up -d` + `just dev`, and see the page within ~5 minutes following `backend/README.md`. + +Delivers FOUND-01..05. **Not** in scope: auth, tablos, tasks, files, worker job processing (worker binary scaffold only — no real jobs yet; jobs land in Phase 6), deployment (Phase 7). + + + + +## Implementation Decisions + +### Directory Layout +- **D-01:** Two-binary layout with shared `internal/`. Final shape: + ``` + backend/ + cmd/ + web/main.go + worker/main.go + internal/ + db/ (sqlc-generated queries + pgx pool wiring) + web/ (chi router, handlers, middleware) + session/ (placeholder package — populated in Phase 2) + tablos/ (placeholder — Phase 3) + tasks/ (placeholder — Phase 4) + files/ (placeholder — Phase 5) + migrations/ (goose .sql files) + templates/ (.templ files) + static/ (tailwind.css output, htmx.min.js) + compose.yaml + justfile + .env.example + README.md + ``` +- **D-02:** Phase 1 creates the directory skeleton for all `internal/` packages (empty `doc.go` is fine) so later phases drop files in without restructuring. +- **D-03:** `cmd/worker` in Phase 1 is a minimal binary that boots, connects to Postgres, logs "worker ready", and exits cleanly on signal. Real job runtime is Phase 6. + +### Migrations +- **D-04:** Use **goose** (`pressly/goose`). Reasons: embeddable as a library (can be called from `cmd/web` startup or a small subcommand so we ship migrations inside the Docker image in Phase 7 without a second binary), supports Go-based migrations if ever needed, one `.sql` file per migration with `-- +goose Up/Down` annotations (sqlc reads the same files). +- **D-05:** `just migrate up` / `just migrate down` / `just migrate status` wired via the goose CLI for local dev. Production migration strategy (embed vs CLI) decided in Phase 7. +- **D-06:** Phase 1 includes one trivial bootstrap migration (e.g., `0001_init.sql` creating an empty `schema_migrations` baseline or a no-op) so the migration pipeline is exercised end-to-end. + +### Templating + Router +- **D-07:** **templ** (`a-h/templ`) for HTML. Type-safe, compiled, plays well with HTMX partials (each fragment is a typed func returning `templ.Component`). +- **D-08:** **chi** (`go-chi/chi/v5`) as the HTTP router. Middleware stack: `RequestID → RealIP → Logger (structured) → Recoverer → GracefulShutdown wiring`. +- **D-09:** `templ generate` runs via `just generate` (alongside `sqlc generate`). Dev loop uses `templ generate --watch` or air's reload hook — pick during planning, both acceptable. +- **D-10:** Base layout template renders a Tailwind-styled page with HTMX loaded from `/static/htmx.min.js` (vendored, not CDN). Include one working `hx-get` example to satisfy success criterion 3. + +### Local Dev Stack +- **D-11:** **podman compose** for local Postgres (matches the developer's machine setup; `compose.yaml` at `backend/compose.yaml`). Document `podman compose` commands in the justfile; if a contributor uses docker, the same `compose.yaml` works — call this out in the README. +- **D-12:** **Standalone Tailwind CLI binary** (no Node/pnpm in `backend/`). Binary is downloaded by a `just bootstrap` recipe into `./bin/tailwindcss` (gitignored) — version pinned in the justfile. Keeps the "no JS toolchain" thesis intact. +- **D-13:** **air** (`cosmtrek/air`) for Go live-reload (`just dev`). Watches `.go` + `.templ` files; triggers `templ generate` and rebuild. +- **D-14:** Tailwind in watch mode runs as a separate process (`just styles`) or via air's `pre_cmd`. Planner decides which is cleaner. + +### Configuration & Operational Basics +- **D-15:** Env-driven config via `.env` (loaded from `.env` at dev startup; production injects real env vars). Required keys at minimum: `DATABASE_URL`, `PORT`, `ENV`. Provide `.env.example` in the repo. +- **D-16:** Postgres driver: **pgx/v5** with `pgxpool` for the connection pool. sqlc configured to emit pgx-compatible code (`sqlc.yaml` engine: postgresql, sql_package: pgx/v5). +- **D-17:** Structured logging: `log/slog` (std lib, Go 1.21+) with JSON handler in prod and text handler in dev, switched by `ENV`. +- **D-18:** Request ID middleware attaches a UUID per request and threads it into the slog logger via `context.Context`. +- **D-19:** Graceful shutdown: `cmd/web` traps SIGINT/SIGTERM, calls `http.Server.Shutdown` with a configurable timeout (default 10s), then closes the pgx pool. +- **D-20:** `/healthz` returns 200 with `{"status":"ok","db":"ok"}` only when `db.Ping` succeeds; otherwise 503 with `{"status":"degraded","db":"down"}`. + +### Claude's Discretion +- Concrete chi middleware order (within the above stack) and slog handler configuration details. +- Exact `air.toml` settings, file watch globs, and whether tailwind runs as a separate `just styles` process or air `pre_cmd`. +- Whether goose runs migrations via library call from a `backend migrate` subcommand or pure CLI in Phase 1 (Phase 7 will likely require the library approach; planner can prepare for that). +- Layout/CSS specifics of the demo page — keep minimal but professional; one visible `hx-get` interaction is enough. +- Whether to include a basic `internal/web/handlers_test.go` smoke test now or defer to Phase 2. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Project & Scope +- `.planning/PROJECT.md` — Core value, constraints, out-of-scope list, key decisions (fresh `backend/`, single VPS, no Node toolchain target). +- `.planning/REQUIREMENTS.md` §Foundation — FOUND-01..05 verbatim. +- `.planning/ROADMAP.md` §"Phase 1: Foundation" — Success criteria + user-in-loop callouts. + +### Codebase Maps (legacy JS app — behavioral reference only) +- `.planning/codebase/STACK.md` — Existing stack inventory (used to identify what we are *replacing*, not copying). +- `.planning/codebase/CONVENTIONS.md` — Existing conventions (most do not apply to the new Go backend, but useful for parity decisions). +- `.planning/codebase/CONCERNS.md` — Known pain points in the JS version that motivated the rewrite. + +### Existing Go scaffold (reference, not foundation) +- `go-backend/` — Scratch scaffold. **Not** the foundation per PROJECT.md. Useful as a sanity check for templ/chi/sqlc/pgx wiring patterns and for the `compose.yaml` + `justfile` shape, but the new `backend/` is built fresh. +- `go-backend/justfile` — Reference for podman compose + tailwind + templ + sqlc justfile layout. +- `go-backend/sqlc.yaml` — Reference for sqlc config shape. + +### External tool docs (planner will pull versions during research) +- goose: https://github.com/pressly/goose +- templ: https://templ.guide +- chi: https://github.com/go-chi/chi +- pgx: https://github.com/jackc/pgx +- air: https://github.com/cosmtrek/air +- Tailwind standalone CLI: https://tailwindcss.com/blog/standalone-cli + + + + +## Existing Code Insights + +### Reusable Assets (reference, copy-with-care) +- `go-backend/justfile`: pattern for podman compose + tailwind + templ + sqlc recipes — adapt, do not copy wholesale (it uses pnpm tailwind, which we are dropping). +- `go-backend/compose.yaml` (postgres service): can be lifted to `backend/compose.yaml` essentially as-is. +- `go-backend/sqlc.yaml`: config shape is a good starting point for `backend/sqlc.yaml`. + +### Established Patterns (from go-backend, validating our choices) +- templ + chi + pgx + sqlc is a known-working combination in this developer's hands (go-backend has all four wired). +- podman compose is the developer's local container runtime — confirmed working. + +### Integration Points +- `backend/` is greenfield — no integration points yet. Future phases attach: Phase 2 adds session middleware to the chi stack and `users`/`sessions` tables via goose migrations. + +### What we are deliberately NOT carrying over +- `go-backend/`'s pnpm + tailwindcss npm dependency (replaced by standalone binary). +- Any sqlc-generated code in `go-backend/internal/db/` (greenfield schema; will be regenerated against the new migrations). + + + + +## Specific Ideas + +- Developer explicitly chose **goose over golang-migrate** after weighing embeddability, sqlc-file-layout, and the (small) chance of needing Go-side migrations. +- Developer explicitly chose **two-cmd layout (`cmd/web` + `cmd/worker`)** over single-binary-with-subcommand. Implication: deploy ships two binaries (or two entrypoints from one image — revisit in Phase 7). +- Developer explicitly chose **podman compose + standalone Tailwind binary** — no Node toolchain in `backend/`. This is a load-bearing decision for the "simpler stack" thesis. +- One visible `hx-get` example on the root page is required (success criterion 3). Keep it minimal — a button that swaps in a server-rendered timestamp or counter is enough. + + + + +## Deferred Ideas + +- **Single-binary subcommand layout** (`backend web` / `backend worker`) — rejected for Phase 1; could be revisited in Phase 7 if the two-binary Docker image proves awkward. +- **Embedded goose migrations called from app startup** — Phase 1 uses CLI-only; embedded library call deferred to Phase 7 deploy decision. +- **`internal/web/handlers_test.go` smoke tests** — testing strategy is established in Phase 2 (auth requires real tests). Phase 1 may include a minimal `/healthz` test only. +- **Production logging configuration** (sampling, redaction, log shipping) — Phase 7 concern. +- **`/readyz` endpoint** — listed in Phase 7 success criteria (DEPLOY-04); Phase 1 ships `/healthz` only. + + + +--- + +*Phase: 1-Foundation* +*Context gathered: 2026-05-14* diff --git a/.planning/phases/01-foundation/01-DISCUSSION-LOG.md b/.planning/phases/01-foundation/01-DISCUSSION-LOG.md new file mode 100644 index 0000000..bc82d71 --- /dev/null +++ b/.planning/phases/01-foundation/01-DISCUSSION-LOG.md @@ -0,0 +1,79 @@ +# Phase 1: Foundation - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-05-14 +**Phase:** 1-Foundation +**Areas discussed:** Directory layout, Migration tool, Templating + router, Local dev stack + +--- + +## Directory Layout + +| Option | Description | Selected | +|--------|-------------|----------| +| Two cmds, shared internal | `backend/cmd/web` + `backend/cmd/worker` + shared `backend/internal/{db,web,session,tablos,tasks,files,...}`. Standard Go layout. | ✓ | +| Single binary, subcommand | One `backend` binary with `backend web` / `backend worker` subcommands. Smaller deploy artifact. | | +| Feature-first internal | `internal//{handler.go,store.go,templates/}` — each domain owns its own templates + handlers. | | + +**User's choice:** Two cmds, shared internal +**Notes:** Locks in conventional Go layout. Single-binary subcommand left available for Phase 7 reconsideration if the two-binary Docker image gets awkward. + +--- + +## Migration Tool + +| Option | Description | Selected | +|--------|-------------|----------| +| goose | pressly/goose. Embeddable library + CLI. Supports Go-based migrations. One file per migration with `-- +goose Up/Down`. | ✓ | +| golang-migrate | golang-migrate/migrate. CLI-first, SQL-only by default. Wider mindshare. Split `*.up.sql` / `*.down.sql`. | | +| atlas | ariga/atlas. Schema-as-code/declarative. Richer features but heavier learning curve. | | + +**User's choice:** goose +**Notes:** User asked for a direct recommendation; Claude argued for goose on three grounds — embeddability (one binary in Phase 7 deploy), single-file-per-migration aligning with sqlc, and the option for Go migrations if a backfill ever requires it. User accepted ("go for goose"). + +--- + +## Templating + Router + +| Option | Description | Selected | +|--------|-------------|----------| +| templ + chi | templ (type-safe, compiled templates) + chi router. Best HTMX-fragment DX. Matches go-backend's existing pick. | ✓ | +| html/template + net/http 1.22 | Std-lib-only — html/template + Go 1.22 enhanced ServeMux (method-prefixed patterns). Zero external deps. | | +| templ + net/http 1.22 | Keep templ's type safety, drop chi. Hand-roll middleware stacking. | | + +**User's choice:** templ + chi +**Notes:** Best DX for HTMX partials wins over a stricter dep budget. chi's middleware ergonomics and `Route()` subrouters will be load-bearing once auth + tablos routes land. + +--- + +## Local Dev Stack + +| Option | Description | Selected | +|--------|-------------|----------| +| podman + standalone Tailwind | podman compose for Postgres; standalone Tailwind binary (no Node/pnpm in `backend/`). | ✓ | +| docker compose + standalone Tailwind | docker compose for portability; same standalone Tailwind binary. | | +| podman + pnpm Tailwind | Keep go-backend's existing pnpm + tailwindcss npm package. | | + +**User's choice:** podman + standalone Tailwind +**Notes:** "No JS toolchain in `backend/`" is a load-bearing thesis. compose.yaml is portable between podman and docker — README will note both. Tailwind binary version is pinned in the justfile. + +--- + +## Claude's Discretion + +- Concrete chi middleware order within the agreed stack (RequestID → RealIP → Logger → Recoverer → graceful shutdown wiring). +- slog handler details (JSON in prod, text in dev), keyed off `ENV`. +- air configuration (`air.toml` watch globs; whether tailwind runs as a separate `just styles` process or air `pre_cmd`). +- Whether goose runs via library call from a `backend migrate` subcommand or pure CLI in Phase 1 (Phase 7 will likely require the library approach). +- Demo page layout/CSS — keep minimal but professional; the visible `hx-get` interaction is the only hard requirement. +- Whether to ship a minimal `/healthz` smoke test in Phase 1 or defer all testing to Phase 2. + +## Deferred Ideas + +- **Single-binary subcommand layout** (`backend web` / `backend worker`) — rejected for Phase 1; revisit in Phase 7 if two-binary Docker image is awkward. +- **Embedded goose migrations at app startup** — Phase 1 uses CLI; embedded library call deferred to Phase 7. +- **`/readyz` endpoint** — Phase 7 (DEPLOY-04). Phase 1 ships `/healthz` only. +- **Production logging configuration** (sampling, redaction, log shipping) — Phase 7. +- **Full handler test suite** — Phase 2 establishes testing strategy; Phase 1 may include a minimal `/healthz` test only. -- 2.45.2 From 6b58230f171e724f9058161ae0ee5a4f734d88cd Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:42:38 +0200 Subject: [PATCH 047/546] docs(state): record phase 1 context session --- .planning/STATE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.planning/STATE.md b/.planning/STATE.md index 07ca114..938fedc 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,3 +1,16 @@ +--- +gsd_state_version: 1.0 +milestone: v1.0 +milestone_name: milestone +status: unknown +last_updated: "2026-05-14T14:42:38.681Z" +progress: + total_phases: 7 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 +--- + # STATE **Project:** Xtablo Go+HTMX Rewrite -- 2.45.2 From 74e1c3c126a09d97182708b813da23a9279a5ef4 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 16:49:21 +0200 Subject: [PATCH 048/546] docs(01): research foundation phase domain Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/phases/01-foundation/01-RESEARCH.md | 799 ++++++++++++++++++ 1 file changed, 799 insertions(+) create mode 100644 .planning/phases/01-foundation/01-RESEARCH.md diff --git a/.planning/phases/01-foundation/01-RESEARCH.md b/.planning/phases/01-foundation/01-RESEARCH.md new file mode 100644 index 0000000..6a4b16c --- /dev/null +++ b/.planning/phases/01-foundation/01-RESEARCH.md @@ -0,0 +1,799 @@ +# Phase 1: Foundation - Research + +**Researched:** 2026-05-14 +**Domain:** Go web server scaffold (chi + templ + HTMX + Tailwind + pgx/pgxpool + goose + sqlc) +**Confidence:** HIGH + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Directory Layout** +- **D-01:** Two-binary layout with shared `internal/`: + ``` + backend/ + cmd/web/main.go + cmd/worker/main.go + internal/ + db/ (sqlc-generated queries + pgx pool wiring) + web/ (chi router, handlers, middleware) + session/ (placeholder package — populated in Phase 2) + tablos/ (placeholder — Phase 3) + tasks/ (placeholder — Phase 4) + files/ (placeholder — Phase 5) + migrations/ (goose .sql files) + templates/ (.templ files) + static/ (tailwind.css output, htmx.min.js) + compose.yaml + justfile + .env.example + README.md + ``` +- **D-02:** Phase 1 creates the directory skeleton for all `internal/` packages (empty `doc.go` is fine) so later phases drop files in without restructuring. +- **D-03:** `cmd/worker` in Phase 1 is a minimal binary that boots, connects to Postgres, logs "worker ready", and exits cleanly on signal. Real job runtime is Phase 6. + +**Migrations** +- **D-04:** Use **goose** (`pressly/goose`). Embeddable library + CLI; supports Go-based migrations; one `.sql` per migration with `-- +goose Up/Down` annotations. +- **D-05:** `just migrate up` / `just migrate down` / `just migrate status` wired via the goose CLI for local dev. Production migration strategy (embed vs CLI) decided in Phase 7. +- **D-06:** Phase 1 includes one trivial bootstrap migration (e.g., `0001_init.sql`) so the migration pipeline is exercised end-to-end. + +**Templating + Router** +- **D-07:** **templ** (`a-h/templ`) for HTML. +- **D-08:** **chi** (`go-chi/chi/v5`) as the HTTP router. Middleware stack: `RequestID → RealIP → Logger (structured) → Recoverer → GracefulShutdown wiring`. +- **D-09:** `templ generate` runs via `just generate` (alongside `sqlc generate`). +- **D-10:** Base layout template renders a Tailwind-styled page with HTMX loaded from `/static/htmx.min.js` (vendored, not CDN). Include one working `hx-get` example. + +**Local Dev Stack** +- **D-11:** **podman compose** for local Postgres (`backend/compose.yaml`). README documents that docker compose also works. +- **D-12:** **Standalone Tailwind CLI binary** (no Node/pnpm in `backend/`). Downloaded by a `just bootstrap` recipe into `./bin/tailwindcss` (gitignored); version pinned in the justfile. +- **D-13:** **air** (`cosmtrek/air` → now `air-verse/air`) for Go live-reload (`just dev`). Watches `.go` + `.templ`; triggers `templ generate` and rebuild. +- **D-14:** Tailwind in watch mode runs as a separate process (`just styles`) or via air's `pre_cmd` — planner decides. + +**Configuration & Operational Basics** +- **D-15:** Env-driven config via `.env`. Required keys: `DATABASE_URL`, `PORT`, `ENV`. Provide `.env.example`. +- **D-16:** Postgres driver: **pgx/v5** with `pgxpool`. sqlc emits pgx-compatible code (`sqlc.yaml` engine: postgresql, sql_package: pgx/v5). +- **D-17:** Structured logging: `log/slog` (Go 1.21+) with JSON handler in prod, text in dev, switched by `ENV`. +- **D-18:** Request ID middleware attaches a UUID per request and threads it into slog via `context.Context`. +- **D-19:** Graceful shutdown: `cmd/web` traps SIGINT/SIGTERM, calls `http.Server.Shutdown` (default 10s), then closes the pgx pool. +- **D-20:** `/healthz` returns 200 with `{"status":"ok","db":"ok"}` only when `db.Ping` succeeds; otherwise 503 with `{"status":"degraded","db":"down"}`. + +### Claude's Discretion +- Concrete chi middleware order within the agreed stack and slog handler configuration details. +- Exact `air.toml` settings, file watch globs, and whether tailwind runs as a separate `just styles` process or air `pre_cmd`. +- Whether goose runs migrations via library call from a `backend migrate` subcommand or pure CLI in Phase 1. +- Layout/CSS specifics of the demo page (minimal but professional; one `hx-get` interaction is enough). +- Whether to include a basic `internal/web/handlers_test.go` smoke test now or defer to Phase 2. + +### Deferred Ideas (OUT OF SCOPE) +- Single-binary subcommand layout (`backend web` / `backend worker`). +- Embedded goose migrations called from app startup (Phase 1 uses CLI-only). +- `/readyz` endpoint (DEPLOY-04, Phase 7). +- Production logging configuration (sampling, redaction, log shipping). +- Full handler test suite (Phase 2 establishes testing strategy). + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| FOUND-01 | Fresh `backend/` Go package with module init, `cmd/web` and `cmd/worker` entrypoints, runnable HTTP server returning `/healthz` | Standard Stack (Go, chi, pgx); Architecture (two-binary layout, healthz handler pattern) | +| FOUND-02 | Postgres connection pool with env-driven config and a versioned migration tool wired into a `justfile` | Standard Stack (pgxpool, goose); Code Examples (pgxpool init, goose CLI invocation) | +| FOUND-03 | HTMX + Tailwind + templ rendering pipeline producing a base layout with a working dev loop (template hot-reload, CSS rebuild) | Standard Stack (templ, tailwind standalone, air); Architecture (static asset serving, dev loop) | +| FOUND-04 | Structured logging, request ID middleware, and graceful shutdown on the web server | Standard Stack (log/slog, chi middleware); Code Examples (slog handler switch, RequestID propagation, http.Server.Shutdown) | +| FOUND-05 | `.env.example`, local Postgres via `compose.yaml`, and a `justfile` documenting `dev`, `migrate`, `test`, `lint` | Architecture (compose.yaml shape, justfile recipes); Environment Availability (podman, just, Go, tailwind binary) | + + +## Project Constraints (from CLAUDE.md) + +The repo CLAUDE.md describes the existing JS monorepo. The Go rewrite section explicitly establishes: +- Go + HTMX + Tailwind + Postgres + sqlc — **no third-party auth, no JS framework, no managed BaaS** in `backend/`. +- Server-managed sessions only (HTTP-only cookies). No JWTs. +- One web binary + one worker binary, same repo. +- Single VPS / container deploy. No Kubernetes. +- GSD workflow enforcement: use `/gsd-execute-phase` for phase work — direct edits outside GSD are disallowed. + +These directly constrain Phase 1: no Node/npm dependency inside `backend/`, no JWT libraries, no Auth provider SDKs. The Tailwind standalone CLI choice exists specifically to honor "no JS toolchain in `backend/`." + +## Summary + +Phase 1 is a Walking Skeleton: the thinnest end-to-end slice that proves `air → templ → chi → pgxpool → Postgres → goose → tailwind` all wire together and live-reload on a developer's machine. Every architectural decision has already been locked in CONTEXT.md, so research focuses on **verified versions, canonical wiring patterns, and known pitfalls** rather than alternatives. + +The stack is well-trodden — the developer's pre-existing `go-backend/` scratch directory already demonstrates a working templ + chi + pgx + sqlc + podman compose pipeline. The new `backend/` discards the pnpm-Tailwind path in favor of the standalone Tailwind binary and adds goose for migrations (which `go-backend/` did not use). + +**Primary recommendation:** Build the smallest possible end-to-end loop first (web boots → `/healthz` calls `db.Ping` → root route renders one templ page with one `hx-get` button → goose applies one no-op migration), then layer in slog/RequestID/graceful shutdown. Resist adding anything that does not satisfy a FOUND-XX requirement. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| HTTP routing & middleware | Go server (`internal/web`) | — | chi router owns all request lifecycle | +| HTML rendering | Go server (templ → HTML) | Browser (HTMX swaps) | templ renders server-side; HTMX issues partial-fetch round-trips | +| Partial fragment fetch | Browser (HTMX `hx-get`) | Go server (templ partial) | HTMX makes the request; server returns an HTML fragment | +| DB connection pool | Go server (`internal/db`, pgxpool) | — | Single pool wired at startup, shared across handlers | +| Migrations | CLI (goose) against local Postgres | — | Phase 1 is CLI-driven via justfile; library embedding deferred | +| Static asset delivery | Go server (`http.FileServer` from `/static`) | — | Self-hosted (no CDN); `htmx.min.js` + `tailwind.css` vendored | +| CSS build | Local toolchain (Tailwind standalone CLI) | — | Compile-time artifact in `static/tailwind.css` | +| Live reload | Local toolchain (air) | — | Dev-only; watches `.go` + `.templ` | +| Process supervision | OS (signal handling in `cmd/web` and `cmd/worker`) | — | SIGINT/SIGTERM → graceful shutdown | +| Containerized Postgres | Local container runtime (podman compose) | — | Local dev only; prod Postgres is external (Phase 7) | +| Observability (logs) | Go server (`log/slog` to stdout) | — | JSON in prod, text in dev. No shipping in Phase 1. | + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Go | 1.22+ (existing `go-backend/` uses 1.26) | Runtime | chi v5.2+ requires Go 1.22 minimum [VERIFIED: chi v5.2.5 release notes] | +| `github.com/go-chi/chi/v5` | v5.2.5 | HTTP router + middleware | Idiomatic, minimal, standard middleware set [VERIFIED: GitHub releases, Feb 5] | +| `github.com/a-h/templ` | v0.3.1020 | Type-safe HTML templates | Compiled, type-checked at build time; first-class HTMX fit [VERIFIED: GitHub releases, May 10] | +| `github.com/jackc/pgx/v5` | v5.9.2 | Postgres driver + pgxpool | Higher performance and richer types than `database/sql`; sqlc's recommended driver [VERIFIED: tags page, Apr 19, 2026] | +| `github.com/pressly/goose/v3` | v3.27.1 | DB migrations (CLI + library) | Embeddable, single-file SQL migrations, supports Go migrations [VERIFIED: GitHub releases, Apr 24] | +| `github.com/sqlc-dev/sqlc` | v1.31.1 | SQL → typed Go code generator | Type-safe queries, pgx integration [VERIFIED: GitHub releases, Apr 22] | +| `log/slog` | std lib (Go 1.21+) | Structured logging | Standard library; no external dep [CITED: pkg.go.dev/log/slog] | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `github.com/google/uuid` | v1.6.0 | Request ID generation | RequestID middleware emits UUIDv4 per request [VERIFIED: go.mod in existing go-backend] | +| `github.com/air-verse/air` | v1.65.1 (CLI; not imported) | Go live-reload | Dev-only; configured via `.air.toml` [VERIFIED: GitHub releases, Apr 12; repo moved from `cosmtrek/air` to `air-verse/air`] | +| Tailwind standalone CLI | v4.x (pin in justfile) | CSS compile | Avoids Node/pnpm in `backend/` [CITED: tailwindcss.com/blog/standalone-cli] | +| HTMX | v2.x (vendor `htmx.min.js` into `static/`) | Client-side AJAX | Required for `hx-get` demo (success criterion 3) [ASSUMED: latest stable; planner verifies during execution] | +| `just` | latest | Task runner | Already in use in `go-backend/`; project standard [VERIFIED: existing justfile] | +| `podman compose` | matches developer's machine | Local Postgres | Locked in D-11 [VERIFIED: existing go-backend uses podman] | + +### Alternatives Considered (rejected per CONTEXT.md) +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| chi | net/http 1.22 ServeMux | Smaller dep budget but hand-rolled middleware composition; rejected | +| goose | golang-migrate, atlas | golang-migrate is split-file; atlas is declarative/heavier — rejected for embeddability + sqlc alignment | +| templ | html/template | Templ is type-checked at compile time; html/template is runtime-typed — rejected | +| Tailwind standalone | pnpm + tailwindcss npm | Would reintroduce Node toolchain — rejected (load-bearing decision) | +| podman | docker | Developer machine standard — both supported via portable `compose.yaml` | + +**Installation:** +```bash +# Go module +go mod init backend +go get github.com/go-chi/chi/v5@v5.2.5 +go get github.com/a-h/templ@v0.3.1020 +go get github.com/jackc/pgx/v5@v5.9.2 +go get github.com/pressly/goose/v3@v3.27.1 +go get github.com/google/uuid@v1.6.0 + +# CLI tools (developer machine) +go install github.com/pressly/goose/v3/cmd/goose@v3.27.1 +go install github.com/a-h/templ/cmd/templ@v0.3.1020 +go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 +go install github.com/air-verse/air@v1.65.1 + +# Tailwind standalone (just bootstrap recipe) +curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-{os}-{arch} +chmod +x tailwindcss-{os}-{arch} +mv tailwindcss-{os}-{arch} backend/bin/tailwindcss +``` + +**Version verification:** All versions listed above were checked against GitHub releases on 2026-05-14. Re-verify with `go list -m -u ` before pinning if more than ~30 days elapse before Phase 1 lands. + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Developer Machine │ +│ │ +│ just dev │ +│ ├─▶ podman compose up -d postgres ──▶ Postgres :5432 │ +│ ├─▶ tailwind --watch ──▶ static/tailwind.css │ +│ └─▶ air ──▶ rebuilds cmd/web on .go/.templ change │ +│ │ +│ just migrate up ──▶ goose CLI ──▶ migrations/*.sql ──▶ DB │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ cmd/web │ +│ │ +│ main() ─▶ load env (.env) │ +│ ─▶ slog handler (JSON/text by ENV) │ +│ ─▶ pgxpool.New(DATABASE_URL) │ +│ ─▶ chi.NewRouter() │ +│ ├─ RequestID (uuid → ctx → slog) │ +│ ├─ RealIP │ +│ ├─ Logger (slog-backed) │ +│ ├─ Recoverer │ +│ ├─ /healthz ──▶ db.Ping → JSON │ +│ ├─ /static/* ──▶ http.FileServer(static/) │ +│ ├─ / ──▶ templ Layout(Index) │ +│ └─ /demo/time ──▶ templ Fragment (hx-get target) │ +│ ─▶ http.Server.ListenAndServe │ +│ ─▶ SIGINT/SIGTERM → Server.Shutdown(10s) → pool.Close │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ Browser │ +│ GET / ──▶ HTML (layout + htmx.min.js + Tailwind CSS) │ +│ Button click ──▶ hx-get /demo/time ──▶ HTML fragment swap │ +└───────────────────────────────────────────────────────────────┘ + +┌───────────────────────────────────────────────────────────────┐ +│ cmd/worker (Phase 1: skeleton only) │ +│ main() ─▶ pgxpool.New ─▶ slog "worker ready" ─▶ wait on sig │ +└───────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure +``` +backend/ +├── cmd/ +│ ├── web/main.go # web entrypoint (chi server) +│ └── worker/main.go # worker entrypoint (skeleton) +├── internal/ +│ ├── db/ +│ │ ├── doc.go # package doc +│ │ ├── pool.go # pgxpool.New wrapper +│ │ └── sqlc/ # generated (empty in Phase 1) +│ ├── web/ +│ │ ├── router.go # chi.Router assembly +│ │ ├── handlers.go # /healthz, /, /demo/time +│ │ ├── middleware.go # RequestID + slog +│ │ └── handlers_test.go # (optional, Claude's discretion) +│ ├── session/doc.go # placeholder (Phase 2) +│ ├── tablos/doc.go # placeholder (Phase 3) +│ ├── tasks/doc.go # placeholder (Phase 4) +│ └── files/doc.go # placeholder (Phase 5) +├── templates/ +│ ├── layout.templ # base HTML + +│ ├── index.templ # root page with hx-get button +│ └── fragments.templ # server-rendered partials +├── migrations/ +│ └── 0001_init.sql # no-op or schema_migrations baseline +├── static/ +│ ├── htmx.min.js # vendored +│ └── tailwind.css # generated by tailwind standalone +├── bin/ # gitignored — tailwind CLI lives here +├── .air.toml +├── .env.example # DATABASE_URL, PORT, ENV +├── .gitignore # bin/, tailwind.css, tmp/, .env +├── compose.yaml # Postgres service +├── go.mod / go.sum +├── justfile # dev, migrate, generate, test, lint, build +├── sqlc.yaml # engine: postgresql, sql_package: pgx/v5 +├── tailwind.input.css # @tailwind base/components/utilities +└── README.md # 5-minute quickstart +``` + +### Pattern 1: pgxpool wiring with health check +**What:** Create a single `*pgxpool.Pool` at startup, share across handlers via a struct, expose `Ping` for `/healthz`. +**When to use:** Every Go+Postgres service; required for FOUND-02 and the `/healthz` DB check. +**Example:** +```go +// Source: pkg.go.dev/github.com/jackc/pgx/v5/pgxpool (canonical pattern) +import "github.com/jackc/pgx/v5/pgxpool" + +func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + cfg.MaxConns = 10 + cfg.MinConns = 1 + return pgxpool.NewWithConfig(ctx, cfg) +} + +// In /healthz handler: +if err := pool.Ping(r.Context()); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{"status":"degraded","db":"down"}) + return +} +w.WriteHeader(http.StatusOK) +json.NewEncoder(w).Encode(map[string]string{"status":"ok","db":"ok"}) +``` + +### Pattern 2: chi middleware order (standard) +**What:** Stack middleware so request IDs/IPs are available to the logger, panics never escape, and shutdown signals are honored. +**When to use:** Every chi router in this project. +**Example:** +```go +// Source: github.com/go-chi/chi v5 README (canonical middleware order) +r := chi.NewRouter() +r.Use(middleware.RequestID) // chi-provided; or custom UUID-emitting middleware +r.Use(middleware.RealIP) +r.Use(slogLoggingMiddleware) // custom: reads RequestID from ctx, attaches to slog.Logger +r.Use(middleware.Recoverer) // recovers from panics; must be after Logger +``` + +### Pattern 3: slog handler switch by ENV +**What:** Text handler in dev (human-readable), JSON handler in prod (machine-parseable). +**When to use:** App startup in both `cmd/web` and `cmd/worker`. +**Example:** +```go +// Source: pkg.go.dev/log/slog (handler constructors) +var handler slog.Handler +opts := &slog.HandlerOptions{Level: slog.LevelInfo} +if os.Getenv("ENV") == "production" { + handler = slog.NewJSONHandler(os.Stdout, opts) +} else { + handler = slog.NewTextHandler(os.Stdout, opts) +} +slog.SetDefault(slog.New(handler)) +``` + +### Pattern 4: RequestID → context → slog +**What:** Generate UUID per request, attach to `context.Context`, derive a per-request `*slog.Logger`. +**When to use:** All HTTP handlers — required for FOUND-04. +**Example:** +```go +type ctxKey string +const requestIDKey ctxKey = "request_id" + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := uuid.NewString() + ctx := context.WithValue(r.Context(), requestIDKey, id) + w.Header().Set("X-Request-ID", id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func LoggerFromContext(ctx context.Context) *slog.Logger { + if id, ok := ctx.Value(requestIDKey).(string); ok { + return slog.Default().With("request_id", id) + } + return slog.Default() +} +``` + +### Pattern 5: Graceful shutdown +**What:** Trap SIGINT/SIGTERM, call `http.Server.Shutdown`, close pgxpool, exit. +**When to use:** Both `cmd/web` and `cmd/worker`. +**Example:** +```go +// Source: chi v5 graceful shutdown example +srv := &http.Server{Addr: ":"+port, Handler: router} +go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err); os.Exit(1) + } +}() +sig := make(chan os.Signal, 1) +signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) +<-sig +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() +if err := srv.Shutdown(ctx); err != nil { slog.Error("shutdown", "err", err) } +pool.Close() +``` + +### Pattern 6: templ + chi handler integration +**What:** templ components implement `Render(ctx, io.Writer) error`. Write directly from a chi handler. +**Example:** +```go +// Source: templ.guide (HTTP server integration) +func indexHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.Index().Render(r.Context(), w) +} + +// For HTMX fragment: +func demoTimeHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TimeFragment(time.Now()).Render(r.Context(), w) +} +``` + +### Pattern 7: One `hx-get` demo (success criterion 3) +```html + + +
+ +``` +Server returns an HTML fragment (e.g. `2026-05-14T12:00:00Z`). Zero JS required client-side beyond HTMX. + +### Anti-Patterns to Avoid +- **Loading HTMX from a CDN.** D-10 mandates vendoring. CDN couples the app to network reachability and breaks the "single binary + static" thesis. +- **Spawning a new `pgxpool` per request.** One pool for the process lifetime; share via dependency injection. +- **Putting `templ generate` inside `go run`.** Run it via `just generate` (or air's `pre_cmd`) before the build — `.templ` files don't compile by themselves. +- **Logging the raw `Authorization` header or `Cookie` in the request logger.** Not in scope Phase 1 but the logger middleware should be written from day one with a known safe-fields list. +- **Using chi's `middleware.Logger`.** It writes plain text. Replace with a slog-backed middleware to keep one logging format. +- **Hardcoding port/DSN.** Read from env per D-15. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Connection pooling | Custom pool over `database/sql` | `pgxpool.Pool` | Battle-tested, exposed `Ping`/`Stat`/`Acquire` | +| Request IDs | Custom random string generator | `github.com/google/uuid` v1.6.0 | RFC 4122 conformant, collision-resistant | +| Middleware composition | Manual `http.Handler` wrapping | chi's `Use` chain | Order and short-circuiting handled correctly | +| Migrations | `psql -f` from a Makefile | `goose` | Version tracking, status, rollback support | +| HTML template typing | string concat or html/template | `templ` | Compile-time type safety prevents XSS by default | +| Live reload | shell loops + inotify | `air` | Handles partial-rebuild edge cases (panic/exit codes) | +| CSS build | Hand-curated CSS | Tailwind standalone CLI | Purges unused classes; consistent design tokens | +| Graceful shutdown wiring | Custom signal goroutine | `signal.Notify` + `http.Server.Shutdown` | Documented stdlib idiom | + +**Key insight:** Phase 1 is well-trodden territory. Every piece of the scaffold has a canonical Go ecosystem answer; deviation should require an explicit reason in the plan. + +## Runtime State Inventory + +> Phase 1 is a **greenfield** phase — `backend/` does not yet exist. No rename/refactor/migration involved. Section omitted. + +## Common Pitfalls + +### Pitfall 1: `templ generate` not run before `go build` +**What goes wrong:** `*.templ.go` files are missing; compilation fails with "undefined: templates.Index". +**Why it happens:** Templ files compile to Go via a separate generator. Devs forget after `git clone`. +**How to avoid:** +- Make `just generate` the first step in `just dev`, `just build`, and `just test`. +- Document the bootstrap order in README.md. +- Add `*.templ.go` to `.gitignore` so generated files are never committed (forces regeneration). +**Warning signs:** Fresh-clone "undefined" compile errors; CI failures only on first builds. + +### Pitfall 2: pgxpool dialing before Postgres is ready +**What goes wrong:** `just dev` starts the web binary before `compose up -d postgres` has finished initializing → first request returns 503. +**Why it happens:** `podman compose up -d` returns once the container exists, not once Postgres is accepting connections. +**How to avoid:** +- Use `compose.yaml` healthcheck (`pg_isready -U xtablo`) as in `go-backend/compose.yaml`. +- In `just dev`, wait for healthy before starting `air`, or accept that `/healthz` returns 503 for the first few seconds (this is actually correct behavior). +- pgxpool's `New` does not eagerly connect — connections are lazy. Don't try to "fix" this by adding a startup `Ping` retry loop. +**Warning signs:** Intermittent first-request 503s on cold starts. + +### Pitfall 3: Tailwind config doesn't see `.templ` files +**What goes wrong:** Tailwind purges all utility classes used only in `.templ` files → blank-looking page after CSS rebuild. +**Why it happens:** Tailwind v4 scans content paths declared in CSS (`@source`) or config. Default glob is `*.html` and `*.{js,ts,jsx,tsx}` — `.templ` is not included. +**How to avoid:** Add explicit content sources in `tailwind.input.css`: +```css +@source "../templates/**/*.templ"; +@source "../internal/web/**/*.go"; +``` +**Warning signs:** Classes work in `templ-generate`d Go files but disappear after Tailwind rebuild. + +### Pitfall 4: Forgetting to close `pgxpool` on shutdown +**What goes wrong:** Process exits with active connections in flight; Postgres logs `client unexpectedly closed`. +**Why it happens:** `os.Exit` after `http.Server.Shutdown` skips deferred `pool.Close()`. +**How to avoid:** Always call `pool.Close()` explicitly after `Shutdown` returns, not via `defer` from `main`. + +### Pitfall 5: air watching too much (or too little) +**What goes wrong:** Rebuilds loop on its own generated output (`*.templ.go`, `tailwind.css`) or fails to pick up `.templ` edits. +**Why it happens:** Default `air.toml` watches `.go` only and includes everything in `tmp/`. +**How to avoid:** Configure `.air.toml` explicitly: +- `include_ext = ["go", "templ"]` +- `exclude_dir = ["tmp", "bin", "static", ".git", "internal/db/sqlc"]` +- `exclude_regex = [".*_templ\\.go$"]` (generated files; let templ regenerate via pre-cmd, then air rebuilds) +- `pre_cmd = ["templ generate"]` + +### Pitfall 6: chi `middleware.Logger` clashes with slog +**What goes wrong:** Two log lines per request, one plain-text from chi, one structured from your slog middleware. +**Why it happens:** Adding `middleware.Logger` from chi alongside a custom slog logger. +**How to avoid:** Don't use chi's built-in Logger. Write a thin slog-backed alternative or use `github.com/go-chi/httplog/v2`. + +### Pitfall 7: Goose migration directory mismatch with sqlc +**What goes wrong:** sqlc generates code from a schema that doesn't match what goose has applied; queries fail at runtime. +**Why it happens:** sqlc's `schema` path doesn't include the goose migrations directory, or the order differs. +**How to avoid:** Point `sqlc.yaml` `schema` at `migrations/` directly so sqlc reads the same `.sql` files goose runs: +```yaml +sql: + - engine: postgresql + schema: "migrations" + queries: "internal/db/queries" + gen: { go: { sql_package: "pgx/v5", ... } } +``` +**Note:** Phase 1 has no queries yet — but get this config right now to avoid Phase 2 friction. + +### Pitfall 8: HTMX served from CDN by accident in the demo +**What goes wrong:** Copy-pasted HTMX example uses ` + + +``` + +**Rules:** +- `htmx.min.js` is loaded once, at the bottom of ``, with `defer`. +- `tailwind.css` is loaded in `` so there is no FOUC. +- No inline ` + + +
+ +
+

Component Catalog

+

All 11 component types with variants. Visual sign-off gate for Phase 14.

+ @catalogSection("badge", "Badge — DS-05", badgeExamples()) + @catalogSection("button", "Button — DS-02", buttonExamples()) + @catalogSection("card", "Card — DS-04", cardExamples()) + @catalogSection("empty-state", "Empty State — DS-09", emptyStateExamples()) + @catalogSection("form-field", "Form Field — DS-08", formFieldExamples()) + @catalogSection("icon-button", "Icon Button — DS-07", iconButtonExamples()) + @catalogSection("input", "Input — DS-03", inputExamples()) + @catalogSection("modal", "Modal — DS-06", modalExamples()) + @catalogSection("select", "Select — DS-10", selectExamples()) + @catalogSection("table", "Table — DS-11", tableExamples()) + @catalogSection("textarea", "Textarea — DS-03b", textareaExamples()) +
+
+ + +} + +templ catalogSection(id string, heading string, examples []Example) { +
+

{ heading }

+ for _, ex := range examples { +
+

{ ex.Title }

+
+ @ex.Preview +
+ if ex.Snippet != "" { +
{ ex.Snippet }
+ } +
+ } +
+} diff --git a/backend/internal/web/ui/catalog/examples.go b/backend/internal/web/ui/catalog/examples.go new file mode 100644 index 0000000..4e031f8 --- /dev/null +++ b/backend/internal/web/ui/catalog/examples.go @@ -0,0 +1,407 @@ +package catalog + +import ( + "context" + "io" + + "github.com/a-h/templ" + + "backend/internal/web/ui" +) + +// Example is a single rendered component variant with a templ call annotation. +type Example struct { + Title string + Preview templ.Component + Snippet string +} + +type anyComponent = templ.Component + +func badgeExamples() []Example { + return []Example{ + { + Title: "Info", + Preview: ui.Badge(ui.BadgeProps{ + Label: "Info", + Variant: ui.BadgeVariantInfo, + }), + Snippet: `@ui.Badge(ui.BadgeProps{Label: "Info", Variant: ui.BadgeVariantInfo})`, + }, + { + Title: "Warning", + Preview: ui.Badge(ui.BadgeProps{ + Label: "Warning", + Variant: ui.BadgeVariantWarning, + }), + Snippet: `@ui.Badge(ui.BadgeProps{Label: "Warning", Variant: ui.BadgeVariantWarning})`, + }, + { + Title: "Success", + Preview: ui.Badge(ui.BadgeProps{ + Label: "Success", + Variant: ui.BadgeVariantSuccess, + }), + Snippet: `@ui.Badge(ui.BadgeProps{Label: "Success", Variant: ui.BadgeVariantSuccess})`, + }, + { + Title: "Danger", + Preview: ui.Badge(ui.BadgeProps{ + Label: "Danger", + Variant: ui.BadgeVariantDanger, + }), + Snippet: `@ui.Badge(ui.BadgeProps{Label: "Danger", Variant: ui.BadgeVariantDanger})`, + }, + { + Title: "Primary", + Preview: ui.Badge(ui.BadgeProps{ + Label: "Primary", + Variant: ui.BadgeVariantPrimary, + }), + Snippet: `@ui.Badge(ui.BadgeProps{Label: "Primary", Variant: ui.BadgeVariantPrimary})`, + }, + } +} + +func buttonExamples() []Example { + return []Example{ + { + Title: "Solid / Default", + Preview: ui.Button(ui.ButtonProps{ + Label: "Create project", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + }), + Snippet: `@ui.Button(ui.ButtonProps{ + Label: "Create project", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", +})`, + }, + { + Title: "Solid / Danger", + Preview: ui.Button(ui.ButtonProps{ + Label: "Delete", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + }), + Snippet: `@ui.Button(ui.ButtonProps{ + Label: "Delete", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", +})`, + }, + { + Title: "Soft / Neutral", + Preview: ui.Button(ui.ButtonProps{ + Label: "Cancel", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + }), + Snippet: `@ui.Button(ui.ButtonProps{ + Label: "Cancel", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", +})`, + }, + { + Title: "Ghost", + Preview: ui.Button(ui.ButtonProps{ + Label: "Learn more", + Variant: ui.ButtonVariantGhost, + Size: ui.SizeMD, + Type: "button", + }), + Snippet: `@ui.Button(ui.ButtonProps{ + Label: "Learn more", + Variant: ui.ButtonVariantGhost, + Size: ui.SizeMD, + Type: "button", +})`, + }, + } +} + +func cardExamples() []Example { + return []Example{ + { + Title: "Card with header, body, and footer", + Preview: ui.Card(ui.CardProps{ + Header: textBody("Card Header"), + Body: textBody("Card body content goes here."), + Footer: textBody("Card Footer"), + }), + Snippet: `@ui.Card(ui.CardProps{ + Header: headerComponent, + Body: bodyComponent, + Footer: footerComponent, +})`, + }, + } +} + +func emptyStateExamples() []Example { + return []Example{ + { + Title: "Empty list", + Preview: ui.EmptyState(ui.EmptyStateProps{ + Title: "Nothing here yet", + Description: "Add your first item to get started.", + Icon: ui.UIIcon("grid3x3"), + Action: ui.Button(ui.ButtonProps{ + Label: "Add item", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + Icon: "plus", + }), + }), + Snippet: `@ui.EmptyState(ui.EmptyStateProps{ + Title: "Nothing here yet", + Description: "Add your first item to get started.", + Icon: ui.UIIcon("grid3x3"), + Action: ui.Button(...), +})`, + }, + } +} + +func formFieldExamples() []Example { + return []Example{ + { + Title: "Field with label, hint, and error", + Preview: ui.FormField(ui.FormFieldProps{ + Label: "Project name", + For: "catalog-name", + Field: ui.Input(ui.InputProps{ + ID: "catalog-name", + Name: "name", + Placeholder: "Enter a name", + Type: "text", + }), + Hint: "Enter a value between 1 and 100.", + Error: "This field is required.", + }), + Snippet: `@ui.FormField(ui.FormFieldProps{ + Label: "Project name", + For: "catalog-name", + Field: ui.Input(ui.InputProps{...}), + Hint: "Enter a value between 1 and 100.", + Error: "This field is required.", +})`, + }, + } +} + +func iconButtonExamples() []Example { + return []Example{ + { + Title: "Ghost / Neutral (plus icon)", + Preview: ui.IconButton(ui.IconButtonProps{ + Label: "Add item", + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", + }), + Snippet: `@ui.IconButton(ui.IconButtonProps{ + Label: "Add item", + Icon: "plus", + Variant: ui.IconButtonVariantNeutral, + Tone: ui.IconButtonToneGhost, + Type: "button", +})`, + }, + { + Title: "Solid / Danger (trash icon)", + Preview: ui.IconButton(ui.IconButtonProps{ + Label: "Delete item", + Icon: "trash", + Variant: ui.IconButtonVariantDanger, + Tone: ui.IconButtonToneSolid, + Type: "button", + }), + Snippet: `@ui.IconButton(ui.IconButtonProps{ + Label: "Delete item", + Icon: "trash", + Variant: ui.IconButtonVariantDanger, + Tone: ui.IconButtonToneSolid, + Type: "button", +})`, + }, + } +} + +func inputExamples() []Example { + return []Example{ + { + Title: "Text input", + Preview: ui.Input(ui.InputProps{ + Name: "name", + Placeholder: "Enter text here", + Type: "text", + }), + Snippet: `@ui.Input(ui.InputProps{ + Name: "name", + Placeholder: "Enter text here", + Type: "text", +})`, + }, + { + Title: "Email input", + Preview: ui.Input(ui.InputProps{ + Name: "email", + Placeholder: "you@example.com", + Type: "email", + }), + Snippet: `@ui.Input(ui.InputProps{ + Name: "email", + Placeholder: "you@example.com", + Type: "email", +})`, + }, + } +} + +func modalExamples() []Example { + return []Example{ + { + Title: "Modal panel (no backdrop)", + // Render only the panel div — not the full Modal component with backdrop. + // Pitfall 7: ui-modal-backdrop is position:fixed and would overlay the catalog page. + Preview: componentFunc(func(ctx context.Context, w io.Writer) error { + _, err := io.WriteString(w, `
`) + if err != nil { + return err + } + if err := renderComponents(ctx, w, + textBody(`

Confirm action

`), + textBody(`

Are you sure you want to proceed?

`), + textBody(`
`), + ); err != nil { + return err + } + if err := ui.Button(ui.ButtonProps{ + Label: "Cancel", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + }).Render(ctx, w); err != nil { + return err + } + if err := ui.Button(ui.ButtonProps{ + Label: "Confirm", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + }).Render(ctx, w); err != nil { + return err + } + _, err = io.WriteString(w, `
`) + return err + }), + Snippet: `@ui.Modal(ui.ModalProps{ + Title: "Confirm action", + Body: bodyContent, + Actions: actionButtons, +})`, + }, + } +} + +func selectExamples() []Example { + return []Example{ + { + Title: "Single select", + Preview: ui.Select(ui.SelectProps{ + Name: "status", + Placeholder: "Select a status", + Value: "in-progress", + Options: []ui.SelectOption{ + {Value: "todo", Label: "To do"}, + {Value: "in-progress", Label: "In progress"}, + {Value: "done", Label: "Done"}, + }, + }), + Snippet: `@ui.Select(ui.SelectProps{ + Name: "status", + Placeholder: "Select a status", + Value: "in-progress", + Options: []ui.SelectOption{ + {Value: "todo", Label: "To do"}, + {Value: "in-progress", Label: "In progress"}, + {Value: "done", Label: "Done"}, + }, +})`, + }, + } +} + +func tableExamples() []Example { + return []Example{ + { + Title: "Data table", + Preview: ui.Table(ui.TableProps{ + Head: textBody(`NameStatus`), + Body: textBody(`Example ProjectIn progressAnother ProjectDone`), + }), + Snippet: `@ui.Table(ui.TableProps{ + Head: tableHead, + Body: tableBody, +})`, + }, + } +} + +func textareaExamples() []Example { + return []Example{ + { + Title: "Textarea with placeholder", + Preview: ui.Textarea(ui.TextareaProps{ + Name: "description", + Placeholder: "Enter a description...", + Rows: 4, + }), + Snippet: `@ui.Textarea(ui.TextareaProps{ + Name: "description", + Placeholder: "Enter a description...", + Rows: 4, +})`, + }, + } +} + +func componentFunc(fn func(context.Context, io.Writer) error) templ.Component { + return templ.ComponentFunc(fn) +} + +func textBody(text string) templ.Component { + return templ.ComponentFunc(func(_ context.Context, w io.Writer) error { + _, err := io.WriteString(w, text) + return err + }) +} + +func renderComponents(ctx context.Context, w io.Writer, components ...templ.Component) error { + for _, c := range components { + if err := c.Render(ctx, w); err != nil { + return err + } + } + return nil +} diff --git a/backend/justfile b/backend/justfile index 43fda88..3e2e250 100644 --- a/backend/justfile +++ b/backend/justfile @@ -137,6 +137,19 @@ worker: db-up S3_USE_PATH_STYLE='{{ s3_use_path_style }}' \ go run ./cmd/worker +# Run the component catalog on localhost:8080/ui-catalog (dev-only, -tags catalog). +# Visit http://localhost:8080/ui-catalog to review all 11 component sections. +catalog: + just generate + DATABASE_URL='{{ database_url }}' \ + S3_ENDPOINT='{{ s3_endpoint }}' \ + S3_BUCKET='{{ s3_bucket }}' \ + S3_REGION='{{ s3_region }}' \ + S3_ACCESS_KEY='{{ s3_access_key }}' \ + S3_SECRET_KEY='{{ s3_secret_key }}' \ + S3_USE_PATH_STYLE='{{ s3_use_path_style }}' \ + go run -tags catalog ./cmd/web + test: just generate go test ./... -- 2.45.2 From ae4bb876c176bacb6366e11c463ce4d80217528d Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 14:14:29 +0200 Subject: [PATCH 384/546] docs(13-05): complete catalog plan summary - Create 13-05-SUMMARY.md with catalog package documentation - Self-check passed: all files and commits verified --- .../13-05-SUMMARY.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 .planning/phases/13-design-system-foundation/13-05-SUMMARY.md diff --git a/.planning/phases/13-design-system-foundation/13-05-SUMMARY.md b/.planning/phases/13-design-system-foundation/13-05-SUMMARY.md new file mode 100644 index 0000000..80300c9 --- /dev/null +++ b/.planning/phases/13-design-system-foundation/13-05-SUMMARY.md @@ -0,0 +1,133 @@ +--- +phase: 13-design-system-foundation +plan: "05" +subsystem: backend/internal/web/ui/catalog +tags: [catalog, build-tags, templ, htmx, tailwind, design-system, go] +dependency_graph: + requires: + - 13-03 + - 13-04 + provides: + - catalog-package + - ui-catalog-route + - catalog-route-build-tag-pattern + - catalog-justfile-target + affects: + - backend/internal/web/ui/catalog/catalog.templ + - backend/internal/web/ui/catalog/examples.go + - backend/internal/web/catalog_route_catalog.go + - backend/internal/web/catalog_route_stub.go + - backend/internal/web/router.go + - backend/justfile +tech_stack: + added: [] + patterns: + - build-tag-gated-route (//go:build catalog + //go:build !catalog stub) + - single-page-catalog-layout (sidebar nav + fluid main, Tailwind shell only) + - modal-panel-only-rendering (no backdrop wrapper in catalog, Pitfall 7) +key_files: + created: + - backend/internal/web/ui/catalog/catalog.templ + - backend/internal/web/ui/catalog/examples.go + - backend/internal/web/catalog_route_catalog.go + - backend/internal/web/catalog_route_stub.go + modified: + - backend/internal/web/router.go + - backend/justfile +key-decisions: + - "Example struct defined in examples.go (same package as catalog.templ) — no separate pages.go needed for single-page catalog design" + - "catalog.templ uses inline