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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user