82a42011d8
CI / test (push) Has been cancelled
A dynamically-created <input type="file"> that's never attached to the DOM drops its first `change` event on a cold-launched iOS PWA — the native photo picker resolves out of the original user-gesture context and the closure that captured the input is gone. Symptom Matt hit 2026-05-14: first image-pick after hard-close + reopen of the PWA silently failed to advance to the crop tool; the second attempt worked. HomeView and LibraryView now keep a hidden <input ref="fileInputEl" type="file"> live in their templates. onAddPhoto clicks that input inside the user-gesture context; @change fires reliably even on cold launches. The picker resets input.value between selections so picking the same file twice still fires. Tests updated to query the template input via wrapper.find() instead of stubbing document.createElement; 347/347 frontend tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1193 lines
39 KiB
Vue
1193 lines
39 KiB
Vue
<template>
|
||
<main class="home-view">
|
||
<PullToRefresh :is-at-top="isAtTop" :on-refresh="refreshDevices">
|
||
<!-- Loading -->
|
||
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
|
||
Loading…
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
|
||
<div class="home-view__empty-card">
|
||
<svg class="home-view__empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21,15 16,10 5,21"/>
|
||
</svg>
|
||
<p class="home-view__empty-title">Set up your first frame</p>
|
||
<p class="home-view__empty-sub">
|
||
Power on your pictureFrame device and scan the QR code it displays to get started.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Single device — large card -->
|
||
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
|
||
<FrameCard
|
||
:deviceId="devicesStore.devices[0].id"
|
||
:name="devicesStore.devices[0].name"
|
||
size="large"
|
||
:status="deviceStatus(devicesStore.devices[0])"
|
||
:orientation="devicesStore.devices[0].orientation"
|
||
:model="devicesStore.devices[0].model"
|
||
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
|
||
:lastSync="lastSyncLabel(devicesStore.devices[0])"
|
||
:nextSync="nextSyncLabel(devicesStore.devices[0])"
|
||
@add-photo="onAddPhoto"
|
||
@edit="onEdit"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Multiple devices — vertical scroll-snap stack of large cards -->
|
||
<div
|
||
v-else
|
||
ref="stackEl"
|
||
class="home-view__stack"
|
||
role="list"
|
||
aria-label="Frames"
|
||
>
|
||
<div
|
||
v-for="device in devicesStore.devices"
|
||
:key="device.id"
|
||
class="home-view__slide"
|
||
role="listitem"
|
||
:aria-label="device.name"
|
||
>
|
||
<FrameCard
|
||
:deviceId="device.id"
|
||
:name="device.name"
|
||
size="large"
|
||
:status="deviceStatus(device)"
|
||
:orientation="device.orientation"
|
||
:model="device.model"
|
||
:thumbnailUrl="previewUrl(device)"
|
||
:lastSync="lastSyncLabel(device)"
|
||
:nextSync="nextSyncLabel(device)"
|
||
@add-photo="onAddPhoto"
|
||
@edit="onEdit"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</PullToRefresh>
|
||
|
||
<!-- Hidden persistent file picker. iOS Safari's PWA shell drops the
|
||
first `change` event of a dynamically-created (DOM-detached)
|
||
input element on cold launches — the photo picker resolves
|
||
out-of-gesture-context and the closure never fires. Keeping the
|
||
input live in the template sidesteps that. -->
|
||
<input
|
||
ref="fileInputEl"
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||
hidden
|
||
@change="onFileSelected"
|
||
/>
|
||
</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>
|
||
|
||
<div class="home-view__sheet-field">
|
||
<p class="home-view__sheet-label">Update frequency</p>
|
||
|
||
<select
|
||
class="home-view__mode-select"
|
||
v-model="editFrequencyMode"
|
||
aria-label="Update frequency mode"
|
||
>
|
||
<option value="times">At specific time(s)</option>
|
||
<option value="interval">Every X minutes</option>
|
||
</select>
|
||
|
||
<div v-if="editFrequencyMode === 'times'" class="home-view__times-mode">
|
||
<div class="home-view__times-list">
|
||
<div
|
||
v-for="(m, idx) in editWakeTimes"
|
||
:key="idx"
|
||
class="home-view__time-row"
|
||
>
|
||
<select
|
||
class="home-view__time-part"
|
||
:value="hmpFromMinutes(m).h"
|
||
aria-label="Hour"
|
||
@change="onTimePart(idx, 'h', ($event.target as HTMLSelectElement).value)"
|
||
>
|
||
<option v-for="h in HOUR_OPTIONS" :key="h" :value="h">{{ h }}</option>
|
||
</select>
|
||
<span class="home-view__time-sep">:</span>
|
||
<select
|
||
class="home-view__time-part"
|
||
:value="hmpFromMinutes(m).mm"
|
||
aria-label="Minutes"
|
||
@change="onTimePart(idx, 'mm', ($event.target as HTMLSelectElement).value)"
|
||
>
|
||
<option v-for="mm in MINUTE_OPTIONS" :key="mm" :value="mm">
|
||
{{ String(mm).padStart(2, '0') }}
|
||
</option>
|
||
</select>
|
||
<select
|
||
class="home-view__time-part home-view__time-part--ampm"
|
||
:value="hmpFromMinutes(m).p"
|
||
aria-label="AM or PM"
|
||
@change="onTimePart(idx, 'p', ($event.target as HTMLSelectElement).value)"
|
||
>
|
||
<option value="AM">AM</option>
|
||
<option value="PM">PM</option>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
class="home-view__time-remove"
|
||
:aria-label="`Remove ${formatTime(m)}`"
|
||
@click="removeTime(idx)"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<polyline points="3 6 5 6 21 6"/>
|
||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||
<path d="M10 11v6"/>
|
||
<path d="M14 11v6"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<p
|
||
v-if="editWakeTimes.length === 0"
|
||
class="home-view__times-empty"
|
||
>No update times yet — add one below.</p>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="home-view__time-add"
|
||
@click="addTime"
|
||
>+ Add time</button>
|
||
|
||
<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>
|
||
|
||
<div v-else class="home-view__interval-mode">
|
||
<div class="home-view__interval-input-row">
|
||
<span>Every</span>
|
||
<input
|
||
type="number"
|
||
inputmode="numeric"
|
||
pattern="[0-9]*"
|
||
min="1"
|
||
max="1440"
|
||
class="home-view__interval-input"
|
||
v-model.number="editIntervalMinutes"
|
||
aria-label="Update interval in minutes"
|
||
/>
|
||
<span>minutes</span>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="home-view__next-update" aria-live="polite">{{ nextUpdatePreview }}</p>
|
||
<p class="home-view__propagation-note">
|
||
Changes will only take effect at the next device update. To force
|
||
an immediate refresh, briefly disconnect and reconnect the frame’s
|
||
power.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="home-view__sheet-field">
|
||
<p class="home-view__sheet-label">Image selection</p>
|
||
|
||
<select
|
||
class="home-view__mode-select"
|
||
v-model="editRotationMode"
|
||
aria-label="Image selection mode"
|
||
>
|
||
<option value="oldest_upload">Oldest upload first</option>
|
||
<option value="newest_upload">Newest upload first</option>
|
||
<option value="least_recently_shown">Least recently shown</option>
|
||
<option value="random">Random</option>
|
||
</select>
|
||
|
||
<label class="home-view__rotation-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
v-model="editPrioritizeNeverShown"
|
||
/>
|
||
<span>Show never-shown images first</span>
|
||
</label>
|
||
</div>
|
||
|
||
<BaseButton
|
||
variant="primary"
|
||
class="home-view__sheet-save"
|
||
:disabled="saving"
|
||
@click="saveSettings"
|
||
>
|
||
{{ saving ? 'Saving…' : 'Save' }}
|
||
</BaseButton>
|
||
|
||
<button
|
||
type="button"
|
||
class="home-view__remove"
|
||
@click="removeConfirmOpen = true"
|
||
>Remove this frame</button>
|
||
</BaseBottomSheet>
|
||
|
||
<Teleport to="body">
|
||
<Transition name="home-view__remove-modal">
|
||
<div
|
||
v-if="removeConfirmOpen"
|
||
class="home-view__remove-modal"
|
||
role="alertdialog"
|
||
aria-labelledby="remove-confirm-title"
|
||
@click.self="removeConfirmOpen = false"
|
||
>
|
||
<div class="home-view__remove-modal-card">
|
||
<p class="home-view__remove-confirm-title" id="remove-confirm-title">
|
||
Remove this frame?
|
||
</p>
|
||
<p class="home-view__remove-confirm-body">
|
||
Use this if you’re selling or giving away the frame. It deletes
|
||
this frame from your account and unlinks it from your photos so
|
||
the next owner can claim it fresh. This can’t be undone.
|
||
</p>
|
||
<p class="home-view__remove-confirm-aside">
|
||
On the frame itself, the new owner can also do a factory reset
|
||
by holding the small button on the back until the screen starts
|
||
to flash. (If they only tap it briefly, the frame just refreshes
|
||
its current image — keep holding until it flashes.)
|
||
</p>
|
||
<div class="home-view__remove-confirm-actions">
|
||
<button
|
||
type="button"
|
||
class="home-view__remove-cancel"
|
||
:disabled="removing"
|
||
@click="removeConfirmOpen = false"
|
||
>Cancel</button>
|
||
<button
|
||
type="button"
|
||
class="home-view__remove-confirm-btn"
|
||
:disabled="removing"
|
||
@click="performRemove"
|
||
>{{ removing ? 'Removing…' : 'Yes, remove' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useDevicesStore } from '@/stores/devices'
|
||
import { useUploadStore } from '@/stores/upload'
|
||
import { useDeviceMercure } from '@/composables/useDeviceMercure'
|
||
import type { Device } from '@/types'
|
||
|
||
// Sync interval for status comparisons. Devices configured with explicit wake
|
||
// times use a 24h window (the longest possible gap between slots); otherwise
|
||
// the rotation interval drives it.
|
||
function syncIntervalMs(device: Device): number {
|
||
if (device.wakeTimes.length > 0) return 24 * 60 * 60 * 1000
|
||
return device.rotationIntervalMinutes * 60_000
|
||
}
|
||
|
||
function deviceStatus(device: Device): 'ok' | 'sync-fail' | 'offline' {
|
||
if (!device.lastSeenAt) return '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 formatTime(minutes: number): string {
|
||
const h24 = Math.floor(minutes / 60)
|
||
const mm = minutes % 60
|
||
const p = h24 >= 12 ? 'PM' : 'AM'
|
||
let h = h24 % 12
|
||
if (h === 0) h = 12
|
||
const mmStr = mm === 0 ? '' : `:${String(mm).padStart(2, '0')}`
|
||
return `${h}${mmStr} ${p}`
|
||
}
|
||
|
||
function getMinuteOfDayInTz(date: Date, tz: string): number {
|
||
const fmt = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: tz,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
})
|
||
const parts = fmt.formatToParts(date)
|
||
const h = parseInt(parts.find(p => p.type === 'hour')?.value ?? '0', 10)
|
||
const mm = parseInt(parts.find(p => p.type === 'minute')?.value ?? '0', 10)
|
||
return h * 60 + mm
|
||
}
|
||
|
||
// Find the next wake time after now (in tz). Returns null if `times` is empty.
|
||
function nextWakeMatch(times: number[], tz: string): { minutes: number; today: boolean } | null {
|
||
if (times.length === 0) return null
|
||
const nowMin = getMinuteOfDayInTz(new Date(), tz)
|
||
let best: { minutes: number; today: boolean } | null = null
|
||
let bestDelta = Infinity
|
||
for (const m of times) {
|
||
const delta = m > nowMin ? m - nowMin : (24 * 60) + (m - nowMin)
|
||
if (delta < bestDelta) {
|
||
bestDelta = delta
|
||
best = { minutes: m, today: m > nowMin }
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// Prefer the server-stamped nextPollExpectedAt — that's the schedule the
|
||
// device is *actually* on, set every poll. Falls back to a local computation
|
||
// for devices that haven't polled since the column was added.
|
||
function nextSyncLabel(device: Device): string | null {
|
||
let nextMs: number | null = null
|
||
if (device.nextPollExpectedAt) {
|
||
nextMs = new Date(device.nextPollExpectedAt).getTime()
|
||
} else if (device.wakeTimes.length > 0) {
|
||
const next = nextWakeMatch(device.wakeTimes, device.timezone || 'UTC')
|
||
if (!next) return null
|
||
return `next sync ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}`
|
||
} else if (device.lastSeenAt) {
|
||
nextMs = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
|
||
} else {
|
||
return null
|
||
}
|
||
|
||
const fromNow = nextMs - 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`
|
||
if (fromNow < 86_400_000) {
|
||
// Long horizons read better as a clock time than "in 14h".
|
||
const tz = device.timezone || 'UTC'
|
||
const minOfDay = getMinuteOfDayInTz(new Date(nextMs), tz)
|
||
const dayDelta = daysFromTodayInTz(new Date(nextMs), tz)
|
||
const dayLabel = dayDelta === 0 ? 'today'
|
||
: dayDelta === 1 ? 'tomorrow'
|
||
: `in ${dayDelta}d`
|
||
return `next sync ~${formatTime(minOfDay)} ${dayLabel}`
|
||
}
|
||
return `next sync in ${Math.round(fromNow / 86_400_000)}d`
|
||
}
|
||
|
||
// Home shows what's actually on the frame right now — the last image the
|
||
// device pulled. Lock/queue state is intentionally ignored; the preview
|
||
// won't change until the frame next polls and switches to the locked image.
|
||
function previewUrl(device: Device): string | undefined {
|
||
return device.currentImageId
|
||
? `/api/devices/${device.id}/preview?v=${device.currentImageId}`
|
||
: undefined
|
||
}
|
||
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'
|
||
import PullToRefresh from '@/components/PullToRefresh.vue'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const devicesStore = useDevicesStore()
|
||
const uploadStore = useUploadStore()
|
||
|
||
// Live updates: server publishes a fresh device payload after every poll
|
||
// (and on PATCH/lock/unlock); the composable splats it into the store.
|
||
useDeviceMercure()
|
||
|
||
onMounted(async () => {
|
||
await devicesStore.fetchDevices()
|
||
document.addEventListener('visibilitychange', onVisibility)
|
||
|
||
// First-time setup landing: SetupController redirects new users to
|
||
// /?setup=<deviceId> after register/login, instead of a separate
|
||
// Twig configure page. Auto-open the settings sheet for that device
|
||
// so the user sees the same rich UI everyone else uses for live edits
|
||
// — pre-populated, themed, with no duplicated form to maintain.
|
||
const setupId = Number(route.query.setup)
|
||
if (setupId) {
|
||
onEdit(setupId)
|
||
// Clean the query param off the URL so a refresh doesn't keep
|
||
// re-opening the sheet, but keep the user on the home view.
|
||
router.replace({ query: { ...route.query, setup: undefined } })
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('visibilitychange', onVisibility)
|
||
})
|
||
|
||
// Quietly re-fetch device state every time the PWA returns to the foreground,
|
||
// so the user doesn't have to pull-to-refresh just because a frame cycled
|
||
// while the app was backgrounded.
|
||
function onVisibility() {
|
||
if (document.visibilityState === 'visible') {
|
||
devicesStore.fetchDevices({ silent: true })
|
||
}
|
||
}
|
||
|
||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
||
|
||
const stackEl = ref<HTMLElement | null>(null)
|
||
|
||
function isAtTop(): boolean {
|
||
if (window.scrollY > 0) return false
|
||
return (stackEl.value?.scrollTop ?? 0) === 0
|
||
}
|
||
|
||
async function refreshDevices() {
|
||
await devicesStore.fetchDevices({ silent: true })
|
||
}
|
||
|
||
// Hidden file <input> lives in the template so iOS PWA cold launches
|
||
// can't drop the first `change` event on a detached node. Click handlers
|
||
// route through this single input.
|
||
const fileInputEl = ref<HTMLInputElement | null>(null)
|
||
let pendingAddDeviceId: number | null = null
|
||
|
||
function onAddPhoto(deviceId: number) {
|
||
// File picker must be triggered in the user-gesture context (the click
|
||
// handler), otherwise browsers block it as a popup.
|
||
pendingAddDeviceId = deviceId
|
||
fileInputEl.value?.click()
|
||
}
|
||
|
||
function onFileSelected(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
// Reset right away so re-selecting the same file later still fires change.
|
||
input.value = ''
|
||
if (!file) return
|
||
uploadStore.init(file, pendingAddDeviceId ?? undefined)
|
||
pendingAddDeviceId = null
|
||
router.push('/upload')
|
||
}
|
||
|
||
// ── Settings sheet ────────────────────────────────────────────────────────────
|
||
|
||
const HOUR_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||
const MINUTE_OPTIONS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
|
||
|
||
type FrequencyMode = 'times' | 'interval'
|
||
type AmPm = 'AM' | 'PM'
|
||
|
||
function hmpFromMinutes(m: number): { h: number; mm: number; p: AmPm } {
|
||
const h24 = Math.floor(m / 60)
|
||
const mm = m % 60
|
||
const p: AmPm = h24 >= 12 ? 'PM' : 'AM'
|
||
let h = h24 % 12
|
||
if (h === 0) h = 12
|
||
return { h, mm, p }
|
||
}
|
||
|
||
function minutesFromHmp(h: number, mm: number, p: AmPm): number {
|
||
let h24 = h % 12
|
||
if (p === 'PM') h24 += 12
|
||
return h24 * 60 + mm
|
||
}
|
||
|
||
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 removing = ref(false)
|
||
const removeConfirmOpen = ref(false)
|
||
const editingDevice = ref<Device | null>(null)
|
||
const editName = ref('')
|
||
const editOrientation = ref<Device['orientation']>('landscape')
|
||
const editFrequencyMode = ref<FrequencyMode>('interval')
|
||
const editWakeTimes = ref<number[]>([])
|
||
const editIntervalMinutes = ref<number>(60)
|
||
const editTimezone = ref('UTC')
|
||
const editRotationMode = ref<Device['rotationMode']>('oldest_upload')
|
||
const editPrioritizeNeverShown = ref<boolean>(false)
|
||
|
||
// Default candidates tried (in order) when adding a new time slot — picks the
|
||
// first one that isn't already in the list, so repeated +Add gives a sensible
|
||
// progression rather than stacking duplicates.
|
||
const DEFAULT_TIME_CANDIDATES = [
|
||
9 * 60, // 9:00 AM
|
||
18 * 60, // 6:00 PM
|
||
12 * 60, // 12:00 PM
|
||
21 * 60, // 9:00 PM
|
||
6 * 60, // 6:00 AM
|
||
15 * 60, // 3:00 PM
|
||
7 * 60 + 30, // 7:30 AM
|
||
19 * 60 + 30, // 7:30 PM
|
||
0, // 12:00 AM
|
||
]
|
||
|
||
// Appends to the end of the list — never sorts mid-edit. Sorting a row away
|
||
// from where the user just clicked is disorienting; they lose track of "the
|
||
// one I just added." Backend's setWakeTimes() sorts on save, so the persisted
|
||
// state stays canonical.
|
||
function addTime() {
|
||
for (const c of DEFAULT_TIME_CANDIDATES) {
|
||
if (!editWakeTimes.value.includes(c)) {
|
||
editWakeTimes.value = [...editWakeTimes.value, c]
|
||
return
|
||
}
|
||
}
|
||
// Fallback: pick the next free 5-minute slot.
|
||
for (let m = 0; m < 24 * 60; m += 5) {
|
||
if (!editWakeTimes.value.includes(m)) {
|
||
editWakeTimes.value = [...editWakeTimes.value, m]
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
function removeTime(idx: number) {
|
||
editWakeTimes.value = editWakeTimes.value.filter((_, i) => i !== idx)
|
||
}
|
||
|
||
function onTimePart(idx: number, part: 'h' | 'mm' | 'p', raw: string) {
|
||
const cur = hmpFromMinutes(editWakeTimes.value[idx])
|
||
const h = part === 'h' ? parseInt(raw, 10) : cur.h
|
||
const mm = part === 'mm' ? parseInt(raw, 10) : cur.mm
|
||
const p = part === 'p' ? (raw as AmPm) : cur.p
|
||
// Update in-place — don't reorder, don't dedupe. Reordering a row while
|
||
// the user's mid-edit would yank their focus to the new position. Backend
|
||
// setWakeTimes() sorts and dedupes on save.
|
||
const arr = [...editWakeTimes.value]
|
||
arr[idx] = minutesFromHmp(h, mm, p)
|
||
editWakeTimes.value = arr
|
||
}
|
||
|
||
// "Next update" is when the device will *actually* next sync — and the new
|
||
// settings only reach the device on that sync, since it's currently asleep
|
||
// under whatever schedule was active at its last poll. So we compute this from
|
||
// the device's CURRENT (saved) settings, ignoring the in-progress edit state.
|
||
//
|
||
// Examples this gets right:
|
||
// - Currently every 1 min, new = "at 4 AM" → "in ~1 min" (next existing
|
||
// poll under current schedule, not 4 AM tomorrow).
|
||
// - Currently "at 4 AM", new = "at 4 AM and 6 PM" → "~4 AM tomorrow"
|
||
// (device is asleep until then; can't act on the 6 PM slot today because
|
||
// it won't learn about it until the 4 AM check-in).
|
||
const nextUpdatePreview = computed<string>(() => {
|
||
const device = editingDevice.value
|
||
if (!device) return ''
|
||
|
||
// The preview is about when the device will *next sync* — it does NOT
|
||
// depend on the proposed new settings, only on the device's current saved
|
||
// schedule. The "no update times yet" hint already lives in the time list.
|
||
const tz = device.timezone || 'UTC'
|
||
|
||
// Prefer the server-stamped expected-next-poll: that timestamp was set
|
||
// under the schedule active at the device's last poll, and isn't disturbed
|
||
// by the user's PATCHes — exactly what we want for "when will the new
|
||
// settings reach the frame?"
|
||
let nextPollMs: number
|
||
if (device.nextPollExpectedAt) {
|
||
nextPollMs = new Date(device.nextPollExpectedAt).getTime()
|
||
} else if (!device.lastSeenAt) {
|
||
return 'Next update: when the frame next connects'
|
||
} else {
|
||
// Legacy fallback for devices that haven't polled since the column was added.
|
||
const lastSeen = new Date(device.lastSeenAt).getTime()
|
||
nextPollMs = device.wakeTimes.length > 0
|
||
? nextWakeAfter(lastSeen, device.wakeTimes, tz)
|
||
: lastSeen + device.rotationIntervalMinutes * 60_000
|
||
}
|
||
// Already-overdue device: it'll poll any moment now.
|
||
if (nextPollMs < Date.now()) nextPollMs = Date.now()
|
||
|
||
return formatNextUpdate(nextPollMs, tz)
|
||
})
|
||
|
||
// Next absolute timestamp (ms) at which any of `times` (minutes-of-day) occurs
|
||
// strictly AFTER `refMs` in `tz`. Approximates DST transitions away.
|
||
function nextWakeAfter(refMs: number, times: number[], tz: string): number {
|
||
const refMin = getMinuteOfDayInTz(new Date(refMs), tz)
|
||
let bestDelta = Infinity
|
||
for (const m of times) {
|
||
let delta = m - refMin
|
||
if (delta <= 0) delta += 24 * 60
|
||
if (delta < bestDelta) bestDelta = delta
|
||
}
|
||
return refMs + bestDelta * 60_000
|
||
}
|
||
|
||
// 0 = today, 1 = tomorrow, 2 = day after, ... in the given timezone.
|
||
function daysFromTodayInTz(date: Date, tz: string): number {
|
||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
||
})
|
||
const todayMs = Date.parse(fmt.format(new Date()) + 'T00:00:00Z')
|
||
const targetMs = Date.parse(fmt.format(date) + 'T00:00:00Z')
|
||
return Math.round((targetMs - todayMs) / 86_400_000)
|
||
}
|
||
|
||
function formatNextUpdate(tsMs: number, tz: string): string {
|
||
const fromNow = tsMs - Date.now()
|
||
if (fromNow <= 0) return 'Next update: any moment'
|
||
if (fromNow < 90_000) return 'Next update: in <1 min'
|
||
if (fromNow < 3_600_000) return `Next update: in ~${Math.round(fromNow / 60_000)} min`
|
||
|
||
const minOfDay = getMinuteOfDayInTz(new Date(tsMs), tz)
|
||
const dayDelta = daysFromTodayInTz(new Date(tsMs), tz)
|
||
const dayLabel = dayDelta === 0 ? 'today'
|
||
: dayDelta === 1 ? 'tomorrow'
|
||
: `in ${dayDelta} days`
|
||
return `Next update: ~${formatTime(minOfDay)} ${dayLabel}`
|
||
}
|
||
|
||
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
|
||
editTimezone.value = device.timezone ?? 'UTC'
|
||
editIntervalMinutes.value = device.rotationIntervalMinutes
|
||
editWakeTimes.value = [...device.wakeTimes]
|
||
editFrequencyMode.value = device.wakeTimes.length > 0 ? 'times' : 'interval'
|
||
editRotationMode.value = device.rotationMode
|
||
editPrioritizeNeverShown.value = device.prioritizeNeverShown
|
||
removeConfirmOpen.value = false
|
||
sheetOpen.value = true
|
||
}
|
||
|
||
async function performRemove() {
|
||
if (!editingDevice.value) return
|
||
removing.value = true
|
||
try {
|
||
await devicesStore.removeDevice(editingDevice.value.id)
|
||
sheetOpen.value = false
|
||
removeConfirmOpen.value = false
|
||
} finally {
|
||
removing.value = false
|
||
}
|
||
}
|
||
|
||
async function saveSettings() {
|
||
if (!editingDevice.value) return
|
||
saving.value = true
|
||
try {
|
||
const patch: Parameters<typeof devicesStore.updateDevice>[1] = {
|
||
name: editName.value.trim() || editingDevice.value.name,
|
||
orientation: editOrientation.value,
|
||
timezone: editTimezone.value,
|
||
rotationMode: editRotationMode.value,
|
||
prioritizeNeverShown: editPrioritizeNeverShown.value,
|
||
}
|
||
if (editFrequencyMode.value === 'times') {
|
||
patch.wakeTimes = [...editWakeTimes.value]
|
||
} else {
|
||
patch.wakeTimes = []
|
||
patch.rotationIntervalMinutes = Math.max(1, Math.min(1440, editIntervalMinutes.value || 1))
|
||
}
|
||
await devicesStore.updateDevice(editingDevice.value.id, patch)
|
||
sheetOpen.value = false
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.home-view {
|
||
flex: 1;
|
||
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
|
||
&__loading {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
padding: var(--space-4) 0;
|
||
text-align: center;
|
||
}
|
||
|
||
&__empty {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding-top: var(--space-6);
|
||
}
|
||
|
||
&__empty-card {
|
||
background: var(--color-surface);
|
||
border: 2px dashed var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-6) var(--space-5);
|
||
text-align: center;
|
||
max-width: 320px;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
}
|
||
|
||
&__empty-icon {
|
||
color: var(--color-text-muted);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
&__empty-title {
|
||
font-size: var(--text-md);
|
||
font-weight: 700;
|
||
}
|
||
|
||
&__empty-sub {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
&__single {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
// Vertical scroll-snap stack of full-size cards. Each slide takes a full
|
||
// screen-port height and snaps as the user scrolls, so flipping between
|
||
// frames feels deliberate (like swiping pages) rather than continuous
|
||
// scrolling through a feed.
|
||
&__stack {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
overflow-y: auto;
|
||
scroll-snap-type: y mandatory;
|
||
scroll-behavior: smooth;
|
||
scrollbar-width: none;
|
||
-webkit-overflow-scrolling: touch;
|
||
|
||
&::-webkit-scrollbar { display: none; }
|
||
}
|
||
|
||
&__slide {
|
||
flex: 0 0 auto;
|
||
min-height: 100%;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
scroll-snap-align: start;
|
||
scroll-snap-stop: always;
|
||
}
|
||
|
||
// Landscape phone: horizontal carousel of smaller cards. Vertical space is
|
||
// tight (~240-280px usable) so we side-scroll instead of paging vertically,
|
||
// and each card shrinks to ~320px wide so 2-3 are visible at a time.
|
||
@media (orientation: landscape) and (max-height: 600px) {
|
||
&__stack {
|
||
flex-direction: row;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scroll-snap-type: x mandatory;
|
||
gap: var(--space-3);
|
||
margin: 0 calc(-1 * var(--space-4));
|
||
padding: 0 var(--space-4);
|
||
}
|
||
|
||
&__slide {
|
||
flex: 0 0 min(320px, 70vw);
|
||
min-height: 0;
|
||
height: 100%;
|
||
scroll-snap-align: center;
|
||
}
|
||
}
|
||
|
||
&__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);
|
||
}
|
||
|
||
&__mode-select,
|
||
&__tz-select {
|
||
width: 100%;
|
||
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;
|
||
}
|
||
}
|
||
|
||
&__tz-select {
|
||
margin-top: var(--space-3);
|
||
}
|
||
|
||
&__times-mode,
|
||
&__interval-mode {
|
||
margin-top: var(--space-3);
|
||
}
|
||
|
||
&__times-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
}
|
||
|
||
&__times-empty {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
padding: var(--space-2) 0;
|
||
}
|
||
|
||
&__time-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-1);
|
||
}
|
||
|
||
&__time-part {
|
||
min-height: var(--touch-min);
|
||
padding: 0 var(--space-2);
|
||
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;
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
|
||
&--ampm {
|
||
flex: 0 0 auto;
|
||
min-width: 64px;
|
||
}
|
||
|
||
&:focus {
|
||
outline: 2px solid var(--color-primary);
|
||
outline-offset: 2px;
|
||
}
|
||
}
|
||
|
||
&__time-sep {
|
||
font-weight: 700;
|
||
color: var(--color-text-muted);
|
||
padding: 0 2px;
|
||
}
|
||
|
||
&__time-remove {
|
||
flex: 0 0 auto;
|
||
width: var(--touch-min);
|
||
height: var(--touch-min);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1.5px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
transition: all var(--duration-fast);
|
||
|
||
&:hover, &:focus-visible {
|
||
border-color: var(--color-danger, #c0392b);
|
||
color: var(--color-danger, #c0392b);
|
||
outline: none;
|
||
}
|
||
}
|
||
|
||
&__time-add {
|
||
margin-top: var(--space-3);
|
||
width: 100%;
|
||
min-height: var(--touch-min);
|
||
border: 1.5px dashed var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all var(--duration-fast);
|
||
|
||
&:hover, &:focus-visible {
|
||
border-color: var(--color-primary);
|
||
color: var(--color-primary);
|
||
outline: none;
|
||
}
|
||
}
|
||
|
||
&__interval-input-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
&__interval-input {
|
||
flex: 0 0 96px;
|
||
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);
|
||
// iOS Safari auto-zooms <input> when font-size < 16px. Use 16px+ to prevent
|
||
// the page from zooming when the user taps the number field.
|
||
font-size: 16px;
|
||
font-family: inherit;
|
||
text-align: center;
|
||
|
||
&:focus {
|
||
outline: 2px solid var(--color-primary);
|
||
outline-offset: 2px;
|
||
}
|
||
}
|
||
|
||
&__next-update {
|
||
margin-top: var(--space-3);
|
||
font-size: var(--text-sm);
|
||
font-weight: 600;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
&__rotation-checkbox {
|
||
margin-top: var(--space-3);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
font-size: var(--text-sm);
|
||
cursor: pointer;
|
||
min-height: var(--touch-min);
|
||
|
||
input[type="checkbox"] {
|
||
// Bigger than browser default so it remains a comfortable touch target.
|
||
width: 22px;
|
||
height: 22px;
|
||
cursor: pointer;
|
||
accent-color: var(--color-primary);
|
||
}
|
||
}
|
||
|
||
&__propagation-note {
|
||
margin-top: var(--space-1);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
&__sheet-save {
|
||
width: 100%;
|
||
margin-top: var(--space-2);
|
||
}
|
||
|
||
&__remove {
|
||
width: 100%;
|
||
margin-top: var(--space-5);
|
||
padding: var(--space-2) var(--space-3);
|
||
min-height: var(--touch-min);
|
||
border: 1.5px solid transparent;
|
||
border-radius: var(--radius-md);
|
||
background: transparent;
|
||
color: var(--color-danger, #c0392b);
|
||
font-size: var(--text-sm);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all var(--duration-fast);
|
||
|
||
&:hover, &:focus-visible {
|
||
border-color: var(--color-danger, #c0392b);
|
||
outline: none;
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
}
|
||
|
||
&__remove-modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: var(--space-4);
|
||
background: rgba(20, 14, 8, 0.55);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
&__remove-modal-card {
|
||
width: 100%;
|
||
max-width: 360px;
|
||
padding: var(--space-4);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface);
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
// Transition: card scales up subtly + fades; backdrop fades.
|
||
&__remove-modal-enter-active,
|
||
&__remove-modal-leave-active {
|
||
transition: opacity var(--duration-fast) ease;
|
||
}
|
||
&__remove-modal-enter-active .home-view__remove-modal-card,
|
||
&__remove-modal-leave-active .home-view__remove-modal-card {
|
||
transition: transform var(--duration-fast) ease;
|
||
}
|
||
&__remove-modal-enter-from,
|
||
&__remove-modal-leave-to { opacity: 0; }
|
||
&__remove-modal-enter-from .home-view__remove-modal-card,
|
||
&__remove-modal-leave-to .home-view__remove-modal-card {
|
||
transform: scale(0.96);
|
||
}
|
||
|
||
&__remove-confirm-title {
|
||
font-size: var(--text-md);
|
||
font-weight: 700;
|
||
color: var(--color-danger, #c0392b);
|
||
margin-bottom: var(--space-2);
|
||
}
|
||
|
||
&__remove-confirm-body {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text);
|
||
line-height: 1.5;
|
||
margin-bottom: var(--space-2);
|
||
}
|
||
|
||
&__remove-confirm-aside {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
line-height: 1.5;
|
||
margin-bottom: var(--space-3);
|
||
padding-top: var(--space-2);
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
&__remove-confirm-actions {
|
||
display: flex;
|
||
gap: var(--space-2);
|
||
}
|
||
|
||
&__remove-cancel,
|
||
&__remove-confirm-btn {
|
||
flex: 1 1 0;
|
||
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-weight: 600;
|
||
cursor: pointer;
|
||
|
||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||
}
|
||
|
||
&__remove-confirm-btn {
|
||
background: var(--color-danger, #c0392b);
|
||
border-color: var(--color-danger, #c0392b);
|
||
color: #fff;
|
||
}
|
||
}
|
||
</style>
|