08d0968af0
CI / test (push) Has been cancelled
Twig configure page replaced with a redirect: SetupController's index,
register, login, and the legacy /configure route all post-link redirect
to /?setup=<deviceId> for unconfigured devices. The SPA's HomeView
auto-opens its existing settings sheet for that id, with the same
controls everyone uses for live edits — themed to the user's choice,
pre-populated from the device record.
Fixes Matt's report:
- "every 6 hours" lost on save: the configure form posted
rotation_interval_hours but the controller read
rotation_interval_minutes, so the value silently defaulted to
1440 every time. Now the SPA's PATCH flow handles it correctly.
- "old settings still there in live settings": SPA settings sheet
pre-populates from the device's current state via onEdit.
- "uniqueness window in setup but not live settings": removed
from the (now-deleted) Twig form; both surfaces are consistent.
- "color scheme didn't match account": SPA respects the user's
theme natively (data-theme on <html>), so the first-setup screen
looks like the rest of the app.
Also adds a "Sign out of pictureFrame" link at the bottom of the
per-frame settings sheet (the existing /settings tab still has the
primary one). Easy escape hatch from a deeply-nested settings flow.
Tests:
- SetupControllerTest: S-03/04/05/06/08 updated for new redirect
targets, S-CLAIM-03 updated.
- HomeView.test.ts: useRoute now mockable per-test, two new cases
pinning the ?setup=<id> auto-open and its absence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1187 lines
39 KiB
Vue
1187 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"
|
||
: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"
|
||
:thumbnailUrl="previewUrl(device)"
|
||
:lastSync="lastSyncLabel(device)"
|
||
:nextSync="nextSyncLabel(device)"
|
||
@add-photo="onAddPhoto"
|
||
@edit="onEdit"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</PullToRefresh>
|
||
</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>
|
||
|
||
<a href="/logout" class="home-view__logout">Sign out of pictureFrame</a>
|
||
</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 })
|
||
}
|
||
|
||
function onAddPhoto(deviceId: number) {
|
||
// 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 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;
|
||
}
|
||
|
||
&__logout {
|
||
display: block;
|
||
margin-top: var(--space-3);
|
||
text-align: center;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
text-decoration: none;
|
||
padding: var(--space-2);
|
||
|
||
&:hover, &:focus-visible {
|
||
color: var(--color-text);
|
||
text-decoration: underline;
|
||
outline: none;
|
||
}
|
||
}
|
||
}
|
||
</style>
|