Appearance
Architecture ​
Request Lifecycle ​
Browser → Nuxt 4 (SSR)
│
├── Middleware (auth-hydrate.global → named guards)
├── Layout (wraps every page)
└── Page (composes components, loads TanStack queries)
│
└── Composable (api/)
└── $api plugin (Bearer + CSRF + workspace_id injection)
└── Laravel backend REST APIFor search: pages call useTypesenseSearch composables which POST to Nitro proxy routes at /api/typesense/*. The Nitro handlers sign requests server-side to avoid exposing the Typesense API key in the browser.
Data Layer Architecture ​
Two-Layer Composable Pattern ​
Every API surface has two layers:
| Layer | Directory | Role |
|---|---|---|
| HTTP composable | composables/api/useXyzApi.ts | Wraps $api calls, owns the request shape |
| Query/mutation composable | composables/queries/useXyzQueries.ts | Wraps TanStack useQuery / useMutation, owns caching keys |
Components always call the queries layer, never the API layer directly. This keeps raw HTTP calls testable in isolation.
TanStack Vue Query ​
- Query keys live in
constants/queryKeys.ts— always use them, never inline strings useQueryfor reads;useMutationfor writes withonSuccesscache invalidationstaleTimedefaults: assets/folders = 30 s; workspace settings = 5 min; user profile = 5 min- On mutation success, invalidate the affected query key immediately via
queryClient.invalidateQueries
$api Plugin ​
plugins/fetch.ts configures $fetch as $api with:
- Bearer token from
useAuthToken()composable X-XSRF-TOKENheader from cookie (CSRF)workspace_idinjected into every requestAbortControllerintegration viacancelTokenManagerutil- Error normalization — throws a typed
ApiErroron non-2xx
Authentication Architecture ​
Auth state lives in three places and must stay in sync:
| Store | Value |
|---|---|
SSR cookie (collage_token) | HTTP-only, read by Nitro during SSR |
localStorage | Client-only persistence |
useState('auth') | Nuxt shared state, source of truth for components |
middleware/auth-hydrate.global.ts runs on every navigation, reads the cookie/localStorage, validates the JWT (expiry check), and populates useState('auth'). Named middleware then gate specific routes.
Passkey auth uses @simplewebauthn/browser and posts to the Laravel backend; after success the response contains the same JWT token and follows the standard auth flow.
State Management (Pinia) ​
Four stores — all Options Store syntax:
| Store | Purpose |
|---|---|
dam.ts | DAM tree state: selected folder, asset selection, view mode |
dialog.ts | Global dialog queue: which dialog is open, its props |
loading.ts | Named loading flags for page-level skeletons |
analytics.ts | Amplitude-enrichment context (workspace, user properties) |
Rule: Components read from stores via getters; they mutate state only by calling actions. Never store.field = value from a component.
Component Architecture ​
Components live in app/components/ with subdirectory namespacing. Nuxt auto-imports them with pathPrefix: false, so a file at components/asset/AssetCard.vue is registered as <AssetCard>, not <AssetAssetCard>.
Groupings:
| Directory | Contents |
|---|---|
global/ | App shell, nav drawer, breadcrumbs, notifications |
asset/ | Asset grid, card, detail panel, uploader, version history |
collage/ | Collage grid, card, detail panel |
dam/ | Folder tree, DAM toolbar, bulk actions |
dam/Dialogs/ | Move/copy/share/delete dialogs for DAM entities |
dialogs/ | General-purpose dialogs (confirm, input, crop) |
search/ | Search bar, filter panels, result lists |
shared/ | Generic reusable UI (buttons, chips, empty states) |
Server (Nitro) ​
Nitro routes proxy three surfaces that must not expose credentials to the client:
| Path | Purpose |
|---|---|
server/api/typesense/* | Typesense search — signs requests with server-side API key |
server/api/s3/* | S3 multipart upload — returns presigned URLs |
server/api/csrf.get.ts | Issues CSRF cookie |
server/api/image-proxy.get.ts | Proxies external image URLs to avoid CORS |
server/routes/health.get.ts | Health check endpoint |
Rate limiting (server/utils/rateLimit.ts) applies to Typesense and S3 routes.
Analytics Architecture ​
See Amplitude Analytics for full event reference.
Amplitude is initialized in plugins/amplitude.client.ts. Context enrichment (workspace, user properties) is performed via a custom plugin that reads from analytics.ts store. Components call tracking composables — they never call amplitude.track() directly.
Real-time (Pusher/Echo) ​
plugins/echo.client.ts initializes Laravel Echo with Pusher transport. Components subscribe to channels in onMounted and unsubscribe in onUnmounted — never at the page level to avoid memory leaks. Channel naming: workspace.{id}, asset.{id}, folder.{id}.