feat: ship as a real iOS-installable PWA, restructure bottom nav, fix safe-area
CI / test (push) Has been cancelled

- Add manifest.webmanifest with standalone display + warm-craft theme colors,
  apple-touch-icon, and 192/512/512-maskable icons (frame-with-sunset glyph).
- Add PWA meta tags + viewport-fit=cover so add-to-home-screen produces a
  true standalone app on iOS instead of a Safari bookmark.
- Drop the Shared bottom-nav tab; the in-page sub-tabs already cover that.
  Three nav tabs total (Home / Library / Settings); pending-share badge
  moves to the Library tab. Predicate-based isActive() now correctly
  disambiguates /library vs /library?tab=shared.
- Safe-area handling: bottom nav, bottom sheet, upload overlay, and #app
  respect env(safe-area-inset-*); sticky Library tabs anchor below the
  iPhone status bar. Introduces --bottom-nav-height token consumed by
  Settings, Library, and the toast.
- LibraryView reactively follows route.query.tab so deep-linking
  /library?tab=shared lands on the right sub-tab.
- Theme-color meta syncs client-side via useTheme.applyTheme so the
  user's chosen theme follows them into Android Chrome's chrome bar.

Test suite expanded to 278 tests / 100% line coverage (99.84% statements,
99.78% branches). Remaining gaps are unreachable defensive code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:07:05 -04:00
parent e0bad975ec
commit 5fcfb806be
58 changed files with 2922 additions and 60 deletions
+11 -2
View File
@@ -2,9 +2,18 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>pictureFrame</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="manifest" href="/build/manifest.webmanifest" />
<title>frontend</title> <link rel="apple-touch-icon" href="/build/icons/apple-touch-icon.png" />
<meta name="theme-color" content="#fdf6ee" />
<meta name="apple-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-title" content="pictureFrame" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+32
View File
@@ -0,0 +1,32 @@
{
"name": "pictureFrame",
"short_name": "pictureFrame",
"description": "Manage photos for your e-ink picture frames.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#fdf6ee",
"theme_color": "#fdf6ee",
"icons": [
{
"src": "/build/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/build/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/build/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+2 -5
View File
@@ -18,10 +18,7 @@ const { applyTheme } = useTheme()
onMounted(() => { onMounted(() => {
const stamped = document.documentElement.dataset.theme const stamped = document.documentElement.dataset.theme
if (stamped && auth.user) { const resolved = stamped || auth.user?.theme
auth.user.theme = stamped if (resolved) applyTheme(resolved)
} else if (auth.user?.theme) {
applyTheme(auth.user.theme)
}
}) })
</script> </script>
+1 -1
View File
@@ -68,7 +68,7 @@ watch(() => props.modelValue, async (open) => {
width: 100%; width: 100%;
background: var(--color-surface); background: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: var(--space-3) var(--space-4) var(--space-6); padding: var(--space-3) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
max-height: 90dvh; max-height: 90dvh;
overflow-y: auto; overflow-y: auto;
outline: none; outline: none;
+18 -18
View File
@@ -4,13 +4,13 @@
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.name" :key="tab.name"
:to="tab.to" :to="tab.to"
:class="['bottom-nav__tab', { 'bottom-nav__tab--active': isActive(tab.to) }]" :class="['bottom-nav__tab', { 'bottom-nav__tab--active': tab.isActive(route) }]"
:aria-label="tab.label" :aria-label="tab.label"
:aria-current="isActive(tab.to) ? 'page' : undefined" :aria-current="tab.isActive(route) ? 'page' : undefined"
> >
<span class="bottom-nav__icon-wrap" aria-hidden="true"> <span class="bottom-nav__icon-wrap" aria-hidden="true">
<span class="bottom-nav__icon" v-html="tab.icon" /> <span class="bottom-nav__icon" v-html="tab.icon" />
<span v-if="tab.name === 'shared' && imagesStore.pendingCount > 0" class="bottom-nav__badge"> <span v-if="tab.name === 'library' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }} {{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}
</span> </span>
</span> </span>
@@ -20,44 +20,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useImagesStore } from '@/stores/images' import { useImagesStore } from '@/stores/images'
const route = useRoute() const route = useRoute()
const imagesStore = useImagesStore() const imagesStore = useImagesStore()
const tabs = [ interface Tab {
name: string
label: string
to: string
icon: string
isActive: (r: RouteLocationNormalizedLoaded) => boolean
}
const tabs: Tab[] = [
{ {
name: 'home', name: 'home',
label: 'Home', label: 'Home',
to: '/', to: '/',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>', icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>',
isActive: r => r.path === '/',
}, },
{ {
name: 'library', name: 'library',
label: 'Library', label: 'Library',
to: '/library', to: '/library',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>', icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
}, isActive: r => r.path.startsWith('/library'),
{
name: 'shared',
label: 'Shared',
to: '/library?tab=shared',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
}, },
{ {
name: 'settings', name: 'settings',
label: 'Settings', label: 'Settings',
to: '/settings', to: '/settings',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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 1 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 1 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 1 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 1 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 1 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 1 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 1 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 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>', icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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 1 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 1 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 1 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 1 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 1 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 1 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 1 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 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
isActive: r => r.path.startsWith('/settings'),
}, },
] ]
function isActive(to: string) {
const path = to.split('?')[0]
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -66,11 +65,11 @@ function isActive(to: string) {
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 64px;
background: var(--color-surface); background: var(--color-surface);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
display: flex; display: flex;
z-index: 50; z-index: 50;
padding-bottom: env(safe-area-inset-bottom);
@media (min-width: 960px) { @media (min-width: 960px) {
display: none; display: none;
@@ -83,6 +82,7 @@ function isActive(to: string) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 2px; gap: 2px;
height: 64px;
color: var(--color-text-muted); color: var(--color-text-muted);
text-decoration: none; text-decoration: none;
min-height: var(--touch-min); min-height: var(--touch-min);
+6
View File
@@ -25,6 +25,12 @@ export function useTheme() {
function applyTheme(themeId: string) { function applyTheme(themeId: string) {
document.documentElement.dataset.theme = themeId document.documentElement.dataset.theme = themeId
if (auth.user) auth.user.theme = themeId if (auth.user) auth.user.theme = themeId
const theme = THEMES.find(t => t.id === themeId)
if (theme) {
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', theme.bg)
}
} }
async function saveTheme(themeId: string) { async function saveTheme(themeId: string) {
+4
View File
@@ -33,6 +33,9 @@
// Touch target minimum // Touch target minimum
--touch-min: 44px; --touch-min: 44px;
// Bottom nav clearance — 64px row + iOS home-indicator inset (zero on devices without one)
--bottom-nav-height: calc(64px + env(safe-area-inset-bottom));
} }
// ─── Themes ────────────────────────────────────────────────────────────────── // ─── Themes ──────────────────────────────────────────────────────────────────
@@ -158,6 +161,7 @@ body {
min-height: 100dvh; min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: env(safe-area-inset-top);
} }
// ─── Focus visible ──────────────────────────────────────────────────────────── // ─── Focus visible ────────────────────────────────────────────────────────────
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import App from '@/App.vue'
import { useAuthStore } from '@/stores/auth'
// Stub child components — we only care about App's mount-time behavior
vi.mock('@/components/BottomNav.vue', () => ({
default: { template: '<nav class="bottom-nav-stub" />' },
}))
vi.mock('@/components/BaseToast.vue', () => ({
default: { template: '<div class="toast-stub" />' },
}))
import { reactive } from 'vue'
const mockRoute = reactive<{ meta: Record<string, unknown> }>({ meta: {} })
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
RouterView: { name: 'RouterView', template: '<div class="router-view-stub" />' },
}))
describe('App', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockRoute.meta = {}
document.documentElement.removeAttribute('data-theme')
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
const meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
meta.setAttribute('content', '#000000')
document.head.appendChild(meta)
})
afterEach(() => {
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
document.documentElement.removeAttribute('data-theme')
})
async function mountApp() {
const { RouterView } = await import('vue-router')
return mount(App, { global: { stubs: { RouterView } } })
}
it('uses the server-stamped <html data-theme> when present', async () => {
document.documentElement.dataset.theme = 'sage-cream'
await mountApp()
expect(document.documentElement.dataset.theme).toBe('sage-cream')
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#f6f8f3')
})
it('falls back to auth.user.theme when no stamped theme is present', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'ocean-dusk', timezone: 'UTC' })
await mountApp()
expect(document.documentElement.dataset.theme).toBe('ocean-dusk')
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#eef3f8')
})
it('does nothing when neither stamped nor user theme exist', async () => {
await mountApp()
expect(document.documentElement.dataset.theme).toBeUndefined()
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#000000')
})
it('renders BottomNav and BaseToast and RouterView', async () => {
const wrapper = await mountApp()
expect(wrapper.find('.router-view-stub').exists()).toBe(true)
expect(wrapper.find('.bottom-nav-stub').exists()).toBe(true)
expect(wrapper.find('.toast-stub').exists()).toBe(true)
})
it('hides BottomNav when route.meta.hideNav is true', async () => {
mockRoute.meta = { hideNav: true }
const wrapper = await mountApp()
expect(wrapper.find('.bottom-nav-stub').exists()).toBe(false)
})
})
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import ApproveCard from '@/components/ApproveCard.vue'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import type { SharedImage, Device } from '@/types'
vi.mock('@/components/DevicePicker.vue', () => ({
default: {
name: 'DevicePicker',
template: '<div class="device-picker-stub"><slot /></div>',
props: ['modelValue', 'devices', 'selected', 'uploading', 'confirmLabel'],
emits: ['update:modelValue', 'update:selected', 'confirm'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'size', 'disabled'],
emits: ['click'],
},
}))
const makeShared = (overrides: Partial<SharedImage> = {}): SharedImage => ({
id: 1,
thumbnailUrl: '/t/1.jpg',
sharedBy: 'alice@example.com',
sharedAt: '2026-01-15T00:00:00Z',
status: 'pending',
...overrides,
} as SharedImage)
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 10,
mac: 'AA',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'UTC',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
currentImageId: null,
...overrides,
})
describe('ApproveCard', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders sharer email and a formatted date', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
expect(wrapper.text()).toContain('alice@example.com')
// date is locale-formatted; just confirm the year shows up
expect(wrapper.text()).toContain('2026')
})
it('shows "Add to frame" + "Decline" actions for a pending share', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'pending' }) } })
expect(wrapper.text()).toContain('Add to frame')
expect(wrapper.text()).toContain('Decline')
})
it('shows "Remove" but no add action for an approved share', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'approved' }) } })
expect(wrapper.text()).not.toContain('Add to frame')
expect(wrapper.text()).toContain('Remove')
})
it('shows "Add anyway" but no decline action for a declined share', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'declined' }) } })
expect(wrapper.text()).toContain('Add anyway')
expect(wrapper.text()).not.toContain('Decline')
})
it('renders a status badge for non-pending shares', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'approved' }) } })
expect(wrapper.find('.approve-card__badge--approved').exists()).toBe(true)
})
it('does not render a status badge for pending shares', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'pending' }) } })
expect(wrapper.find('.approve-card__badge').exists()).toBe(false)
})
it('opens the device picker when the add button is clicked', async () => {
const devices = useDevicesStore()
devices.devices = [makeDevice()]
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
const addBtn = wrapper.findAll('button').find(b => b.text() === 'Add to frame')!
await addBtn.trigger('click')
const picker = wrapper.findComponent({ name: 'DevicePicker' })
expect(picker.props('modelValue')).toBe(true)
})
it('calls approveShared and emits updated when picker confirms', async () => {
const images = useImagesStore()
const updated = makeShared({ status: 'approved' })
vi.spyOn(images, 'approveShared').mockResolvedValue(updated)
const devices = useDevicesStore()
devices.devices = [makeDevice()]
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
await wrapper.findAll('button').find(b => b.text() === 'Add to frame')!.trigger('click')
const picker = wrapper.findComponent({ name: 'DevicePicker' })
await picker.vm.$emit('update:selected', [10])
await picker.vm.$emit('confirm')
await flushPromises()
expect(images.approveShared).toHaveBeenCalledWith(1, [10])
expect(wrapper.emitted('updated')).toEqual([[updated]])
})
it('forwards update:modelValue from the picker so it can be closed', async () => {
const devices = useDevicesStore()
devices.devices = [makeDevice()]
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
await wrapper.findAll('button').find(b => b.text() === 'Add to frame')!.trigger('click')
const picker = wrapper.findComponent({ name: 'DevicePicker' })
expect(picker.props('modelValue')).toBe(true)
await picker.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false)
})
it('calls declineShared and emits updated when decline is clicked', async () => {
const images = useImagesStore()
const updated = makeShared({ status: 'declined' })
vi.spyOn(images, 'declineShared').mockResolvedValue(updated)
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
await wrapper.findAll('button').find(b => b.text() === 'Decline')!.trigger('click')
await flushPromises()
expect(images.declineShared).toHaveBeenCalledWith(1)
expect(wrapper.emitted('updated')).toEqual([[updated]])
})
})
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
describe('BaseBottomSheet', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('does not render the overlay when modelValue is false', async () => {
mount(BaseBottomSheet, {
props: { modelValue: false, label: 'Test' },
slots: { default: '<p class="content">hi</p>' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
expect(document.querySelector('.sheet-overlay')).toBeNull()
})
it('renders the sheet teleported to body when open', async () => {
mount(BaseBottomSheet, {
props: { modelValue: true, label: 'Frame settings' },
slots: { default: '<p class="content">hi</p>' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const overlay = document.querySelector('.sheet-overlay')
expect(overlay).not.toBeNull()
expect(overlay?.getAttribute('aria-label')).toBe('Frame settings')
expect(document.querySelector('.sheet .content')).not.toBeNull()
})
it('emits update:modelValue=false when the overlay is clicked', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const overlay = document.querySelector('.sheet-overlay') as HTMLElement
overlay.click()
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('emits update:modelValue=false on Escape', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const overlay = document.querySelector('.sheet-overlay') as HTMLElement
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('focuses the sheet when opened and restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: false, label: 'X' },
attachTo: document.body,
})
await wrapper.setProps({ modelValue: true })
await new Promise(r => setTimeout(r, 0))
const sheet = document.querySelector('.sheet') as HTMLElement
expect(document.activeElement).toBe(sheet)
await wrapper.setProps({ modelValue: false })
await new Promise(r => setTimeout(r, 0))
expect(document.activeElement).toBe(trigger)
})
})
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseCard from '@/components/BaseCard.vue'
describe('BaseCard', () => {
it('renders slot content inside .card', () => {
const wrapper = mount(BaseCard, { slots: { default: 'hello' } })
expect(wrapper.find('.card').exists()).toBe(true)
expect(wrapper.text()).toContain('hello')
})
it('forwards $attrs onto the root element', () => {
const wrapper = mount(BaseCard, { attrs: { 'data-testid': 'x' } })
expect(wrapper.find('.card').attributes('data-testid')).toBe('x')
})
})
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseChip from '@/components/BaseChip.vue'
describe('BaseChip', () => {
it('renders default variant when no prop is passed', () => {
const wrapper = mount(BaseChip, { slots: { default: 'tag' } })
expect(wrapper.classes()).toContain('chip')
expect(wrapper.classes()).toContain('chip--default')
expect(wrapper.text()).toBe('tag')
})
for (const variant of ['default', 'primary', 'success', 'warning', 'error'] as const) {
it(`renders ${variant} variant class`, () => {
const wrapper = mount(BaseChip, {
props: { variant },
slots: { default: 'x' },
})
expect(wrapper.classes()).toContain(`chip--${variant}`)
})
}
it('forwards $attrs onto the root span', () => {
const wrapper = mount(BaseChip, { attrs: { title: 'tip' } })
expect(wrapper.attributes('title')).toBe('tip')
})
})
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseInput from '@/components/BaseInput.vue'
describe('BaseInput', () => {
it('renders with label and default empty value', () => {
const wrapper = mount(BaseInput, { props: { label: 'Name' } })
expect(wrapper.find('label').text()).toBe('Name')
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
expect(wrapper.find('.input-wrap--filled').exists()).toBe(false)
})
it('binds modelValue and emits update:modelValue on input', async () => {
const wrapper = mount(BaseInput, { props: { label: 'Name', modelValue: 'Bob' } })
const input = wrapper.find('input')
expect((input.element as HTMLInputElement).value).toBe('Bob')
await input.setValue('Alice')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['Alice'])
})
it('marks .input-wrap--filled when modelValue is non-empty', () => {
const wrapper = mount(BaseInput, { props: { label: 'Name', modelValue: 'x' } })
expect(wrapper.find('.input-wrap--filled').exists()).toBe(true)
})
it('emits blur events', async () => {
const wrapper = mount(BaseInput, { props: { label: 'Name' } })
await wrapper.find('input').trigger('blur')
expect(wrapper.emitted('blur')).toBeTruthy()
})
it('renders error message and applies error class when error is set', () => {
const wrapper = mount(BaseInput, { props: { label: 'Name', error: 'Required' } })
expect(wrapper.find('.input-wrap--error').exists()).toBe(true)
expect(wrapper.find('.input-wrap__error').text()).toBe('Required')
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
})
it('honors a custom id and links the label via for=', () => {
const wrapper = mount(BaseInput, { props: { label: 'Email', id: 'email' } })
expect(wrapper.find('input').attributes('id')).toBe('email')
expect(wrapper.find('label').attributes('for')).toBe('email')
})
it('uses a generated id when none is provided', () => {
const wrapper = mount(BaseInput, { props: { label: 'Email' } })
const id = wrapper.find('input').attributes('id')
expect(id).toMatch(/^input-/)
expect(wrapper.find('label').attributes('for')).toBe(id)
})
it('passes type prop down to the underlying input', () => {
const wrapper = mount(BaseInput, { props: { label: 'Email', type: 'email' } })
expect(wrapper.find('input').attributes('type')).toBe('email')
})
})
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import BaseToast from '@/components/BaseToast.vue'
import { useToastStore } from '@/stores/toast'
describe('BaseToast', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders nothing when there are no toasts', () => {
const wrapper = mount(BaseToast)
expect(wrapper.findAll('.toast')).toHaveLength(0)
})
it('renders an info toast pushed via the store', async () => {
const wrapper = mount(BaseToast)
const toast = useToastStore()
toast.show('hello', 'info')
await wrapper.vm.$nextTick()
const items = wrapper.findAll('.toast')
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('hello')
expect(items[0].classes()).toContain('toast--info')
})
it('renders multiple toasts and dismisses on close button click', async () => {
vi.useFakeTimers()
const wrapper = mount(BaseToast)
const toast = useToastStore()
toast.show('first', 'success')
toast.show('second', 'error')
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.toast')).toHaveLength(2)
await wrapper.findAll('.toast__close')[0].trigger('click')
expect(toast.toasts).toHaveLength(1)
expect(toast.toasts[0].message).toBe('second')
vi.runAllTimers()
vi.useRealTimers()
})
})
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import BottomNav from '@/components/BottomNav.vue'
import { useImagesStore } from '@/stores/images'
const Stub = { template: '<div />' }
function makeRouter(): Router {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/library', component: Stub },
{ path: '/settings', component: Stub },
],
})
}
async function mountAt(router: Router, path: string) {
await router.push(path)
await router.isReady()
return mount(BottomNav, { global: { plugins: [router] } })
}
function activeTabName(wrapper: ReturnType<typeof mount>): string | undefined {
const active = wrapper.find('.bottom-nav__tab--active')
if (!active.exists()) return undefined
return active.find('.bottom-nav__label').text()
}
describe('BottomNav', () => {
let router: Router
beforeEach(() => {
setActivePinia(createPinia())
router = makeRouter()
})
it('renders three tabs (Home, Library, Settings)', async () => {
const wrapper = await mountAt(router, '/')
const labels = wrapper.findAll('.bottom-nav__label').map(n => n.text())
expect(labels).toEqual(['Home', 'Library', 'Settings'])
})
it('marks Home active on /', async () => {
const wrapper = await mountAt(router, '/')
expect(activeTabName(wrapper)).toBe('Home')
})
it('marks Library active on /library', async () => {
const wrapper = await mountAt(router, '/library')
expect(activeTabName(wrapper)).toBe('Library')
})
it('marks Library active on /library?tab=shared', async () => {
const wrapper = await mountAt(router, '/library?tab=shared')
expect(activeTabName(wrapper)).toBe('Library')
})
it('marks Settings active on /settings', async () => {
const wrapper = await mountAt(router, '/settings')
expect(activeTabName(wrapper)).toBe('Settings')
})
it('does not mark Home active on /library (regression: startsWith bug)', async () => {
const wrapper = await mountAt(router, '/library')
const homeTab = wrapper.findAll('.bottom-nav__tab')
.find(t => t.find('.bottom-nav__label').text() === 'Home')
expect(homeTab?.classes()).not.toContain('bottom-nav__tab--active')
})
it('updates active tab when route changes', async () => {
const wrapper = await mountAt(router, '/')
expect(activeTabName(wrapper)).toBe('Home')
await router.push('/library')
await flushPromises()
expect(activeTabName(wrapper)).toBe('Library')
})
it('shows the pending-share count as a badge on the Library tab', async () => {
const images = useImagesStore()
images.pendingCount = 3
const wrapper = await mountAt(router, '/')
const libraryTab = wrapper.findAll('.bottom-nav__tab')
.find(t => t.find('.bottom-nav__label').text() === 'Library')!
const badge = libraryTab.find('.bottom-nav__badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('3')
})
it('clamps the badge count to "9+" when 10 or more are pending', async () => {
const images = useImagesStore()
images.pendingCount = 12
const wrapper = await mountAt(router, '/')
expect(wrapper.find('.bottom-nav__badge').text()).toBe('9+')
})
it('hides the badge when no shares are pending', async () => {
const images = useImagesStore()
images.pendingCount = 0
const wrapper = await mountAt(router, '/')
expect(wrapper.find('.bottom-nav__badge').exists()).toBe(false)
})
})
@@ -110,4 +110,44 @@ describe('DevicePicker', () => {
expect(wrapper.text()).toContain('Living Room') expect(wrapper.text()).toContain('Living Room')
expect(wrapper.text()).toContain('Bedroom') expect(wrapper.text()).toContain('Bedroom')
}) })
// DP-07: Clicking the enabled confirm button emits 'confirm'
it('emits confirm when the confirm button is clicked', async () => {
const wrapper = mountPicker([1])
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
})
// DP-08: Confirm label adapts to selection count (singular / plural / none)
it('renders the singular confirm label for one selected device', () => {
const wrapper = mountPicker([1])
expect(wrapper.find('button').text()).toBe('Add to 1 frame')
})
it('renders the plural confirm label for multiple selected devices', () => {
const wrapper = mountPicker([1, 2])
expect(wrapper.find('button').text()).toBe('Add to 2 frames')
})
it('renders the no-selection confirm label when nothing is picked', () => {
const wrapper = mountPicker([])
expect(wrapper.find('button').text()).toBe('Add to frame')
})
// DP-09: Uploading state — label changes and button is disabled
it('shows uploading label and disables the button while uploading', () => {
const wrapper = mount(DevicePicker, {
props: { modelValue: true, devices, selected: [1], uploading: true },
})
const btn = wrapper.find('button')
expect(btn.text()).toBe('Uploading…')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
// DP-10: Forwards update:modelValue from the wrapped sheet
it('forwards update:modelValue from the wrapped sheet', async () => {
const wrapper = mountPicker([])
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
}) })
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import OrientationPicker from '@/components/OrientationPicker.vue'
describe('OrientationPicker', () => {
it('renders both landscape and portrait options', () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'landscape' } })
const buttons = wrapper.findAll('[role="radio"]')
expect(buttons).toHaveLength(2)
const labels = buttons.map(b => b.find('.orientation-opt__label').text())
expect(labels).toEqual(['Landscape', 'Portrait'])
})
it('marks the active option with --active and aria-checked=true', () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'portrait' } })
const buttons = wrapper.findAll('[role="radio"]')
const portrait = buttons.find(b => b.text().includes('Portrait'))!
const landscape = buttons.find(b => b.text().includes('Landscape'))!
expect(portrait.classes()).toContain('orientation-opt--active')
expect(portrait.attributes('aria-checked')).toBe('true')
expect(landscape.classes()).not.toContain('orientation-opt--active')
expect(landscape.attributes('aria-checked')).toBe('false')
})
it('emits update:modelValue when an option is clicked', async () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'landscape' } })
const portrait = wrapper.findAll('[role="radio"]').find(b => b.text().includes('Portrait'))!
await portrait.trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([['portrait']])
})
it('renders an SVG diagram per option', () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'landscape' } })
expect(wrapper.findAll('svg.orientation-opt__diagram')).toHaveLength(2)
})
})
@@ -5,6 +5,7 @@ import ShareSheet from '@/components/ShareSheet.vue'
import { useImagesStore } from '@/stores/images' import { useImagesStore } from '@/stores/images'
const BaseBottomSheetStub = { const BaseBottomSheetStub = {
name: 'BaseBottomSheet',
template: '<div><slot /></div>', template: '<div><slot /></div>',
props: ['modelValue', 'label'], props: ['modelValue', 'label'],
emits: ['update:modelValue'], emits: ['update:modelValue'],
@@ -74,4 +75,48 @@ describe('ShareSheet', () => {
const button = wrapper.find('button') const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined() expect(button.attributes('disabled')).toBeDefined()
}) })
// SS-04: Enter key on input triggers submit
it('submits when Enter is pressed inside the email input', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await input.trigger('keydown.enter')
await flushPromises()
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
})
// SS-05: empty/whitespace email is a no-op even if submit() is called directly
it('does not call shareImage when email is whitespace-only', async () => {
const store = useImagesStore()
const spy = vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue(' ')
await input.trigger('keydown.enter')
await flushPromises()
expect(spy).not.toHaveBeenCalled()
})
// SS-06: non-Error rejections fall back to a generic error message
it('renders a generic error when the rejection is not an Error', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockRejectedValue('something weird')
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('a@b')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Failed to send')
})
// SS-07: forwards update:modelValue from the underlying sheet
it('forwards update:modelValue from the wrapped sheet', async () => {
const wrapper = mountShareSheet()
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
}) })
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import StickerTray from '@/components/StickerTray.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
// Stub the bottom sheet so the modal contents are always rendered inline
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
describe('StickerTray', () => {
it('renders one tab per sticker category', () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
expect(wrapper.findAll('.sticker-tray__cat')).toHaveLength(STICKER_CATEGORIES.length)
})
it('starts on the seasonal category', () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const seasonal = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Seasonal')
expect(seasonal?.classes()).toContain('sticker-tray__cat--active')
const seasonalCount = STICKERS.filter(s => s.category === 'seasonal').length
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(seasonalCount)
})
it('switches the visible grid when a different category tab is clicked', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const fun = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Fun')!
await fun.trigger('click')
const funCount = STICKERS.filter(s => s.category === 'fun').length
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(funCount)
expect(fun.classes()).toContain('sticker-tray__cat--active')
})
it('emits "pick" with the sticker id when an item is clicked', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const firstItem = wrapper.find('.sticker-tray__item')
await firstItem.trigger('click')
const events = wrapper.emitted('pick')
expect(events).toBeTruthy()
expect(typeof events![0][0]).toBe('string')
})
it('forwards update:modelValue from the wrapped sheet', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
})
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTheme, THEMES } from '@/composables/useTheme'
import { useAuthStore } from '@/stores/auth'
describe('useTheme', () => {
beforeEach(() => {
setActivePinia(createPinia())
document.documentElement.removeAttribute('data-theme')
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
const meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
meta.setAttribute('content', '#000000')
document.head.appendChild(meta)
})
afterEach(() => {
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
document.documentElement.removeAttribute('data-theme')
})
it('exports six themes with id, label, primary, bg, text', () => {
expect(THEMES).toHaveLength(6)
for (const t of THEMES) {
expect(t.id).toBeTypeOf('string')
expect(t.label).toBeTypeOf('string')
expect(t.primary).toMatch(/^#[0-9a-f]{6}$/i)
expect(t.bg).toMatch(/^#[0-9a-f]{6}$/i)
expect(t.text).toMatch(/^#[0-9a-f]{6}$/i)
}
})
it('applyTheme writes data-theme on <html>', () => {
const { applyTheme } = useTheme()
applyTheme('ocean-dusk')
expect(document.documentElement.dataset.theme).toBe('ocean-dusk')
})
it('applyTheme syncs auth.user.theme when a user is signed in', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const { applyTheme } = useTheme()
applyTheme('sage-cream')
expect(auth.user?.theme).toBe('sage-cream')
})
it('applyTheme updates the theme-color meta tag with the theme bg', () => {
const { applyTheme } = useTheme()
applyTheme('playful-pop')
const meta = document.querySelector('meta[name="theme-color"]')
const bg = THEMES.find(t => t.id === 'playful-pop')!.bg
expect(meta?.getAttribute('content')).toBe(bg)
})
it('applyTheme is a no-op for unknown theme ids on the meta tag', () => {
const { applyTheme } = useTheme()
applyTheme('does-not-exist')
// data-theme still updates (we mirror the input as-is)
expect(document.documentElement.dataset.theme).toBe('does-not-exist')
// meta tag is left at its previous value
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#000000')
})
it('applyTheme silently skips meta sync when no theme-color meta exists', () => {
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
const { applyTheme } = useTheme()
expect(() => applyTheme('warm-craft')).not.toThrow()
expect(document.documentElement.dataset.theme).toBe('warm-craft')
})
it('saveTheme applies and PATCHes /api/user/theme on success', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { saveTheme } = useTheme()
await saveTheme('honey-slate')
expect(document.documentElement.dataset.theme).toBe('honey-slate')
expect(fetchMock).toHaveBeenCalledWith(
'/api/user/theme',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: 'honey-slate' }),
}),
)
vi.unstubAllGlobals()
})
it('saveTheme shows a toast on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const { saveTheme } = useTheme()
// Spy on the toast store via the module
const { useToastStore } = await import('@/stores/toast')
const toast = useToastStore()
const showSpy = vi.spyOn(toast, 'show')
await saveTheme('dusty-mauve')
expect(showSpy).toHaveBeenCalledWith(expect.stringContaining('Could not save'), 'error')
vi.unstubAllGlobals()
})
it('saveTheme shows a toast when fetch rejects', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
const { saveTheme } = useTheme()
const { useToastStore } = await import('@/stores/toast')
const toast = useToastStore()
const showSpy = vi.spyOn(toast, 'show')
await saveTheme('warm-craft')
expect(showSpy).toHaveBeenCalled()
vi.unstubAllGlobals()
})
})
+3
View File
@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount } from '@vue/test-utils'
import { vi, beforeEach, afterEach } from 'vitest' import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => { beforeEach(() => {
@@ -8,3 +9,5 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
enableAutoUnmount(afterEach)
+46
View File
@@ -156,4 +156,50 @@ describe('devices store', () => {
await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock') await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock')
}) })
// DS-06: non-Error rejections fall back to "Unknown error"
it('fetchDevices stores "Unknown error" when the rejection is not an Error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('boom'))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.error).toBe('Unknown error')
})
// DS-07: returns the patched device even when it isn't in the local list
it('updateDevice no-ops the list when the id is not present', async () => {
const updated = makeDevice({ id: 99, name: 'New' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useDevicesStore()
store.devices = [makeDevice({ id: 1 })]
const result = await store.updateDevice(99, { name: 'New' })
expect(result.name).toBe('New')
expect(store.devices.find(d => d.id === 99)).toBeUndefined()
})
it('lockImage no-ops the list when the device id is not present', async () => {
const locked = makeDevice({ id: 99, lockedImageId: 42 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(locked),
}))
const store = useDevicesStore()
store.devices = [makeDevice({ id: 1 })]
await store.lockImage(99, 42)
expect(store.devices.find(d => d.id === 99)).toBeUndefined()
})
it('unlockImage no-ops the list when the device id is not present', async () => {
const unlocked = makeDevice({ id: 99, lockedImageId: null })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(unlocked),
}))
const store = useDevicesStore()
store.devices = [makeDevice({ id: 1 })]
await store.unlockImage(99)
expect(store.devices.find(d => d.id === 99)).toBeUndefined()
})
}) })
+233
View File
@@ -163,4 +163,237 @@ describe('images store', () => {
expect(store.pendingCount).toBe(1) expect(store.pendingCount).toBe(1)
}) })
it('fetchImages records "Unknown error" when the rejection is not an Error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('boom'))
const store = useImagesStore()
await store.fetchImages()
expect(store.error).toBe('Unknown error')
})
it('fetchImages sets error when the response is not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await store.fetchImages()
expect(store.error).toBe('Failed to load images')
expect(store.loading).toBe(false)
})
it('uploadImage forwards optional extras as form fields', async () => {
const newImage = makeImage({ id: 5 })
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(newImage),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
const file = new File(['x'], 'x.jpg')
const original = new File(['y'], 'orig.jpg')
await store.uploadImage(file, {
original,
cropParams: { natX: 0, natY: 0, natW: 50, natH: 50 },
stickerState: [{ id: 's', type: 'emoji', x: 1, y: 2, scale: 1, rotation: 0 } as any],
cropOrientation: 'portrait',
})
const sent = fetchMock.mock.calls[0][1].body as FormData
expect(sent.get('file')).toBeInstanceOf(File)
expect(sent.get('original')).toBeInstanceOf(File)
expect(sent.get('cropParams')).toBe(JSON.stringify({ natX: 0, natY: 0, natW: 50, natH: 50 }))
expect(sent.get('cropOrientation')).toBe('portrait')
})
it('uploadImage falls back to "Upload failed" when the server returns no error message', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.reject(new Error('not json')),
}))
const store = useImagesStore()
await expect(store.uploadImage(new File(['x'], 'x.jpg'))).rejects.toThrow('Upload failed')
})
it('reprocessImage replaces the matching image with the server response', async () => {
const updated = makeImage({ id: 9, approvedDeviceIds: [1] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [makeImage({ id: 9 })]
const result = await store.reprocessImage(9, new File(['x'], 'x.jpg'), {
cropParams: { natX: 0, natY: 0, natW: 1, natH: 1 },
stickerState: [],
cropOrientation: 'landscape',
})
expect(result).toEqual(updated)
expect(store.images[0].approvedDeviceIds).toEqual([1])
})
it('reprocessImage no-ops the list when the image id is not present', async () => {
const updated = makeImage({ id: 99 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [makeImage({ id: 1 })]
await store.reprocessImage(99, new File(['x'], 'x.jpg'))
expect(store.images.find(i => i.id === 99)).toBeUndefined()
})
it('reprocessImage throws Reprocess failed when the server omits an error message', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.reject(new Error('not json')),
}))
const store = useImagesStore()
await expect(store.reprocessImage(1, new File(['x'], 'x.jpg'))).rejects.toThrow('Reprocess failed')
})
it('reprocessImage surfaces server-provided error messages', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'too big' }),
}))
const store = useImagesStore()
await expect(store.reprocessImage(1, new File(['x'], 'x.jpg'))).rejects.toThrow('too big')
})
it('setApproval uses DELETE when approved=false', async () => {
const updated = makeImage({ id: 1, approvedDeviceIds: [] })
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
store.images = [makeImage({ id: 1, approvedDeviceIds: [42] })]
await store.setApproval(1, 42, false)
expect(fetchMock.mock.calls[0][1].method).toBe('DELETE')
expect(store.images[0].approvedDeviceIds).toEqual([])
})
it('setApproval throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.setApproval(1, 1, true)).rejects.toThrow('Failed to update approval')
})
it('setApproval does nothing to the list when the image id is not present', async () => {
const updated = makeImage({ id: 100, approvedDeviceIds: [42] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [makeImage({ id: 1 })]
await store.setApproval(100, 42, true)
expect(store.images.find(i => i.id === 100)).toBeUndefined()
})
it('fetchSharedImages includes status in the query when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [], total: 0, page: 1, limit: 20, totalPages: 1 }),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
await store.fetchSharedImages('approved', 2, 10)
const url = String(fetchMock.mock.calls[0][0])
expect(url).toContain('status=approved')
expect(url).toContain('page=2')
expect(url).toContain('limit=10')
})
it('fetchSharedImages omits status when not provided', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [], total: 0, page: 1, limit: 20, totalPages: 1 }),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
await store.fetchSharedImages()
expect(String(fetchMock.mock.calls[0][0])).not.toContain('status=')
})
it('fetchSharedImages throws on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.fetchSharedImages()).rejects.toThrow('Failed to load shared images')
})
it('fetchPendingCount silently no-ops on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
store.pendingCount = 5
await store.fetchPendingCount()
expect(store.pendingCount).toBe(5)
})
it('approveShared does not go negative when pendingCount is already zero', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'approved' }),
}))
const store = useImagesStore()
store.pendingCount = 0
await store.approveShared(1, [42])
expect(store.pendingCount).toBe(0)
})
it('approveShared throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.approveShared(1, [42])).rejects.toThrow('Failed to approve')
})
it('declineShared does not go negative when pendingCount is already zero', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'declined' }),
}))
const store = useImagesStore()
store.pendingCount = 0
await store.declineShared(1)
expect(store.pendingCount).toBe(0)
})
it('declineShared throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.declineShared(1)).rejects.toThrow('Failed to decline')
})
it('shareImage POSTs to the share endpoint with the recipient email', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
await store.shareImage(7, 'a@b.com')
expect(fetchMock).toHaveBeenCalledWith(
'/api/images/7/share',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ recipientEmail: 'a@b.com' }),
}),
)
})
it('shareImage surfaces server-provided error messages', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'rate limited' }),
}))
const store = useImagesStore()
await expect(store.shareImage(1, 'a@b')).rejects.toThrow('rate limited')
})
it('shareImage falls back to a generic message when no body is parseable', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.reject(new Error('not json')),
}))
const store = useImagesStore()
await expect(store.shareImage(1, 'a@b')).rejects.toThrow('Failed to share')
})
}) })
+72
View File
@@ -125,6 +125,78 @@ describe('upload store', () => {
expect(store.stickers[0].id).toBe('b') expect(store.stickers[0].id).toBe('b')
}) })
it('initEdit fetches the original blob and seeds edit state from the image', async () => {
const store = useUploadStore()
const blob = new Blob(['orig'], { type: 'image/jpeg' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
blob: () => Promise.resolve(blob),
}))
const image = {
id: 42,
originalFilename: 'orig.jpg',
originalUrl: '/o/42.jpg',
thumbnailUrl: '/t/42.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [3, 4],
cropParams: { natX: 0, natY: 0, natW: 100, natH: 100 },
cropOrientation: 'portrait',
stickerState: [makeSticker({ id: 's1' })],
} as any
await store.initEdit(image, 7)
expect(store.editingImageId).toBe(42)
expect(store.cropParams).toEqual(image.cropParams)
expect(store.cropOrientation).toBe('portrait')
expect(store.stickers).toHaveLength(1)
expect(store.stickers[0].id).toBe('s1')
expect(store.selectedDeviceIds).toEqual([3, 4])
expect(store.contextDeviceId).toBe(7)
expect(store.originalUrl).toBe('blob:mock-url')
})
it('initEdit handles images with no cropParams or stickers', async () => {
const store = useUploadStore()
const blob = new Blob(['orig'])
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
blob: () => Promise.resolve(blob),
}))
const image = {
id: 1,
originalFilename: 'a.jpg',
originalUrl: '/o/1.jpg',
thumbnailUrl: '/t/1.jpg',
uploadedAt: '',
approvedDeviceIds: [],
cropParams: null,
cropOrientation: null,
stickerState: null,
} as any
await store.initEdit(image)
expect(store.cropParams).toBeNull()
expect(store.cropOrientation).toBeNull()
expect(store.stickers).toEqual([])
expect(store.contextDeviceId).toBeNull()
})
it('setCrop revokes a previous croppedUrl before assigning a new one', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
const params = { natX: 0, natY: 0, natW: 1, natH: 1 }
store.setCrop(new Blob(['a']), params, 'landscape')
const url = store.croppedUrl
expect(url).toBe('blob:mock-url')
// Second call must revoke the previous URL before assigning the new one
store.setCrop(new Blob(['b']), params, 'portrait')
expect(URL.revokeObjectURL).toHaveBeenCalledWith(url)
})
it('cleanup resets all state', () => { it('cleanup resets all state', () => {
const store = useUploadStore() const store = useUploadStore()
const file = new File(['data'], 'photo.jpg') const file = new File(['data'], 'photo.jpg')
+305 -3
View File
@@ -1,16 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices' import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import HomeView from '@/views/HomeView.vue' import HomeView from '@/views/HomeView.vue'
import type { Device } from '@/types' import type { Device } from '@/types'
const routerPush = vi.fn()
// Stub heavy child components so tests focus on HomeView logic // Stub heavy child components so tests focus on HomeView logic
vi.mock('@/components/FrameCard.vue', () => ({ vi.mock('@/components/FrameCard.vue', () => ({
default: { default: {
name: 'FrameCard', name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />', template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation'], props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl'],
emits: ['add-photo', 'edit'], emits: ['add-photo', 'edit'],
}, },
})) }))
@@ -48,7 +51,7 @@ vi.mock('@/components/OrientationPicker.vue', () => ({
// Stub vue-router so HomeView can call useRouter() without a real router // Stub vue-router so HomeView can call useRouter() without a real router
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: routerPush }),
})) }))
// Stub URL.createObjectURL used by upload store // Stub URL.createObjectURL used by upload store
@@ -161,4 +164,303 @@ describe('HomeView', () => {
expect(wrapper.find('.home-view__loading').exists()).toBe(true) expect(wrapper.find('.home-view__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading') expect(wrapper.text()).toContain('Loading')
}) })
// HV-04: add-photo opens a file picker, primes the upload store, and navigates
it('add-photo from a FrameCard primes upload state and routes to /upload', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
routerPush.mockClear()
// Spy on createElement so we can intercept the synthetic file input
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
// Don't actually open a file dialog
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('add-photo', 7)
expect(capturedInput).not.toBeNull()
expect(capturedInput!.type).toBe('file')
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change'))
const upload = useUploadStore()
expect(upload.originalFile).toStrictEqual(file)
expect(upload.contextDeviceId).toBe(7)
expect(routerPush).toHaveBeenCalledWith('/upload')
})
it('add-photo without a chosen file does not navigate', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
routerPush.mockClear()
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('add-photo', 7)
Object.defineProperty(capturedInput, 'files', { value: [], configurable: true })
capturedInput!.onchange?.(new Event('change'))
expect(routerPush).not.toHaveBeenCalled()
})
// HV-05: edit opens the settings sheet pre-filled from the device record
it('edit emits open the settings sheet pre-populated from the device', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeHour: 22, timezone: 'America/Chicago' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('edit', 9)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
// The name input is the first BaseInput stub
const nameInput = wrapper.findComponent({ name: 'BaseInput' })
expect(nameInput.props('modelValue')).toBe('Den')
})
it('edit for an unknown device id is a no-op', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('edit', 999)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(false)
})
// HV-06: saving the sheet calls updateDevice and closes it
it('saving the settings sheet PATCHes via the store and closes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeHour: 4, timezone: 'UTC' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
// Click save (the only button in the sheet stub for now)
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
orientation: 'landscape',
wakeHour: 4,
timezone: 'UTC',
}))
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(false)
})
it('passes status="ok" to the FrameCard when lastSeenAt is recent', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: new Date().toISOString() })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('ok')
})
it('passes status="offline" when lastSeenAt is older than the rotation window', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('offline')
})
it('builds a thumbnail URL when the device has a current image', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, currentImageId: 42 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
})
it('prefers lockedImageId over currentImageId for the thumbnail', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: 42 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=99')
})
it('updates editWakeHour when the user picks a different hour chip', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, wakeHour: 4 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const chips = wrapper.findAll('.home-view__interval-chip')
const chip8pm = chips.find(c => c.text() === '8 PM')!
await chip8pm.trigger('click')
expect(chip8pm.classes()).toContain('home-view__interval-chip--on')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ wakeHour: 20 }))
})
it('saving while no device is being edited is a no-op (defensive guard)', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice())
const wrapper = mountView()
await flushPromises()
// The BaseBottomSheet stub always renders its slot, so the Save button is in
// the DOM even before onEdit is called. Clicking it now exercises the
// editingDevice null-guard.
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).not.toHaveBeenCalled()
})
it('updates editName/orientation/timezone when their components emit changes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeHour: 4, timezone: 'UTC' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', 'New Name')
await wrapper.findComponent({ name: 'OrientationPicker' }).vm.$emit('update:modelValue', 'portrait')
const select = wrapper.find('select.home-view__tz-select')
;(select.element as HTMLSelectElement).value = 'America/New_York'
await select.trigger('change')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
name: 'New Name',
orientation: 'portrait',
timezone: 'America/New_York',
}))
})
it('edit defaults wakeHour to 4 and timezone to UTC when the device has neither', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 5,
name: 'Den',
wakeHour: null,
timezone: null as any,
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
wakeHour: 4,
timezone: 'UTC',
}))
})
it('the settings sheet closes when the underlying bottom-sheet emits close', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
await sheet.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'BaseBottomSheet' }).props('modelValue')).toBe(false)
})
it('saving falls back to the original name when the user clears the field', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Original' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', ' ')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ name: 'Original' }))
})
}) })
+698 -9
View File
@@ -1,11 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { reactive } from 'vue'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images' import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices' import { useDevicesStore } from '@/stores/devices'
import LibraryView from '@/views/LibraryView.vue' import LibraryView from '@/views/LibraryView.vue'
import type { Image, Device } from '@/types' import type { Image, Device } from '@/types'
const routerPush = vi.fn()
// Stub complex child components // Stub complex child components
vi.mock('@/components/BaseBottomSheet.vue', () => ({ vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: { default: {
@@ -40,20 +43,21 @@ vi.mock('@/components/ShareSheet.vue', () => ({
}, },
})) }))
// Stub vue-router // Stub vue-router with a reactive route so watchers fire when query changes
const mockRoute = reactive<{ query: Record<string, string | undefined> }>({ query: {} })
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: routerPush }),
useRoute: () => ({ query: {} }), useRoute: () => mockRoute,
})) }))
// Stub toast store // Stable mocks for toast + upload so individual tests can spy on them
const toastShow = vi.fn()
vi.mock('@/stores/toast', () => ({ vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: vi.fn() }), useToastStore: () => ({ show: toastShow }),
})) }))
const uploadInitEdit = vi.fn()
// Stub upload store
vi.mock('@/stores/upload', () => ({ vi.mock('@/stores/upload', () => ({
useUploadStore: () => ({ initEdit: vi.fn() }), useUploadStore: () => ({ initEdit: uploadInitEdit }),
})) }))
const makeImage = (overrides: Partial<Image> = {}): Image => ({ const makeImage = (overrides: Partial<Image> = {}): Image => ({
@@ -93,6 +97,10 @@ describe('LibraryView', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
pinia = createPinia() pinia = createPinia()
setActivePinia(pinia) setActivePinia(pinia)
mockRoute.query = {}
toastShow.mockClear()
uploadInitEdit.mockClear()
routerPush.mockClear()
// Default fetch stub — returns empty lists so onMounted doesn't error // Default fetch stub — returns empty lists so onMounted doesn't error
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@@ -236,6 +244,66 @@ describe('LibraryView', () => {
expect(wrapper.text()).toContain('No photos yet') expect(wrapper.text()).toContain('No photos yet')
}) })
// LV-08: Deep-link via ?tab=shared — Shared sub-tab is initially active
it('starts on Shared tab when route enters with ?tab=shared', async () => {
mockRoute.query = { tab: 'shared' }
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
expect(sharedTab?.attributes('aria-selected')).toBe('true')
expect(wrapper.find('.library__subtabs').exists()).toBe(true)
})
// LV-09: Watcher — switching the route query swaps the active tab and loads shared
it('switches active tab when route.query.tab changes', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const initialAll = wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')
expect(initialAll?.attributes('aria-selected')).toBe('true')
expect(fetchSharedSpy).not.toHaveBeenCalled()
mockRoute.query = { tab: 'shared' }
await flushPromises()
const sharedTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'Shared')
expect(sharedTab?.attributes('aria-selected')).toBe('true')
expect(fetchSharedSpy).toHaveBeenCalled()
})
// LV-10: Unknown tab values fall back to 'all'
it('falls back to All when route.query.tab is an unknown value', async () => {
mockRoute.query = { tab: 'bogus' }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const allTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')
expect(allTab?.attributes('aria-selected')).toBe('true')
})
// LV-07b: Empty state on shared sub-tab (pending) // LV-07b: Empty state on shared sub-tab (pending)
it('shows shared empty state when no shared items exist', async () => { it('shows shared empty state when no shared items exist', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 } const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
@@ -259,4 +327,625 @@ describe('LibraryView', () => {
expect(wrapper.find('.library__shared-empty').exists()).toBe(true) expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
}) })
// LV-11: Edit (loadShared cropped image) routes to /upload after initEdit
it('clicking edit calls upload.initEdit and navigates to /upload', async () => {
uploadInitEdit.mockResolvedValue(undefined)
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 7 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const editBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.startsWith('Edit'))!
await editBtn.trigger('click')
await flushPromises()
expect(uploadInitEdit).toHaveBeenCalled()
expect(routerPush).toHaveBeenCalledWith('/upload')
})
it('shows a toast when edit fails to load', async () => {
uploadInitEdit.mockRejectedValue(new Error('boom'))
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 7 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.startsWith('Edit'))!.trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith(expect.stringContaining('Could not load'), 'error')
})
it('ignores a second edit click while another edit is already in flight', async () => {
let resolveEdit: () => void = () => {}
uploadInitEdit.mockImplementation(() => new Promise<void>(r => { resolveEdit = r }))
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const editButtons = wrapper.findAll('.library__action-btn')
.filter(b => b.attributes('aria-label')?.startsWith('Edit'))
expect(editButtons).toHaveLength(2)
await editButtons[0].trigger('click')
// Second click on a *different* photo's edit button — startEdit should
// see editingId is set and early-return without invoking initEdit again.
await editButtons[1].trigger('click')
expect(uploadInitEdit).toHaveBeenCalledTimes(1)
resolveEdit()
await flushPromises()
})
// LV-12: Delete confirmation flow (success + failure)
it('confirming delete calls deleteImage and shows success toast', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const deleteSpy = vi.spyOn(imagesStore, 'deleteImage').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const deleteBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label') === 'Delete photo')!
await deleteBtn.trigger('click')
await flushPromises()
// Sheet now open with a Delete button
const sheetDelete = wrapper.findAll('button').find(b => b.text() === 'Delete')!
await sheetDelete.trigger('click')
await flushPromises()
expect(deleteSpy).toHaveBeenCalledWith(9)
expect(toastShow).toHaveBeenCalledWith('Photo deleted', 'success')
})
it('shows an error toast when delete fails', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'deleteImage').mockRejectedValue(new Error('nope'))
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label') === 'Delete photo')!.trigger('click')
await wrapper.findAll('button').find(b => b.text() === 'Delete')!.trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Delete failed', 'error')
})
// LV-13: Approval toggle success + failure
it('clicking an approval chip toggles approval via the store', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const setApproval = vi.spyOn(imagesStore, 'setApproval').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const chip = wrapper.find('.library__approval-chip')
await chip.trigger('click')
await flushPromises()
expect(setApproval).toHaveBeenCalledWith(1, 4, true)
})
it('toasts when approval toggle fails', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4 })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'setApproval').mockRejectedValue(new Error('nope'))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__approval-chip').trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Failed to update frame approval', 'error')
})
// LV-14: Lock toggle — lock + unlock + failure
it('clicking a lock chip locks the image to the device', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall', lockedImageId: null })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const lockSpy = vi.spyOn(devicesStore, 'lockImage').mockResolvedValue(makeDevice({ id: 4, lockedImageId: 1 }))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__lock-chip').trigger('click')
await flushPromises()
expect(lockSpy).toHaveBeenCalledWith(4, 1)
})
it('clicking a lock chip on an already-locked photo unlocks it', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, lockedImageId: 1 })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const unlockSpy = vi.spyOn(devicesStore, 'unlockImage').mockResolvedValue(makeDevice({ id: 4, lockedImageId: null }))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__lock-chip').trigger('click')
await flushPromises()
expect(unlockSpy).toHaveBeenCalledWith(4)
})
it('toasts when lock toggle fails', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4 })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'lockImage').mockRejectedValue(new Error('nope'))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__lock-chip').trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Failed to update lock', 'error')
})
// LV-15: Mismatch warning shown when photo orientation differs from device
it('shows the mismatch warning when an approved device orientation differs', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'portrait' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: 'landscape',
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
})
it('infers landscape from cropParams when natW >= natH', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'portrait' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: { natX: 0, natY: 0, natW: 200, natH: 100 } as any,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
// photo inferred landscape, device portrait → mismatch warn
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
})
it('infers crop orientation from cropParams when cropOrientation is missing', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'landscape' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: { natX: 0, natY: 0, natW: 100, natH: 200 } as any,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
})
// LV-16: Pagination buttons in the shared tab
it('shared pagination next button requests the following page', async () => {
const page1: any = { items: [{ id: 1, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/1.jpg' }], total: 30, page: 1, limit: 1, totalPages: 30 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(page1)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const next = wrapper.findAll('.library__page-btn').find(b => b.text().includes('Next'))!
await next.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('pending', 2)
})
it('shared pagination prev button requests the previous page when not on page 1', async () => {
const page2: any = { items: [{ id: 1, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/1.jpg' }], total: 30, page: 2, limit: 1, totalPages: 30 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(page2)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const prev = wrapper.findAll('.library__page-btn').find(b => b.text().includes('Prev'))!
expect(prev.attributes('disabled')).toBeUndefined()
await prev.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('pending', 1)
})
// LV-17: Switching between Pending/Approved/Declined sub-tabs reloads with new status
it('switching shared sub-tabs reloads with the new status', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approved = wrapper.findAll('.library__subtab').find(b => b.text() === 'Approved')!
await approved.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('approved', 1)
const declined = wrapper.findAll('.library__subtab').find(b => b.text() === 'Declined')!
await declined.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('declined', 1)
})
// LV-18: onSharedUpdated patches the matching item in the rendered list
it('updated event from ApproveCard replaces the matching shared item', async () => {
const original: any = { id: 5, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/5.jpg' }
const updated: any = { ...original, status: 'approved' }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue({ items: [original], total: 1, page: 1, limit: 20, totalPages: 1 })
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approveCard = wrapper.findComponent({ name: 'ApproveCard' })
expect(approveCard.props('item')).toEqual(original)
await approveCard.vm.$emit('updated', updated)
await flushPromises()
expect(wrapper.findComponent({ name: 'ApproveCard' }).props('item')).toEqual(updated)
})
// LV-19: empty-state copy reflects the active sub-tab
it('renders the approved empty-state copy when on the approved sub-tab', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approved = wrapper.findAll('.library__subtab').find(b => b.text() === 'Approved')!
await approved.trigger('click')
await flushPromises()
expect(wrapper.find('.library__shared-empty').text()).toContain('No approved photos')
expect(wrapper.find('.library__shared-empty').text()).toContain('added to a frame')
})
it('renders the declined empty-state copy when on the declined sub-tab', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const declined = wrapper.findAll('.library__subtab').find(b => b.text() === 'Declined')!
await declined.trigger('click')
await flushPromises()
expect(wrapper.find('.library__shared-empty').text()).toContain('No declined photos')
})
// LV-29: main loading state
it('shows the main loading row when imagesStore.loading is true', async () => {
const imagesStore = useImagesStore()
imagesStore.loading = true
vi.spyOn(imagesStore, 'fetchImages').mockReturnValue(new Promise(() => {}))
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading')
})
// LV-30: route.query.tab transitions FROM shared back to all — watcher's tab !== 'shared' branch
it('switching the route query off "shared" updates active tab without reloading shared', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
expect(fetchSharedSpy).toHaveBeenCalledTimes(1)
mockRoute.query = { tab: 'mine' }
await flushPromises()
// Query change: was 'shared', now 'mine' — watcher runs, activeTab is updated but no extra reload of shared
expect(fetchSharedSpy).toHaveBeenCalledTimes(1)
const mineTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'Mine')!
expect(mineTab.attributes('aria-selected')).toBe('true')
})
// LV-25b: query.tab change that resolves to the same active tab is a no-op
it('does not reload shared when route.query.tab changes but resolves to the same tab', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(fetchSharedSpy).not.toHaveBeenCalled() // active tab is 'all'
// bogus and missing both resolve to 'all' — no reload, watcher returns early
mockRoute.query = { tab: 'bogus' }
await flushPromises()
expect(fetchSharedSpy).not.toHaveBeenCalled()
expect(wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')!.attributes('aria-selected')).toBe('true')
})
// LV-26: ApproveCard updates with an unknown id are ignored
it('updated event with an unknown id leaves the shared list untouched', async () => {
const item: any = { id: 5, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/5.jpg' }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue({ items: [item], total: 1, page: 1, limit: 20, totalPages: 1 })
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approveCard = wrapper.findComponent({ name: 'ApproveCard' })
await approveCard.vm.$emit('updated', { ...item, id: 999, status: 'approved' })
await flushPromises()
expect(wrapper.findAllComponents({ name: 'ApproveCard' })).toHaveLength(1)
expect(wrapper.findComponent({ name: 'ApproveCard' }).props('item').id).toBe(5)
})
// LV-27: photoCropOrientation falls through to null when cropParams has zero dims
it('does not render warn when cropOrientation is null and cropParams has zero dims', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'landscape' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: { natX: 0, natY: 0, natW: 0, natH: 0 } as any,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
})
// LV-25c: defensive guard — clicking the sheet Delete button before confirmDelete is a no-op
it('clicking sheet Delete with no pending id is a no-op (defensive guard)', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const deleteSpy = vi.spyOn(imagesStore, 'deleteImage').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
// The BaseBottomSheet stub always renders its slot, so the sheet Delete
// button is in the DOM. Click it without first triggering confirmDelete
// — exercises the !deletingId.value guard in doDelete.
const sheetDelete = wrapper.findAll('button').find(b => b.text() === 'Delete')!
await sheetDelete.trigger('click')
await flushPromises()
expect(deleteSpy).not.toHaveBeenCalled()
})
// LV-25: closing the delete sheet via the underlying bottom-sheet
it('closes the delete sheet when the bottom-sheet emits update:modelValue=false', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label') === 'Delete photo')!.trigger('click')
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
await sheet.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'BaseBottomSheet' }).props('modelValue')).toBe(false)
})
// LV-21: clicking the orientation-mismatch warning starts an edit for that device
it('clicking the warn button calls initEdit with the mismatched device id', async () => {
uploadInitEdit.mockResolvedValue(undefined)
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall', orientation: 'portrait' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4], cropOrientation: 'landscape' })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const warn = wrapper.find('.library__action-btn--warn')
await warn.trigger('click')
await flushPromises()
expect(uploadInitEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 4)
expect(routerPush).toHaveBeenCalledWith('/upload')
})
// LV-22: ShareSheet emits close — v-model setter runs
it('closes the share sheet when ShareSheet emits update:modelValue=false', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 5 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const shareBtn = wrapper.findAll('.library__action-btn').find(b => b.attributes('aria-label')?.includes('Share'))!
await shareBtn.trigger('click')
await flushPromises()
const sheet = wrapper.findComponent({ name: 'ShareSheet' })
expect(sheet.exists()).toBe(true)
await sheet.vm.$emit('update:modelValue', false)
await flushPromises()
// shareSheetOpen flips back to false; image id remains, so the component is still mounted
expect(wrapper.findComponent({ name: 'ShareSheet' }).props('modelValue')).toBe(false)
})
// LV-23: photo whose approved devices all have matching orientation — no warn
it('does not render the warn button when all approved devices match the photo orientation', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 4, orientation: 'landscape' }),
makeDevice({ id: 5, orientation: 'landscape' }),
]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4, 5],
cropOrientation: 'landscape',
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
})
// LV-24: image with no crop info → mismatchedDevice returns null up front
it('does not render the warn button when the image has no crop info', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4 })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: null,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
})
// LV-20: shared-loading state
it('shows a loading row while shared items are being fetched', async () => {
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockReturnValue(new Promise(() => {}))
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__loading').exists()).toBe(true)
})
}) })
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import SettingsView from '@/views/SettingsView.vue'
import { useAuthStore } from '@/stores/auth'
import { THEMES } from '@/composables/useTheme'
describe('SettingsView', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders one swatch per theme and the user email', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
expect(wrapper.findAll('.theme-swatch')).toHaveLength(THEMES.length)
expect(wrapper.text()).toContain('matt@example.com')
})
it('marks the user current theme as the active swatch', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'ocean-dusk', timezone: 'UTC' })
const wrapper = mount(SettingsView)
const active = wrapper.find('.theme-swatch--active')
expect(active.attributes('aria-label')).toBe('Ocean Dusk')
expect(active.attributes('aria-checked')).toBe('true')
})
it('falls back to warm-craft as active when user has no theme set', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: null, timezone: 'UTC' })
const wrapper = mount(SettingsView)
expect(wrapper.find('.theme-swatch--active').attributes('aria-label')).toBe('Warm Craft')
})
it('clicking a swatch saves the theme via PATCH /api/user/theme', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
const swatch = wrapper.findAll('.theme-swatch').find(s => s.attributes('aria-label') === 'Sage & Cream')!
await swatch.trigger('click')
expect(fetchMock).toHaveBeenCalledWith(
'/api/user/theme',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ theme: 'sage-cream' }),
}),
)
vi.unstubAllGlobals()
})
it('renders a Sign out link to /logout', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
const logout = wrapper.find('a.settings__logout')
expect(logout.text()).toBe('Sign out')
expect(logout.attributes('href')).toBe('/logout')
})
})
+512
View File
@@ -0,0 +1,512 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import UploadView from '@/views/UploadView.vue'
import { useUploadStore } from '@/stores/upload'
import { useDevicesStore } from '@/stores/devices'
import { useImagesStore } from '@/stores/images'
import type { Device } from '@/types'
const routerReplace = vi.fn()
vi.mock('@/components/CropEditor.vue', () => ({
default: {
name: 'CropEditor',
template: '<div class="crop-editor-stub" />',
props: ['src', 'orientation', 'deviceName', 'initialParams', 'initialOrientation'],
emits: ['crop'],
},
}))
vi.mock('@/components/StickerCanvas.vue', () => ({
default: {
name: 'StickerCanvas',
template: '<div class="sticker-canvas-stub" />',
props: ['croppedUrl', 'orientation', 'stickers'],
emits: ['add-sticker', 'update-sticker', 'remove-sticker', 'done'],
},
}))
vi.mock('@/components/DevicePicker.vue', () => ({
default: {
name: 'DevicePicker',
template: '<div class="device-picker-stub" />',
props: ['modelValue', 'devices', 'selected', 'uploading'],
emits: ['update:modelValue', 'update:selected', 'confirm'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ replace: routerReplace, push: vi.fn() }),
}))
const toastShow = vi.fn()
vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: toastShow }),
}))
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'UTC',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
currentImageId: null,
...overrides,
})
function primeUploadStore(file?: File) {
const upload = useUploadStore()
upload.originalFile = file ?? new File(['x'], 'orig.jpg', { type: 'image/jpeg' })
upload.originalUrl = 'blob:original'
return upload
}
describe('UploadView', () => {
beforeEach(() => {
setActivePinia(createPinia())
routerReplace.mockClear()
toastShow.mockClear()
vi.unstubAllGlobals()
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock'),
revokeObjectURL: vi.fn(),
})
})
it('redirects to / when no upload is in progress', async () => {
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mount(UploadView)
await flushPromises()
expect(routerReplace).toHaveBeenCalledWith('/')
})
it('starts on the crop step with the original URL passed to CropEditor', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const crop = wrapper.findComponent({ name: 'CropEditor' })
expect(crop.exists()).toBe(true)
expect(crop.props('src')).toBe('blob:original')
expect(wrapper.text()).toContain('Crop photo')
})
it('uses "Edit crop" as the step label when editing', async () => {
const upload = primeUploadStore()
upload.editingImageId = 7
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.text()).toContain('Edit crop')
})
it('falls back to landscape orientation when no devices are loaded', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('landscape')
})
it('uses the context device orientation when contextDeviceId matches', async () => {
const upload = primeUploadStore()
upload.contextDeviceId = 2
const devices = useDevicesStore()
devices.devices = [makeDevice({ id: 1, orientation: 'landscape' }), makeDevice({ id: 2, orientation: 'portrait' })]
vi.spyOn(devices, 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('portrait')
expect(wrapper.findComponent({ name: 'CropEditor' }).props('deviceName')).toBe(devices.devices[1].name)
})
it('uses the first device as the context when no contextDeviceId is set', async () => {
primeUploadStore()
const devices = useDevicesStore()
devices.devices = [makeDevice({ id: 1, orientation: 'portrait' })]
vi.spyOn(devices, 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('portrait')
})
it('crop emit advances to stickers step and stores the crop on the upload store', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const blob = new Blob(['x'])
const params = { natX: 0, natY: 0, natW: 100, natH: 50 }
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params, orientation: 'landscape' })
await flushPromises()
expect(wrapper.findComponent({ name: 'StickerCanvas' }).exists()).toBe(true)
expect(wrapper.text()).toContain('Add stickers')
})
it('skip on stickers step opens the device picker for new uploads', async () => {
const upload = primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const blob = new Blob(['x'])
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape' })
upload.croppedBlob = blob
await flushPromises()
const skip = wrapper.find('.upload-view__skip')
await skip.trigger('click')
await flushPromises()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true)
})
it('skip on stickers triggers reprocess directly when editing', async () => {
const upload = primeUploadStore()
upload.editingImageId = 11
const devices = useDevicesStore()
vi.spyOn(devices, 'fetchDevices').mockResolvedValue()
const images = useImagesStore()
const reprocess = vi.spyOn(images, 'reprocessImage').mockResolvedValue({ id: 11 } as any)
const wrapper = mount(UploadView)
await flushPromises()
const blob = new Blob(['x'])
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape' })
upload.croppedBlob = blob
await flushPromises()
await wrapper.find('.upload-view__skip').trigger('click')
await flushPromises()
expect(reprocess).toHaveBeenCalled()
expect(wrapper.text()).toContain('Photo updated!')
})
it('skip is a no-op when there is no cropped blob yet', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
// Force into stickers without having a croppedBlob
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: null as any, params: null as any, orientation: 'landscape' })
// Even if we click skip, no DevicePicker should open
const skip = wrapper.find('.upload-view__skip')
if (skip.exists()) await skip.trigger('click')
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false)
})
it('stickers done opens the picker for new uploads', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true)
})
it('stickers done triggers reprocess directly when editing', async () => {
const upload = primeUploadStore()
upload.editingImageId = 22
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const reprocess = vi.spyOn(useImagesStore(), 'reprocessImage').mockResolvedValue({ id: 22 } as any)
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
expect(reprocess).toHaveBeenCalled()
expect(wrapper.text()).toContain('Photo updated!')
})
it('back from crop cleans up and routes to /library', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.find('.upload-view__back').trigger('click')
expect(routerReplace).toHaveBeenCalledWith('/library')
})
it('back from stickers returns to the crop step', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
expect(wrapper.text()).toContain('Add stickers')
await wrapper.find('.upload-view__back').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Crop photo')
})
it('confirm in the device picker uploads, sets approvals, and shows the done step', async () => {
const upload = primeUploadStore()
upload.selectedDeviceIds = [1, 2]
const images = useImagesStore()
const uploadSpy = vi.spyOn(images, 'uploadImage').mockResolvedValue({ id: 100 } as any)
const approvalSpy = vi.spyOn(images, 'setApproval').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(uploadSpy).toHaveBeenCalled()
expect(approvalSpy).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Photo added!')
})
it('upload errors surface as a toast', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockRejectedValue(new Error('disk full'))
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('disk full', 'error')
})
it('falls back to a generic message for non-Error rejections', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockRejectedValue('weird')
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Upload failed', 'error')
})
it('Done button on the success step routes to /library', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
vi.spyOn(useImagesStore(), 'setApproval').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
routerReplace.mockClear()
await wrapper.find('.upload-view__done-btn').trigger('click')
expect(routerReplace).toHaveBeenCalledWith('/library')
})
it('updates selectedDeviceIds when the picker emits update:selected', async () => {
const upload = primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('update:selected', [3, 4])
expect(upload.selectedDeviceIds).toEqual([3, 4])
})
it('forwards sticker emits to the upload store', async () => {
const upload = primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
const canvas = wrapper.findComponent({ name: 'StickerCanvas' })
const sticker = { id: 's1', type: 'emoji', x: 0, y: 0, scale: 1, rotation: 0 } as any
await canvas.vm.$emit('add-sticker', sticker)
expect(upload.stickers).toHaveLength(1)
await canvas.vm.$emit('update-sticker', 's1', { x: 9 })
expect(upload.stickers[0].x).toBe(9)
await canvas.vm.$emit('remove-sticker', 's1')
expect(upload.stickers).toHaveLength(0)
})
it('closes the device picker when it emits update:modelValue=false', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const picker = wrapper.findComponent({ name: 'DevicePicker' })
expect(picker.props('modelValue')).toBe(false)
await picker.vm.$emit('update:modelValue', true)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true)
await picker.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false)
})
it('confirm fired before any stickers/crop is a no-op (defensive guard)', async () => {
primeUploadStore()
const uploadSpy = vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
// Picker exists but finalBlob is null — confirm should early-return
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(uploadSpy).not.toHaveBeenCalled()
})
it('reprocess on edit forwards undefined when cropParams/cropOrientation are null', async () => {
const upload = primeUploadStore()
upload.editingImageId = 33
upload.cropParams = null
upload.cropOrientation = null
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const reprocess = vi.spyOn(useImagesStore(), 'reprocessImage').mockResolvedValue({ id: 33 } as any)
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
// After crop, upload.cropParams is set by setCrop. Reset to null to exercise null branch.
upload.cropParams = null
upload.cropOrientation = null
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
expect(reprocess).toHaveBeenCalledWith(33, expect.any(File), expect.objectContaining({
cropParams: undefined,
cropOrientation: undefined,
}))
})
it('uploadImage on a new upload forwards undefined for null optional fields', async () => {
const upload = primeUploadStore()
upload.originalFile = null
upload.cropParams = null
upload.cropOrientation = null
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const uploadSpy = vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
// No originalFile → onMounted will redirect; instead set it after the redirect would have run
upload.originalFile = new File(['x'], 'x.jpg')
upload.originalUrl = 'blob:x'
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
// Reset to null after onCrop sets these via the store's setCrop
upload.cropParams = null
upload.cropOrientation = null
upload.originalFile = null
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(uploadSpy).toHaveBeenCalledWith(expect.any(File), expect.objectContaining({
original: undefined,
cropParams: undefined,
cropOrientation: undefined,
}))
})
it('does not render the device picker when in edit mode', async () => {
const upload = primeUploadStore()
upload.editingImageId = 12
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'DevicePicker' }).exists()).toBe(false)
})
it('hides the back button on the done step', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
vi.spyOn(useImagesStore(), 'setApproval').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(wrapper.find('.upload-view__back').exists()).toBe(false)
expect(wrapper.text()).toContain('Added')
})
})
+15 -4
View File
@@ -203,7 +203,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useImagesStore } from '@/stores/images' import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices' import { useDevicesStore } from '@/stores/devices'
@@ -229,7 +229,18 @@ const TABS = [
] as const ] as const
type Tab = typeof TABS[number]['id'] type Tab = typeof TABS[number]['id']
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all') function tabFromQuery(value: unknown): Tab {
return TABS.some(t => t.id === value) ? (value as Tab) : 'all'
}
const activeTab = ref<Tab>(tabFromQuery(route.query.tab))
watch(() => route.query.tab, (next) => {
const tab = tabFromQuery(next)
if (tab === activeTab.value) return
activeTab.value = tab
if (tab === 'shared') loadShared(sharedTab.value)
})
const SHARED_TABS = [ const SHARED_TABS = [
{ id: 'pending', label: 'Pending' }, { id: 'pending', label: 'Pending' },
@@ -383,13 +394,13 @@ async function doDelete() {
<style scoped lang="scss"> <style scoped lang="scss">
.library { .library {
padding-bottom: calc(64px + var(--space-4)); padding-bottom: calc(var(--bottom-nav-height) + var(--space-4));
&__tabs { &__tabs {
display: flex; display: flex;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
position: sticky; position: sticky;
top: 0; top: env(safe-area-inset-top);
background: var(--color-bg); background: var(--color-bg);
z-index: 5; z-index: 5;
} }
+1 -1
View File
@@ -54,7 +54,7 @@ function select(themeId: string) {
<style scoped lang="scss"> <style scoped lang="scss">
.settings { .settings {
padding: var(--space-4) var(--space-4) calc(64px + var(--space-6)); padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));
max-width: 480px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
+2
View File
@@ -230,6 +230,8 @@ function finish() {
background: var(--color-bg); background: var(--color-bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
&__header { &__header {
flex-shrink: 0; flex-shrink: 0;
+4
View File
@@ -21,11 +21,15 @@ export default defineConfig({
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'html'], reporter: ['text', 'html'],
include: ['src/**/*.{ts,vue}'],
exclude: [ exclude: [
'src/components/CropEditor.vue', 'src/components/CropEditor.vue',
'src/components/StickerCanvas.vue', 'src/components/StickerCanvas.vue',
'src/assets/**', 'src/assets/**',
'src/test/**', 'src/test/**',
'src/types/**',
'src/main.ts',
'src/router/**',
] ]
} }
} }
@@ -1 +1 @@
import{C as e,H as t,M as n,P as r,R as i,_ as a,d as o,f as s,k as c,p as l,r as u,s as d,t as f,u as p,v as m,w as h,z as g}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{c as _,d as v,f as y}from"./index-DCUs53vX.js";var b=u(`devices`,()=>{let e=t([]),n=t(!1),r=t(null);async function i(){n.value=!0,r.value=null;try{let t=await fetch(`/api/devices`);if(!t.ok)throw Error(`Failed to load devices`);e.value=await t.json()}catch(e){r.value=e instanceof Error?e.message:`Unknown error`}finally{n.value=!1}}async function a(t,n){let r=await fetch(`/api/devices/${t}`,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:JSON.stringify(n)});if(!r.ok)throw Error(`Failed to update device`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function o(t,n){let r=await fetch(`/api/devices/${t}/lock`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({imageId:n})});if(!r.ok)throw Error(`Failed to lock image`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function s(t){let n=await fetch(`/api/devices/${t}/lock`,{method:`DELETE`});if(!n.ok)throw Error(`Failed to unlock`);let r=await n.json(),i=e.value.findIndex(e=>e.id===t);return i!==-1&&(e.value[i]=r),r}return{devices:e,loading:n,error:r,fetchDevices:i,updateDevice:a,lockImage:o,unlockImage:s}}),x=u(`upload`,()=>{let e=t(null),n=t(null),r=t(null),i=t(null),a=t(null),o=t(null),s=t([]),c=t(null),l=t([]),u=t(null);function d(t,r){_(),e.value=t,n.value=URL.createObjectURL(t),c.value=r??null,l.value=r?[r]:[]}async function f(t,r){_();let i=await(await fetch(t.originalUrl)).blob();e.value=new File([i],t.originalFilename,{type:i.type}),n.value=URL.createObjectURL(i),u.value=t.id,a.value=t.cropParams??null,o.value=t.cropOrientation??null,s.value=t.stickerState?[...t.stickerState]:[],l.value=t.approvedDeviceIds,c.value=r??null}function p(e,t,n){i.value&&URL.revokeObjectURL(i.value),r.value=e,i.value=URL.createObjectURL(e),a.value=t,o.value=n}function m(e){s.value=[...s.value,e]}function h(e,t){s.value=s.value.map(n=>n.id===e?{...n,...t}:n)}function g(e){s.value=s.value.filter(t=>t.id!==e)}function _(){n.value&&URL.revokeObjectURL(n.value),i.value&&URL.revokeObjectURL(i.value),e.value=null,n.value=null,r.value=null,i.value=null,a.value=null,o.value=null,s.value=[],c.value=null,l.value=[],u.value=null}return{originalFile:e,originalUrl:n,croppedBlob:r,croppedUrl:i,cropParams:a,cropOrientation:o,stickers:s,contextDeviceId:c,selectedDeviceIds:l,editingImageId:u,init:d,initEdit:f,setCrop:p,addSticker:m,updateSticker:h,removeSticker:g,cleanup:_}}),S={key:0,class:`btn__spinner`,"aria-hidden":`true`},C=f(m({__name:`BaseButton`,props:{variant:{default:`primary`},tag:{default:`button`},type:{default:`button`},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1}},setup(t){return(i,a)=>(c(),o(r(t.tag),e({type:t.tag===`button`?t.type:void 0,disabled:t.disabled||t.loading,class:[`btn`,`btn--${t.variant}`,{"btn--loading":t.loading}]},i.$attrs),{default:g(()=>[t.loading?(c(),l(`span`,S)):s(``,!0),n(i.$slots,`default`,{},void 0,!0)]),_:3},16,[`type`,`disabled`,`class`]))}}),[[`__scopeId`,`data-v-7d3f1e61`]]),w=[`aria-label`],T=f(m({__name:`BaseBottomSheet`,props:{modelValue:{type:Boolean},label:{}},emits:[`update:modelValue`],setup(e,{emit:r}){let u=e,f=r,m=t(null),b=null;function x(){f(`update:modelValue`,!1)}return i(()=>u.modelValue,async e=>{e?(b=document.activeElement,await h(),m.value?.focus()):(b?.focus(),b=null)}),(t,r)=>(c(),o(d,{to:`body`},[a(_,{name:`sheet`},{default:g(()=>[e.modelValue?(c(),l(`div`,{key:0,class:`sheet-overlay`,role:`dialog`,"aria-label":e.label,"aria-modal":`true`,onClick:y(x,[`self`]),onKeydown:v(x,[`esc`])},[p(`div`,{ref_key:`sheetRef`,ref:m,class:`sheet`,tabindex:`-1`},[r[0]||=p(`div`,{class:`sheet__handle`,"aria-hidden":`true`},null,-1),n(t.$slots,`default`,{},void 0,!0)],512)],40,w)):s(``,!0)]),_:3})]))}}),[[`__scopeId`,`data-v-81ce2dd1`]]);export{b as i,C as n,x as r,T as t}; import{C as e,H as t,M as n,P as r,R as i,_ as a,d as o,f as s,k as c,p as l,r as u,s as d,t as f,u as p,v as m,w as h,z as g}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{c as _,d as v,f as y}from"./index-D13oAsTG.js";var b=u(`devices`,()=>{let e=t([]),n=t(!1),r=t(null);async function i(){n.value=!0,r.value=null;try{let t=await fetch(`/api/devices`);if(!t.ok)throw Error(`Failed to load devices`);e.value=await t.json()}catch(e){r.value=e instanceof Error?e.message:`Unknown error`}finally{n.value=!1}}async function a(t,n){let r=await fetch(`/api/devices/${t}`,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:JSON.stringify(n)});if(!r.ok)throw Error(`Failed to update device`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function o(t,n){let r=await fetch(`/api/devices/${t}/lock`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({imageId:n})});if(!r.ok)throw Error(`Failed to lock image`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function s(t){let n=await fetch(`/api/devices/${t}/lock`,{method:`DELETE`});if(!n.ok)throw Error(`Failed to unlock`);let r=await n.json(),i=e.value.findIndex(e=>e.id===t);return i!==-1&&(e.value[i]=r),r}return{devices:e,loading:n,error:r,fetchDevices:i,updateDevice:a,lockImage:o,unlockImage:s}}),x=u(`upload`,()=>{let e=t(null),n=t(null),r=t(null),i=t(null),a=t(null),o=t(null),s=t([]),c=t(null),l=t([]),u=t(null);function d(t,r){_(),e.value=t,n.value=URL.createObjectURL(t),c.value=r??null,l.value=r?[r]:[]}async function f(t,r){_();let i=await(await fetch(t.originalUrl)).blob();e.value=new File([i],t.originalFilename,{type:i.type}),n.value=URL.createObjectURL(i),u.value=t.id,a.value=t.cropParams??null,o.value=t.cropOrientation??null,s.value=t.stickerState?[...t.stickerState]:[],l.value=t.approvedDeviceIds,c.value=r??null}function p(e,t,n){i.value&&URL.revokeObjectURL(i.value),r.value=e,i.value=URL.createObjectURL(e),a.value=t,o.value=n}function m(e){s.value=[...s.value,e]}function h(e,t){s.value=s.value.map(n=>n.id===e?{...n,...t}:n)}function g(e){s.value=s.value.filter(t=>t.id!==e)}function _(){n.value&&URL.revokeObjectURL(n.value),i.value&&URL.revokeObjectURL(i.value),e.value=null,n.value=null,r.value=null,i.value=null,a.value=null,o.value=null,s.value=[],c.value=null,l.value=[],u.value=null}return{originalFile:e,originalUrl:n,croppedBlob:r,croppedUrl:i,cropParams:a,cropOrientation:o,stickers:s,contextDeviceId:c,selectedDeviceIds:l,editingImageId:u,init:d,initEdit:f,setCrop:p,addSticker:m,updateSticker:h,removeSticker:g,cleanup:_}}),S={key:0,class:`btn__spinner`,"aria-hidden":`true`},C=f(m({__name:`BaseButton`,props:{variant:{default:`primary`},tag:{default:`button`},type:{default:`button`},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1}},setup(t){return(i,a)=>(c(),o(r(t.tag),e({type:t.tag===`button`?t.type:void 0,disabled:t.disabled||t.loading,class:[`btn`,`btn--${t.variant}`,{"btn--loading":t.loading}]},i.$attrs),{default:g(()=>[t.loading?(c(),l(`span`,S)):s(``,!0),n(i.$slots,`default`,{},void 0,!0)]),_:3},16,[`type`,`disabled`,`class`]))}}),[[`__scopeId`,`data-v-7d3f1e61`]]),w=[`aria-label`],T=f(m({__name:`BaseBottomSheet`,props:{modelValue:{type:Boolean},label:{}},emits:[`update:modelValue`],setup(e,{emit:r}){let u=e,f=r,m=t(null),b=null;function x(){f(`update:modelValue`,!1)}return i(()=>u.modelValue,async e=>{e?(b=document.activeElement,await h(),m.value?.focus()):(b?.focus(),b=null)}),(t,r)=>(c(),o(d,{to:`body`},[a(_,{name:`sheet`},{default:g(()=>[e.modelValue?(c(),l(`div`,{key:0,class:`sheet-overlay`,role:`dialog`,"aria-label":e.label,"aria-modal":`true`,onClick:y(x,[`self`]),onKeydown:v(x,[`esc`])},[p(`div`,{ref_key:`sheetRef`,ref:m,class:`sheet`,tabindex:`-1`},[r[0]||=p(`div`,{class:`sheet__handle`,"aria-hidden":`true`},null,-1),n(t.$slots,`default`,{},void 0,!0)],512)],40,w)):s(``,!0)]),_:3})]))}}),[[`__scopeId`,`data-v-9ba072ca`]]);export{b as i,C as n,x as r,T as t};
@@ -1 +1 @@
.btn[data-v-7d3f1e61]{justify-content:center;align-items:center;gap:var(--space-2);min-height:var(--touch-min);padding:0 var(--space-5);border-radius:var(--radius-full);font-family:var(--font-family);font-size:var(--text-base);cursor:pointer;transition:opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);white-space:nowrap;border:none;font-weight:600;line-height:1;text-decoration:none;display:inline-flex}.btn[data-v-7d3f1e61]:disabled{opacity:.4;cursor:not-allowed}.btn[data-v-7d3f1e61]:not(:disabled):active{transform:scale(.96)}.btn--primary[data-v-7d3f1e61]{background:var(--color-primary);color:var(--color-primary-fg)}.btn--secondary[data-v-7d3f1e61]{background:var(--color-secondary);color:var(--color-secondary-fg);border:1px solid var(--color-border)}.btn--ghost[data-v-7d3f1e61]{color:var(--color-text);border:1px solid var(--color-border);background:0 0}.btn--destructive[data-v-7d3f1e61]{background:var(--color-destructive);color:var(--color-destructive-fg)}.btn--icon-pill[data-v-7d3f1e61]{width:var(--touch-min);border-radius:var(--radius-full);background:var(--color-surface-2);color:var(--color-text);padding:0}.btn__spinner[data-v-7d3f1e61]{border:2px solid;border-top-color:#0000;border-radius:50%;width:16px;height:16px;animation:.7s linear infinite spin-7d3f1e61}@keyframes spin-7d3f1e61{to{transform:rotate(360deg)}}.sheet-overlay[data-v-81ce2dd1]{z-index:100;background:#0006;align-items:flex-end;display:flex;position:fixed;inset:0}.sheet[data-v-81ce2dd1]{background:var(--color-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;width:100%;padding:var(--space-3) var(--space-4) var(--space-6);outline:none;max-height:90dvh;overflow-y:auto}.sheet__handle[data-v-81ce2dd1]{border-radius:var(--radius-full);background:var(--color-border);width:36px;height:4px;margin:0 auto var(--space-4)}.sheet-enter-active .sheet-overlay[data-v-81ce2dd1]{transition:background var(--duration-base) var(--ease-out)}.sheet-enter-active .sheet[data-v-81ce2dd1]{transition:transform .25s var(--ease-out)}.sheet-leave-active .sheet[data-v-81ce2dd1]{transition:transform .2s ease-in}.sheet-leave-active[data-v-81ce2dd1]{transition:background .2s ease-in}.sheet-enter-from[data-v-81ce2dd1]{background:0 0}.sheet-enter-from .sheet[data-v-81ce2dd1]{transform:translateY(100%)}.sheet-leave-to[data-v-81ce2dd1]{background:0 0}.sheet-leave-to .sheet[data-v-81ce2dd1]{transform:translateY(100%)} .btn[data-v-7d3f1e61]{justify-content:center;align-items:center;gap:var(--space-2);min-height:var(--touch-min);padding:0 var(--space-5);border-radius:var(--radius-full);font-family:var(--font-family);font-size:var(--text-base);cursor:pointer;transition:opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);white-space:nowrap;border:none;font-weight:600;line-height:1;text-decoration:none;display:inline-flex}.btn[data-v-7d3f1e61]:disabled{opacity:.4;cursor:not-allowed}.btn[data-v-7d3f1e61]:not(:disabled):active{transform:scale(.96)}.btn--primary[data-v-7d3f1e61]{background:var(--color-primary);color:var(--color-primary-fg)}.btn--secondary[data-v-7d3f1e61]{background:var(--color-secondary);color:var(--color-secondary-fg);border:1px solid var(--color-border)}.btn--ghost[data-v-7d3f1e61]{color:var(--color-text);border:1px solid var(--color-border);background:0 0}.btn--destructive[data-v-7d3f1e61]{background:var(--color-destructive);color:var(--color-destructive-fg)}.btn--icon-pill[data-v-7d3f1e61]{width:var(--touch-min);border-radius:var(--radius-full);background:var(--color-surface-2);color:var(--color-text);padding:0}.btn__spinner[data-v-7d3f1e61]{border:2px solid;border-top-color:#0000;border-radius:50%;width:16px;height:16px;animation:.7s linear infinite spin-7d3f1e61}@keyframes spin-7d3f1e61{to{transform:rotate(360deg)}}.sheet-overlay[data-v-9ba072ca]{z-index:100;background:#0006;align-items:flex-end;display:flex;position:fixed;inset:0}.sheet[data-v-9ba072ca]{background:var(--color-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;width:100%;padding:var(--space-3) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));outline:none;max-height:90dvh;overflow-y:auto}.sheet__handle[data-v-9ba072ca]{border-radius:var(--radius-full);background:var(--color-border);width:36px;height:4px;margin:0 auto var(--space-4)}.sheet-enter-active .sheet-overlay[data-v-9ba072ca]{transition:background var(--duration-base) var(--ease-out)}.sheet-enter-active .sheet[data-v-9ba072ca]{transition:transform .25s var(--ease-out)}.sheet-leave-active .sheet[data-v-9ba072ca]{transition:transform .2s ease-in}.sheet-leave-active[data-v-9ba072ca]{transition:background .2s ease-in}.sheet-enter-from[data-v-9ba072ca]{background:0 0}.sheet-enter-from .sheet[data-v-9ba072ca]{transform:translateY(100%)}.sheet-leave-to[data-v-9ba072ca]{background:0 0}.sheet-leave-to .sheet[data-v-9ba072ca]{transform:translateY(100%)}
@@ -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-CzdI27OS.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-CO3Iefke.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
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.settings[data-v-76ec3881]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-76ec3881]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-76ec3881]{margin-bottom:var(--space-6)}.settings__section-title[data-v-76ec3881]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-76ec3881]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-76ec3881]{color:var(--color-text-muted)}.settings__row-value[data-v-76ec3881]{font-weight:600}.settings__logout[data-v-76ec3881]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.theme-grid[data-v-76ec3881]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-76ec3881]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-76ec3881]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-76ec3881]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-76ec3881]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-76ec3881]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-76ec3881]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-76ec3881]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
@@ -1 +0,0 @@
.settings[data-v-1446e085]{padding:var(--space-4) var(--space-4) calc(64px + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-1446e085]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-1446e085]{margin-bottom:var(--space-6)}.settings__section-title[data-v-1446e085]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-1446e085]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-1446e085]{color:var(--color-text-muted)}.settings__row-value[data-v-1446e085]{font-weight:600}.settings__logout[data-v-1446e085]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.theme-grid[data-v-1446e085]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-1446e085]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-1446e085]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-1446e085]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-1446e085]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-1446e085]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-1446e085]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-1446e085]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
@@ -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-DCUs53vX.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-1446e085`]]);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-D13oAsTG.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};
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
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+13 -4
View File
@@ -2,12 +2,21 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>pictureFrame</title>
<link rel="icon" type="image/svg+xml" href="/build/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/build/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="manifest" href="/build/manifest.webmanifest" />
<title>frontend</title> <link rel="apple-touch-icon" href="/build/icons/apple-touch-icon.png" />
<script type="module" crossorigin src="/build/assets/index-DCUs53vX.js"></script>
<meta name="theme-color" content="#fdf6ee" />
<meta name="apple-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-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-D13oAsTG.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-DlN2hqev.css"> <link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "pictureFrame",
"short_name": "pictureFrame",
"description": "Manage photos for your e-ink picture frames.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#fdf6ee",
"theme_color": "#fdf6ee",
"icons": [
{
"src": "/build/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/build/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/build/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}