12245759ac
CI / test (push) Has been cancelled
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>
588 lines
18 KiB
Vue
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>
|