fix(home): shrink frame card, three-state status, draggable sheet, label overlap
CI / test (push) Has been cancelled
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:
@@ -3,15 +3,9 @@
|
||||
:class="[
|
||||
'frame-card',
|
||||
`frame-card--${size}`,
|
||||
status !== 'ok' && `frame-card--${status}`,
|
||||
`frame-card--${status}`,
|
||||
]"
|
||||
>
|
||||
<!-- Status badge (color + text — never color alone) -->
|
||||
<div v-if="status !== 'ok'" class="frame-card__status-badge" aria-live="polite">
|
||||
<span class="frame-card__status-dot" aria-hidden="true" />
|
||||
{{ status === 'offline' ? 'Offline' : 'Sync issue' }}
|
||||
</div>
|
||||
|
||||
<!-- Settings button (large card only) -->
|
||||
<button
|
||||
v-if="size === 'large'"
|
||||
@@ -44,14 +38,25 @@
|
||||
</div>
|
||||
|
||||
<div class="frame-card__body">
|
||||
<p class="frame-card__name">{{ name }}</p>
|
||||
<p v-if="size === 'compact'" class="frame-card__count">
|
||||
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
|
||||
</p>
|
||||
<div class="frame-card__info">
|
||||
<p class="frame-card__name">{{ name }}</p>
|
||||
<p class="frame-card__status-line" aria-live="polite">
|
||||
<span class="frame-card__status-dot" aria-hidden="true" />
|
||||
<span class="frame-card__status-text">{{ statusText }}</span>
|
||||
</p>
|
||||
<p v-if="size === 'large' && (lastSync || nextSync)" class="frame-card__sync-line">
|
||||
<span v-if="lastSync">synced {{ lastSync }}</span>
|
||||
<span v-if="lastSync && nextSync" class="frame-card__sync-sep" aria-hidden="true">·</span>
|
||||
<span v-if="nextSync">{{ nextSync }}</span>
|
||||
</p>
|
||||
<p v-else-if="size === 'compact' && photoCount !== undefined" class="frame-card__count">
|
||||
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
:variant="size === 'large' ? 'primary' : 'icon-pill'"
|
||||
:aria-label="size === 'large' ? `Add photo to ${name}` : `Add photo to ${name}`"
|
||||
:aria-label="`Add photo to ${name}`"
|
||||
class="frame-card__add-btn"
|
||||
@click="$emit('add-photo', deviceId)"
|
||||
>
|
||||
@@ -74,6 +79,8 @@ const props = defineProps<{
|
||||
orientation: 'landscape' | 'portrait'
|
||||
thumbnailUrl?: string
|
||||
photoCount?: number
|
||||
lastSync?: string | null
|
||||
nextSync?: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
||||
@@ -83,30 +90,58 @@ const previewStyle = computed(() =>
|
||||
? { aspectRatio: props.orientation === 'portrait' ? '3/5' : '5/3' }
|
||||
: {}
|
||||
)
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'ok': return 'Online'
|
||||
case 'sync-fail': return 'Sync issue'
|
||||
case 'offline': return 'Offline'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.frame-card {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--duration-fast);
|
||||
|
||||
&--offline { border-color: #c0392b; }
|
||||
&--ok { border-color: var(--color-border); }
|
||||
&--sync-fail { border-color: #c49a20; }
|
||||
&--offline { border-color: #c0392b; }
|
||||
|
||||
&__status-badge {
|
||||
&__settings-btn {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
background: var(--color-surface-2);
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.frame-card--offline & { color: #c0392b; }
|
||||
&__status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
|
||||
.frame-card--ok & { color: #1a7f4b; }
|
||||
.frame-card--sync-fail & { color: #8a6a00; }
|
||||
.frame-card--offline & { color: #c0392b; }
|
||||
}
|
||||
|
||||
&__status-dot {
|
||||
@@ -115,22 +150,41 @@ const previewStyle = computed(() =>
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
.frame-card--offline & { background: #c0392b; }
|
||||
.frame-card--ok & { background: #1a7f4b; }
|
||||
.frame-card--sync-fail & { background: #c49a20; }
|
||||
.frame-card--offline & { background: #c0392b; }
|
||||
}
|
||||
|
||||
&__sync-line {
|
||||
margin-top: 2px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 6px;
|
||||
}
|
||||
|
||||
&__sync-sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// ── Large (single device) ────────────────────────────────────────────────
|
||||
// Portrait frames have aspect 3:5 — at full mobile width (~360px) that would
|
||||
// be 600px tall and totally dominate the screen. Cap so the card stays
|
||||
// phone-friendly while still showing the photo at the frame's real shape.
|
||||
&--large &__preview {
|
||||
background: var(--color-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-height: min(240px, 30dvh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--large &__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&--large &__empty-preview {
|
||||
@@ -141,11 +195,19 @@ const previewStyle = computed(() =>
|
||||
&--large &__body {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
&--large &__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&--large &__name {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
@@ -188,6 +250,15 @@ const previewStyle = computed(() =>
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--compact &__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&--compact &__name {
|
||||
@@ -196,7 +267,7 @@ const previewStyle = computed(() =>
|
||||
}
|
||||
|
||||
&--compact &__count {
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user