| name | waaseyaa:admin-spa |
| description | Use when working with the admin interface, Vue composables, schema-driven forms, SSE integration, i18n translations, or files in packages/admin/ |
Admin SPA Specialist
Scope
This skill covers the Nuxt 3 admin SPA at packages/admin/. Use it when:
- Adding or modifying Vue components in
packages/admin/app/components/
- Working with composables in
packages/admin/app/composables/
- Adding pages to
packages/admin/app/pages/
- Modifying i18n translations in
packages/admin/app/i18n/en.json
- Working on schema-driven form rendering
- Integrating SSE real-time updates
- Adding or modifying field widgets
- Debugging API proxy or JSON:API communication issues
Out of scope: PHP backend code, entity storage, access policies (see project-level CLAUDE.md for those).
Key Interfaces
JsonApiResource / JsonApiDocument (packages/admin/app/composables/useEntity.ts)
interface JsonApiResource {
type: string; id: string; attributes: Record<string, any>
relationships?: Record<string, any>; links?: Record<string, string>; meta?: Record<string, any>
}
interface JsonApiDocument {
jsonapi: { version: string }; data: JsonApiResource | JsonApiResource[] | null
errors?: Array<{ status: string; title: string; detail?: string }>
}
SchemaProperty / EntitySchema (packages/admin/app/composables/useSchema.ts)
interface SchemaProperty {
type: string; description?: string; format?: string; readOnly?: boolean
enum?: string[]; minimum?: number; maximum?: number; maxLength?: number
'x-widget'?: string; 'x-label'?: string; 'x-description'?: string
'x-weight'?: number; 'x-required'?: boolean; 'x-enum-labels'?: Record<string, string>
'x-target-type'?: string; 'x-access-restricted'?: boolean
}
interface EntitySchema {
$schema: string; title: string; description: string; type: string
'x-entity-type': string; 'x-translatable': boolean; 'x-revisionable': boolean
properties: Record<string, SchemaProperty>; required?: string[]
}
BroadcastMessage (packages/admin/app/composables/useRealtime.ts)
interface BroadcastMessage {
channel: string; event: string; data: Record<string, unknown>; timestamp: number
}
Widget Component Props Contract
Every widget in packages/admin/app/components/widgets/ must accept:
defineProps<{
modelValue: any
label?: string
description?: string
required?: boolean
disabled?: boolean
schema?: SchemaProperty
}>()
defineEmits<{ 'update:modelValue': [value: any] }>()
Architecture
Data Flow
- Routing: Nuxt file-based routing. Pages at
packages/admin/app/pages/. Dynamic segments use [param] syntax.
- Layout:
app.vue -> layouts/default.vue -> LayoutAdminShell (topbar + sidebar + main content area).
- Navigation:
NavBuilder fetches entity types from GET /api/entity-types and renders sidebar links.
- Schema loading:
useSchema(entityType).fetch() calls GET /api/schema/{entityType}, caches in module-level Map.
- Form rendering:
SchemaForm iterates sortedProperties(true) -> SchemaField resolves widget from x-widget -> widget component renders the input.
- CRUD:
useEntity() provides list, get, create, update, remove, search against GET/POST/PATCH/DELETE /api/{type}[/{id}].
- Real-time:
useRealtime(['admin']) opens SSE to GET /api/broadcast?channels=admin. SchemaList watches messages and auto-refreshes on entity.saved / entity.deleted events.
API Proxy
All /api/* requests are proxied to http://localhost:8080/api via Nuxt config. This is configured in packages/admin/nuxt.config.ts under both nitro.devProxy (dev) and routeRules (production).
Widget Resolution
SchemaField (packages/admin/app/components/schema/SchemaField.vue) maps x-widget to components:
| x-widget | Component resolved via |
|---|
text (default) | resolveComponent('WidgetsTextInput') |
email, url, password, image, file | WidgetsTextInput |
textarea | WidgetsTextArea |
richtext | WidgetsRichText |
number | WidgetsNumberInput |
boolean | WidgetsToggle |
select | WidgetsSelect |
datetime | WidgetsDateTimeInput |
entity_autocomplete | WidgetsEntityAutocomplete |
hidden | WidgetsHiddenField |
Fallback for unknown widgets: WidgetsTextInput.
Access-Restricted vs ReadOnly
Two distinct concepts in schema properties:
- System readOnly (
readOnly: true, no x-access-restricted): Fields like id, uuid. Excluded from forms entirely by sortedProperties(true).
- Access-restricted (
readOnly: true + x-access-restricted: true): Fields the user can view but not edit. Rendered as disabled inputs. SchemaForm guards the @update:model-value handler to prevent writes.
SSE Reconnection Strategy
useRealtime (packages/admin/app/composables/useRealtime.ts):
- Max retries: 10
- Backoff formula:
Math.min(3000 * Math.pow(2, retryCount - 1), 30000)
- Retry counter resets on successful connection
- Auto-disconnects on component unmount via
onUnmounted
reconnect() resets error state and retry counter, then reconnects
Common Mistakes
Using useFetch instead of $fetch
All composables use $fetch (imperative) not useFetch (SSR-aware). The admin SPA fetches data in onMounted callbacks and event handlers, not during SSR. Using useFetch would cause hydration issues and unwanted caching behavior.
Forgetting x-access-restricted guard in form handlers
When adding new form submission logic, always check x-access-restricted before writing to formData. The pattern in SchemaForm:
@update:model-value="(val: any) => { if (!fieldSchema['x-access-restricted']) formData[fieldName] = val }"
Omitting this guard allows users to modify access-restricted fields via browser devtools.
Widget component naming
Nuxt auto-import resolves components by directory path in PascalCase. A widget at components/widgets/TextInput.vue is referenced as WidgetsTextInput (not TextInput). When adding widgets to widgetMap in SchemaField.vue, use resolveComponent('Widgets{Name}').
Schema cache invalidation
useSchema uses a module-level Map for caching. If you modify schema data on the backend and the frontend still shows stale data, call invalidate() on the relevant useSchema instance. The cache is never automatically invalidated.
Mutating the messages array directly
useRealtime replaces the array reference on each message (messages.value = [...messages.value.slice(-99), msg]). Do not push to messages.value directly -- Vue reactivity tracks the ref assignment, not array mutations.
Adding translations without updating en.json
All user-facing strings must go through useLanguage().t('key'). When adding new UI text, add the key to packages/admin/app/i18n/en.json first. The t() function falls back to the key string itself if no translation exists, which can mask missing translations.
RichText sanitization bypass
The RichText widget emits raw innerHTML from the contenteditable div. The sanitization runs on render (via computed), not on emit. Server-side sanitization is required. Do not trust client-side sanitization for security.
EntityAutocomplete hardcoded label field
EntityAutocomplete currently hardcodes labelField to 'title'. For entity types where the label field is not title, this must be updated. The schema's x-target-type is used but there is no x-label-field extension yet.
SSE endpoint dependency
useRealtime connects to GET /api/broadcast?channels=admin. This endpoint must be wired in public/index.php. If the endpoint is not available, the composable will retry 10 times with exponential backoff and then show an error. The frontend works without SSE (graceful degradation).
Testing Patterns
Build Verification
No frontend test framework is configured. TypeScript correctness is verified via build:
cd packages/admin && npm run build
This catches type errors, missing imports, and component resolution failures.
Manual Testing Workflow
- Start PHP backend:
php -S localhost:8080 public/index.php
- Start Nuxt dev server:
cd packages/admin && npm run dev
- Access admin at
http://localhost:3000/
- Verify: dashboard loads entity types, list views show data, forms render widgets, autocomplete searches, SSE connection indicator appears
Adding a New Widget
- Create
packages/admin/app/components/widgets/{Name}.vue implementing the widget props contract
- Add entry to
widgetMap in packages/admin/app/components/schema/SchemaField.vue
- Ensure the PHP
SchemaPresenter sets the corresponding x-widget value on fields
- Run
cd packages/admin && npm run build to verify compilation
Adding a New Composable
- Create
packages/admin/app/composables/use{Name}.ts
- Export a function named
use{Name} returning reactive refs and functions
- Nuxt auto-imports from
composables/ -- no explicit import needed in components
- Run
cd packages/admin && npm run build to verify
Adding a New Page
- Create file under
packages/admin/app/pages/ following Nuxt file-based routing conventions
- Use
[param] for dynamic route segments
- Import composables as needed (auto-imported by Nuxt)
- Wrap content in the default layout (automatic via
layouts/default.vue)
- Add navigation link in
NavBuilder if the page should appear in the sidebar
Adding i18n Keys
- Add the key-value pair to
packages/admin/app/i18n/en.json
- Use
const { t } = useLanguage() in the component
- Reference with
t('your_key') or t('your_key', { param: 'value' }) for replacements
Key Files
| File | Role |
|---|
packages/admin/nuxt.config.ts | Nuxt config, API proxy setup |
packages/admin/app/composables/useEntity.ts | JSON:API CRUD + search |
packages/admin/app/composables/useSchema.ts | Schema fetching, caching, field sorting |
packages/admin/app/composables/useLanguage.ts | i18n with token replacement |
packages/admin/app/composables/useRealtime.ts | SSE connection management |
packages/admin/app/components/schema/SchemaForm.vue | Schema-driven entity form |
packages/admin/app/components/schema/SchemaField.vue | Widget resolver (x-widget -> component) |
packages/admin/app/components/schema/SchemaList.vue | Entity list with SSE auto-refresh |
packages/admin/app/components/widgets/EntityAutocomplete.vue | Typeahead entity reference widget |
packages/admin/app/components/layout/AdminShell.vue | Layout shell + global CSS |
packages/admin/app/components/layout/NavBuilder.vue | Dynamic sidebar navigation |
packages/admin/app/i18n/en.json | English translations |
Related Specs
docs/specs/admin-spa.md -- Full specification with all interfaces, widget mapping, and component details