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:
@@ -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