chore: stage all in-progress work before repo split
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>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
+162 -5
View File
@@ -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);
+581 -3
View File
@@ -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>
-9
View File
@@ -1,9 +0,0 @@
<template>
<main class="view">
<h1>Shared</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+308
View File
@@ -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>