feat(story-1.2): Vue 3 SPA scaffold, base component library, User entity, SpaController
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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?
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Generated
+1833
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Home</h1>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Library</h1>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Settings</h1>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Shared</h1>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
</style>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user