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:
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user