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 {
|
||||
|
||||
Reference in New Issue
Block a user