b0fc07b94e
CI / test (push) Has been cancelled
When the PWA is rotated on a phone, vertical space is too tight for the full-bleed vertical stack. Detect landscape phones via @media (orientation: landscape) and (max-height: 600px) and: - Flip the stack to a horizontal scroll-snap carousel - Shrink each slide to min(320px, 70vw) so 2-3 cards are visible at a time - Restructure the card body to a single row: name + status on the left, Add button on the right; sync line is dropped to keep things tight - Constrain the photo to fill card height (object-fit: contain) instead of card width, so it never overflows the short viewport Manifest also updated to orientation: any so iOS doesn't lock the standalone PWA back to portrait. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
348 lines
9.5 KiB
Vue
348 lines
9.5 KiB
Vue
<template>
|
|
<div
|
|
:class="[
|
|
'frame-card',
|
|
`frame-card--${size}`,
|
|
`frame-card--${status}`,
|
|
]"
|
|
>
|
|
<!-- Settings button (large card only) -->
|
|
<button
|
|
v-if="size === 'large'"
|
|
class="frame-card__settings-btn"
|
|
type="button"
|
|
aria-label="Frame settings"
|
|
@click="$emit('edit', deviceId)"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Preview area -->
|
|
<div class="frame-card__preview" :style="previewStyle">
|
|
<img
|
|
v-if="thumbnailUrl"
|
|
:src="thumbnailUrl"
|
|
:alt="`Current photo on ${name}`"
|
|
class="frame-card__img"
|
|
/>
|
|
<div v-else class="frame-card__empty-preview" :style="emptyAspectStyle" aria-hidden="true">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="frame-card__body">
|
|
<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="`Add photo to ${name}`"
|
|
class="frame-card__add-btn"
|
|
@click="$emit('add-photo', deviceId)"
|
|
>
|
|
<span v-if="size === 'large'">+ Add Photo</span>
|
|
<span v-else aria-hidden="true">+</span>
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import BaseButton from '@/components/BaseButton.vue'
|
|
|
|
const props = defineProps<{
|
|
deviceId: number
|
|
name: string
|
|
size: 'large' | 'compact'
|
|
status: 'ok' | 'offline' | 'sync-fail'
|
|
orientation: 'landscape' | 'portrait'
|
|
thumbnailUrl?: string
|
|
photoCount?: number
|
|
lastSync?: string | null
|
|
nextSync?: string | null
|
|
}>()
|
|
|
|
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
|
|
|
// On large cards we let the <img> drive the preview height (its intrinsic
|
|
// aspect ratio is the device's actual aspect). Until the image loads — or
|
|
// when no thumbnail exists yet — we want the empty placeholder to reserve
|
|
// roughly the same shape so the layout doesn't jump.
|
|
const previewStyle = computed(() => ({}))
|
|
|
|
const emptyAspectStyle = computed(() =>
|
|
props.size === 'large'
|
|
? { 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);
|
|
|
|
&--ok { border-color: var(--color-border); }
|
|
&--sync-fail { border-color: #c49a20; }
|
|
&--offline { border-color: #c0392b; }
|
|
|
|
&__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;
|
|
justify-content: center;
|
|
z-index: 1;
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
&__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 {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
|
|
.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 or carousel slide) ──────────────────────────────
|
|
// The card stretches to fill its parent (slide or single-card container).
|
|
// The preview hugs the photo's natural aspect — no letterbox bars, no min-
|
|
// height forcing extra blank space. Whatever vertical room is left after
|
|
// the photo gets absorbed by the body, with the Add button pinned to the
|
|
// bottom so the card never has a "huge gap" floating above the nav.
|
|
&--large {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
&--large &__preview {
|
|
flex: 0 0 auto;
|
|
background: var(--color-surface-2);
|
|
display: block;
|
|
overflow: hidden;
|
|
}
|
|
|
|
&--large &__img {
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
}
|
|
|
|
&--large &__empty-preview {
|
|
color: var(--color-text-muted);
|
|
opacity: 0.5;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
}
|
|
|
|
&--large &__body {
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: var(--space-4);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
&--large &__info {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
&--large &__name {
|
|
font-size: var(--text-md);
|
|
font-weight: 700;
|
|
}
|
|
|
|
&--large &__add-btn {
|
|
margin-top: auto;
|
|
width: 100%;
|
|
}
|
|
|
|
// ── Compact (multi device) ───────────────────────────────────────────────
|
|
&--compact {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: var(--space-3) var(--space-4);
|
|
}
|
|
|
|
&--compact &__preview {
|
|
width: 52px;
|
|
height: 52px;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-surface-2);
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
&--compact &__img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
&--compact &__empty-preview {
|
|
color: var(--color-text-muted);
|
|
opacity: 0.4;
|
|
}
|
|
|
|
&--compact &__body {
|
|
flex: 1;
|
|
display: flex;
|
|
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 {
|
|
font-size: var(--text-base);
|
|
font-weight: 700;
|
|
}
|
|
|
|
&--compact &__count {
|
|
font-size: var(--text-xs);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
&__add-btn { flex-shrink: 0; }
|
|
|
|
// ── Landscape phone: dense horizontal layout ─────────────────────────────
|
|
// Photo is height-constrained (preview takes flex:1, image letterboxed) so
|
|
// it doesn't blow past the short viewport. Body collapses to a single row
|
|
// — name + status on the left, Add button on the right — and the sync
|
|
// line is dropped to keep the card tight.
|
|
@media (orientation: landscape) and (max-height: 600px) {
|
|
&--large &__preview {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
&--large &__img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
&--large &__body {
|
|
flex: 0 0 auto;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: var(--space-2) var(--space-3);
|
|
}
|
|
|
|
&--large &__info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
&--large &__sync-line { display: none; }
|
|
|
|
&--large &__add-btn {
|
|
margin-top: 0;
|
|
width: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
&--large &__settings-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
top: var(--space-1);
|
|
right: var(--space-1);
|
|
}
|
|
}
|
|
}
|
|
</style>
|