fix(home): shrink frame card, three-state status, draggable sheet, label overlap
CI / test (push) Has been cancelled

- HomeView clears the bottom nav so + Add Photo isn't covered.
- Cap large frame-card preview to min(240px, 30dvh) so portrait frames
  no longer dominate the screen at full mobile width.
- Three-state device status — green/Online (recent sync), yellow/Sync
  issue (one window missed), red/Offline (two+ windows missed). Window
  is rotationIntervalMinutes for interval-mode devices, 24h for daily
  wakeHour-mode devices.
- Show last-sync ("synced 2h ago") and next-expected-sync line on the
  large card. wakeHour devices show local-hour ("next sync ~4 AM
  tomorrow") in the device's configured timezone.
- BaseBottomSheet drag-to-dismiss on the handle. Touch and pointer
  events; releases past 80px close the sheet. Snaps back below.
- BaseInput floating label rewrite — taller field, label re-anchors
  to top: 8px when filled/focused so it sits cleanly above the value
  instead of overlapping it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:23:35 -04:00
parent 5fcfb806be
commit 78ff21fb98
20 changed files with 486 additions and 59 deletions
+56 -5
View File
@@ -30,6 +30,8 @@
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
:lastSync="lastSyncLabel(devicesStore.devices[0])"
:nextSync="nextSyncLabel(devicesStore.devices[0])"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
@@ -46,6 +48,8 @@
:status="deviceStatus(device)"
:orientation="device.orientation"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
@@ -105,11 +109,58 @@ import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import type { Device } from '@/types'
function deviceStatus(device: Device): 'ok' | 'offline' {
// Sync interval for status comparisons. Devices configured with a daily wake
// hour use a 24h window; otherwise the rotation interval drives it.
function syncIntervalMs(device: Device): number {
if (device.wakeHour !== null) return 24 * 60 * 60 * 1000
return device.rotationIntervalMinutes * 60_000
}
function deviceStatus(device: Device): 'ok' | 'sync-fail' | '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'
const elapsed = Date.now() - new Date(device.lastSeenAt).getTime()
const interval = syncIntervalMs(device)
if (elapsed <= interval) return 'ok'
if (elapsed <= 2 * interval) return 'sync-fail'
return 'offline'
}
function lastSyncLabel(device: Device): string | null {
if (!device.lastSeenAt) return null
const ago = Date.now() - new Date(device.lastSeenAt).getTime()
if (ago < 60_000) return 'just now'
if (ago < 3_600_000) return `${Math.round(ago / 60_000)}m ago`
if (ago < 86_400_000) return `${Math.round(ago / 3_600_000)}h ago`
const days = Math.round(ago / 86_400_000)
if (days === 1) return 'yesterday'
return `${days} days ago`
}
function formatHour(h: number): string {
if (h === 0) return '12 AM'
if (h < 12) return `${h} AM`
if (h === 12) return '12 PM'
return `${h - 12} PM`
}
function nextSyncLabel(device: Device): string | null {
if (device.wakeHour !== null) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: device.timezone || 'UTC',
hour: 'numeric',
hour12: false,
})
const currentHour = parseInt(fmt.format(new Date()), 10)
const tag = currentHour < device.wakeHour ? 'today' : 'tomorrow'
return `next sync ~${formatHour(device.wakeHour)} ${tag}`
}
if (!device.lastSeenAt) return null
const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
const fromNow = next - Date.now()
if (fromNow <= 0) return null
if (fromNow < 60_000) return 'next sync in <1m'
if (fromNow < 3_600_000) return `next sync in ${Math.round(fromNow / 60_000)}m`
return `next sync in ${Math.round(fromNow / 3_600_000)}h`
}
function previewUrl(device: Device): string | undefined {
@@ -250,7 +301,7 @@ async function saveSettings() {
<style scoped lang="scss">
.home-view {
padding: var(--space-4);
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
display: flex;
flex-direction: column;
gap: var(--space-3);