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:
@@ -27,7 +27,7 @@
|
||||
:deviceId="devicesStore.devices[0].id"
|
||||
:name="devicesStore.devices[0].name"
|
||||
size="large"
|
||||
status="ok"
|
||||
:status="deviceStatus(devicesStore.devices[0])"
|
||||
:orientation="devicesStore.devices[0].orientation"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
@@ -42,7 +42,7 @@
|
||||
:deviceId="device.id"
|
||||
:name="device.name"
|
||||
size="compact"
|
||||
status="ok"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
@@ -67,6 +67,24 @@
|
||||
<OrientationPicker v-model="editOrientation" />
|
||||
</div>
|
||||
|
||||
<div class="home-view__sheet-field">
|
||||
<p class="home-view__sheet-label">Update time</p>
|
||||
<div class="home-view__interval-grid">
|
||||
<button
|
||||
v-for="opt in WAKE_TIME_OPTIONS"
|
||||
:key="opt.hour"
|
||||
type="button"
|
||||
:class="['home-view__interval-chip', { 'home-view__interval-chip--on': editWakeHour === opt.hour }]"
|
||||
@click="editWakeHour = opt.hour"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<select class="home-view__tz-select" v-model="editTimezone">
|
||||
<optgroup v-for="group in TIMEZONE_GROUPS" :key="group.label" :label="group.label">
|
||||
<option v-for="tz in group.zones" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="home-view__sheet-save"
|
||||
@@ -80,30 +98,120 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
function deviceStatus(device: Device): 'ok' | 'offline' {
|
||||
if (!device.lastSeenAt) return 'offline'
|
||||
const seenMs = Date.now() - new Date(device.lastSeenAt).getTime()
|
||||
const windowMs = Math.max(device.rotationIntervalMinutes * 2 * 60_000, 30 * 60_000)
|
||||
return seenMs <= windowMs ? 'ok' : 'offline'
|
||||
}
|
||||
import FrameCard from '@/components/FrameCard.vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import BaseInput from '@/components/BaseInput.vue'
|
||||
import OrientationPicker from '@/components/OrientationPicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const devicesStore = useDevicesStore()
|
||||
const uploadStore = useUploadStore()
|
||||
|
||||
onMounted(() => devicesStore.fetchDevices())
|
||||
onMounted(() => {
|
||||
devicesStore.fetchDevices()
|
||||
})
|
||||
|
||||
function onAddPhoto(deviceId: number) {
|
||||
// Photo upload flow — Epic 3
|
||||
console.log('add-photo', deviceId)
|
||||
// File picker must be triggered in the user-gesture context (the click handler)
|
||||
// before navigating, otherwise browsers block it as a popup.
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
uploadStore.init(file, deviceId)
|
||||
router.push('/upload')
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// ── Settings sheet ────────────────────────────────────────────────────────────
|
||||
|
||||
const WAKE_TIME_OPTIONS = [
|
||||
{ hour: 0, label: '12 AM' },
|
||||
{ hour: 2, label: '2 AM' },
|
||||
{ hour: 4, label: '4 AM' },
|
||||
{ hour: 6, label: '6 AM' },
|
||||
{ hour: 8, label: '8 AM' },
|
||||
{ hour: 10, label: '10 AM' },
|
||||
{ hour: 12, label: '12 PM' },
|
||||
{ hour: 18, label: '6 PM' },
|
||||
{ hour: 20, label: '8 PM' },
|
||||
{ hour: 22, label: '10 PM' },
|
||||
]
|
||||
|
||||
const TIMEZONE_GROUPS = [
|
||||
{ label: 'Americas', zones: [
|
||||
{ value: 'America/New_York', label: 'Eastern — New York, Toronto' },
|
||||
{ value: 'America/Chicago', label: 'Central — Chicago, Mexico City' },
|
||||
{ value: 'America/Denver', label: 'Mountain — Denver, Calgary' },
|
||||
{ value: 'America/Phoenix', label: 'Mountain (no DST) — Phoenix' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific — Los Angeles, Vancouver' },
|
||||
{ value: 'America/Anchorage', label: 'Alaska — Anchorage' },
|
||||
{ value: 'Pacific/Honolulu', label: 'Hawaii — Honolulu' },
|
||||
{ value: 'America/Sao_Paulo', label: 'Brasília — São Paulo' },
|
||||
{ value: 'America/Argentina/Buenos_Aires', label: 'Argentina — Buenos Aires' },
|
||||
{ value: 'America/Bogota', label: 'Colombia — Bogotá' },
|
||||
]},
|
||||
{ label: 'Europe', zones: [
|
||||
{ value: 'Europe/London', label: 'GMT/BST — London, Dublin' },
|
||||
{ value: 'Europe/Lisbon', label: 'WET/WEST — Lisbon' },
|
||||
{ value: 'Europe/Paris', label: 'CET/CEST — Paris, Brussels, Amsterdam' },
|
||||
{ value: 'Europe/Berlin', label: 'CET/CEST — Berlin, Vienna, Zurich' },
|
||||
{ value: 'Europe/Stockholm', label: 'CET/CEST — Stockholm, Oslo, Copenhagen'},
|
||||
{ value: 'Europe/Helsinki', label: 'EET/EEST — Helsinki, Tallinn, Riga' },
|
||||
{ value: 'Europe/Warsaw', label: 'CET/CEST — Warsaw, Prague, Budapest' },
|
||||
{ value: 'Europe/Rome', label: 'CET/CEST — Rome, Madrid' },
|
||||
{ value: 'Europe/Athens', label: 'EET/EEST — Athens, Bucharest' },
|
||||
{ value: 'Europe/Istanbul', label: 'TRT — Istanbul' },
|
||||
{ value: 'Europe/Moscow', label: 'MSK — Moscow' },
|
||||
]},
|
||||
{ label: 'Asia & Pacific', zones: [
|
||||
{ value: 'Asia/Dubai', label: 'GST — Dubai, Abu Dhabi' },
|
||||
{ value: 'Asia/Karachi', label: 'PKT — Karachi, Islamabad' },
|
||||
{ value: 'Asia/Kolkata', label: 'IST — India' },
|
||||
{ value: 'Asia/Dhaka', label: 'BST — Dhaka, Bangladesh' },
|
||||
{ value: 'Asia/Bangkok', label: 'ICT — Bangkok, Jakarta, Hanoi' },
|
||||
{ value: 'Asia/Singapore', label: 'SGT — Singapore, Kuala Lumpur' },
|
||||
{ value: 'Asia/Shanghai', label: 'CST — Beijing, Shanghai, Taipei' },
|
||||
{ value: 'Asia/Seoul', label: 'KST — Seoul' },
|
||||
{ value: 'Asia/Tokyo', label: 'JST — Tokyo' },
|
||||
{ value: 'Australia/Sydney', label: 'AEDT/AEST — Sydney, Melbourne' },
|
||||
{ value: 'Australia/Brisbane',label: 'AEST (no DST) — Brisbane' },
|
||||
{ value: 'Australia/Perth', label: 'AWST — Perth' },
|
||||
{ value: 'Pacific/Auckland', label: 'NZDT/NZST — Auckland' },
|
||||
]},
|
||||
{ label: 'Africa & Middle East', zones: [
|
||||
{ value: 'Africa/Cairo', label: 'EET — Cairo' },
|
||||
{ value: 'Africa/Nairobi', label: 'EAT — Nairobi, East Africa'},
|
||||
{ value: 'Africa/Johannesburg', label: 'SAST — Johannesburg' },
|
||||
{ value: 'Africa/Lagos', label: 'WAT — Lagos, West Africa' },
|
||||
]},
|
||||
{ label: 'UTC', zones: [
|
||||
{ value: 'UTC', label: 'UTC — Coordinated Universal Time' },
|
||||
]},
|
||||
]
|
||||
|
||||
const sheetOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingDevice = ref<Device | null>(null)
|
||||
const editName = ref('')
|
||||
const editOrientation = ref<Device['orientation']>('landscape')
|
||||
const editWakeHour = ref<number>(4)
|
||||
const editTimezone = ref('UTC')
|
||||
|
||||
function onEdit(deviceId: number) {
|
||||
const device = devicesStore.devices.find(d => d.id === deviceId)
|
||||
@@ -111,6 +219,8 @@ function onEdit(deviceId: number) {
|
||||
editingDevice.value = device
|
||||
editName.value = device.name
|
||||
editOrientation.value = device.orientation
|
||||
editWakeHour.value = device.wakeHour ?? 4
|
||||
editTimezone.value = device.timezone ?? 'UTC'
|
||||
sheetOpen.value = true
|
||||
}
|
||||
|
||||
@@ -121,6 +231,8 @@ async function saveSettings() {
|
||||
await devicesStore.updateDevice(editingDevice.value.id, {
|
||||
name: editName.value.trim() || editingDevice.value.name,
|
||||
orientation: editOrientation.value,
|
||||
wakeHour: editWakeHour.value,
|
||||
timezone: editTimezone.value,
|
||||
})
|
||||
sheetOpen.value = false
|
||||
} finally {
|
||||
@@ -208,6 +320,51 @@ async function saveSettings() {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__interval-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__tz-select {
|
||||
width: 100%;
|
||||
margin-top: var(--space-3);
|
||||
min-height: var(--touch-min);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__interval-chip {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
min-height: var(--touch-min);
|
||||
|
||||
&--on {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
}
|
||||
|
||||
&__sheet-save {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Shared</h1>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="upload-view">
|
||||
<!-- Header -->
|
||||
<header class="upload-view__header">
|
||||
<button
|
||||
v-if="step !== 'done'"
|
||||
class="upload-view__back"
|
||||
type="button"
|
||||
:aria-label="step === 'crop' ? 'Cancel' : 'Back'"
|
||||
@click="goBack"
|
||||
>
|
||||
<svg v-if="step === 'crop'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="upload-view__step-label">{{ stepLabel }}</span>
|
||||
<button
|
||||
v-if="step === 'stickers'"
|
||||
class="upload-view__skip"
|
||||
type="button"
|
||||
@click="skipStickers"
|
||||
>Skip</button>
|
||||
</header>
|
||||
|
||||
<!-- Crop step -->
|
||||
<CropEditor
|
||||
v-if="step === 'crop' && uploadStore.originalUrl"
|
||||
:src="uploadStore.originalUrl"
|
||||
:orientation="contextOrientation"
|
||||
:device-name="contextDeviceName"
|
||||
:initial-params="uploadStore.cropParams"
|
||||
class="upload-view__stage"
|
||||
@crop="onCrop"
|
||||
/>
|
||||
|
||||
<!-- Stickers step -->
|
||||
<StickerCanvas
|
||||
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
|
||||
:cropped-url="uploadStore.croppedUrl"
|
||||
:orientation="contextOrientation"
|
||||
:stickers="uploadStore.stickers"
|
||||
class="upload-view__stage"
|
||||
@add-sticker="uploadStore.addSticker"
|
||||
@update-sticker="uploadStore.updateSticker"
|
||||
@remove-sticker="uploadStore.removeSticker"
|
||||
@done="onStickersDone"
|
||||
/>
|
||||
|
||||
<!-- Done -->
|
||||
<div v-else-if="step === 'done'" class="upload-view__done">
|
||||
<div class="upload-view__done-icon" aria-hidden="true">🎉</div>
|
||||
<p class="upload-view__done-title">{{ isEdit ? 'Photo updated!' : 'Photo added!' }}</p>
|
||||
<p class="upload-view__done-sub">It'll appear on your frame at the next update.</p>
|
||||
<BaseButton variant="primary" class="upload-view__done-btn" @click="finish">Done</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Device picker (only on new uploads, not edits) -->
|
||||
<DevicePicker
|
||||
v-if="!isEdit"
|
||||
v-model="devicePickerOpen"
|
||||
:devices="devicesStore.devices"
|
||||
:selected="uploadStore.selectedDeviceIds"
|
||||
:uploading="uploading"
|
||||
@update:selected="uploadStore.selectedDeviceIds = $event"
|
||||
@confirm="doUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { CropParams } from '@/types'
|
||||
import CropEditor from '@/components/CropEditor.vue'
|
||||
import StickerCanvas from '@/components/StickerCanvas.vue'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const uploadStore = useUploadStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
const imagesStore = useImagesStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
type Step = 'crop' | 'stickers' | 'done'
|
||||
const step = ref<Step>('crop')
|
||||
const uploading = ref(false)
|
||||
const devicePickerOpen = ref(false)
|
||||
let finalBlob: Blob | null = null
|
||||
|
||||
const isEdit = computed(() => uploadStore.editingImageId !== null)
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
await devicesStore.fetchDevices()
|
||||
|
||||
if (!uploadStore.originalFile) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
|
||||
// When opening for edit, jump straight to crop (state already loaded by caller)
|
||||
step.value = 'crop'
|
||||
})
|
||||
|
||||
// ── Context device ────────────────────────────────────────────────────────────
|
||||
|
||||
const contextDevice = computed(() =>
|
||||
uploadStore.contextDeviceId
|
||||
? devicesStore.devices.find(d => d.id === uploadStore.contextDeviceId)
|
||||
: devicesStore.devices[0]
|
||||
)
|
||||
|
||||
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
|
||||
contextDevice.value?.orientation ?? 'landscape'
|
||||
)
|
||||
|
||||
const contextDeviceName = computed(() => contextDevice.value?.name)
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stepLabel = computed(() => {
|
||||
if (step.value === 'crop') return isEdit.value ? 'Edit crop' : 'Crop photo'
|
||||
if (step.value === 'stickers') return 'Add stickers'
|
||||
return isEdit.value ? 'Updated' : 'Added'
|
||||
})
|
||||
|
||||
function onCrop({ blob, params }: { blob: Blob; params: CropParams }) {
|
||||
uploadStore.setCrop(blob, params)
|
||||
step.value = 'stickers'
|
||||
}
|
||||
|
||||
function skipStickers() {
|
||||
if (!uploadStore.croppedBlob) return
|
||||
finalBlob = uploadStore.croppedBlob
|
||||
if (isEdit.value) {
|
||||
doUpload()
|
||||
} else {
|
||||
devicePickerOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onStickersDone(blob: Blob) {
|
||||
finalBlob = blob
|
||||
if (isEdit.value) {
|
||||
doUpload()
|
||||
} else {
|
||||
devicePickerOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (step.value === 'crop') {
|
||||
uploadStore.cleanup()
|
||||
router.replace('/library')
|
||||
return
|
||||
}
|
||||
if (step.value === 'stickers') {
|
||||
step.value = 'crop'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload / reprocess ────────────────────────────────────────────────────────
|
||||
|
||||
async function doUpload() {
|
||||
if (!finalBlob) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const composited = new File([finalBlob], 'photo.jpg', { type: 'image/jpeg' })
|
||||
|
||||
if (isEdit.value) {
|
||||
await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, {
|
||||
cropParams: uploadStore.cropParams ?? undefined,
|
||||
stickerState: uploadStore.stickers,
|
||||
})
|
||||
devicePickerOpen.value = false
|
||||
step.value = 'done'
|
||||
return
|
||||
}
|
||||
|
||||
const image = await imagesStore.uploadImage(composited, {
|
||||
original: uploadStore.originalFile ?? undefined,
|
||||
cropParams: uploadStore.cropParams ?? undefined,
|
||||
stickerState: uploadStore.stickers,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
uploadStore.selectedDeviceIds.map(deviceId =>
|
||||
imagesStore.setApproval(image.id, deviceId, true)
|
||||
)
|
||||
)
|
||||
devicePickerOpen.value = false
|
||||
step.value = 'done'
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : 'Upload failed', 'error')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
uploadStore.cleanup()
|
||||
router.replace('/library')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.upload-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__back {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
&__step-label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__skip {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
&__stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__done {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) var(--space-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__done-icon {
|
||||
font-size: 64px;
|
||||
line-height: 1;
|
||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
||||
}
|
||||
|
||||
&__done-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__done-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
max-width: 260px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__done-btn {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user