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
+24 -2
View File
@@ -21,7 +21,7 @@ export const useDevicesStore = defineStore('devices', () => {
}
}
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalHours' | 'uniquenessWindow'>>) {
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeHour' | 'timezone' | 'uniquenessWindow'>>) {
const res = await fetch(`/api/devices/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -34,5 +34,27 @@ export const useDevicesStore = defineStore('devices', () => {
return updated
}
return { devices, loading, error, fetchDevices, updateDevice }
async function lockImage(deviceId: number, imageId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageId }),
})
if (!res.ok) throw new Error('Failed to lock image')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
async function unlockImage(deviceId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to unlock')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
return { devices, loading, error, fetchDevices, updateDevice, lockImage, unlockImage }
})
+131
View File
@@ -0,0 +1,131 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Image, CropParams, StickerLayer, SharedImage, SharedImagePage } from '@/types'
interface UploadExtras {
original?: File
cropParams?: CropParams
stickerState?: StickerLayer[]
}
export const useImagesStore = defineStore('images', () => {
const images = ref<Image[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const pendingCount = ref(0)
async function fetchImages() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/images')
if (!res.ok) throw new Error('Failed to load images')
images.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
async function uploadImage(file: File, extras?: UploadExtras): Promise<Image> {
const form = new FormData()
form.append('file', file)
if (extras?.original) form.append('original', extras.original)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
const res = await fetch('/api/images', { method: 'POST', body: form })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Upload failed')
}
const image: Image = await res.json()
images.value.unshift(image)
return image
}
async function reprocessImage(imageId: number, composited: File, extras?: { cropParams?: CropParams; stickerState?: StickerLayer[] }): Promise<Image> {
const form = new FormData()
form.append('file', composited)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
const res = await fetch(`/api/images/${imageId}/reprocess`, { method: 'POST', body: form })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Reprocess failed')
}
const updated: Image = await res.json()
const idx = images.value.findIndex(i => i.id === imageId)
if (idx !== -1) images.value[idx] = updated
return updated
}
async function deleteImage(id: number): Promise<void> {
const res = await fetch(`/api/images/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
images.value = images.value.filter(img => img.id !== id)
}
async function setApproval(imageId: number, deviceId: number, approved: boolean): Promise<void> {
const method = approved ? 'POST' : 'DELETE'
const res = await fetch(`/api/images/${imageId}/approve/${deviceId}`, { method })
if (!res.ok) throw new Error('Failed to update approval')
const updated: Image = await res.json()
const idx = images.value.findIndex(i => i.id === imageId)
if (idx !== -1) images.value[idx] = updated
}
async function fetchSharedImages(status?: string, page = 1, limit = 20): Promise<SharedImagePage> {
const params = new URLSearchParams({ page: String(page), limit: String(limit) })
if (status) params.set('status', status)
const res = await fetch(`/api/shared-images?${params}`)
if (!res.ok) throw new Error('Failed to load shared images')
return res.json()
}
async function fetchPendingCount(): Promise<void> {
const res = await fetch('/api/shared-images/pending-count')
if (res.ok) {
const data = await res.json()
pendingCount.value = data.count
}
}
async function approveShared(sharedId: number, deviceIds: number[]): Promise<SharedImage> {
const res = await fetch(`/api/shared-images/${sharedId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceIds }),
})
if (!res.ok) throw new Error('Failed to approve')
if (pendingCount.value > 0) pendingCount.value--
return res.json()
}
async function declineShared(sharedId: number): Promise<SharedImage> {
const res = await fetch(`/api/shared-images/${sharedId}/decline`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to decline')
if (pendingCount.value > 0) pendingCount.value--
return res.json()
}
async function shareImage(imageId: number, recipientEmail: string): Promise<void> {
const res = await fetch(`/api/images/${imageId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipientEmail }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Failed to share')
}
}
return {
images, loading, error, pendingCount,
fetchImages, uploadImage, reprocessImage, deleteImage, setApproval,
fetchSharedImages, fetchPendingCount, approveShared, declineShared, shareImage,
}
})
+76
View File
@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { StickerLayer, CropParams } from '@/types'
export const useUploadStore = defineStore('upload', () => {
const originalFile = ref<File | null>(null)
const originalUrl = ref<string | null>(null)
const croppedBlob = ref<Blob | null>(null)
const croppedUrl = ref<string | null>(null)
const cropParams = ref<CropParams | null>(null)
const stickers = ref<StickerLayer[]>([])
const contextDeviceId = ref<number | null>(null)
const selectedDeviceIds = ref<number[]>([])
const editingImageId = ref<number | null>(null)
function init(file: File, deviceId?: number) {
cleanup()
originalFile.value = file
originalUrl.value = URL.createObjectURL(file)
contextDeviceId.value = deviceId ?? null
selectedDeviceIds.value = deviceId ? [deviceId] : []
}
async function initEdit(image: import('@/types').Image, deviceId?: number) {
cleanup()
const res = await fetch(image.originalUrl)
const blob = await res.blob()
originalFile.value = new File([blob], image.originalFilename, { type: blob.type })
originalUrl.value = URL.createObjectURL(blob)
editingImageId.value = image.id
cropParams.value = image.cropParams ?? null
stickers.value = image.stickerState ? [...image.stickerState] : []
selectedDeviceIds.value = image.approvedDeviceIds
contextDeviceId.value = deviceId ?? null
}
function setCrop(blob: Blob, params: CropParams) {
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
croppedBlob.value = blob
croppedUrl.value = URL.createObjectURL(blob)
cropParams.value = params
}
function addSticker(s: StickerLayer) {
stickers.value = [...stickers.value, s]
}
function updateSticker(id: string, patch: Partial<StickerLayer>) {
stickers.value = stickers.value.map(s => s.id === id ? { ...s, ...patch } : s)
}
function removeSticker(id: string) {
stickers.value = stickers.value.filter(s => s.id !== id)
}
function cleanup() {
if (originalUrl.value) URL.revokeObjectURL(originalUrl.value)
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
originalFile.value = null
originalUrl.value = null
croppedBlob.value = null
croppedUrl.value = null
cropParams.value = null
stickers.value = []
contextDeviceId.value = null
selectedDeviceIds.value = []
editingImageId.value = null
}
return {
originalFile, originalUrl,
croppedBlob, croppedUrl, cropParams,
stickers, contextDeviceId, selectedDeviceIds, editingImageId,
init, initEdit, setCrop, addSticker, updateSticker, removeSticker, cleanup,
}
})