feat: orientation model, password confirm, frontend build

- Collapse orientation to landscape/portrait (ribbon left = portrait standard)
- Add OrientationPicker component and wire settings sheet in HomeView
- Add password confirmation field to registration form (RepeatedType)
- Build frontend SPA to public/build/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 16:59:03 -04:00
parent 6c7c7a1a6f
commit 6c891d6fad
119 changed files with 82314 additions and 75 deletions
+25 -4
View File
@@ -12,8 +12,22 @@
{{ status === 'offline' ? 'Offline' : 'Sync issue' }}
</div>
<!-- Settings button (large card only) -->
<button
v-if="size === 'large'"
class="frame-card__settings-btn"
type="button"
aria-label="Frame settings"
@click="$emit('edit', deviceId)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<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 0 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 0 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 0 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 0 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 0 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 0 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 0 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 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Preview area -->
<div class="frame-card__preview">
<div class="frame-card__preview" :style="previewStyle">
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
@@ -49,18 +63,26 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
defineProps<{
const props = defineProps<{
deviceId: number
name: string
size: 'large' | 'compact'
status: 'ok' | 'offline' | 'sync-fail'
orientation: 'landscape' | 'portrait'
thumbnailUrl?: string
photoCount?: number
}>()
defineEmits<{ 'add-photo': [deviceId: number] }>()
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
const previewStyle = computed(() =>
props.size === 'large'
? { aspectRatio: props.orientation === 'portrait' ? '3/5' : '5/3' }
: {}
)
</script>
<style scoped lang="scss">
@@ -99,7 +121,6 @@ defineEmits<{ 'add-photo': [deviceId: number] }>()
// ── Large (single device) ────────────────────────────────────────────────
&--large &__preview {
aspect-ratio: 5/3;
background: var(--color-surface-2);
display: flex;
align-items: center;
@@ -0,0 +1,118 @@
<template>
<div class="orientation-picker" role="radiogroup" aria-label="Display orientation">
<button
v-for="opt in OPTIONS"
:key="opt.value"
type="button"
role="radio"
:aria-checked="modelValue === opt.value"
:aria-label="opt.label"
:class="['orientation-opt', { 'orientation-opt--active': modelValue === opt.value }]"
@click="$emit('update:modelValue', opt.value)"
>
<!-- Frame diagram: rectangle + ribbon indicator -->
<svg
class="orientation-opt__diagram"
:viewBox="opt.viewBox"
fill="none"
aria-hidden="true"
>
<!-- Display body -->
<rect v-bind="opt.rect" rx="2"
:stroke="modelValue === opt.value ? 'var(--color-primary)' : 'currentColor'"
stroke-width="1.5"
:fill="modelValue === opt.value ? 'color-mix(in srgb, var(--color-primary) 12%, transparent)' : 'var(--color-surface-2)'"
/>
<!-- Ribbon cable indicator -->
<rect v-bind="opt.ribbon"
:fill="modelValue === opt.value ? 'var(--color-primary)' : 'var(--color-text-muted)'"
rx="1"
/>
</svg>
<span class="orientation-opt__label">{{ opt.label }}</span>
<span class="orientation-opt__sub">{{ opt.sub }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import type { Device } from '@/types'
type Orientation = Device['orientation']
defineProps<{ modelValue: Orientation }>()
defineEmits<{ 'update:modelValue': [value: Orientation] }>()
// All diagrams use a 48×48 viewBox.
// rect = the display body; ribbon = the cable tab protruding from one edge.
const OPTIONS: Array<{
value: Orientation
label: string
sub: string
viewBox: string
rect: Record<string, number>
ribbon: Record<string, number>
}> = [
{
value: 'landscape',
label: 'Landscape',
sub: 'Ribbon at bottom',
viewBox: '0 0 48 48',
rect: { x: 4, y: 12, width: 40, height: 24 },
ribbon: { x: 20, y: 36, width: 8, height: 5 },
},
{
value: 'portrait',
label: 'Portrait',
sub: 'Ribbon on left',
viewBox: '0 0 48 48',
rect: { x: 12, y: 4, width: 24, height: 40 },
ribbon: { x: 7, y: 20, width: 5, height: 8 },
},
]
</script>
<style scoped lang="scss">
.orientation-picker {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.orientation-opt {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-3) var(--space-2);
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--duration-fast);
min-height: var(--touch-min);
&--active {
border-color: var(--color-primary);
}
&__diagram {
width: 48px;
height: 48px;
color: var(--color-text-muted);
}
&__label {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text);
}
&__sub {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
}
}
</style>