feat(library): photo + status badge + ManageImageSheet (Concept A)
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Library was rendering one approval chip per device per photo PLUS one lock chip per approved device. That's O(photos × devices) buttons — fine at one or two frames, breaks at four+ (see _bmad-output/.../library-many-frames-design-ideas.md). Concept A from the design memo: - Each photo card stays a square thumb + a single "Manage" row. - Manage row summarises state: "3/5 frames · 🔒 Mom's Place". - A corner-lock badge sits on the thumb itself when any frame has the image locked, so the lock status is glanceable from the grid. - Tapping Manage opens the new ManageImageSheet bottom sheet, which lists every frame with an approve toggle + per-frame lock pill. Lock pill is disabled until the frame is approved. Per-photo widgets drop from O(photos × devices) to O(photos). Works identically at 1 or 50 frames. Curation principle stays "manage photos TO the frame" — same store calls (imagesStore.setApproval, devicesStore.lockImage/unlockImage), just routed through the sheet instead of inline chip rows. 10 new ManageImageSheet unit tests + LibraryView tests rewritten to cover the sheet-open + event-forwarding flow. 358/358 frontend tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<BaseBottomSheet
|
||||
:model-value="modelValue"
|
||||
label="Manage frames for this photo"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<h2 class="manage__title">Manage frames</h2>
|
||||
<p class="manage__sub">
|
||||
Toggle which frames show this photo, or lock it to a frame so it stays
|
||||
visible until you unlock it.
|
||||
</p>
|
||||
|
||||
<div v-if="!devices.length" class="manage__empty">
|
||||
You don't have any frames set up yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="manage__list">
|
||||
<div
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
class="manage__row"
|
||||
>
|
||||
<div class="manage__device">
|
||||
<span class="manage__device-name">{{ device.name }}</span>
|
||||
<span class="manage__device-meta">{{ device.orientation }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="manage__lock"
|
||||
:class="{ 'manage__lock--on': device.lockedImageId === image?.id }"
|
||||
:disabled="!isApproved(device.id) || pendingLock === device.id"
|
||||
:aria-label="device.lockedImageId === image?.id
|
||||
? `Unlock from ${device.name}`
|
||||
: `Lock to ${device.name}`"
|
||||
@click="onLockClick(device)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path v-if="device.lockedImageId === image?.id" d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<path v-else d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
||||
</svg>
|
||||
<span>{{ device.lockedImageId === image?.id ? 'Locked' : 'Rotate' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="manage__toggle"
|
||||
:class="{ 'manage__toggle--on': isApproved(device.id) }"
|
||||
:disabled="pendingApproval === device.id"
|
||||
:aria-label="isApproved(device.id)
|
||||
? `Remove this photo from ${device.name}`
|
||||
: `Add this photo to ${device.name}`"
|
||||
@click="onApprovalClick(device)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="manage__done"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
>
|
||||
Done
|
||||
</BaseButton>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { Device, Image } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
image: Image | null
|
||||
devices: Device[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'approval', payload: { imageId: number; deviceId: number; approved: boolean }): void
|
||||
(e: 'lock', payload: { imageId: number; deviceId: number; locked: boolean }): void
|
||||
}>()
|
||||
|
||||
// Optimistic-locking guards so a fast double-tap doesn't fire two requests.
|
||||
const pendingApproval = ref<number | null>(null)
|
||||
const pendingLock = ref<number | null>(null)
|
||||
|
||||
function isApproved(deviceId: number): boolean {
|
||||
return !!props.image?.approvedDeviceIds.includes(deviceId)
|
||||
}
|
||||
|
||||
function onApprovalClick(device: Device) {
|
||||
if (!props.image) return
|
||||
pendingApproval.value = device.id
|
||||
const approved = !isApproved(device.id)
|
||||
emit('approval', { imageId: props.image.id, deviceId: device.id, approved })
|
||||
// The parent updates the store; we just need a brief debounce.
|
||||
setTimeout(() => { pendingApproval.value = null }, 200)
|
||||
}
|
||||
|
||||
function onLockClick(device: Device) {
|
||||
if (!props.image) return
|
||||
if (!isApproved(device.id)) return
|
||||
pendingLock.value = device.id
|
||||
const locked = device.lockedImageId !== props.image.id
|
||||
emit('lock', { imageId: props.image.id, deviceId: device.id, locked })
|
||||
setTimeout(() => { pendingLock.value = null }, 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.manage {
|
||||
&__title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 var(--space-4);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-6) 0;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 56px;
|
||||
}
|
||||
&__row:last-child { border-bottom: 0; }
|
||||
|
||||
&__device {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
&__device-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&__device-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
// Lock pill — visible only when approved; disabled state for not-approved.
|
||||
&__lock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
transition: background var(--duration-fast, 150ms), color var(--duration-fast, 150ms);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&--on {
|
||||
background: #c49a20;
|
||||
border-color: #c49a20;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS-style approval toggle
|
||||
&__toggle {
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
background: var(--color-border);
|
||||
border: 0;
|
||||
border-radius: var(--radius-full);
|
||||
position: relative;
|
||||
transition: background var(--duration-fast, 150ms);
|
||||
flex-shrink: 0;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: left var(--duration-fast, 150ms);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
&--on {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
&--on::after {
|
||||
left: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
&__done {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ManageImageSheet from '@/components/ManageImageSheet.vue'
|
||||
import type { Device, Image } from '@/types'
|
||||
|
||||
function makeDevice(over: Partial<Device> = {}): Device {
|
||||
return {
|
||||
id: 1,
|
||||
mac: 'AA:BB:CC:DD:EE:01',
|
||||
name: 'Living Room',
|
||||
model: 'v1',
|
||||
orientation: 'landscape',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'UTC',
|
||||
uniquenessWindow: 0,
|
||||
rotationMode: 'random',
|
||||
prioritizeNeverShown: false,
|
||||
linkedAt: new Date().toISOString(),
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function makeImage(over: Partial<Image> = {}): Image {
|
||||
return {
|
||||
id: 1,
|
||||
originalFilename: 'a.jpg',
|
||||
thumbnailUrl: '',
|
||||
originalUrl: '',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
approvedDeviceIds: [],
|
||||
cropParams: null,
|
||||
stickerState: null,
|
||||
cropOrientation: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mountSheet(opts: { image: Image | null; devices: Device[] }) {
|
||||
return mount(ManageImageSheet, {
|
||||
props: { modelValue: true, image: opts.image, devices: opts.devices },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseBottomSheet: {
|
||||
name: 'BaseBottomSheet',
|
||||
template: '<div><slot/></div>',
|
||||
},
|
||||
BaseButton: { template: '<button><slot/></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ManageImageSheet', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('renders a row per device', () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage(),
|
||||
devices: [makeDevice({ id: 1 }), makeDevice({ id: 2, name: 'Kitchen' })],
|
||||
})
|
||||
expect(w.findAll('.manage__row').length).toBe(2)
|
||||
})
|
||||
|
||||
it('shows the approval toggle ON for approved devices, OFF for others', () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage({ approvedDeviceIds: [1] }),
|
||||
devices: [makeDevice({ id: 1 }), makeDevice({ id: 2 })],
|
||||
})
|
||||
const toggles = w.findAll('.manage__toggle')
|
||||
expect(toggles[0].classes()).toContain('manage__toggle--on')
|
||||
expect(toggles[1].classes()).not.toContain('manage__toggle--on')
|
||||
})
|
||||
|
||||
it('emits approval=true when tapping an off toggle', async () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage({ id: 7, approvedDeviceIds: [] }),
|
||||
devices: [makeDevice({ id: 4 })],
|
||||
})
|
||||
await w.find('.manage__toggle').trigger('click')
|
||||
expect(w.emitted('approval')![0][0]).toEqual({ imageId: 7, deviceId: 4, approved: true })
|
||||
})
|
||||
|
||||
it('emits approval=false when tapping an on toggle', async () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage({ id: 7, approvedDeviceIds: [4] }),
|
||||
devices: [makeDevice({ id: 4 })],
|
||||
})
|
||||
await w.find('.manage__toggle').trigger('click')
|
||||
expect(w.emitted('approval')![0][0]).toEqual({ imageId: 7, deviceId: 4, approved: false })
|
||||
})
|
||||
|
||||
it('disables the lock pill when the image is not approved on the device', () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage({ approvedDeviceIds: [] }),
|
||||
devices: [makeDevice({ id: 1 })],
|
||||
})
|
||||
const lock = w.find('.manage__lock')
|
||||
expect(lock.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows the lock pill in --on state when the device is locked to this image', () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage({ id: 7, approvedDeviceIds: [1] }),
|
||||
devices: [makeDevice({ id: 1, lockedImageId: 7 })],
|
||||
})
|
||||
expect(w.find('.manage__lock').classes()).toContain('manage__lock--on')
|
||||
})
|
||||
|
||||
it('emits lock=true when tapping an unlocked lock pill on an approved device', async () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage({ id: 7, approvedDeviceIds: [4] }),
|
||||
devices: [makeDevice({ id: 4, lockedImageId: null })],
|
||||
})
|
||||
await w.find('.manage__lock').trigger('click')
|
||||
expect(w.emitted('lock')![0][0]).toEqual({ imageId: 7, deviceId: 4, locked: true })
|
||||
})
|
||||
|
||||
it('emits lock=false when tapping an already-locked lock pill', async () => {
|
||||
const w = mountSheet({
|
||||
image: makeImage({ id: 7, approvedDeviceIds: [4] }),
|
||||
devices: [makeDevice({ id: 4, lockedImageId: 7 })],
|
||||
})
|
||||
await w.find('.manage__lock').trigger('click')
|
||||
expect(w.emitted('lock')![0][0]).toEqual({ imageId: 7, deviceId: 4, locked: false })
|
||||
})
|
||||
|
||||
it('renders an empty state when the user has no devices', () => {
|
||||
const w = mountSheet({ image: makeImage(), devices: [] })
|
||||
expect(w.find('.manage__empty').exists()).toBe(true)
|
||||
expect(w.findAll('.manage__row').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does nothing if image is null (defensive)', async () => {
|
||||
const w = mountSheet({ image: null, devices: [makeDevice({ id: 4 })] })
|
||||
await w.find('.manage__toggle').trigger('click')
|
||||
expect(w.emitted('approval')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -232,11 +232,13 @@ describe('LibraryView', () => {
|
||||
})
|
||||
|
||||
// LV-03: Lock chip shown for device when image is approved for it
|
||||
it('renders lock chip for device when image is approved for that device', async () => {
|
||||
// Corner-lock badge appears on the photo thumb when ANY device has the
|
||||
// image locked. Replaces the old per-device lock-chip row.
|
||||
it('renders the corner-lock badge when the image is locked on any device', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom' })]
|
||||
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom', lockedImageId: 1 })]
|
||||
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
@@ -246,10 +248,24 @@ describe('LibraryView', () => {
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Lock chips are rendered only for approved devices
|
||||
const lockChips = wrapper.findAll('.library__lock-chip')
|
||||
expect(lockChips.length).toBeGreaterThan(0)
|
||||
expect(lockChips[0].text()).toContain('Bedroom')
|
||||
expect(wrapper.find('.library__thumb-lock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT render the corner-lock badge when image is approved but unlocked', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom', lockedImageId: null })]
|
||||
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.library__thumb-lock').exists()).toBe(false)
|
||||
})
|
||||
|
||||
// LV-06: Share button click renders the ShareSheet
|
||||
@@ -486,8 +502,33 @@ describe('LibraryView', () => {
|
||||
expect(toastShow).toHaveBeenCalledWith('Delete failed', 'error')
|
||||
})
|
||||
|
||||
// LV-13: Approval toggle success + failure
|
||||
it('clicking an approval chip toggles approval via the store', async () => {
|
||||
// LV-13/14: Curation now happens through ManageImageSheet (Concept A in
|
||||
// _bmad-output/.../library-many-frames-design-ideas.md). LibraryView's
|
||||
// only responsibility is opening the sheet and forwarding its events to
|
||||
// the right store mutation; the sheet's own toggle interactions are
|
||||
// covered in ManageImageSheet.test.ts.
|
||||
|
||||
it('clicking Manage opens the ManageImageSheet for that image', 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()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
|
||||
expect(sheet.props('modelValue')).toBe(false)
|
||||
await wrapper.find('.library__manage').trigger('click')
|
||||
expect(sheet.props('modelValue')).toBe(true)
|
||||
expect(sheet.props('image')!.id).toBe(1)
|
||||
})
|
||||
|
||||
it('forwards an approval event from the sheet to the store', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall' })]
|
||||
@@ -500,36 +541,18 @@ describe('LibraryView', () => {
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.find('.library__manage').trigger('click')
|
||||
|
||||
const chip = wrapper.find('.library__approval-chip')
|
||||
await chip.trigger('click')
|
||||
const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
|
||||
await sheet.vm.$emit('approval', { imageId: 1, deviceId: 4, approved: true })
|
||||
await flushPromises()
|
||||
expect(setApproval).toHaveBeenCalledWith(1, 4, true)
|
||||
})
|
||||
|
||||
it('toasts when approval toggle fails', async () => {
|
||||
it('forwards a lock event from the sheet to devicesStore.lockImage', 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 })]
|
||||
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall' })]
|
||||
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
@@ -539,12 +562,14 @@ describe('LibraryView', () => {
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.find('.library__lock-chip').trigger('click')
|
||||
await wrapper.find('.library__manage').trigger('click')
|
||||
const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
|
||||
await sheet.vm.$emit('lock', { imageId: 1, deviceId: 4, locked: true })
|
||||
await flushPromises()
|
||||
expect(lockSpy).toHaveBeenCalledWith(4, 1)
|
||||
})
|
||||
|
||||
it('clicking a lock chip on an already-locked photo unlocks it', async () => {
|
||||
it('forwards an unlock event from the sheet to devicesStore.unlockImage', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 4, lockedImageId: 1 })]
|
||||
@@ -557,12 +582,14 @@ describe('LibraryView', () => {
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.find('.library__lock-chip').trigger('click')
|
||||
await wrapper.find('.library__manage').trigger('click')
|
||||
const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
|
||||
await sheet.vm.$emit('lock', { imageId: 1, deviceId: 4, locked: false })
|
||||
await flushPromises()
|
||||
expect(unlockSpy).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('toasts when lock toggle fails', async () => {
|
||||
it('toasts when a lock-store call from the sheet fails', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 4 })]
|
||||
@@ -575,7 +602,9 @@ describe('LibraryView', () => {
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.find('.library__lock-chip').trigger('click')
|
||||
await wrapper.find('.library__manage').trigger('click')
|
||||
const sheet = wrapper.findComponent({ name: 'ManageImageSheet' })
|
||||
await sheet.vm.$emit('lock', { imageId: 1, deviceId: 4, locked: true })
|
||||
await flushPromises()
|
||||
expect(toastShow).toHaveBeenCalledWith('Failed to update lock', 'error')
|
||||
})
|
||||
@@ -890,11 +919,16 @@ describe('LibraryView', () => {
|
||||
await wrapper.findAll('.library__action-btn')
|
||||
.find(b => b.attributes('aria-label') === 'Delete photo')!.trigger('click')
|
||||
await flushPromises()
|
||||
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
|
||||
// The view has multiple bottom sheets (manage, delete). Find the one
|
||||
// with the delete label so we don't accidentally toggle the manage sheet.
|
||||
const sheet = wrapper.findAllComponents({ name: 'BaseBottomSheet' })
|
||||
.find(c => c.props('label') === 'Delete photo')!
|
||||
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)
|
||||
const after = wrapper.findAllComponents({ name: 'BaseBottomSheet' })
|
||||
.find(c => c.props('label') === 'Delete photo')!
|
||||
expect(after.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
// LV-21: clicking the orientation-mismatch warning starts an edit for that device
|
||||
|
||||
@@ -44,6 +44,17 @@
|
||||
class="library__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-if="lockedDeviceFor(image)"
|
||||
class="library__thumb-lock"
|
||||
:title="`Locked on ${lockedDeviceFor(image)!.name}`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="library__thumb-actions">
|
||||
<button
|
||||
v-if="mismatchedDevice(image)"
|
||||
@@ -102,36 +113,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="devicesStore.devices.length > 0" class="library__approvals">
|
||||
<button
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
:class="['library__approval-chip', { 'library__approval-chip--on': image.approvedDeviceIds.includes(device.id) }]"
|
||||
type="button"
|
||||
:aria-label="`${image.approvedDeviceIds.includes(device.id) ? 'Remove from' : 'Add to'} ${device.name}`"
|
||||
@click="toggleApproval(image.id, device.id, !image.approvedDeviceIds.includes(device.id))"
|
||||
>
|
||||
{{ device.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="devicesStore.devices.length > 0" class="library__locks">
|
||||
<button
|
||||
v-for="device in devicesStore.devices.filter(d => image.approvedDeviceIds.includes(d.id))"
|
||||
:key="device.id"
|
||||
:class="['library__lock-chip', { 'library__lock-chip--on': device.lockedImageId === image.id }]"
|
||||
type="button"
|
||||
:aria-label="`${device.lockedImageId === image.id ? 'Unlock from' : 'Lock to'} ${device.name}`"
|
||||
@click="toggleLock(image.id, device)"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path v-if="device.lockedImageId === image.id" d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<path v-else d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
||||
</svg>
|
||||
{{ device.name }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="devicesStore.devices.length > 0"
|
||||
type="button"
|
||||
class="library__manage"
|
||||
:aria-label="`Manage frames for ${image.originalFilename}`"
|
||||
@click="openManage(image)"
|
||||
>
|
||||
<span class="library__manage-summary">
|
||||
<b>{{ image.approvedDeviceIds.length }}</b>/{{ devicesStore.devices.length }}
|
||||
{{ devicesStore.devices.length === 1 ? 'frame' : 'frames' }}
|
||||
<span
|
||||
v-if="lockedDeviceFor(image)"
|
||||
class="library__manage-lock"
|
||||
>· 🔒 {{ lockedDeviceFor(image)!.name }}</span>
|
||||
</span>
|
||||
<span class="library__manage-action">Manage ▸</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -196,6 +194,14 @@
|
||||
<!-- Share sheet -->
|
||||
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
|
||||
|
||||
<ManageImageSheet
|
||||
v-model="manageSheetOpen"
|
||||
:image="manageImage"
|
||||
:devices="devicesStore.devices"
|
||||
@approval="onManageApproval"
|
||||
@lock="onManageLock"
|
||||
/>
|
||||
|
||||
<!-- Confirm delete sheet -->
|
||||
<BaseBottomSheet v-model="deleteSheetOpen" label="Delete photo">
|
||||
<h2 class="library__sheet-title">Delete this photo?</h2>
|
||||
@@ -230,6 +236,7 @@ import { useToastStore } from '@/stores/toast'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import ApproveCard from '@/components/ApproveCard.vue'
|
||||
import ManageImageSheet from '@/components/ManageImageSheet.vue'
|
||||
import ShareSheet from '@/components/ShareSheet.vue'
|
||||
import PullToRefresh from '@/components/PullToRefresh.vue'
|
||||
import type { Device, Image, SharedImage } from '@/types'
|
||||
@@ -397,28 +404,62 @@ function mismatchedDevice(image: Image): Device | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Lock ──────────────────────────────────────────────────────────────────────
|
||||
// ── Lock + approval helpers ──────────────────────────────────────────────────
|
||||
// Curation happens through the ManageImageSheet — see openManage(). These
|
||||
// helpers stay around because the sheet emits one-shot events the parent
|
||||
// pushes to the stores.
|
||||
|
||||
async function toggleLock(imageId: number, device: Device) {
|
||||
/** First device that currently has this image locked, if any. Drives the
|
||||
* corner-lock badge on the thumb and the "🔒 [name]" suffix in the status
|
||||
* badge. Multiple-lock case shows the first match. */
|
||||
function lockedDeviceFor(image: Image): Device | null {
|
||||
return devicesStore.devices.find(d => d.lockedImageId === image.id) ?? null
|
||||
}
|
||||
|
||||
async function applyApproval(imageId: number, deviceId: number, approved: boolean) {
|
||||
try {
|
||||
if (device.lockedImageId === imageId) {
|
||||
await devicesStore.unlockImage(device.id)
|
||||
await imagesStore.setApproval(imageId, deviceId, approved)
|
||||
} catch {
|
||||
toast.show('Failed to update frame approval', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function applyLock(imageId: number, deviceId: number, locked: boolean) {
|
||||
try {
|
||||
if (locked) {
|
||||
await devicesStore.lockImage(deviceId, imageId)
|
||||
} else {
|
||||
await devicesStore.lockImage(device.id, imageId)
|
||||
await devicesStore.unlockImage(deviceId)
|
||||
}
|
||||
} catch {
|
||||
toast.show('Failed to update lock', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approval toggles ──────────────────────────────────────────────────────────
|
||||
// ── Manage sheet (per-photo, all-device approve + lock) ─────────────────────
|
||||
|
||||
async function toggleApproval(imageId: number, deviceId: number, approved: boolean) {
|
||||
try {
|
||||
await imagesStore.setApproval(imageId, deviceId, approved)
|
||||
} catch {
|
||||
toast.show('Failed to update frame approval', 'error')
|
||||
}
|
||||
const manageSheetOpen = ref(false)
|
||||
const manageImageId = ref<number | null>(null)
|
||||
|
||||
/** Live-bound to the store so toggles inside the sheet update the row
|
||||
* immediately as the store mutates, without re-mount. */
|
||||
const manageImage = computed<Image | null>(() =>
|
||||
manageImageId.value === null
|
||||
? null
|
||||
: imagesStore.images.find(i => i.id === manageImageId.value) ?? null,
|
||||
)
|
||||
|
||||
function openManage(image: Image) {
|
||||
manageImageId.value = image.id
|
||||
manageSheetOpen.value = true
|
||||
}
|
||||
|
||||
function onManageApproval(p: { imageId: number; deviceId: number; approved: boolean }) {
|
||||
applyApproval(p.imageId, p.deviceId, p.approved)
|
||||
}
|
||||
|
||||
function onManageLock(p: { imageId: number; deviceId: number; locked: boolean }) {
|
||||
applyLock(p.imageId, p.deviceId, p.locked)
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
@@ -634,56 +675,59 @@ async function doDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
&__approvals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__approval-chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
font-size: var(--text-xs, 11px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--on {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__locks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__lock-chip {
|
||||
// Locked-on-some-frame badge tucked into the bottom-left of the thumb.
|
||||
// Visual mirror of the per-device lock chip on the manage sheet.
|
||||
&__thumb-lock {
|
||||
position: absolute;
|
||||
left: var(--space-2);
|
||||
bottom: var(--space-2);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #c49a20;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px dashed var(--color-border);
|
||||
font-size: var(--text-xs, 11px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--on {
|
||||
background: var(--color-warning, #f59e0b);
|
||||
border-color: var(--color-warning, #f59e0b);
|
||||
border-style: solid;
|
||||
color: #fff;
|
||||
}
|
||||
// Single tappable summary row beneath the thumb — opens the manage sheet.
|
||||
// Replaces the per-device approval + lock chip grids that didn't scale
|
||||
// beyond a few frames (see _bmad-output/.../library-many-frames-design-ideas.md).
|
||||
&__manage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--duration-fast);
|
||||
|
||||
&:hover { background: var(--color-surface-2); }
|
||||
}
|
||||
|
||||
&__manage-summary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
b { color: var(--color-text); font-weight: 700; }
|
||||
}
|
||||
|
||||
&__manage-lock {
|
||||
color: #6b5210;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__manage-action {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__sheet-title {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||
<script type="module" crossorigin src="/build/assets/index-Bx9Y0zPK.js"></script>
|
||||
<script type="module" crossorigin src="/build/assets/index-Ds9OAB3e.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user