feat(library): photo + status badge + ManageImageSheet (Concept A)
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:
2026-05-14 15:26:41 -04:00
parent 9854688a49
commit 84642ed13f
13 changed files with 589 additions and 135 deletions
@@ -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()
})
})
+72 -38
View File
@@ -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
+133 -89
View File
@@ -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 {