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>
@@ -2,9 +2,18 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<link rel="manifest" href="/build/manifest.webmanifest" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,10 +18,7 @@ const { applyTheme } = useTheme()
|
||||
|
||||
onMounted(() => {
|
||||
const stamped = document.documentElement.dataset.theme
|
||||
if (stamped && auth.user) {
|
||||
auth.user.theme = stamped
|
||||
} else if (auth.user?.theme) {
|
||||
applyTheme(auth.user.theme)
|
||||
}
|
||||
const resolved = stamped || auth.user?.theme
|
||||
if (resolved) applyTheme(resolved)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,7 @@ watch(() => props.modelValue, async (open) => {
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
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;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
: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-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" 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 }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -20,44 +20,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const route = useRoute()
|
||||
const imagesStore = useImagesStore()
|
||||
|
||||
const tabs = [
|
||||
interface Tab {
|
||||
name: string
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
isActive: (r: RouteLocationNormalizedLoaded) => boolean
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: 'home',
|
||||
label: 'Home',
|
||||
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>',
|
||||
isActive: r => r.path === '/',
|
||||
},
|
||||
{
|
||||
name: 'library',
|
||||
label: '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>',
|
||||
},
|
||||
{
|
||||
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>',
|
||||
isActive: r => r.path.startsWith('/library'),
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
label: '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>',
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -66,11 +65,11 @@ function isActive(to: string) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
z-index: 50;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
@media (min-width: 960px) {
|
||||
display: none;
|
||||
@@ -83,6 +82,7 @@ function isActive(to: string) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 64px;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
min-height: var(--touch-min);
|
||||
|
||||
@@ -25,6 +25,12 @@ export function useTheme() {
|
||||
function applyTheme(themeId: string) {
|
||||
document.documentElement.dataset.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) {
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
|
||||
// Touch target minimum
|
||||
--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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -158,6 +161,7 @@ body {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
// ─── Focus visible ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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('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'
|
||||
|
||||
const BaseBottomSheetStub = {
|
||||
name: 'BaseBottomSheet',
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue', 'label'],
|
||||
emits: ['update:modelValue'],
|
||||
@@ -74,4 +75,48 @@ describe('ShareSheet', () => {
|
||||
const button = wrapper.find('button')
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount } from '@vue/test-utils'
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -8,3 +9,5 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
@@ -156,4 +156,50 @@ describe('devices store', () => {
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,4 +163,237 @@ describe('images store', () => {
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,6 +125,78 @@ describe('upload store', () => {
|
||||
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', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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 { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
const routerPush = vi.fn()
|
||||
|
||||
// Stub heavy child components so tests focus on HomeView logic
|
||||
vi.mock('@/components/FrameCard.vue', () => ({
|
||||
default: {
|
||||
name: 'FrameCard',
|
||||
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'],
|
||||
},
|
||||
}))
|
||||
@@ -48,7 +51,7 @@ vi.mock('@/components/OrientationPicker.vue', () => ({
|
||||
|
||||
// Stub vue-router so HomeView can call useRouter() without a real router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
}))
|
||||
|
||||
// Stub URL.createObjectURL used by upload store
|
||||
@@ -161,4 +164,303 @@ describe('HomeView', () => {
|
||||
expect(wrapper.find('.home-view__loading').exists()).toBe(true)
|
||||
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' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import LibraryView from '@/views/LibraryView.vue'
|
||||
import type { Image, Device } from '@/types'
|
||||
|
||||
const routerPush = vi.fn()
|
||||
|
||||
// Stub complex child components
|
||||
vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
||||
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', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
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', () => ({
|
||||
useToastStore: () => ({ show: vi.fn() }),
|
||||
useToastStore: () => ({ show: toastShow }),
|
||||
}))
|
||||
|
||||
// Stub upload store
|
||||
const uploadInitEdit = vi.fn()
|
||||
vi.mock('@/stores/upload', () => ({
|
||||
useUploadStore: () => ({ initEdit: vi.fn() }),
|
||||
useUploadStore: () => ({ initEdit: uploadInitEdit }),
|
||||
}))
|
||||
|
||||
const makeImage = (overrides: Partial<Image> = {}): Image => ({
|
||||
@@ -93,6 +97,10 @@ describe('LibraryView', () => {
|
||||
vi.restoreAllMocks()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
mockRoute.query = {}
|
||||
toastShow.mockClear()
|
||||
uploadInitEdit.mockClear()
|
||||
routerPush.mockClear()
|
||||
|
||||
// Default fetch stub — returns empty lists so onMounted doesn't error
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
@@ -236,6 +244,66 @@ describe('LibraryView', () => {
|
||||
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)
|
||||
it('shows shared empty state when no shared items exist', async () => {
|
||||
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)
|
||||
})
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -203,7 +203,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
@@ -229,7 +229,18 @@ const TABS = [
|
||||
] as const
|
||||
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 = [
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
@@ -383,13 +394,13 @@ async function doDelete() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.library {
|
||||
padding-bottom: calc(64px + var(--space-4));
|
||||
padding-bottom: calc(var(--bottom-nav-height) + var(--space-4));
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: env(safe-area-inset-top);
|
||||
background: var(--color-bg);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ function select(themeId: string) {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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;
|
||||
margin: 0 auto;
|
||||
|
||||
|
||||
@@ -230,6 +230,8 @@ function finish() {
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -21,11 +21,15 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/**/*.{ts,vue}'],
|
||||
exclude: [
|
||||
'src/components/CropEditor.vue',
|
||||
'src/components/StickerCanvas.vue',
|
||||
'src/assets/**',
|
||||
'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};
|
||||
@@ -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};
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -2,12 +2,21 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/build/assets/index-DCUs53vX.js"></script>
|
||||
<link rel="manifest" href="/build/manifest.webmanifest" />
|
||||
<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" />
|
||||
<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="stylesheet" crossorigin href="/build/assets/index-DlN2hqev.css">
|
||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||