feat(story-1.2): Vue 3 SPA scaffold, base component library, User entity, SpaController

Frontend:
- Vue 3 + Vite + TypeScript strict in frontend/; builds to public/build/
- Vue Router (hash-history, requiresAuth guard → /login) and Pinia
- Global SCSS design tokens with 6 full themes (Warm Craft, Playful Pop, Sage & Cream, Dusty Mauve, Ocean Dusk, Honey & Slate)
- Base components: BaseButton (5 variants), BaseInput (floating label, error state),
  BaseBottomSheet (slide-up, focus trap, tap-outside dismiss), BaseCard, BaseChip,
  BaseToast (2.5s auto-dismiss, aria-live polite), BottomNav (4 tabs, hides at 960px)
- Type stubs for all API shapes: User, Device, Image, StickerLayer, RenderedAsset, Token

Backend:
- SpaController catch-all serves public/build/index.html; excludes api/setup/token/login/register
- User entity (email+password+roles+theme); UserRepository with PasswordUpgrader
- SecurityController with /login, /logout, /register stubs; Twig login form
- security.yaml: form_login firewall, remember_me, role_hierarchy, access_control
- Migration: create user table

Verified: npm run build succeeds, GET / → 302 /login (unauthenticated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 23:21:29 -04:00
parent 378b0b858b
commit a55b3bd187
39 changed files with 3243 additions and 19 deletions
+29 -19
View File
@@ -1,39 +1,49 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
pattern: ^/(_profiler|_wdt|build)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
form_login:
login_path: /login
check_path: /login
default_target_path: /
enable_csrf: true
logout:
path: /logout
target: /login
remember_me:
secret: '%kernel.secret%'
lifetime: 2592000 # 30 days
always_remember_me: false
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
role_hierarchy:
ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN]
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/setup, roles: PUBLIC_ACCESS }
- { path: ^/token, roles: PUBLIC_ACCESS }
- { path: ^/api/device, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
when@test:
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
cost: 4
time_cost: 3
memory_cost: 10
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1833
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^3.0.4",
"sass": "^1.99.0",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.10",
"vue-tsc": "^3.2.7"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+10
View File
@@ -0,0 +1,10 @@
<template>
<RouterView />
<BottomNav />
<BaseToast />
</template>
<script setup lang="ts">
import BottomNav from '@/components/BottomNav.vue'
import BaseToast from '@/components/BaseToast.vue'
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+104
View File
@@ -0,0 +1,104 @@
<template>
<Teleport to="body">
<Transition name="sheet">
<div
v-if="modelValue"
class="sheet-overlay"
role="dialog"
:aria-label="label"
aria-modal="true"
@click.self="close"
@keydown.esc="close"
>
<div
ref="sheetRef"
class="sheet"
tabindex="-1"
>
<div class="sheet__handle" aria-hidden="true" />
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = defineProps<{
modelValue: boolean
label: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const sheetRef = ref<HTMLElement | null>(null)
let triggerEl: HTMLElement | null = null
function close() {
emit('update:modelValue', false)
}
watch(() => props.modelValue, async (open) => {
if (open) {
triggerEl = document.activeElement as HTMLElement
await nextTick()
sheetRef.value?.focus()
} else {
triggerEl?.focus()
triggerEl = null
}
})
</script>
<style scoped lang="scss">
.sheet-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 100;
display: flex;
align-items: flex-end;
}
.sheet {
width: 100%;
background: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: var(--space-3) var(--space-4) var(--space-6);
max-height: 90dvh;
overflow-y: auto;
outline: none;
&__handle {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--color-border);
margin: 0 auto var(--space-4);
}
}
.sheet-enter-active {
.sheet-overlay { transition: background var(--duration-base) var(--ease-out); }
.sheet { transition: transform 250ms var(--ease-out); }
}
.sheet-leave-active {
.sheet { transition: transform 200ms ease-in; }
transition: background 200ms ease-in;
}
.sheet-enter-from {
background: rgba(0, 0, 0, 0);
.sheet { transform: translateY(100%); }
}
.sheet-leave-to {
background: rgba(0, 0, 0, 0);
.sheet { transform: translateY(100%); }
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<component
:is="tag"
:type="tag === 'button' ? type : undefined"
:disabled="disabled || loading"
:class="['btn', `btn--${variant}`, { 'btn--loading': loading }]"
v-bind="$attrs"
>
<span v-if="loading" class="btn__spinner" aria-hidden="true" />
<slot />
</component>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'icon-pill'
tag?: 'button' | 'a' | 'router-link'
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
loading?: boolean
}>(), {
variant: 'primary',
tag: 'button',
type: 'button',
disabled: false,
loading: false,
})
</script>
<style scoped lang="scss">
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: var(--touch-min);
padding: 0 var(--space-5);
border-radius: var(--radius-full);
font-family: var(--font-family);
font-size: var(--text-base);
font-weight: 600;
line-height: 1;
cursor: pointer;
border: none;
transition: opacity var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
text-decoration: none;
white-space: nowrap;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:not(:disabled):active {
transform: scale(0.96);
}
&--primary {
background: var(--color-primary);
color: var(--color-primary-fg);
}
&--secondary {
background: var(--color-secondary);
color: var(--color-secondary-fg);
border: 1px solid var(--color-border);
}
&--ghost {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
}
&--destructive {
background: var(--color-destructive);
color: var(--color-destructive-fg);
}
&--icon-pill {
width: var(--touch-min);
padding: 0;
border-radius: var(--radius-full);
background: var(--color-surface-2);
color: var(--color-text);
}
&__spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="card" v-bind="$attrs">
<slot />
</div>
</template>
<style scoped lang="scss">
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<span :class="['chip', `chip--${variant}`]" v-bind="$attrs">
<slot />
</span>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error'
}>(), { variant: 'default' })
</script>
<style scoped lang="scss">
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
&--default {
background: var(--color-surface-2);
color: var(--color-text-muted);
}
&--primary {
background: var(--color-primary);
color: var(--color-primary-fg);
}
&--success {
background: #d4edda;
color: #155724;
}
&--warning {
background: #fff3cd;
color: #856404;
}
&--error {
background: #f8d7da;
color: #721c24;
}
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<template>
<div :class="['input-wrap', { 'input-wrap--error': !!error, 'input-wrap--filled': !!modelValue }]">
<input
:id="inputId"
v-bind="$attrs"
:value="modelValue"
:type="type"
class="input-wrap__field"
placeholder=" "
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="emit('blur', $event)"
/>
<label :for="inputId" class="input-wrap__label">{{ label }}</label>
<p v-if="error" :id="`${inputId}-error`" class="input-wrap__error" role="alert">
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
modelValue?: string
label: string
type?: string
error?: string
id?: string
}>(), {
modelValue: '',
type: 'text',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
blur: [event: FocusEvent]
}>()
const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).slice(2)}`)
</script>
<style scoped lang="scss">
.input-wrap {
position: relative;
width: 100%;
&__field {
width: 100%;
min-height: var(--touch-min);
padding: var(--space-4) var(--space-4) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-family);
font-size: var(--text-base);
transition: border-color var(--duration-fast);
&::placeholder {
color: transparent;
}
&:focus {
outline: none;
border-color: var(--color-primary);
}
&:not(:placeholder-shown) ~ .input-wrap__label,
&:focus ~ .input-wrap__label {
transform: translateY(-10px) scale(0.78);
color: var(--color-primary);
}
}
&__label {
position: absolute;
left: var(--space-4);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
font-size: var(--text-base);
pointer-events: none;
transform-origin: left center;
transition: transform var(--duration-fast), color var(--duration-fast);
}
&--error &__field {
border-color: var(--color-destructive);
}
&__error {
margin-top: var(--space-1);
padding-left: var(--space-4);
font-size: var(--text-sm);
color: var(--color-destructive);
}
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<template>
<div class="toast-region" aria-live="polite" aria-atomic="false">
<TransitionGroup name="toast" tag="ul" class="toast-list">
<li
v-for="toast in toastStore.toasts"
:key="toast.id"
:class="['toast', `toast--${toast.type}`]"
role="status"
>
{{ toast.message }}
<button class="toast__close" aria-label="Dismiss" @click="toastStore.dismiss(toast.id)">
×
</button>
</li>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
</script>
<style scoped lang="scss">
.toast-region {
position: fixed;
bottom: calc(var(--bottom-nav-height, 64px) + var(--space-4));
left: 50%;
transform: translateX(-50%);
z-index: 200;
width: min(calc(100vw - var(--space-8)), 420px);
pointer-events: none;
}
.toast-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
pointer-events: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
&--info { background: var(--color-surface); color: var(--color-text); border: 1px solid var(--color-border); }
&--success { background: #d4edda; color: #155724; }
&--error { background: #f8d7da; color: #721c24; }
&__close {
background: none;
border: none;
cursor: pointer;
font-size: var(--text-lg);
line-height: 1;
padding: 0 0 0 var(--space-3);
color: inherit;
min-height: var(--touch-min);
min-width: var(--touch-min);
display: flex;
align-items: center;
justify-content: flex-end;
}
}
.toast-enter-active,
.toast-leave-active {
transition: all var(--duration-base) var(--ease-out);
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<template>
<nav class="bottom-nav" aria-label="Main navigation">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="tab.to"
:class="['bottom-nav__tab', { 'bottom-nav__tab--active': isActive(tab.to) }]"
:aria-label="tab.label"
:aria-current="isActive(tab.to) ? 'page' : undefined"
>
<span class="bottom-nav__icon" aria-hidden="true" v-html="tab.icon" />
<span class="bottom-nav__label">{{ tab.label }}</span>
</RouterLink>
</nav>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const tabs = [
{
name: 'home',
label: 'Home',
to: '/',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>',
},
{
name: 'library',
label: 'Library',
to: '/library',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
},
{
name: 'shared',
label: 'Shared',
to: '/shared',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
},
{
name: 'settings',
label: 'Settings',
to: '/settings',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
},
]
function isActive(path: string) {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script>
<style scoped lang="scss">
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
display: flex;
z-index: 50;
@media (min-width: 960px) {
display: none;
}
&__tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--color-text-muted);
text-decoration: none;
min-height: var(--touch-min);
transition: color var(--duration-fast);
&--active {
color: var(--color-primary);
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
&__label {
font-size: var(--text-xs);
font-weight: 600;
}
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import '@/styles/global.scss'
import App from './App.vue'
import router from '@/router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+46
View File
@@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: { requiresAuth: true },
},
{
path: '/library',
name: 'library',
component: () => import('@/views/LibraryView.vue'),
meta: { requiresAuth: true },
},
{
path: '/shared',
name: 'shared',
component: () => import('@/views/SharedView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
window.location.href = '/login'
return false
}
})
export default router
+18
View File
@@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'
export const useAuthStore = defineStore('auth', () => {
// Bootstrapped from the SPA shell injected by SpaController
const user = ref<User | null>(
(window as unknown as Record<string, unknown>).__PF_USER__ as User | null ?? null
)
const isAuthenticated = computed(() => user.value !== null)
function setUser(u: User | null) {
user.value = u
}
return { user, isAuthenticated, setUser }
})
+27
View File
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface ToastMessage {
id: number
message: string
type: 'info' | 'success' | 'error'
}
let nextId = 0
export const useToastStore = defineStore('toast', () => {
const toasts = ref<ToastMessage[]>([])
function show(message: string, type: ToastMessage['type'] = 'info') {
const id = ++nextId
toasts.value.push({ id, message, type })
setTimeout(() => dismiss(id), 2500)
}
function dismiss(id: number) {
const idx = toasts.value.findIndex((t) => t.id === id)
if (idx !== -1) toasts.value.splice(idx, 1)
}
return { toasts, show, dismiss }
})
+187
View File
@@ -0,0 +1,187 @@
// ─── Design tokens ───────────────────────────────────────────────────────────
:root {
// Typography
--font-family: 'Nunito Variable', 'Nunito', sans-serif;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-md: 17px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 28px;
// Spacing
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
// Radius
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
// Motion
--duration-fast: 150ms;
--duration-base: 250ms;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
// Touch target minimum
--touch-min: 44px;
}
// ─── Themes ──────────────────────────────────────────────────────────────────
[data-theme="warm-craft"],
:root {
--color-bg: #fdf6ee;
--color-surface: #fff9f2;
--color-surface-2: #f5ead8;
--color-border: #e8d9c4;
--color-text: #3a2e22;
--color-text-muted: #8a7060;
--color-primary: #c97c3a;
--color-primary-fg: #ffffff;
--color-secondary: #e8d9c4;
--color-secondary-fg:#3a2e22;
--color-destructive: #c0392b;
--color-destructive-fg: #ffffff;
--color-focus-ring: #c97c3a;
}
[data-theme="playful-pop"] {
--color-bg: #fff0fb;
--color-surface: #fff8fe;
--color-surface-2: #ffe4f7;
--color-border: #f0c8ea;
--color-text: #2d0a28;
--color-text-muted: #7a4272;
--color-primary: #d63aab;
--color-primary-fg: #ffffff;
--color-secondary: #ffe4f7;
--color-secondary-fg:#2d0a28;
--color-destructive: #e03030;
--color-destructive-fg: #ffffff;
--color-focus-ring: #d63aab;
}
[data-theme="sage-cream"] {
--color-bg: #f6f8f3;
--color-surface: #fafcf7;
--color-surface-2: #e4ede0;
--color-border: #ccd9c4;
--color-text: #1e2b1a;
--color-text-muted: #607050;
--color-primary: #4e7c3a;
--color-primary-fg: #ffffff;
--color-secondary: #e4ede0;
--color-secondary-fg:#1e2b1a;
--color-destructive: #a83020;
--color-destructive-fg: #ffffff;
--color-focus-ring: #4e7c3a;
}
[data-theme="dusty-mauve"] {
--color-bg: #f6f0f4;
--color-surface: #fdf8fb;
--color-surface-2: #ead8e8;
--color-border: #d8c4d4;
--color-text: #2a1828;
--color-text-muted: #7a5874;
--color-primary: #8e4a84;
--color-primary-fg: #ffffff;
--color-secondary: #ead8e8;
--color-secondary-fg:#2a1828;
--color-destructive: #b83030;
--color-destructive-fg: #ffffff;
--color-focus-ring: #8e4a84;
}
[data-theme="ocean-dusk"] {
--color-bg: #eef3f8;
--color-surface: #f4f8fc;
--color-surface-2: #d4e4f0;
--color-border: #b8d0e4;
--color-text: #0e2030;
--color-text-muted: #4a6880;
--color-primary: #1a6ea8;
--color-primary-fg: #ffffff;
--color-secondary: #d4e4f0;
--color-secondary-fg:#0e2030;
--color-destructive: #b83020;
--color-destructive-fg: #ffffff;
--color-focus-ring: #1a6ea8;
}
[data-theme="honey-slate"] {
--color-bg: #f2f2ee;
--color-surface: #f8f8f4;
--color-surface-2: #e4e0d4;
--color-border: #d0cc bc;
--color-text: #1c1c18;
--color-text-muted: #6c6858;
--color-primary: #c49a20;
--color-primary-fg: #1c1c18;
--color-secondary: #e4e0d4;
--color-secondary-fg:#1c1c18;
--color-destructive: #b03020;
--color-destructive-fg: #ffffff;
--color-focus-ring: #c49a20;
}
// ─── Reset & base ─────────────────────────────────────────────────────────────
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-family);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100dvh;
}
#app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
// ─── Focus visible ────────────────────────────────────────────────────────────
:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
// ─── Utility mixins ───────────────────────────────────────────────────────────
@mixin touch-target {
min-height: var(--touch-min);
min-width: var(--touch-min);
}
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
+54
View File
@@ -0,0 +1,54 @@
export interface User {
id: number
email: string
roles: string[]
theme: string | null
}
export interface Device {
id: number
mac: string
name: string
orientation: 'landscape' | 'portrait'
rotationInterval: number
uniquenessWindow: number
linkedAt: string
}
export interface Image {
id: number
source: 'uploaded' | 'shared'
filename: string
thumbnailUrl: string
deletedAt: string | null
approvedDeviceIds: number[]
}
export interface StickerLayer {
id: string
type: string
x: number
y: number
scale: number
rotation: number
}
export interface RenderedAsset {
id: number
imageId: number
deviceModel: string
orientation: 'landscape' | 'portrait'
status: 'pending' | 'processing' | 'ready' | 'failed'
}
export interface Token {
uuid: string
type: 'share_approve' | 'share_decline' | 'hard_delete_confirm'
expiresAt: string
usedAt: string | null
}
export interface ApiError {
message: string
errors?: Record<string, string[]>
}
+9
View File
@@ -0,0 +1,9 @@
<template>
<main class="view">
<h1>Home</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+9
View File
@@ -0,0 +1,9 @@
<template>
<main class="view">
<h1>Library</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+9
View File
@@ -0,0 +1,9 @@
<template>
<main class="view">
<h1>Settings</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+9
View File
@@ -0,0 +1,9 @@
<template>
<main class="view">
<h1>Shared</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
outDir: '../public/build',
emptyOutDir: true,
},
})
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260428032100 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, theme VARCHAR(50) DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)');
$this->addSql('CREATE TABLE messenger_messages (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE "user"');
$this->addSql('DROP TABLE messenger_messages');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('spa');
}
return $this->render('security/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): never
{
throw new \LogicException('Handled by Symfony Security firewall.');
}
#[Route('/register', name: 'app_register', methods: ['GET', 'POST'])]
public function register(): Response
{
// Implemented in Story 1.3
return $this->render('security/register.html.twig');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
class SpaController extends AbstractController
{
public function __construct(
#[Autowire('%kernel.project_dir%/public/build')]
private readonly string $buildDir,
) {}
#[Route(
'/{path}',
name: 'spa',
requirements: ['path' => '^(?!api|setup|token|login|register|logout|_profiler|_wdt).*'],
defaults: ['path' => ''],
)]
public function index(): Response
{
$indexFile = $this->buildDir . '/index.html';
if (!file_exists($indexFile)) {
throw $this->createNotFoundException('SPA not built — run npm run build in frontend/.');
}
return new Response(
content: (string) file_get_contents($indexFile),
headers: ['Content-Type' => 'text/html; charset=utf-8'],
);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private string $email = '';
/** @var list<string> */
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private string $password = '';
#[ORM\Column(length: 50, nullable: true)]
private ?string $theme = null;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getUserIdentifier(): string
{
return $this->email;
}
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/** @param list<string> $roles */
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getTheme(): ?string
{
return $this->theme;
}
public function setTheme(?string $theme): static
{
$this->theme = $theme;
return $this;
}
public function eraseCredentials(): void {}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — pictureFrame</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fdf6ee; }
form { width: 100%; max-width: 360px; padding: 2rem; background: #fff; border-radius: 12px; border: 1px solid #e8d9c4; }
h1 { margin: 0 0 1.5rem; font-size: 1.4rem; }
label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; }
input { width: 100%; padding: 0.75rem; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; margin-bottom: 1rem; box-sizing: border-box; }
button { width: 100%; padding: 0.875rem; background: #c97c3a; color: #fff; border: none; border-radius: 9999px; font-size: 1rem; font-weight: 600; cursor: pointer; }
.error { color: #c0392b; font-size: 0.875rem; margin-bottom: 1rem; }
a { display: block; text-align: center; margin-top: 1rem; color: #c97c3a; }
</style>
</head>
<body>
<form method="post">
<h1>Sign in</h1>
{% if error %}
<p class="error">{{ error.messageKey|trans(error.messageData, 'security') }}</p>
{% endif %}
<label for="inputEmail">Email</label>
<input type="email" id="inputEmail" name="_username" value="{{ last_username }}" required autofocus>
<label for="inputPassword">Password</label>
<input type="password" id="inputPassword" name="_password" required>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button type="submit">Sign in</button>
<a href="/register">Create account</a>
</form>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create account — pictureFrame</title>
</head>
<body>
<p>Registration — Story 1.3</p>
</body>
</html>