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:
Generated
-48
@@ -260,9 +260,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -283,9 +280,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -306,9 +300,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -329,9 +320,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -352,9 +340,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -375,9 +360,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -544,9 +526,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -564,9 +543,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -584,9 +560,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -604,9 +577,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -624,9 +594,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -644,9 +611,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1271,9 +1235,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1295,9 +1256,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1319,9 +1277,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1343,9 +1298,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user