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)
|
||||
|
||||
@@ -37,14 +37,28 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Multiple devices — compact stack -->
|
||||
<div v-else class="home-view__list">
|
||||
<FrameCard
|
||||
<!-- 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="compact"
|
||||
size="large"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
@@ -54,6 +68,20 @@
|
||||
@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 {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-CaSppT-T.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-C3K0Qa0V.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-Dtb3F_Km.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,`✓`)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
||||
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-CtkfMKf5.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,`✓`)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||
<script type="module" crossorigin src="/build/assets/index-Dtb3F_Km.js"></script>
|
||||
<script type="module" crossorigin src="/build/assets/index-CtkfMKf5.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
|
||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user