Files
pictureFrame-webApp/frontend/src/views/LibraryView.vue
T
football2801 12245759ac
CI / test (push) Has been cancelled
chore: stage all in-progress work before repo split
Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:11:31 -04:00

588 lines
18 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
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) {
if (editingId.value) return
editingId.value = image.id
try {
await uploadStore.initEdit(image)
router.push('/upload')
} catch {
toast.show('Could not load photo for editing', 'error')
} finally {
editingId.value = 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); }
}
&__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>