Files
pictureFrame-webApp/frontend/src/components/FrameCard.vue
T
football2801 b0fc07b94e
CI / test (push) Has been cancelled
feat(home): landscape-phone layout — horizontal carousel of compact cards
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>
2026-05-06 18:54:48 -04:00

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>