Files
pictureFrame-webApp/frontend/src/views/LibraryView.vue
T
football2801 cbb5bb1ff3
CI / test (push) Has been cancelled
feat: surface orientation-mismatch warning in the library
Each thumbnail now shows a yellow warning triangle in its action stack
when at least one approved device's orientation does not match the
photo's crop orientation. Tap opens the edit flow with that device set
as the crop context, so the existing in-crop-tool indicator can guide
the re-crop. Photos without a stored cropOrientation fall back to
inferring it from the saved cropParams aspect, so older uploads aren't
left blind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:25:55 -04:00

631 lines
20 KiB
Vue

<template>
<main class="library">
<!-- Tabs -->
<div class="library__tabs" role="tablist">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
role="tab"
:aria-selected="activeTab === tab.id"
:class="['library__tab', { 'library__tab--active': activeTab === tab.id }]"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<!-- Loading -->
<div v-if="imagesStore.loading" class="library__loading">Loading</div>
<!-- All / Mine tab -->
<template v-else-if="activeTab !== 'shared'">
<div v-if="visibleImages.length === 0" class="library__empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="library__empty-title">No photos yet</p>
<p class="library__empty-sub">Tap "+ Add Photo" on the home screen to get started.</p>
</div>
<div v-else class="library__grid">
<div v-for="image in visibleImages" :key="image.id" class="library__item">
<div class="library__thumb">
<img
:src="image.thumbnailUrl"
:alt="image.originalFilename"
class="library__img"
loading="lazy"
/>
<div class="library__thumb-actions">
<button
v-if="mismatchedDevice(image)"
class="library__action-btn library__action-btn--warn"
type="button"
:aria-label="`Crop orientation does not match ${mismatchedDevice(image)!.name}; tap to re-crop`"
:title="`Cropped ${photoCropOrientation(image)}, but ${mismatchedDevice(image)!.name} is set to ${mismatchedDevice(image)!.orientation}.`"
@click="startEdit(image, mismatchedDevice(image)!.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</button>
<button
class="library__action-btn"
type="button"
:aria-label="`Edit ${image.originalFilename}`"
:disabled="editingId === image.id"
@click="startEdit(image)"
>
<svg v-if="editingId !== image.id" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</button>
<button
class="library__action-btn"
type="button"
:aria-label="`Share ${image.originalFilename}`"
@click="openShare(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
<button
class="library__action-btn library__action-btn--danger"
type="button"
aria-label="Delete photo"
@click="confirmDelete(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</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>
</div>
</div>
</template>
<!-- Shared tab -->
<template v-else>
<!-- Sub-tabs -->
<div class="library__subtabs" role="tablist">
<button
v-for="st in SHARED_TABS"
:key="st.id"
type="button"
role="tab"
:aria-selected="sharedTab === st.id"
:class="['library__subtab', { 'library__subtab--active': sharedTab === st.id }]"
@click="switchSharedTab(st.id)"
>{{ st.label }}</button>
</div>
<div v-if="sharedLoading" class="library__loading">Loading</div>
<div v-else-if="sharedItems.length === 0" class="library__shared-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<p class="library__empty-title">
{{ sharedTab === 'pending' ? 'No pending photos' : sharedTab === 'approved' ? 'No approved photos' : 'No declined photos' }}
</p>
<p class="library__empty-sub">
{{ sharedTab === 'pending' ? 'Photos shared with you will appear here.' : 'Photos you\'ve added to a frame will appear here.' }}
</p>
</div>
<div v-else class="library__shared-list">
<ApproveCard
v-for="item in sharedItems"
:key="item.id"
:item="item"
@updated="onSharedUpdated"
/>
</div>
<!-- Pagination -->
<div v-if="sharedTotalPages > 1" class="library__pagination">
<button
class="library__page-btn"
:disabled="sharedPage <= 1"
@click="goSharedPage(sharedPage - 1)"
> Prev</button>
<span class="library__page-info">{{ sharedPage }} / {{ sharedTotalPages }}</span>
<button
class="library__page-btn"
:disabled="sharedPage >= sharedTotalPages"
@click="goSharedPage(sharedPage + 1)"
>Next </button>
</div>
</template>
<!-- Share sheet -->
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
<!-- Confirm delete sheet -->
<BaseBottomSheet v-model="deleteSheetOpen" label="Delete photo">
<h2 class="library__sheet-title">Delete this photo?</h2>
<p class="library__sheet-sub">It will be removed from all frames.</p>
<div class="library__sheet-actions">
<BaseButton variant="secondary" @click="deleteSheetOpen = false">Cancel</BaseButton>
<BaseButton variant="destructive" :disabled="deleting" @click="doDelete">
{{ deleting ? 'Deleting' : 'Delete' }}
</BaseButton>
</div>
</BaseBottomSheet>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import { useToastStore } from '@/stores/toast'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import ApproveCard from '@/components/ApproveCard.vue'
import ShareSheet from '@/components/ShareSheet.vue'
import type { Device, Image, SharedImage } from '@/types'
const router = useRouter()
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
const toast = useToastStore()
const route = useRoute()
const TABS = [
{ id: 'all', label: 'All' },
{ id: 'mine', label: 'Mine' },
{ id: 'shared', label: 'Shared' },
] as const
type Tab = typeof TABS[number]['id']
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all')
const SHARED_TABS = [
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'declined', label: 'Declined' },
] as const
type SharedTab = typeof SHARED_TABS[number]['id']
const sharedTab = ref<SharedTab>('pending')
const sharedItems = ref<SharedImage[]>([])
const sharedLoading = ref(false)
const sharedPage = ref(1)
const sharedTotalPages = ref(1)
async function loadShared(tab: SharedTab, page = 1) {
sharedLoading.value = true
try {
const result = await imagesStore.fetchSharedImages(tab, page)
sharedItems.value = result.items
sharedPage.value = result.page
sharedTotalPages.value = result.totalPages
} finally {
sharedLoading.value = false
}
}
function switchSharedTab(tab: SharedTab) {
sharedTab.value = tab
loadShared(tab, 1)
}
function goSharedPage(page: number) {
loadShared(sharedTab.value, page)
}
function onSharedUpdated(updated: SharedImage) {
const idx = sharedItems.value.findIndex(i => i.id === updated.id)
if (idx !== -1) sharedItems.value[idx] = updated
}
onMounted(() => {
imagesStore.fetchImages()
devicesStore.fetchDevices()
imagesStore.fetchPendingCount()
if (activeTab.value === 'shared') loadShared(sharedTab.value)
})
// For now "mine" and "all" show the same list; shared is a placeholder
const visibleImages = computed(() => imagesStore.images)
// ── Share ─────────────────────────────────────────────────────────────────────
const shareSheetOpen = ref(false)
const shareImageId = ref<number | null>(null)
function openShare(id: number) {
shareImageId.value = id
shareSheetOpen.value = true
}
// ── Edit ──────────────────────────────────────────────────────────────────────
const editingId = ref<number | null>(null)
async function startEdit(image: Image, deviceId?: number) {
if (editingId.value) return
editingId.value = image.id
try {
await uploadStore.initEdit(image, deviceId)
router.push('/upload')
} catch {
toast.show('Could not load photo for editing', 'error')
} finally {
editingId.value = null
}
}
// ── Orientation mismatch ──────────────────────────────────────────────────────
// Photo's effective crop orientation. Prefers the explicit cropOrientation
// field; falls back to inferring from cropParams aspect for legacy uploads
// predating the field. Returns null when neither is available.
function photoCropOrientation(image: Image): 'landscape' | 'portrait' | null {
if (image.cropOrientation) return image.cropOrientation
const p = image.cropParams
if (!p?.natW || !p?.natH) return null
return p.natW >= p.natH ? 'landscape' : 'portrait'
}
// First approved device whose orientation doesn't match the photo's crop
// orientation. Drives the warning triangle and the click target for re-crop.
function mismatchedDevice(image: Image): Device | null {
const photoOri = photoCropOrientation(image)
if (!photoOri) return null
for (const id of image.approvedDeviceIds) {
const d = devicesStore.devices.find(dev => dev.id === id)
if (d && d.orientation !== photoOri) return d
}
return null
}
// ── Lock ──────────────────────────────────────────────────────────────────────
async function toggleLock(imageId: number, device: Device) {
try {
if (device.lockedImageId === imageId) {
await devicesStore.unlockImage(device.id)
} else {
await devicesStore.lockImage(device.id, imageId)
}
} catch {
toast.show('Failed to update lock', 'error')
}
}
// ── Approval toggles ──────────────────────────────────────────────────────────
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')
}
}
// ── Delete ────────────────────────────────────────────────────────────────────
const deleteSheetOpen = ref(false)
const deletingId = ref<number | null>(null)
const deleting = ref(false)
function confirmDelete(id: number) {
deletingId.value = id
deleteSheetOpen.value = true
}
async function doDelete() {
if (!deletingId.value) return
deleting.value = true
try {
await imagesStore.deleteImage(deletingId.value)
deleteSheetOpen.value = false
toast.show('Photo deleted', 'success')
} catch {
toast.show('Delete failed', 'error')
} finally {
deleting.value = false
}
}
</script>
<style scoped lang="scss">
.library {
padding-bottom: calc(64px + var(--space-4));
&__tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--color-bg);
z-index: 5;
}
&__tab {
flex: 1;
padding: var(--space-3) 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__loading {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4);
text-align: center;
}
&__subtabs {
display: flex;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
&__subtab {
flex: 1;
padding: var(--space-2) 0;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__shared-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
&__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-4);
}
&__page-btn {
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: none;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
cursor: pointer;
&:disabled { opacity: .4; cursor: default; }
}
&__page-info {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
&__empty, &__shared-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
&__empty-title {
font-size: var(--text-md);
font-weight: 700;
color: var(--color-text);
}
&__empty-sub {
font-size: var(--text-sm);
max-width: 280px;
line-height: 1.5;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
padding: var(--space-4);
}
&__item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__thumb {
position: relative;
aspect-ratio: 4/3;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-2);
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&__thumb-actions {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: 4px;
}
&__action-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background var(--duration-fast);
flex-shrink: 0;
&:disabled { opacity: 0.5; cursor: default; }
&:hover:not(:disabled) { background: rgba(0, 0, 0, 0.75); }
&--danger:hover:not(:disabled) { background: rgba(180, 0, 0, 0.8); }
&--warn {
background: var(--color-warning, #f59e0b);
&:hover:not(:disabled) { background: color-mix(in srgb, var(--color-warning, #f59e0b) 85%, black); }
}
}
&__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 {
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);
&--on {
background: var(--color-warning, #f59e0b);
border-color: var(--color-warning, #f59e0b);
border-style: solid;
color: #fff;
}
}
&__sheet-title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__sheet-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-5);
}
&__sheet-actions {
display: flex;
gap: var(--space-3);
> * { flex: 1; }
}
}
</style>