4002ff9fbf
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>
309 lines
8.6 KiB
Vue
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>
|