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
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="approve-card">
<img :src="item.thumbnailUrl" :alt="`Photo from ${item.sharedBy}`" class="approve-card__thumb" loading="lazy" />
<div class="approve-card__body">
<p class="approve-card__from">From <strong>{{ item.sharedBy }}</strong></p>
<p class="approve-card__date">{{ formattedDate }}</p>
<div class="approve-card__status" v-if="item.status !== 'pending'">
<span :class="['approve-card__badge', `approve-card__badge--${item.status}`]">
{{ item.status }}
</span>
</div>
<div class="approve-card__actions">
<template v-if="item.status === 'pending' || item.status === 'declined'">
<BaseButton variant="primary" size="sm" :disabled="busy" @click="showPicker = true">
{{ item.status === 'declined' ? 'Add anyway' : 'Add to frame' }}
</BaseButton>
</template>
<template v-if="item.status === 'pending' || item.status === 'approved'">
<BaseButton variant="ghost" size="sm" :disabled="busy" @click="decline">
{{ item.status === 'approved' ? 'Remove' : 'Decline' }}
</BaseButton>
</template>
</div>
</div>
</div>
<DevicePicker
v-model="showPicker"
:devices="devicesStore.devices"
:selected="selectedDeviceIds"
:uploading="busy"
confirm-label="Add to frames"
@update:selected="selectedDeviceIds = $event"
@confirm="approve"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { SharedImage } from '@/types'
import BaseButton from '@/components/BaseButton.vue'
import DevicePicker from '@/components/DevicePicker.vue'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
const props = defineProps<{ item: SharedImage }>()
const emit = defineEmits<{ (e: 'updated', v: SharedImage): void }>()
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
const showPicker = ref(false)
const busy = ref(false)
const selectedDeviceIds = ref<number[]>([])
const formattedDate = computed(() =>
new Date(props.item.sharedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
)
async function approve() {
showPicker.value = false
busy.value = true
try {
const updated = await imagesStore.approveShared(props.item.id, selectedDeviceIds.value)
emit('updated', updated)
} finally {
busy.value = false
selectedDeviceIds.value = []
}
}
async function decline() {
busy.value = true
try {
const updated = await imagesStore.declineShared(props.item.id)
emit('updated', updated)
} finally {
busy.value = false
}
}
</script>
<style scoped lang="scss">
.approve-card {
display: flex;
gap: var(--space-3);
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
background: var(--color-border);
}
&__body {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
&__from { font-size: var(--text-sm); }
&__date { font-size: var(--text-xs); color: var(--color-text-muted); }
&__badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
text-transform: capitalize;
&--approved { background: #d4edda; color: #1a7f4b; }
&--declined { background: #fde8e8; color: #d93025; }
}
&__actions {
display: flex;
gap: var(--space-2);
margin-top: auto;
flex-wrap: wrap;
}
}
</style>
+39 -4
View File
@@ -8,7 +8,12 @@
:aria-label="tab.label"
:aria-current="isActive(tab.to) ? 'page' : undefined"
>
<span class="bottom-nav__icon" aria-hidden="true" v-html="tab.icon" />
<span class="bottom-nav__icon-wrap" aria-hidden="true">
<span class="bottom-nav__icon" v-html="tab.icon" />
<span v-if="tab.name === 'shared' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}
</span>
</span>
<span class="bottom-nav__label">{{ tab.label }}</span>
</RouterLink>
</nav>
@@ -16,8 +21,10 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useImagesStore } from '@/stores/images'
const route = useRoute()
const route = useRoute()
const imagesStore = useImagesStore()
const tabs = [
{
@@ -35,7 +42,7 @@ const tabs = [
{
name: 'shared',
label: 'Shared',
to: '/shared',
to: '/library?tab=shared',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
},
{
@@ -46,7 +53,8 @@ const tabs = [
},
]
function isActive(path: string) {
function isActive(to: string) {
const path = to.split('?')[0]
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
@@ -85,6 +93,15 @@ function isActive(path: string) {
}
}
&__icon-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
&__icon {
display: flex;
align-items: center;
@@ -93,6 +110,24 @@ function isActive(path: string) {
height: 24px;
}
&__badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--color-primary);
color: var(--color-primary-fg);
border-radius: 999px;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
&__label {
font-size: var(--text-xs);
font-weight: 600;
+337
View File
@@ -0,0 +1,337 @@
<template>
<div class="crop-editor" ref="containerRef">
<canvas
ref="canvasRef"
class="crop-editor__canvas"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<div class="crop-editor__label" v-if="deviceName">{{ deviceName }}</div>
<div class="crop-editor__actions">
<BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop">
Use this crop
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import type { CropParams } from '@/types'
const props = defineProps<{
src: string
orientation: 'landscape' | 'portrait'
deviceName?: string
initialParams?: CropParams | null
}>()
const emit = defineEmits<{
(e: 'crop', result: { blob: Blob; params: CropParams }): void
}>()
// Dimensions for each orientation
const OUTPUT_W = props.orientation === 'landscape' ? 1600 : 960
const OUTPUT_H = props.orientation === 'landscape' ? 960 : 1600
const ASPECT = OUTPUT_W / OUTPUT_H
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
let ctx: CanvasRenderingContext2D | null = null
let img: HTMLImageElement | null = null
let rafId = 0
// State: pan (canvas px from centered) + zoom multiplier
const panX = ref(0)
const panY = ref(0)
const zoom = ref(1)
// Crop rect on canvas (set when canvas is sized)
let cropRect = { x: 0, y: 0, w: 0, h: 0 }
let minScale = 1 // natural px → canvas px at zoom=1 (cover)
function sizeCanvas() {
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
const available = container.getBoundingClientRect()
// Leave space for bottom button bar
const availH = available.height - 80
const availW = available.width
canvas.width = availW
canvas.height = availH
ctx = canvas.getContext('2d')
// Compute crop rect (inset 24px each side for comfort)
const pad = 24
const maxW = availW - pad * 2
const maxH = availH - pad * 2
let cropW: number, cropH: number
if (maxW / maxH > ASPECT) {
cropH = maxH
cropW = cropH * ASPECT
} else {
cropW = maxW
cropH = cropW / ASPECT
}
cropRect = {
x: (availW - cropW) / 2,
y: (availH - cropH) / 2,
w: cropW,
h: cropH,
}
if (img) {
resetView()
}
}
function resetView() {
if (!img) return
minScale = Math.max(cropRect.w / img.naturalWidth, cropRect.h / img.naturalHeight)
if (props.initialParams) {
restoreView(props.initialParams)
} else {
zoom.value = 1
panX.value = 0
panY.value = 0
draw()
}
}
function restoreView(p: CropParams) {
if (!img) return
// actualScale such that natW fills the crop frame width
const actualScale = cropRect.w / p.natW
zoom.value = actualScale / minScale
// pan: offset from centered position so crop center = frame center
panX.value = actualScale * (img.naturalWidth / 2 - p.natX - p.natW / 2)
panY.value = actualScale * (img.naturalHeight / 2 - p.natY - p.natH / 2)
const [cx, cy] = clampPan(panX.value, panY.value)
panX.value = cx
panY.value = cy
draw()
}
function clampPan(px: number, py: number): [number, number] {
if (!img) return [px, py]
const actualScale = minScale * zoom.value
const imgW = img.naturalWidth * actualScale
const imgH = img.naturalHeight * actualScale
const maxPx = (imgW - cropRect.w) / 2
const maxPy = (imgH - cropRect.h) / 2
return [
Math.max(-maxPx, Math.min(maxPx, px)),
Math.max(-maxPy, Math.min(maxPy, py)),
]
}
function draw() {
if (!ctx || !img || !canvasRef.value) return
const { width, height } = canvasRef.value
const actualScale = minScale * zoom.value
const imgW = img.naturalWidth * actualScale
const imgH = img.naturalHeight * actualScale
const cx = cropRect.x + cropRect.w / 2 + panX.value
const cy = cropRect.y + cropRect.h / 2 + panY.value
const imgLeft = cx - imgW / 2
const imgTop = cy - imgH / 2
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
// Dark overlay outside crop
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.fillRect(0, 0, width, height)
ctx.globalCompositeOperation = 'destination-out'
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
ctx.restore()
// Crop border + corner marks
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.strokeRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
const cLen = 20
ctx.lineWidth = 3
;[
[cropRect.x, cropRect.y, cLen, 0, 0, cLen],
[cropRect.x + cropRect.w, cropRect.y, -cLen, 0, 0, cLen],
[cropRect.x, cropRect.y + cropRect.h, cLen, 0, 0, -cLen],
[cropRect.x + cropRect.w, cropRect.y + cropRect.h, -cLen, 0, 0, -cLen],
].forEach(([x, y, dx1, dy1, dx2, dy2]) => {
ctx!.beginPath()
ctx!.moveTo(x + dx1, y + dy1)
ctx!.lineTo(x, y)
ctx!.lineTo(x + dx2, y + dy2)
ctx!.stroke()
})
}
// ── Touch / pointer handling ──────────────────────────────────────────────────
const activePointers = new Map<number, { x: number; y: number }>()
let lastPinchDist = 0
function onPointerDown(e: PointerEvent) {
canvasRef.value?.setPointerCapture(e.pointerId)
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (activePointers.size === 2) {
const pts = [...activePointers.values()]
lastPinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
}
}
function onPointerMove(e: PointerEvent) {
if (!activePointers.has(e.pointerId)) return
const prev = activePointers.get(e.pointerId)!
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (activePointers.size === 1) {
const dx = e.clientX - prev.x
const dy = e.clientY - prev.y
const [cx, cy] = clampPan(panX.value + dx, panY.value + dy)
panX.value = cx
panY.value = cy
scheduleDraw()
return
}
if (activePointers.size === 2) {
const pts = [...activePointers.values()]
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
if (lastPinchDist > 0) {
const ratio = dist / lastPinchDist
const newZoom = Math.max(1, zoom.value * ratio)
zoom.value = newZoom
const [cx, cy] = clampPan(panX.value, panY.value)
panX.value = cx
panY.value = cy
scheduleDraw()
}
lastPinchDist = dist
}
}
function onPointerUp(e: PointerEvent) {
activePointers.delete(e.pointerId)
lastPinchDist = 0
}
function scheduleDraw() {
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(draw)
}
// ── Output ────────────────────────────────────────────────────────────────────
async function useCrop() {
if (!img) return
const actualScale = minScale * zoom.value
const cx = cropRect.x + cropRect.w / 2 + panX.value
const cy = cropRect.y + cropRect.h / 2 + panY.value
const imgLeft = cx - img.naturalWidth * actualScale / 2
const imgTop = cy - img.naturalHeight * actualScale / 2
const natCropX = (cropRect.x - imgLeft) / actualScale
const natCropY = (cropRect.y - imgTop) / actualScale
const natCropW = cropRect.w / actualScale
const natCropH = cropRect.h / actualScale
const out = new OffscreenCanvas(OUTPUT_W, OUTPUT_H)
const outCtx = out.getContext('2d')!
outCtx.drawImage(img, natCropX, natCropY, natCropW, natCropH, 0, 0, OUTPUT_W, OUTPUT_H)
const blob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
emit('crop', {
blob,
params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH },
})
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
const ro = new ResizeObserver(sizeCanvas)
onMounted(() => {
if (containerRef.value) ro.observe(containerRef.value)
sizeCanvas()
img = new Image()
img.onload = () => {
sizeCanvas()
resetView()
}
img.src = props.src
})
watch(() => props.src, src => {
if (!img) return
img.onload = () => resetView()
img.src = src
})
onBeforeUnmount(() => {
ro.disconnect()
cancelAnimationFrame(rafId)
})
</script>
<style scoped lang="scss">
.crop-editor {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #000;
touch-action: none;
&__canvas {
flex: 1;
min-height: 0;
display: block;
touch-action: none;
cursor: grab;
&:active { cursor: grabbing; }
}
&__label {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
letter-spacing: 0.04em;
pointer-events: none;
}
&__actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--space-4);
display: flex;
justify-content: center;
}
&__use-btn {
width: 100%;
max-width: 320px;
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Choose frames" @update:model-value="$emit('update:modelValue', $event)">
<h2 class="device-picker__title">Add to frames</h2>
<p class="device-picker__sub">Choose which frames will show this photo.</p>
<div class="device-picker__list">
<label
v-for="device in devices"
:key="device.id"
class="device-picker__row"
>
<input
type="checkbox"
class="device-picker__check"
:checked="selected.includes(device.id)"
@change="toggle(device.id)"
/>
<span class="device-picker__name">{{ device.name }}</span>
<span class="device-picker__orientation">{{ device.orientation }}</span>
</label>
</div>
<BaseButton
variant="primary"
class="device-picker__confirm"
:disabled="selected.length === 0 || uploading"
@click="$emit('confirm')"
>
{{ uploading ? 'Uploading…' : confirmLabel }}
</BaseButton>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import type { Device } from '@/types'
const props = defineProps<{
modelValue: boolean
devices: Device[]
selected: number[]
uploading?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'update:selected', v: number[]): void
(e: 'confirm'): void
}>()
function toggle(id: number) {
if (props.selected.includes(id)) {
emit('update:selected', props.selected.filter(d => d !== id))
} else {
emit('update:selected', [...props.selected, id])
}
}
const confirmLabel = computed(() => {
const n = props.selected.length
return n === 0 ? 'Add to frame' : `Add to ${n} frame${n > 1 ? 's' : ''}`
})
</script>
<style scoped lang="scss">
.device-picker {
&__title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
&__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-5);
}
&__row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
min-height: var(--touch-min);
}
&__check {
width: 20px;
height: 20px;
accent-color: var(--color-primary);
cursor: pointer;
flex-shrink: 0;
}
&__name {
flex: 1;
font-size: var(--text-base);
font-weight: 600;
}
&__orientation {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: capitalize;
}
&__confirm {
width: 100%;
}
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Share photo" @update:model-value="$emit('update:modelValue', $event)">
<h2 class="share-sheet__title">Share with someone</h2>
<p class="share-sheet__sub">They'll get an email and can add it to their frame.</p>
<div class="share-sheet__field">
<input
v-model="email"
type="email"
class="share-sheet__input"
placeholder="their@email.com"
autocomplete="email"
@keydown.enter.prevent="submit"
/>
</div>
<p v-if="errorMsg" class="share-sheet__error">{{ errorMsg }}</p>
<p v-if="successMsg" class="share-sheet__success">{{ successMsg }}</p>
<BaseButton
variant="primary"
class="share-sheet__btn"
:disabled="sending || !email.trim()"
@click="submit"
>
{{ sending ? 'Sending' : 'Send invite' }}
</BaseButton>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import { useImagesStore } from '@/stores/images'
const props = defineProps<{
modelValue: boolean
imageId: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const imagesStore = useImagesStore()
const email = ref('')
const sending = ref(false)
const errorMsg = ref('')
const successMsg = ref('')
async function submit() {
errorMsg.value = ''
successMsg.value = ''
if (!email.value.trim()) return
sending.value = true
try {
await imagesStore.shareImage(props.imageId, email.value.trim())
successMsg.value = `Invite sent to ${email.value.trim()}`
email.value = ''
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'Failed to send'
} finally {
sending.value = false
}
}
</script>
<style scoped lang="scss">
.share-sheet {
&__title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-1); }
&__sub { font-size: var(--text-sm); color: var(--color-text-muted); margin-bottom: var(--space-4); }
&__field { margin-bottom: var(--space-3); }
&__input {
width: 100%;
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
&:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; }
}
&__error { font-size: var(--text-sm); color: var(--color-danger, #d93025); margin-bottom: var(--space-3); }
&__success { font-size: var(--text-sm); color: var(--color-success, #1a7f4b); margin-bottom: var(--space-3); }
&__btn { width: 100%; }
}
</style>
+353
View File
@@ -0,0 +1,353 @@
<template>
<div class="sticker-canvas" ref="containerRef">
<v-stage
ref="stageRef"
:config="stageConfig"
@click="onStageClick"
@tap="onStageClick"
>
<v-layer>
<v-image :config="imageConfig" />
</v-layer>
<v-layer ref="stickerLayerRef">
<v-text
v-for="s in stickers"
:key="s.id"
:config="stickerConfig(s)"
@click="selectSticker(s.id, $event)"
@tap="selectSticker(s.id, $event)"
@dragend="onDragEnd(s.id, $event)"
@transformend="onTransformEnd(s.id, $event)"
/>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<!-- Delete selected sticker button -->
<button
v-if="selectedId"
class="sticker-canvas__delete"
type="button"
aria-label="Remove sticker"
@click="removeSelected"
>
<svg width="16" height="16" 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>
</button>
<!-- Add sticker / Next -->
<div class="sticker-canvas__bar">
<button class="sticker-canvas__add-btn" type="button" @click="trayOpen = true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
</svg>
Add sticker
</button>
<BaseButton variant="primary" class="sticker-canvas__next-btn" @click="done">Next</BaseButton>
</div>
<!-- Sticker tray -->
<StickerTray v-model="trayOpen" @pick="addStickerFromTray" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import Konva from 'konva'
import BaseButton from '@/components/BaseButton.vue'
import StickerTray from '@/components/StickerTray.vue'
import type { StickerLayer } from '@/types'
const props = defineProps<{
croppedUrl: string
orientation: 'landscape' | 'portrait'
stickers: StickerLayer[]
}>()
const emit = defineEmits<{
(e: 'add-sticker', s: StickerLayer): void
(e: 'update-sticker', id: string, patch: Partial<StickerLayer>): void
(e: 'remove-sticker', id: string): void
(e: 'done', blob: Blob): void
}>()
const containerRef = ref<HTMLDivElement>()
const stageRef = ref()
const transformerRef = ref()
const stickerLayerRef = ref()
const trayOpen = ref(false)
const selectedId = ref<string | null>(null)
// Stage dimensions fitted to container
const stageW = ref(375)
const stageH = ref(225)
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600)
function sizeStage() {
if (!containerRef.value) return
const { width, height } = containerRef.value.getBoundingClientRect()
const availH = height - 72 // bottom bar
if (width / availH > ASPECT) {
stageH.value = availH
stageW.value = availH * ASPECT
} else {
stageW.value = width
stageH.value = width / ASPECT
}
loadImage()
}
const ro = new ResizeObserver(sizeStage)
onMounted(() => {
if (containerRef.value) ro.observe(containerRef.value)
sizeStage()
attachPinchListeners()
})
onBeforeUnmount(() => {
ro.disconnect()
detachPinchListeners()
})
// ── Image ─────────────────────────────────────────────────────────────────────
const bgImage = ref<HTMLImageElement | null>(null)
function loadImage() {
const i = new Image()
i.onload = () => { bgImage.value = i }
i.src = props.croppedUrl
}
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
const imageConfig = computed(() => ({
image: bgImage.value,
x: 0, y: 0,
width: stageW.value,
height: stageH.value,
}))
const transformerConfig = {
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
rotateEnabled: true,
borderStroke: 'rgba(255,255,255,0.8)',
anchorFill: '#fff',
anchorSize: 18, // larger for touch
keepRatio: true,
boundBoxFunc: (_: any, newBox: any) => newBox,
}
// ── Stickers ──────────────────────────────────────────────────────────────────
const EMOJI_FONT_SIZE = 52
function stickerConfig(s: StickerLayer) {
return {
id: s.id,
text: stickerEmoji(s.type),
fontSize: EMOJI_FONT_SIZE,
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
x: s.x,
y: s.y,
scaleX: s.scale,
scaleY: s.scale,
rotation: s.rotation,
draggable: true,
offsetX: EMOJI_FONT_SIZE / 2,
offsetY: EMOJI_FONT_SIZE / 2,
}
}
import { STICKERS } from '@/assets/stickers/index'
function stickerEmoji(type: string): string {
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
}
function selectSticker(id: string, e: any) {
e.cancelBubble = true
selectedId.value = id
nextTick(() => {
const layer = stickerLayerRef.value?.getNode()
const node = layer?.findOne(`#${id}`)
const tr = transformerRef.value?.getNode()
if (node && tr) tr.nodes([node])
})
}
function onStageClick(e: any) {
if (e.target === e.target.getStage()) {
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
}
}
function removeSelected() {
if (!selectedId.value) return
emit('remove-sticker', selectedId.value)
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
}
function onDragEnd(id: string, e: any) {
emit('update-sticker', id, { x: e.target.x(), y: e.target.y() })
}
function onTransformEnd(id: string, e: any) {
emit('update-sticker', id, {
x: e.target.x(),
y: e.target.y(),
scale: e.target.scaleX(),
rotation: e.target.rotation(),
})
}
function addStickerFromTray(stickerId: string) {
const s: StickerLayer = {
id: `${stickerId}-${Date.now()}`,
type: stickerId,
x: stageW.value / 2,
y: stageH.value / 2,
scale: 1,
rotation: 0,
}
emit('add-sticker', s)
trayOpen.value = false
// Auto-select the new sticker
nextTick(() => selectSticker(s.id, { cancelBubble: false }))
}
// ── Pinch-to-resize ───────────────────────────────────────────────────────────
let pinchStartDist = 0
let pinchStartScale = 1
function touchDist(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.hypot(dx, dy)
}
function onPinchStart(e: TouchEvent) {
if (e.touches.length !== 2 || !selectedId.value) return
pinchStartDist = touchDist(e.touches)
const s = props.stickers.find(x => x.id === selectedId.value)
pinchStartScale = s?.scale ?? 1
}
function onPinchMove(e: TouchEvent) {
if (e.touches.length !== 2 || !selectedId.value || pinchStartDist === 0) return
e.preventDefault()
const newScale = Math.max(0.2, Math.min(6, pinchStartScale * (touchDist(e.touches) / pinchStartDist)))
emit('update-sticker', selectedId.value, { scale: newScale })
}
function onPinchEnd() {
pinchStartDist = 0
pinchStartScale = 1
}
function attachPinchListeners() {
const el = containerRef.value
if (!el) return
el.addEventListener('touchstart', onPinchStart, { passive: true })
el.addEventListener('touchmove', onPinchMove, { passive: false })
el.addEventListener('touchend', onPinchEnd, { passive: true })
}
function detachPinchListeners() {
const el = containerRef.value
if (!el) return
el.removeEventListener('touchstart', onPinchStart)
el.removeEventListener('touchmove', onPinchMove)
el.removeEventListener('touchend', onPinchEnd)
}
// ── Output ────────────────────────────────────────────────────────────────────
async function done() {
// Deselect to hide transformer handles before capture
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
await nextTick()
const stage: Konva.Stage = stageRef.value?.getNode()
if (!stage) return
const outputW = props.orientation === 'landscape' ? 1600 : 960
const pixelRatio = outputW / stageW.value
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
if (blob) emit('done', blob)
}
</script>
<style scoped lang="scss">
.sticker-canvas {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
background: #111;
overflow: hidden;
:deep(.konvajs-content) {
flex-shrink: 0;
}
&__delete {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(200, 30, 30, 0.85);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
z-index: 10;
}
&__bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 72px;
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-4);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
&__add-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: 1.5px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
&__next-btn {
margin-left: auto;
min-width: 96px;
}
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
<div class="sticker-tray">
<div class="sticker-tray__cats" role="tablist">
<button
v-for="cat in STICKER_CATEGORIES"
:key="cat.id"
type="button"
role="tab"
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
@click="activeCategory = cat.id"
>{{ cat.label }}</button>
</div>
<div class="sticker-tray__grid" role="tabpanel">
<button
v-for="s in visibleStickers"
:key="s.id"
type="button"
class="sticker-tray__item"
:aria-label="s.label"
@click="$emit('pick', s.id)"
>
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
<span class="sticker-tray__label">{{ s.label }}</span>
</button>
</div>
</div>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
import type { StickerCategory } from '@/assets/stickers/index'
defineProps<{ modelValue: boolean }>()
defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'pick', stickerId: string): void
}>()
const activeCategory = ref<StickerCategory>('seasonal')
const visibleStickers = computed(() =>
STICKERS.filter(s => s.category === activeCategory.value)
)
</script>
<style scoped lang="scss">
.sticker-tray {
&__cats {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-3);
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
&__cat {
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-fg);
}
}
&__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-2);
}
&__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-2) var(--space-1);
border-radius: var(--radius-sm);
border: none;
background: transparent;
cursor: pointer;
transition: background var(--duration-fast);
&:active { background: var(--color-surface-2); }
}
&__emoji {
font-size: 36px;
line-height: 1;
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
}
&__label {
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
}
}
</style>