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