chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
+162 -5
View File
@@ -27,7 +27,7 @@
:deviceId="devicesStore.devices[0].id"
:name="devicesStore.devices[0].name"
size="large"
status="ok"
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
@@ -42,7 +42,7 @@
:deviceId="device.id"
:name="device.name"
size="compact"
status="ok"
:status="deviceStatus(device)"
:orientation="device.orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
@@ -67,6 +67,24 @@
<OrientationPicker v-model="editOrientation" />
</div>
<div class="home-view__sheet-field">
<p class="home-view__sheet-label">Update time</p>
<div class="home-view__interval-grid">
<button
v-for="opt in WAKE_TIME_OPTIONS"
:key="opt.hour"
type="button"
:class="['home-view__interval-chip', { 'home-view__interval-chip--on': editWakeHour === opt.hour }]"
@click="editWakeHour = opt.hour"
>{{ opt.label }}</button>
</div>
<select class="home-view__tz-select" v-model="editTimezone">
<optgroup v-for="group in TIMEZONE_GROUPS" :key="group.label" :label="group.label">
<option v-for="tz in group.zones" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
</optgroup>
</select>
</div>
<BaseButton
variant="primary"
class="home-view__sheet-save"
@@ -80,30 +98,120 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import type { Device } from '@/types'
function deviceStatus(device: Device): 'ok' | 'offline' {
if (!device.lastSeenAt) return 'offline'
const seenMs = Date.now() - new Date(device.lastSeenAt).getTime()
const windowMs = Math.max(device.rotationIntervalMinutes * 2 * 60_000, 30 * 60_000)
return seenMs <= windowMs ? 'ok' : 'offline'
}
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 router = useRouter()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
onMounted(() => devicesStore.fetchDevices())
onMounted(() => {
devicesStore.fetchDevices()
})
function onAddPhoto(deviceId: number) {
// Photo upload flow — Epic 3
console.log('add-photo', deviceId)
// File picker must be triggered in the user-gesture context (the click handler)
// before navigating, otherwise browsers block it as a popup.
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
uploadStore.init(file, deviceId)
router.push('/upload')
}
input.click()
}
// ── Settings sheet ────────────────────────────────────────────────────────────
const WAKE_TIME_OPTIONS = [
{ hour: 0, label: '12 AM' },
{ hour: 2, label: '2 AM' },
{ hour: 4, label: '4 AM' },
{ hour: 6, label: '6 AM' },
{ hour: 8, label: '8 AM' },
{ hour: 10, label: '10 AM' },
{ hour: 12, label: '12 PM' },
{ hour: 18, label: '6 PM' },
{ hour: 20, label: '8 PM' },
{ hour: 22, label: '10 PM' },
]
const TIMEZONE_GROUPS = [
{ label: 'Americas', zones: [
{ value: 'America/New_York', label: 'Eastern — New York, Toronto' },
{ value: 'America/Chicago', label: 'Central — Chicago, Mexico City' },
{ value: 'America/Denver', label: 'Mountain — Denver, Calgary' },
{ value: 'America/Phoenix', label: 'Mountain (no DST) — Phoenix' },
{ value: 'America/Los_Angeles', label: 'Pacific — Los Angeles, Vancouver' },
{ value: 'America/Anchorage', label: 'Alaska — Anchorage' },
{ value: 'Pacific/Honolulu', label: 'Hawaii — Honolulu' },
{ value: 'America/Sao_Paulo', label: 'Brasília — São Paulo' },
{ value: 'America/Argentina/Buenos_Aires', label: 'Argentina — Buenos Aires' },
{ value: 'America/Bogota', label: 'Colombia — Bogotá' },
]},
{ label: 'Europe', zones: [
{ value: 'Europe/London', label: 'GMT/BST — London, Dublin' },
{ value: 'Europe/Lisbon', label: 'WET/WEST — Lisbon' },
{ value: 'Europe/Paris', label: 'CET/CEST — Paris, Brussels, Amsterdam' },
{ value: 'Europe/Berlin', label: 'CET/CEST — Berlin, Vienna, Zurich' },
{ value: 'Europe/Stockholm', label: 'CET/CEST — Stockholm, Oslo, Copenhagen'},
{ value: 'Europe/Helsinki', label: 'EET/EEST — Helsinki, Tallinn, Riga' },
{ value: 'Europe/Warsaw', label: 'CET/CEST — Warsaw, Prague, Budapest' },
{ value: 'Europe/Rome', label: 'CET/CEST — Rome, Madrid' },
{ value: 'Europe/Athens', label: 'EET/EEST — Athens, Bucharest' },
{ value: 'Europe/Istanbul', label: 'TRT — Istanbul' },
{ value: 'Europe/Moscow', label: 'MSK — Moscow' },
]},
{ label: 'Asia & Pacific', zones: [
{ value: 'Asia/Dubai', label: 'GST — Dubai, Abu Dhabi' },
{ value: 'Asia/Karachi', label: 'PKT — Karachi, Islamabad' },
{ value: 'Asia/Kolkata', label: 'IST — India' },
{ value: 'Asia/Dhaka', label: 'BST — Dhaka, Bangladesh' },
{ value: 'Asia/Bangkok', label: 'ICT — Bangkok, Jakarta, Hanoi' },
{ value: 'Asia/Singapore', label: 'SGT — Singapore, Kuala Lumpur' },
{ value: 'Asia/Shanghai', label: 'CST — Beijing, Shanghai, Taipei' },
{ value: 'Asia/Seoul', label: 'KST — Seoul' },
{ value: 'Asia/Tokyo', label: 'JST — Tokyo' },
{ value: 'Australia/Sydney', label: 'AEDT/AEST — Sydney, Melbourne' },
{ value: 'Australia/Brisbane',label: 'AEST (no DST) — Brisbane' },
{ value: 'Australia/Perth', label: 'AWST — Perth' },
{ value: 'Pacific/Auckland', label: 'NZDT/NZST — Auckland' },
]},
{ label: 'Africa & Middle East', zones: [
{ value: 'Africa/Cairo', label: 'EET — Cairo' },
{ value: 'Africa/Nairobi', label: 'EAT — Nairobi, East Africa'},
{ value: 'Africa/Johannesburg', label: 'SAST — Johannesburg' },
{ value: 'Africa/Lagos', label: 'WAT — Lagos, West Africa' },
]},
{ label: 'UTC', zones: [
{ value: 'UTC', label: 'UTC — Coordinated Universal Time' },
]},
]
const sheetOpen = ref(false)
const saving = ref(false)
const editingDevice = ref<Device | null>(null)
const editName = ref('')
const editOrientation = ref<Device['orientation']>('landscape')
const editWakeHour = ref<number>(4)
const editTimezone = ref('UTC')
function onEdit(deviceId: number) {
const device = devicesStore.devices.find(d => d.id === deviceId)
@@ -111,6 +219,8 @@ function onEdit(deviceId: number) {
editingDevice.value = device
editName.value = device.name
editOrientation.value = device.orientation
editWakeHour.value = device.wakeHour ?? 4
editTimezone.value = device.timezone ?? 'UTC'
sheetOpen.value = true
}
@@ -121,6 +231,8 @@ async function saveSettings() {
await devicesStore.updateDevice(editingDevice.value.id, {
name: editName.value.trim() || editingDevice.value.name,
orientation: editOrientation.value,
wakeHour: editWakeHour.value,
timezone: editTimezone.value,
})
sheetOpen.value = false
} finally {
@@ -208,6 +320,51 @@ async function saveSettings() {
margin-bottom: var(--space-2);
}
&__interval-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
&__tz-select {
width: 100%;
margin-top: var(--space-3);
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
cursor: pointer;
appearance: auto;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&__interval-chip {
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
min-height: var(--touch-min);
&--on {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-fg);
}
}
&__sheet-save {
width: 100%;
margin-top: var(--space-2);