docs: update architecture to reflect Vue 3 SPA frontend decision

Replace AssetMapper + Stimulus + Turbo with Vue 3 SPA (Vite,
TypeScript strict, SCSS modules, Konva.js). Authenticated app is
now a full SPA served by Symfony catch-all; public flows (provisioning,
email approve/decline) remain Symfony + Twig. Add JSON API controllers
for SPA, SpaController catch-all, updated directory structure, and
revised implementation sequence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 21:57:06 -04:00
parent 2a2c8ae343
commit 94dae685e2
+110 -67
View File
@@ -72,7 +72,7 @@ symfony new pictureframe --webapp
**Language & Runtime:** PHP 8.4+, Symfony 8.0
**Templating:** Twig with Stimulus and UX Turbo (Hotwire) — lightweight interactivity without a JS build step
**Templating:** Twig — used only for public flows (provisioning setup, email approve/decline pages). Authenticated app is a Vue 3 SPA; Twig is not used there.
**ORM & Database:** Doctrine ORM, PostgreSQL 16 (DDEV local), migrations via `doctrine/migrations`
@@ -80,17 +80,19 @@ symfony new pictureframe --webapp
**Email:** Symfony Mailer — handles sharing flows and hard-delete confirmations
**Build Tooling:** AssetMapper (no Webpack/Node build step required)
**Build Tooling:** Vite (Vue SPA, outputs to `public/build/`). AssetMapper not used — no Stimulus, no Turbo, no `importmap.php`.
**Testing:** PHPUnit via `symfony/test-pack`
**Code Organization:** Standard Symfony structure — `src/Entity`, `src/Controller`, `src/Repository`, `src/Service`, `templates/`
**Code Organization:** Standard Symfony structure — `src/Entity`, `src/Controller`, `src/Repository`, `src/Service`, `templates/` (public flows only) + `frontend/` (Vue SPA source)
**Additional packages required:**
- `symfony/messenger` + Doctrine transport — async image processing worker
- `symfony/scheduler` — rotation engine and scheduled cleanup
- Image processing library (TBD step 4: GD vs Imagick)
**Post-scaffold cleanup:** After `symfony new pictureframe --webapp`, remove `symfony/stimulus-bundle`, `symfony/ux-turbo`, and AssetMapper. Initialize the Vue SPA in `frontend/` with `npm create vite@latest frontend -- --template vue-ts`.
**Local Dev:** DDEV configured to mirror aqua-iq (PHP 8.4, Nginx-FPM, PostgreSQL 16)
**Note:** Project initialization using this command is the first implementation story.
@@ -141,7 +143,9 @@ symfony new pictureframe --webapp
**Device API:** Single endpoint — `GET /api/device/{mac}/image` — returns raw binary (`application/octet-stream`). This is the only machine-to-machine API surface. No versioning scheme beyond URL stability guarantee (no breaking changes in V1).
**Web Controllers:** Standard Symfony controllers returning Twig responses. No JSON API for the web application.
**Web Controllers (authenticated app):** Symfony controllers return JSON responses for all authenticated app API calls under the `/api/` prefix. No Twig rendering for authenticated routes — Symfony serves the SPA shell only. Controllers use `JsonResponse` or Symfony Serializer.
**Web Controllers (public flows):** `/setup/{mac}`, `/token/{uuid}/approve`, `/token/{uuid}/decline` return Twig responses. These are the only controllers that render HTML directly.
**Email:** Symfony Mailer. Transactional emails: image share notification (with approve link), hard-delete confirmation. Authorization links embedded as tokenized URLs pointing to Symfony routes.
@@ -153,13 +157,26 @@ symfony new pictureframe --webapp
### Frontend Architecture
**Templating:** Twig. Identical pattern to aqua-iq.
**Authenticated App — Vue 3 SPA:**
All authenticated routes are served by a Vue 3 SPA built with Vite + TypeScript strict mode. Vue Router handles client-side navigation; Pinia manages shared state (current user, device list, upload funnel state). SCSS modules scoped per SFC for component styles. Konva.js + Vue-Konva for the sticker canvas editor. No Stimulus, no Turbo, no AssetMapper.
**Interactivity:** Stimulus controllers + Turbo Drive (Hotwire). No SPA, no build step required.
Symfony serves the SPA shell (`public/build/index.html`) via a catch-all route for all authenticated paths. Vue Router takes over client-side navigation from that point.
**Forms:** Symfony Form component.
**Public Flows — Symfony + Twig:**
Three routes remain as Symfony Twig pages with no Vue dependency:
- `/setup/{mac}` — device provisioning setup (post-QR scan)
- `/token/{uuid}/approve` — email approve page (no login required)
- `/token/{uuid}/decline` — email decline page (no login required)
**Assets:** AssetMapper (no Webpack/Node).
These must work with images disabled, CSS disabled, and screen reader only.
**TypeScript:** Strict mode. `frontend/src/types/` holds interfaces mirroring every Symfony API response shape: `Device`, `Image`, `StickerLayer`, `RenderedAsset`, `Token`. The compiler surfaces API contract drift before it reaches deployed devices.
**SCSS:** Modules scoped per SFC (`<style scoped lang="scss">`). Global tokens in `frontend/src/styles/global.scss`. No Tailwind, no utility CSS framework, no pre-built component library.
**Build:** Vite outputs to `public/build/`. `vite.config.ts` sets `outDir: '../public/build'`. The Symfony catch-all controller renders `public/build/index.html` directly.
**Forms:** Symfony Form component is not used for authenticated app forms — Vue handles all form logic. Symfony Form is only used in Twig public flows if needed.
### Infrastructure & Deployment
@@ -178,14 +195,15 @@ symfony new pictureframe --webapp
### Decision Impact Analysis
**Implementation Sequence:**
1. DDEV setup + Symfony scaffold (`symfony new pictureframe --webapp`)
2. Add Messenger, Scheduler, Imagick
3. Domain + Nginx config on VPS
4. Core entities (User, Device, Image, RenderedAsset, Token)
5. Image processing worker
6. Device pull endpoint
7. Web application features (library, approval, sharing, admin)
8. Firmware (after domain + API contract confirmed)
1. DDEV setup + Symfony scaffold (`symfony new pictureframe --webapp`); remove Stimulus/Turbo/AssetMapper
2. Vue SPA scaffold in `frontend/` (`npm create vite@latest frontend -- --template vue-ts`); configure `vite.config.ts` to output to `public/build/`
3. Add Messenger, Scheduler, Imagick to Symfony
4. Domain + Nginx config on VPS
5. Core entities (User, Device, Image, RenderedAsset, Token)
6. Image processing worker
7. Symfony JSON API endpoints + SpaController catch-all
8. Vue SPA features (library, upload funnel, sticker editor, approvals, admin)
9. Firmware (after domain + API contract confirmed)
**Cross-Component Dependencies:**
- Firmware cannot be finalized until `pictureframe.edholm.me` is live and API endpoint format is confirmed
@@ -302,13 +320,13 @@ Mandatory rules for all code in this project:
### Requirements to Structure Mapping
**User & Account Management**`src/Controller/SecurityController.php`, `src/Entity/User.php`, `src/Repository/UserRepository.php`, `templates/security/`
**User & Account Management**`src/Controller/SecurityController.php`, `src/Controller/Api/UserApiController.php`, `src/Entity/User.php`, `src/Repository/UserRepository.php`
**Device Management**`src/Controller/Device/`, `src/Entity/Device.php`, `src/Service/DeviceService.php`, `src/Repository/DeviceRepository.php`, `templates/device/`
**Device Management**`src/Controller/Api/DeviceApiController.php`, `src/Entity/Device.php`, `src/Service/DeviceService.php`, `src/Repository/DeviceRepository.php`, `frontend/src/views/HomeView.vue`
**Image Library**`src/Controller/Image/`, `src/Entity/Image.php`, `src/Entity/RenderedAsset.php`, `src/Service/ImageService.php`, `src/Service/ImageProcessingService.php`, `templates/image/`
**Image Library**`src/Controller/Api/ImageApiController.php`, `src/Entity/Image.php`, `src/Entity/RenderedAsset.php`, `src/Service/ImageService.php`, `src/Service/ImageProcessingService.php`, `frontend/src/views/LibraryView.vue`
**Image Approval & Sharing**`src/Controller/Token/`, `src/Entity/Token.php`, `src/Service/TokenService.php`, `templates/token/`
**Image Approval & Sharing**`src/Controller/Token/TokenActionController.php`, `src/Entity/Token.php`, `src/Service/TokenService.php`, `templates/token/` (email approve/decline pages), `frontend/src/components/ApproveCard.vue`
**Device Provisioning**`src/Controller/Device/DeviceProvisionController.php`, `templates/device/provision.html.twig`
@@ -316,7 +334,7 @@ Mandatory rules for all code in this project:
**Display & Status (device pull endpoint)**`src/Controller/Api/DeviceImageController.php`
**Admin & Moderation**`src/Controller/Admin/`, `templates/admin/`
**Admin & Moderation**`src/Controller/Admin/`, `frontend/src/views/AdminView.vue` (if applicable)
**Cross-Cutting: Async Processing**`src/Message/ProcessImageMessage.php`, `src/MessageHandler/ProcessImageMessageHandler.php`, `config/packages/messenger.yaml`
@@ -333,14 +351,55 @@ pictureframe/
│ └── docker-compose.imagick.yaml ← adds Imagick to web container
├── .gitea/
│ └── workflows/
│ └── ci.yml ← Gitea Actions: lint + test on push
├── assets/
│ ├── app.js ← AssetMapper entry, imports Stimulus + Turbo
│ ├── controllers/
│ │ ├── image_upload_controller.js
│ │ ── device_status_controller.js
└── styles/
└── app.css
│ └── ci.yml ← Gitea Actions: lint + test + vite build on push
├── frontend/ ← Vue 3 SPA source (Vite + TypeScript)
│ ├── src/
│ ├── assets/
│ │ │ └── stickers/ ← SVG sticker assets (Seasonal, Holidays, Fun, Family, Nature)
│ │ ── components/
│ │ ├── base/
│ │ │ ├── BaseButton.vue
│ │ │ │ ├── BaseInput.vue
│ │ │ │ ├── BaseBottomSheet.vue
│ │ │ │ ├── BaseCard.vue
│ │ │ │ ├── BaseChip.vue
│ │ │ │ └── BaseToast.vue
│ │ │ ├── FrameCard.vue
│ │ │ ├── CropEditor.vue
│ │ │ ├── StickerCanvas.vue
│ │ │ ├── StickerTray.vue
│ │ │ ├── DevicePicker.vue
│ │ │ ├── PhotoThumb.vue
│ │ │ ├── ShareSheet.vue
│ │ │ ├── ApproveCard.vue
│ │ │ └── BottomNav.vue
│ │ ├── router/
│ │ │ └── index.ts ← Vue Router; catch-all handled by Symfony
│ │ ├── stores/
│ │ │ ├── auth.ts ← current user, login state
│ │ │ ├── devices.ts ← device list, current device
│ │ │ └── upload.ts ← upload funnel state, sticker composition
│ │ ├── styles/
│ │ │ ├── _breakpoints.scss ← $bp-tablet: 640px, $bp-desktop: 960px
│ │ │ ├── _tokens.scss ← CSS custom properties for all 6 themes
│ │ │ └── global.scss ← reset, typography, base layout
│ │ ├── types/
│ │ │ ├── Device.ts
│ │ │ ├── Image.ts
│ │ │ ├── StickerLayer.ts ← { id, type, x, y, scale, rotation }
│ │ │ ├── RenderedAsset.ts
│ │ │ └── Token.ts
│ │ ├── views/
│ │ │ ├── HomeView.vue ← FrameList — FrameCard per device
│ │ │ ├── LibraryView.vue ← photo grid, search, tabs (All/Mine/Shared)
│ │ │ ├── UploadView.vue ← funnel: crop → sticker → device picker
│ │ │ └── SettingsView.vue
│ │ ├── App.vue
│ │ └── main.ts
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ └── vite.config.ts ← outDir: '../public/build'
├── bin/
│ └── console
├── config/
@@ -352,30 +411,30 @@ pictureframe/
│ │ ├── scheduler.yaml
│ │ ├── security.yaml ← form_login, remember_me, ROLE_SUPER_ADMIN hierarchy
│ │ └── twig.yaml
│ ├── routes.yaml
│ ├── routes.yaml ← catch-all route → SpaController for authenticated paths
│ ├── routes/
│ │ └── api.yaml ← /api/device/{mac}/image route
│ │ └── api.yaml ← /api/device/{mac}/image + /api/* app endpoints
│ └── services.yaml
├── migrations/
├── public/
│ ├── build/ ← gitignored; Vite output (index.html + hashed assets)
│ └── index.php
├── src/
│ ├── Controller/
│ │ ├── Api/
│ │ │ ── DeviceImageController.php ← GET /api/device/{mac}/image → 200/204/404
│ │ │ ── DeviceImageController.php ← GET /api/device/{mac}/image → 200/204/404 (binary)
│ │ │ ├── DeviceApiController.php ← JSON CRUD for devices (Vue SPA)
│ │ │ ├── ImageApiController.php ← JSON CRUD for images, upload, share (Vue SPA)
│ │ │ └── UserApiController.php ← current user, settings (Vue SPA)
│ │ ├── Admin/
│ │ │ ├── AdminDashboardController.php
│ │ │ └── AdminModerationController.php
│ │ ├── SpaController.php ← catch-all; renders public/build/index.html
│ │ ├── SecurityController.php ← login/logout (form POST, JSON response)
│ │ ├── Device/
│ │ │ ── DeviceController.php
│ │ │ └── DeviceProvisionController.php
│ │ ├── Image/
│ │ │ ├── ImageLibraryController.php
│ │ │ ├── ImageUploadController.php
│ │ │ └── ImageShareController.php
│ │ ├── SecurityController.php
│ │ │ ── DeviceProvisionController.php ← GET/POST /setup/{mac} → Twig
│ │ └── Token/
│ │ └── TokenActionController.php ← consume approve/decline/hard-delete tokens
│ │ └── TokenActionController.php ← /token/{uuid}/approve|decline → Twig
│ ├── Entity/
│ │ ├── Device.php
│ │ ├── Image.php
@@ -386,10 +445,6 @@ pictureframe/
│ │ ├── Orientation.php
│ │ ├── RenderStatus.php
│ │ └── TokenType.php
│ ├── Form/
│ │ ├── DeviceType.php
│ │ ├── ImageUploadType.php
│ │ └── RegistrationType.php
│ ├── Message/
│ │ └── ProcessImageMessage.php ← DTO: imageId, deviceModel, orientation
│ ├── MessageHandler/
@@ -413,31 +468,20 @@ pictureframe/
├── storage/
│ └── images/ ← gitignored; STORAGE_PATH points here
├── templates/
│ ├── admin/
│ │ ├── dashboard.html.twig
│ │ └── moderation.html.twig
│ ├── device/
│ │ ── index.html.twig
│ │ ├── provision.html.twig
│ │ └── show.html.twig
│ ├── image/
│ │ ├── library.html.twig
│ │ ├── share.html.twig
│ │ └── upload.html.twig
│ ├── security/
│ │ └── login.html.twig
│ │ ── provision.html.twig ← /setup/{mac} public provisioning page
│ ├── token/
│ │ ├── approve.html.twig
│ │ └── decline.html.twig
│ └── base.html.twig
│ │ ├── approve.html.twig ← email approve page (no login)
│ │ └── decline.html.twig ← email decline page (no login)
│ └── base_public.html.twig ← minimal base for public Twig pages only
├── tests/
│ ├── Functional/
│ │ ├── Api/
│ │ │ ── DeviceImageControllerTest.php
│ │ ├── Device/
│ │ │ └── DeviceControllerTest.php
│ │ └── Image/
│ │ └── ImageLibraryControllerTest.php
│ │ │ ── DeviceImageControllerTest.php
│ │ ├── DeviceApiControllerTest.php
│ │ │ └── ImageApiControllerTest.php
│ │ └── Token/
│ │ └── TokenActionControllerTest.php
│ ├── Integration/
│ │ ├── MessageHandler/
│ │ │ └── ProcessImageMessageHandlerTest.php
@@ -461,7 +505,6 @@ pictureframe/
├── .gitignore
├── composer.json
├── composer.lock
├── importmap.php
├── phpunit.xml.dist
└── symfony.lock
```
@@ -472,7 +515,7 @@ pictureframe/
`GET /api/device/{mac}/image` is the only machine-to-machine surface. MAC address validated against `Device` entity before serving. Returns `application/octet-stream`, 204, or 404. Isolated in `config/routes/api.yaml`.
**Web Application Boundary**
All other routes behind Symfony form-login firewall. Twig responses only. `ROLE_SUPER_ADMIN` gates admin controllers.
Authenticated routes are gated by Symfony form-login firewall. `SpaController` serves `public/build/index.html` as the catch-all — Vue Router handles client-side navigation from there. All authenticated data flows through `/api/*` JSON endpoints. `ROLE_SUPER_ADMIN` gates admin API controllers. Public flows (`/setup/{mac}`, `/token/{uuid}/approve|decline`) are outside the firewall and return Twig responses — no Vue involved.
**Async Processing Boundary**
`ImageService` → Messenger bus → `ProcessImageMessageHandler`. Handler is the only component writing to `storage/images/`. `RenderedAsset.status` is the only signal crossing this boundary.
@@ -513,7 +556,7 @@ User soft-deletes image → Image.deleted_at set (excluded by findActive*)
### Coherence Validation ✅
**Decision Compatibility:** PHP 8.4 + Symfony 8.0 + Doctrine ORM + Messenger + Scheduler + PostgreSQL 16 is a fully supported combination. Imagick is a standalone PHP extension with no framework conflicts. AssetMapper + Stimulus + Turbo has first-party Symfony UX support and requires no build tooling.
**Decision Compatibility:** PHP 8.4 + Symfony 8.0 + Doctrine ORM + Messenger + Scheduler + PostgreSQL 16 is a fully supported combination. Imagick is a standalone PHP extension with no framework conflicts. Vue 3 + Vite + TypeScript strict + SCSS modules + Konva.js is a standard, well-supported SPA stack. The Symfony/Vue boundary is clean: Symfony serves the SPA shell and JSON API; Vue owns all authenticated UI rendering. No build-step conflicts — Vite and Symfony operate independently.
**Pattern Consistency:** `findActive*` naming, `storage/images/` path convention, exclusive Messenger dispatch points, and backed enum usage are consistently applied. One contradiction (`var/images/` in Data Architecture section) found during validation and corrected — all references now point to `storage/images/`.