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
+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 {