feat(home): full-size frame card; horizontal carousel for multi-frame setups
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Reverts the 240px preview cap — frames render at their natural device aspect again. Single-frame layout unchanged. For multi-frame setups, replaces the compact stack with a horizontal scroll-snap carousel: one large card per slide, full-bleed to the viewport edges, with dot navigation below that tracks the active slide and supports tap-to-jump. Native CSS scroll-snap drives the swipe gesture; no extra JS gesture library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,23 +168,18 @@ const statusText = computed(() => {
|
||||
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 (single device or carousel slide) ──────────────────────────────
|
||||
&--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: contain;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&--large &__empty-preview {
|
||||
|
||||
@@ -106,22 +106,71 @@ describe('HomeView', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// HV-01: N devices renders N FrameCard stubs
|
||||
it('renders one FrameCard per device when devices are present', async () => {
|
||||
// HV-01: N devices renders a carousel of N large FrameCard stubs + N dots
|
||||
it('renders one FrameCard per device in a carousel when multiple devices present', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [
|
||||
makeDevice({ id: 1, name: 'Frame A' }),
|
||||
makeDevice({ id: 2, name: 'Frame B' }),
|
||||
makeDevice({ id: 3, name: 'Frame C' }),
|
||||
]
|
||||
// Mock fetchDevices so onMounted doesn't overwrite devices
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const cards = wrapper.findAll('.frame-card-stub')
|
||||
expect(cards).toHaveLength(3)
|
||||
expect(wrapper.find('.home-view__carousel').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
|
||||
// All cards should be the large variant (no more compact stack)
|
||||
const cards = wrapper.findAllComponents({ name: 'FrameCard' })
|
||||
for (const c of cards) expect(c.props('size')).toBe('large')
|
||||
// One navigation dot per device
|
||||
expect(wrapper.findAll('.home-view__dot')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('marks the first dot active by default and updates active dot on scroll', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [
|
||||
makeDevice({ id: 1, name: 'A' }),
|
||||
makeDevice({ id: 2, name: 'B' }),
|
||||
]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
let dots = wrapper.findAll('.home-view__dot')
|
||||
expect(dots[0].classes()).toContain('home-view__dot--active')
|
||||
expect(dots[1].classes()).not.toContain('home-view__dot--active')
|
||||
|
||||
// Simulate the carousel having scrolled to the second slide
|
||||
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
|
||||
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
|
||||
Object.defineProperty(carousel, 'scrollLeft', { configurable: true, value: 360 })
|
||||
await wrapper.find('.home-view__carousel').trigger('scroll')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
dots = wrapper.findAll('.home-view__dot')
|
||||
expect(dots[1].classes()).toContain('home-view__dot--active')
|
||||
expect(dots[0].classes()).not.toContain('home-view__dot--active')
|
||||
})
|
||||
|
||||
it('clicking a dot scrolls the carousel to that slide', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 1 }), makeDevice({ id: 2 }), makeDevice({ id: 3 })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
|
||||
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
|
||||
const scrollToSpy = vi.fn()
|
||||
;(carousel as any).scrollTo = scrollToSpy
|
||||
|
||||
await wrapper.findAll('.home-view__dot')[2].trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({ left: 720, behavior: 'smooth' }))
|
||||
})
|
||||
|
||||
// HV-01b: single device still renders one FrameCard (large variant branch)
|
||||
|
||||
+108
-20
@@ -37,23 +37,51 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Multiple devices — compact stack -->
|
||||
<div v-else class="home-view__list">
|
||||
<FrameCard
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
:deviceId="device.id"
|
||||
:name="device.name"
|
||||
size="compact"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
:lastSync="lastSyncLabel(device)"
|
||||
:nextSync="nextSyncLabel(device)"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
<!-- Multiple devices — swipeable carousel of large cards -->
|
||||
<template v-else>
|
||||
<div
|
||||
ref="carouselEl"
|
||||
class="home-view__carousel"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="Frames"
|
||||
@scroll.passive="onCarouselScroll"
|
||||
>
|
||||
<div
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
class="home-view__slide"
|
||||
role="group"
|
||||
:aria-roledescription="`Slide`"
|
||||
: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>
|
||||
<div class="home-view__dots" role="tablist" aria-label="Frame selector">
|
||||
<button
|
||||
v-for="(d, i) in devicesStore.devices"
|
||||
:key="d.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="['home-view__dot', { 'home-view__dot--active': i === activeSlide }]"
|
||||
:aria-label="`Go to ${d.name}`"
|
||||
:aria-selected="i === activeSlide"
|
||||
@click="goToSlide(i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<!-- Frame settings sheet -->
|
||||
@@ -103,7 +131,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
@@ -181,6 +209,25 @@ onMounted(() => {
|
||||
devicesStore.fetchDevices()
|
||||
})
|
||||
|
||||
// ── Multi-device carousel ─────────────────────────────────────────────────────
|
||||
|
||||
const carouselEl = ref<HTMLElement | null>(null)
|
||||
const activeSlide = ref(0)
|
||||
|
||||
function onCarouselScroll() {
|
||||
const el = carouselEl.value
|
||||
if (!el) return
|
||||
// Each slide is 100% of the carousel width — stride is clientWidth.
|
||||
activeSlide.value = Math.round(el.scrollLeft / el.clientWidth)
|
||||
}
|
||||
|
||||
async function goToSlide(i: number) {
|
||||
await nextTick()
|
||||
const el = carouselEl.value
|
||||
if (!el) return
|
||||
el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -354,10 +401,51 @@ async function saveSettings() {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__list {
|
||||
// Carousel: full-bleed horizontal scroll-snap. The container ignores the
|
||||
// page's side padding so cards reach edge-to-edge; each slide pads itself
|
||||
// back in so the card visually aligns with the rest of the page.
|
||||
&__carousel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 calc(-1 * var(--space-4));
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
&__slide {
|
||||
flex: 0 0 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--space-4);
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
&__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast), transform var(--duration-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
&__sheet-title {
|
||||
|
||||
Reference in New Issue
Block a user