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 2e5ef7fe78
commit 6bce4822e7
124 changed files with 82380 additions and 82 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>
+14 -1
View File
@@ -21,5 +21,18 @@ export const useDevicesStore = defineStore('devices', () => {
}
}
return { devices, loading, error, fetchDevices }
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalHours' | 'uniquenessWindow'>>) {
const res = await fetch(`/api/devices/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
if (!res.ok) throw new Error('Failed to update device')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === id)
if (idx !== -1) devices.value[idx] = updated
return updated
}
return { devices, loading, error, fetchDevices, updateDevice }
})
+91 -1
View File
@@ -28,7 +28,9 @@
:name="devicesStore.devices[0].name"
size="large"
status="ok"
:orientation="devicesStore.devices[0].orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
@@ -41,16 +43,50 @@
:name="device.name"
size="compact"
status="ok"
:orientation="device.orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
</main>
<!-- Frame settings sheet -->
<BaseBottomSheet v-model="sheetOpen" label="Frame settings">
<h2 class="home-view__sheet-title">Frame settings</h2>
<div class="home-view__sheet-field">
<BaseInput
v-model="editName"
label="Frame name"
maxlength="100"
/>
</div>
<div class="home-view__sheet-field">
<p class="home-view__sheet-label">Orientation</p>
<OrientationPicker v-model="editOrientation" />
</div>
<BaseButton
variant="primary"
class="home-view__sheet-save"
:disabled="saving"
@click="saveSettings"
>
{{ saving ? 'Saving…' : 'Save' }}
</BaseButton>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { useDevicesStore } from '@/stores/devices'
import type { Device } from '@/types'
import FrameCard from '@/components/FrameCard.vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import BaseInput from '@/components/BaseInput.vue'
import OrientationPicker from '@/components/OrientationPicker.vue'
const devicesStore = useDevicesStore()
@@ -60,6 +96,37 @@ function onAddPhoto(deviceId: number) {
// Photo upload flow — Epic 3
console.log('add-photo', deviceId)
}
// ── Settings sheet ────────────────────────────────────────────────────────────
const sheetOpen = ref(false)
const saving = ref(false)
const editingDevice = ref<Device | null>(null)
const editName = ref('')
const editOrientation = ref<Device['orientation']>('landscape')
function onEdit(deviceId: number) {
const device = devicesStore.devices.find(d => d.id === deviceId)
if (!device) return
editingDevice.value = device
editName.value = device.name
editOrientation.value = device.orientation
sheetOpen.value = true
}
async function saveSettings() {
if (!editingDevice.value) return
saving.value = true
try {
await devicesStore.updateDevice(editingDevice.value.id, {
name: editName.value.trim() || editingDevice.value.name,
orientation: editOrientation.value,
})
sheetOpen.value = false
} finally {
saving.value = false
}
}
</script>
<style scoped lang="scss">
@@ -122,5 +189,28 @@ function onAddPhoto(deviceId: number) {
flex-direction: column;
gap: var(--space-2);
}
&__sheet-title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-4);
}
&__sheet-field {
margin-bottom: var(--space-4);
}
&__sheet-label {
display: block;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
&__sheet-save {
width: 100%;
margin-top: var(--space-2);
}
}
</style>