fix(home): card fills the slide; preview uses photo's natural aspect
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Two complaints, one root cause: the FrameCard was floating in the slide with a min-height-padded preview, so (1) photos got top/bottom gray bars instead of fitting their container, and (2) there was a fat empty gap between the card body and the bottom nav. Restructured the large card to flex-fill its slide: - preview hugs the photo's intrinsic aspect ratio (img with width:100% height:auto); no min-height, no aspect-ratio override → no letterbox - card body has flex:1, info pinned at top, Add Photo button pinned at bottom via margin-top:auto and width:100% - HomeView main / single-card / carousel all flex:1 down through the layout so the slide gets the full available height - empty-state placeholder still reserves the device's aspect so the card doesn't jump while images load Result: the photo fills its container left/right with no bars; the body absorbs all remaining space below, with the action button always sitting just above the bottom nav. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@
|
|||||||
:alt="`Current photo on ${name}`"
|
:alt="`Current photo on ${name}`"
|
||||||
class="frame-card__img"
|
class="frame-card__img"
|
||||||
/>
|
/>
|
||||||
<div v-else class="frame-card__empty-preview" aria-hidden="true">
|
<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">
|
<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"/>
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
@@ -85,9 +85,15 @@ const props = defineProps<{
|
|||||||
|
|
||||||
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
||||||
|
|
||||||
const previewStyle = computed(() =>
|
// 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'
|
props.size === 'large'
|
||||||
? { aspectRatio: props.orientation === 'portrait' ? '3/5' : '5/3' }
|
? { aspectRatio: props.orientation === 'portrait' ? '3 / 5' : '5 / 3' }
|
||||||
: {}
|
: {}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -169,38 +175,49 @@ const statusText = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Large (single device or carousel slide) ──────────────────────────────
|
// ── Large (single device or carousel slide) ──────────────────────────────
|
||||||
// Floor the preview to a healthy chunk of the viewport so landscape frames
|
// The card stretches to fill its parent (slide or single-card container).
|
||||||
// (5:3) don't render as a thin strip. Portrait frames (3:5) keep their
|
// The preview hugs the photo's natural aspect — no letterbox bars, no min-
|
||||||
// natural aspect — taller still wins.
|
// height forcing extra blank space. Whatever vertical room is left after
|
||||||
&--large &__preview {
|
// the photo gets absorbed by the body, with the Add button pinned to the
|
||||||
background: var(--color-surface-2);
|
// bottom so the card never has a "huge gap" floating above the nav.
|
||||||
|
&--large {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
height: 100%;
|
||||||
min-height: 50dvh;
|
}
|
||||||
|
|
||||||
|
&--large &__preview {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--large &__img {
|
&--large &__img {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--large &__empty-preview {
|
&--large &__empty-preview {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--large &__body {
|
&--large &__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--large &__info {
|
&--large &__info {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -212,6 +229,11 @@ const statusText = computed(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--large &__add-btn {
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Compact (multi device) ───────────────────────────────────────────────
|
// ── Compact (multi device) ───────────────────────────────────────────────
|
||||||
&--compact {
|
&--compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -123,20 +123,19 @@ describe('FrameCard', () => {
|
|||||||
expect(wrapper.emitted('edit')![0]).toEqual([1])
|
expect(wrapper.emitted('edit')![0]).toEqual([1])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets landscape aspect ratio style in large mode', () => {
|
it('reserves a 5:3 aspect placeholder when no thumbnail is present (landscape)', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
|
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
|
||||||
})
|
})
|
||||||
const preview = wrapper.find('.frame-card__preview')
|
const empty = wrapper.find('.frame-card__empty-preview')
|
||||||
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
|
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
|
||||||
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets portrait aspect ratio style in large mode', () => {
|
it('reserves a 3:5 aspect placeholder when no thumbnail is present (portrait)', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
|
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
|
||||||
})
|
})
|
||||||
const preview = wrapper.find('.frame-card__preview')
|
const empty = wrapper.find('.frame-card__empty-preview')
|
||||||
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
|
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ async function saveSettings() {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.home-view {
|
.home-view {
|
||||||
|
flex: 1;
|
||||||
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
|
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -397,6 +398,7 @@ async function saveSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__single {
|
&__single {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -405,6 +407,8 @@ async function saveSettings() {
|
|||||||
// page's side padding so cards reach edge-to-edge; each slide pads itself
|
// 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.
|
// back in so the card visually aligns with the rest of the page.
|
||||||
&__carousel {
|
&__carousel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
@@ -423,6 +427,8 @@ async function saveSettings() {
|
|||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
scroll-snap-stop: always;
|
scroll-snap-stop: always;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__dots {
|
&__dots {
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":"4.1.5","results":[[":frontend/src/test/views/LibraryView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/views/HomeView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/views/UploadView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/images.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/upload.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/devices.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/ApproveCard.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseBottomSheet.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/DevicePicker.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/FrameCard.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/ShareSheet.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/composables/useTheme.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BottomNav.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseButton.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/App.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseInput.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/views/SettingsView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/StickerTray.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/toast.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/auth.test.ts",{"duration":53.48111400000005,"failed":true}],[":frontend/src/test/components/OrientationPicker.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseToast.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseChip.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseCard.test.ts",{"duration":0,"failed":true}]]}
|
||||||
+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-DCuRF4Y8.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-CXcLrct9.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-BxH1sIci.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-aP_uBWCi.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="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||||
<script type="module" crossorigin src="/build/assets/index-BxH1sIci.js"></script>
|
<script type="module" crossorigin src="/build/assets/index-aP_uBWCi.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
|
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user