Files
pictureFrame/frontend/src/views/UploadView.vue
T
football2801 4002ff9fbf
CI / test (push) Has been cancelled
chore: stage all in-progress work before repo split
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>
2026-05-06 12:11:31 -04:00

309 lines
8.6 KiB
Vue

<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>