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>
This commit is contained in:
@@ -1,9 +1,587 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Library</h1>
|
||||
<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">
|
||||
.view { padding: var(--space-4); }
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user