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
+4 -3
View File
@@ -1,21 +1,22 @@
<template>
<RouterView />
<BottomNav />
<BottomNav v-if="!route.meta.hideNav" />
<BaseToast />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import BottomNav from '@/components/BottomNav.vue'
import BaseToast from '@/components/BaseToast.vue'
import { useAuthStore } from '@/stores/auth'
import { useTheme } from '@/composables/useTheme'
const auth = useAuthStore()
const route = useRoute()
const auth = useAuthStore()
const { applyTheme } = useTheme()
onMounted(() => {
// Sync Vue's theme state with whatever SpaController stamped on <html>
const stamped = document.documentElement.dataset.theme
if (stamped && auth.user) {
auth.user.theme = stamped
+44
View File
@@ -0,0 +1,44 @@
export interface StickerDef {
id: string
category: 'seasonal' | 'holidays' | 'fun' | 'family' | 'nature'
label: string
emoji: string
}
export const STICKER_CATEGORIES = [
{ id: 'seasonal', label: 'Seasonal' },
{ id: 'holidays', label: 'Holidays' },
{ id: 'fun', label: 'Fun' },
{ id: 'family', label: 'Family' },
{ id: 'nature', label: 'Nature' },
] as const
export type StickerCategory = typeof STICKER_CATEGORIES[number]['id']
export const STICKERS: StickerDef[] = [
{ id: 'sea-snow', category: 'seasonal', label: 'Snowflake', emoji: '❄️' },
{ id: 'sea-sun', category: 'seasonal', label: 'Sun', emoji: '☀️' },
{ id: 'sea-leaves', category: 'seasonal', label: 'Autumn', emoji: '🍂' },
{ id: 'sea-blossom', category: 'seasonal', label: 'Blossom', emoji: '🌸' },
{ id: 'sea-snowman', category: 'seasonal', label: 'Snowman', emoji: '⛄' },
{ id: 'hol-tree', category: 'holidays', label: 'Tree', emoji: '🎄' },
{ id: 'hol-gift', category: 'holidays', label: 'Gift', emoji: '🎁' },
{ id: 'hol-heart', category: 'holidays', label: 'Heart', emoji: '❤️' },
{ id: 'hol-party', category: 'holidays', label: 'Party', emoji: '🎉' },
{ id: 'hol-cake', category: 'holidays', label: 'Cake', emoji: '🎂' },
{ id: 'fun-star', category: 'fun', label: 'Star', emoji: '⭐' },
{ id: 'fun-rainbow', category: 'fun', label: 'Rainbow', emoji: '🌈' },
{ id: 'fun-balloon', category: 'fun', label: 'Balloon', emoji: '🎈' },
{ id: 'fun-sparkle', category: 'fun', label: 'Sparkles', emoji: '✨' },
{ id: 'fun-fire', category: 'fun', label: 'Fire', emoji: '🔥' },
{ id: 'fam-house', category: 'family', label: 'Home', emoji: '🏠' },
{ id: 'fam-paw', category: 'family', label: 'Paw', emoji: '🐾' },
{ id: 'fam-camera', category: 'family', label: 'Camera', emoji: '📷' },
{ id: 'fam-plane', category: 'family', label: 'Airplane', emoji: '✈️' },
{ id: 'fam-music', category: 'family', label: 'Music', emoji: '🎵' },
{ id: 'nat-tree', category: 'nature', label: 'Tree', emoji: '🌲' },
{ id: 'nat-flower', category: 'nature', label: 'Flower', emoji: '🌺' },
{ id: 'nat-bee', category: 'nature', label: 'Bee', emoji: '🐝' },
{ id: 'nat-fly', category: 'nature', label: 'Butterfly', emoji: '🦋' },
{ id: 'nat-moon', category: 'nature', label: 'Moon', emoji: '🌙' },
]
+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>
+2
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import VueKonva from 'vue-konva'
import '@/styles/global.scss'
import App from './App.vue'
import router from '@/router'
@@ -7,4 +8,5 @@ import router from '@/router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(VueKonva)
app.mount('#app')
+9 -4
View File
@@ -17,10 +17,10 @@ const router = createRouter({
meta: { requiresAuth: true },
},
{
path: '/shared',
name: 'shared',
component: () => import('@/views/SharedView.vue'),
meta: { requiresAuth: true },
path: '/upload',
name: 'upload',
component: () => import('@/views/UploadView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/settings',
@@ -28,6 +28,11 @@ const router = createRouter({
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true },
},
// Redirect old /shared to library shared tab
{
path: '/shared',
redirect: '/library?tab=shared',
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
+24 -2
View File
@@ -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 }
})
+131
View File
@@ -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,
}
})
+76
View File
@@ -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,
}
})
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'
describe('BaseButton', () => {
it('renders slot content', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toContain('Click me')
})
it('renders as a <button> by default', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'OK' },
})
expect(wrapper.element.tagName).toBe('BUTTON')
})
it('applies primary variant class by default', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.classes()).toContain('btn--primary')
})
it('applies the given variant class', () => {
const wrapper = mount(BaseButton, {
props: { variant: 'destructive' },
slots: { default: 'Delete' },
})
expect(wrapper.classes()).toContain('btn--destructive')
})
it('shows spinner element when loading is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Saving...' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(true)
})
it('does not show spinner when loading is false', () => {
const wrapper = mount(BaseButton, {
props: { loading: false },
slots: { default: 'Save' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(false)
})
it('applies btn--loading class when loading', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Wait' },
})
expect(wrapper.classes()).toContain('btn--loading')
})
it('is disabled when disabled prop is true', () => {
const wrapper = mount(BaseButton, {
props: { disabled: true },
slots: { default: 'Blocked' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is disabled when loading prop is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Loading' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is not disabled when neither disabled nor loading', () => {
const wrapper = mount(BaseButton, {
props: { disabled: false, loading: false },
slots: { default: 'Go' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(false)
})
it('emits click event when clicked and not disabled', async () => {
const wrapper = mount(BaseButton, { slots: { default: 'Go' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('type attribute defaults to button', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.attributes('type')).toBe('button')
})
it('type attribute can be set to submit', () => {
const wrapper = mount(BaseButton, {
props: { type: 'submit' },
slots: { default: 'Submit' },
})
expect(wrapper.attributes('type')).toBe('submit')
})
it('renders as an anchor when tag is a', () => {
const wrapper = mount(BaseButton, {
props: { tag: 'a' },
slots: { default: 'Link' },
})
expect(wrapper.element.tagName).toBe('A')
// <a> should not have a type attribute
expect(wrapper.attributes('type')).toBeUndefined()
})
})
@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DevicePicker from '@/components/DevicePicker.vue'
import type { Device } from '@/types'
// Stub child components DevicePicker wraps
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('DevicePicker', () => {
const devices = [
makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'Bedroom' }),
]
function mountPicker(selected: number[] = []) {
return mount(DevicePicker, {
props: {
modelValue: true,
devices,
selected,
},
})
}
// DP-01: Selecting a device emits update:selected with the device added
it('checking a device emits update:selected with device id added', async () => {
const wrapper = mountPicker([])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1)
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toEqual([1])
})
// DP-02: Deselecting a device emits update:selected with device id removed
it('unchecking a device emits update:selected with device id removed', async () => {
// Start with both selected
const wrapper = mountPicker([1, 2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1) — it's currently checked, so this deselects
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
// Should emit [2] — Living Room removed
expect(emitted![0][0]).toEqual([2])
})
// DP-03: Checkboxes reflect the selected prop
it('checkboxes are checked for ids in selected prop', async () => {
const wrapper = mountPicker([2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false) // id=1 not selected
expect((checkboxes[1].element as HTMLInputElement).checked).toBe(true) // id=2 selected
})
// DP-04: Confirm button disabled when nothing selected
it('confirm button is disabled when selected is empty', async () => {
const wrapper = mountPicker([])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
// DP-05: Confirm button enabled when at least one device selected
it('confirm button is enabled when a device is selected', async () => {
const wrapper = mountPicker([1])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
})
// DP-06: Device names are rendered
it('renders all device names', () => {
const wrapper = mountPicker([])
expect(wrapper.text()).toContain('Living Room')
expect(wrapper.text()).toContain('Bedroom')
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FrameCard from '@/components/FrameCard.vue'
// Mock vue-konva to avoid canvas issues if transitively imported
vi.mock('vue-konva', () => ({}))
const defaultProps = {
deviceId: 1,
name: 'Living Room',
size: 'large' as const,
status: 'ok' as const,
orientation: 'landscape' as const,
}
describe('FrameCard', () => {
it('renders device name', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.text()).toContain('Living Room')
})
it('does not show status badge when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
})
it('shows "Offline" badge when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Offline')
})
it('shows "Sync issue" badge when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Sync issue')
})
it('applies offline modifier class when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
expect(wrapper.classes()).toContain('frame-card--offline')
})
it('applies sync-fail modifier class when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
expect(wrapper.classes()).toContain('frame-card--sync-fail')
})
it('shows settings button in large size', () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(true)
})
it('does not show settings button in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact' },
})
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(false)
})
it('shows img element when thumbnailUrl is provided', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, thumbnailUrl: '/thumb/test.jpg' },
})
const img = wrapper.find('img.frame-card__img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/thumb/test.jpg')
})
it('shows empty preview placeholder when no thumbnailUrl', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__empty-preview').exists()).toBe(true)
expect(wrapper.find('img.frame-card__img').exists()).toBe(false)
})
it('shows photo count in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 3 },
})
expect(wrapper.text()).toContain('3 photos')
})
it('uses singular "photo" when photoCount is 1', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 1 },
})
expect(wrapper.text()).toContain('1 photo')
expect(wrapper.text()).not.toContain('1 photos')
})
it('emits add-photo with deviceId when add button clicked', async () => {
const wrapper = mount(FrameCard, { props: defaultProps })
await wrapper.find('.frame-card__add-btn').trigger('click')
expect(wrapper.emitted('add-photo')).toBeTruthy()
expect(wrapper.emitted('add-photo')![0]).toEqual([1])
})
it('emits edit with deviceId when settings button clicked (large)', async () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
await wrapper.find('.frame-card__settings-btn').trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('sets landscape aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
})
const preview = wrapper.find('.frame-card__preview')
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
})
it('sets portrait aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
})
const preview = wrapper.find('.frame-card__preview')
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareSheet from '@/components/ShareSheet.vue'
import { useImagesStore } from '@/stores/images'
const BaseBottomSheetStub = {
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
}
const BaseButtonStub = {
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
}
describe('ShareSheet', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
function mountShareSheet(imageId = 1) {
return mount(ShareSheet, {
props: { modelValue: true, imageId },
global: {
stubs: {
BaseBottomSheet: BaseBottomSheetStub,
BaseButton: BaseButtonStub,
},
},
})
}
// SS-01: successful share shows success message and clears email field
it('shows success message and clears email on successful share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
expect(wrapper.text()).toContain('Invite sent to friend@example.com')
expect((input.element as HTMLInputElement).value).toBe('')
})
// SS-02: failed share shows error message
it('shows error message on failed share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockRejectedValue(new Error('Server error'))
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Server error')
expect(wrapper.find('.share-sheet__error').exists()).toBe(true)
})
// SS-03: button is disabled when email input is empty
it('button is disabled when email is empty', () => {
const wrapper = mountShareSheet()
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
})
})
+10
View File
@@ -0,0 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.unstubAllGlobals()
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import type { User } from '@/types'
const makeUser = (overrides: Partial<User> = {}): User => ({
id: 1,
email: 'test@example.com',
roles: ['ROLE_USER'],
theme: null,
timezone: 'America/Chicago',
...overrides,
})
describe('auth store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('isAuthenticated is false when __PF_USER__ is not set', async () => {
// No __PF_USER__ on window — should be null
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('isAuthenticated is true when user is set via setUser', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
expect(store.user?.email).toBe('test@example.com')
})
it('setUser(null) clears user and isAuthenticated becomes false', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
store.setUser(null)
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('bootstraps user from window.__PF_USER__ when present', async () => {
const user = makeUser({ id: 99, email: 'bootstrapped@example.com' })
// Stub window.__PF_USER__ before the store module is evaluated
vi.stubGlobal('__PF_USER__', user)
// Dynamically re-import so the store sees the stub
vi.resetModules()
const { useAuthStore } = await import('@/stores/auth')
setActivePinia(createPinia())
const store = useAuthStore()
expect(store.isAuthenticated).toBe(true)
expect(store.user?.id).toBe(99)
})
})
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import type { Device } from '@/types'
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('devices store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
// DS-01
it('fetchDevices success populates devices and clears loading', async () => {
const mockDevices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockDevices),
}))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual(mockDevices)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
// DS-02
it('fetchDevices network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBe('Network failure')
})
// DS-02b — non-ok response
it('fetchDevices non-ok response sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.error).toBe('Failed to load devices')
expect(store.loading).toBe(false)
})
// DS-03
it('updateDevice patches local array entry', async () => {
const original = makeDevice({ id: 1, name: 'Old Name' })
const updated = makeDevice({ id: 1, name: 'New Name' })
const store = useDevicesStore()
store.devices = [original]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const result = await store.updateDevice(1, { name: 'New Name' })
expect(result.name).toBe('New Name')
expect(store.devices[0].name).toBe('New Name')
})
// DS-03b — updateDevice throws on failure
it('updateDevice throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.updateDevice(1, { name: 'x' })).rejects.toThrow('Failed to update device')
})
// DS-04
it('lockImage sets lockedImageId on local device', async () => {
const device = makeDevice({ id: 1, lockedImageId: null })
const locked = makeDevice({ id: 1, lockedImageId: 42 })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(locked),
}))
const result = await store.lockImage(1, 42)
expect(result.lockedImageId).toBe(42)
expect(store.devices[0].lockedImageId).toBe(42)
})
// DS-05
it('unlockImage clears lockedImageId', async () => {
const device = makeDevice({ id: 1, lockedImageId: 42 })
const unlocked = makeDevice({ id: 1, lockedImageId: null })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(unlocked),
}))
const result = await store.unlockImage(1)
expect(result.lockedImageId).toBeNull()
expect(store.devices[0].lockedImageId).toBeNull()
})
// DS-05b — lockImage throws on failure
it('lockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.lockImage(1, 42)).rejects.toThrow('Failed to lock image')
})
// DS-05c — unlockImage throws on failure
it('unlockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock')
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import type { Image } from '@/types'
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
describe('images store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
it('fetchImages success populates images and clears loading', async () => {
const mockImages = [makeImage(), makeImage({ id: 2 })]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockImages),
}))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual(mockImages)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('fetchImages network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Net error')))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual([])
expect(store.error).toBe('Net error')
expect(store.loading).toBe(false)
})
it('uploadImage prepends to images list on success', async () => {
const existing = makeImage({ id: 1 })
const newImage = makeImage({ id: 2 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(newImage),
}))
const store = useImagesStore()
store.images = [existing]
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
const result = await store.uploadImage(file)
expect(result).toEqual(newImage)
expect(store.images[0]).toEqual(newImage)
expect(store.images).toHaveLength(2)
})
it('uploadImage throws with error message on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'File too large' }),
}))
const store = useImagesStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
await expect(store.uploadImage(file)).rejects.toThrow('File too large')
})
it('deleteImage removes image from list', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const store = useImagesStore()
store.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
await store.deleteImage(1)
expect(store.images).toHaveLength(1)
expect(store.images[0].id).toBe(2)
})
it('deleteImage throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
store.images = [makeImage()]
await expect(store.deleteImage(1)).rejects.toThrow('Delete failed')
})
it('setApproval updates image in list', async () => {
const original = makeImage({ id: 1, approvedDeviceIds: [] })
const updated = makeImage({ id: 1, approvedDeviceIds: [42] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [original]
await store.setApproval(1, 42, true)
expect(store.images[0].approvedDeviceIds).toEqual([42])
})
it('fetchPendingCount stores the count', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ count: 5 }),
}))
const store = useImagesStore()
await store.fetchPendingCount()
expect(store.pendingCount).toBe(5)
})
it('approveShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'approved' }),
}))
const store = useImagesStore()
store.pendingCount = 3
await store.approveShared(1, [42])
expect(store.pendingCount).toBe(2)
})
it('declineShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'declined' }),
}))
const store = useImagesStore()
store.pendingCount = 2
await store.declineShared(1)
expect(store.pendingCount).toBe(1)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useToastStore } from '@/stores/toast'
describe('toast store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('show adds a message to toasts', () => {
const store = useToastStore()
store.show('Hello!')
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Hello!')
expect(store.toasts[0].type).toBe('info')
})
it('show with explicit type sets correct type', () => {
const store = useToastStore()
store.show('Saved', 'success')
expect(store.toasts[0].type).toBe('success')
})
it('show with error type sets error type', () => {
const store = useToastStore()
store.show('Something broke', 'error')
expect(store.toasts[0].type).toBe('error')
})
it('multiple show calls add multiple toasts', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
expect(store.toasts).toHaveLength(2)
})
it('auto-dismisses after 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
expect(store.toasts).toHaveLength(1)
vi.advanceTimersByTime(2500)
expect(store.toasts).toHaveLength(0)
})
it('does not dismiss before 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
vi.advanceTimersByTime(2499)
expect(store.toasts).toHaveLength(1)
})
it('dismiss removes a specific toast by id', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
const id = store.toasts[0].id
store.dismiss(id)
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Second')
})
it('dismiss with unknown id does nothing', () => {
const store = useToastStore()
store.show('Msg')
store.dismiss(99999)
expect(store.toasts).toHaveLength(1)
})
it('each toast gets a unique id', () => {
const store = useToastStore()
store.show('A')
store.show('B')
store.show('C')
const ids = store.toasts.map(t => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
})
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUploadStore } from '@/stores/upload'
import type { StickerLayer } from '@/types'
const makeSticker = (overrides: Partial<StickerLayer> = {}): StickerLayer => ({
id: 'sticker-1',
type: 'emoji',
x: 100,
y: 100,
scale: 1,
rotation: 0,
...overrides,
})
describe('upload store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
// happy-dom has URL.createObjectURL as a stub; ensure it returns something predictable
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('init sets originalFile and originalUrl', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.originalFile).toStrictEqual(file)
expect(store.originalUrl).toBe('blob:mock-url')
})
it('init with deviceId sets contextDeviceId and selectedDeviceIds', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file, 7)
expect(store.contextDeviceId).toBe(7)
expect(store.selectedDeviceIds).toEqual([7])
})
it('init without deviceId leaves selectedDeviceIds empty', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
})
it('setCrop stores croppedBlob and cropParams', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
const blob = new Blob(['crop'], { type: 'image/jpeg' })
const params = { natX: 0, natY: 0, natW: 200, natH: 200 }
store.setCrop(blob, params)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.croppedBlob).toStrictEqual(blob)
expect(store.croppedUrl).toBe('blob:mock-url')
expect(store.cropParams).toEqual(params)
})
it('addSticker appends to stickers', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
expect(store.stickers).toHaveLength(2)
expect(store.stickers[0].id).toBe('a')
expect(store.stickers[1].id).toBe('b')
})
it('updateSticker patches matching sticker', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[0].x).toBe(99)
})
it('updateSticker leaves non-matching stickers unchanged', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.addSticker(makeSticker({ id: 'b', x: 20 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[1].x).toBe(20)
})
it('removeSticker removes by id', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
store.removeSticker('a')
expect(store.stickers).toHaveLength(1)
expect(store.stickers[0].id).toBe('b')
})
it('cleanup resets all state', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file, 5)
store.addSticker(makeSticker())
store.cleanup()
expect(store.originalFile).toBeNull()
expect(store.originalUrl).toBeNull()
expect(store.croppedBlob).toBeNull()
expect(store.croppedUrl).toBeNull()
expect(store.cropParams).toBeNull()
expect(store.stickers).toHaveLength(0)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
expect(store.editingImageId).toBeNull()
})
})
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import HomeView from '@/views/HomeView.vue'
import type { Device } from '@/types'
// Stub heavy child components so tests focus on HomeView logic
vi.mock('@/components/FrameCard.vue', () => ({
default: {
name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation'],
emits: ['add-photo', 'edit'],
},
}))
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button><slot /></button>',
props: ['variant', 'disabled'],
},
}))
vi.mock('@/components/BaseInput.vue', () => ({
default: {
name: 'BaseInput',
template: '<input />',
props: ['modelValue', 'label', 'maxlength'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/OrientationPicker.vue', () => ({
default: {
name: 'OrientationPicker',
template: '<div />',
props: ['modelValue'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router so HomeView can call useRouter() without a real router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
// Stub URL.createObjectURL used by upload store
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('HomeView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Re-stub URL after unstubAllGlobals
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
// Stub fetch so onMounted fetchDevices doesn't fail
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(HomeView, {
global: {
plugins: [pinia],
},
})
}
// HV-01: N devices renders N FrameCard stubs
it('renders one FrameCard per device when devices are present', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'Frame A' }),
makeDevice({ id: 2, name: 'Frame B' }),
makeDevice({ id: 3, name: 'Frame C' }),
]
// Mock fetchDevices so onMounted doesn't overwrite devices
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(3)
})
// HV-01b: single device still renders one FrameCard (large variant branch)
it('renders one FrameCard for a single device', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, name: 'Solo Frame' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(1)
})
// HV-02: empty state shown when no devices
it('shows empty state when devices list is empty', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = []
devicesStore.loading = false
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('Set up your first frame')
})
// HV-03: loading state shown while fetching
it('shows loading indicator when store is loading', async () => {
const devicesStore = useDevicesStore()
devicesStore.loading = true
// Keep fetchDevices pending so loading stays true
vi.spyOn(devicesStore, 'fetchDevices').mockReturnValue(new Promise(() => {}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading')
})
})
+260
View File
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import LibraryView from '@/views/LibraryView.vue'
import type { Image, Device } from '@/types'
// Stub complex child components
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
vi.mock('@/components/ApproveCard.vue', () => ({
default: {
name: 'ApproveCard',
template: '<div class="approve-card-stub" />',
props: ['item'],
emits: ['updated'],
},
}))
vi.mock('@/components/ShareSheet.vue', () => ({
default: {
name: 'ShareSheet',
template: '<div class="share-sheet-stub" />',
props: ['modelValue', 'imageId'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ query: {} }),
}))
// Stub toast store
vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: vi.fn() }),
}))
// Stub upload store
vi.mock('@/stores/upload', () => ({
useUploadStore: () => ({ initEdit: vi.fn() }),
}))
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('LibraryView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Default fetch stub — returns empty lists so onMounted doesn't error
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(LibraryView, {
global: {
plugins: [pinia],
},
})
}
// LV-01: Default tab shows "All" tab active
it('renders the All tab as active by default', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// The "All" tab button should have aria-selected=true
const tabs = wrapper.findAll('[role="tab"]')
const allTab = tabs.find(t => t.text() === 'All')
expect(allTab?.attributes('aria-selected')).toBe('true')
})
// LV-01b: Images from imagesStore are rendered in the grid
it('renders image grid when images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 }), makeImage({ id: 3 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const grid = wrapper.find('.library__grid')
expect(grid.exists()).toBe(true)
expect(wrapper.findAll('.library__item')).toHaveLength(3)
})
// LV-02: Switching to Shared tab shows the shared sub-tabs UI
it('switching to Shared tab shows shared sub-tabs and triggers a fetch', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
// Set up fetch so fetchSharedImages network call resolves
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(sharedPage),
}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
// After clicking Shared, the sub-tabs (Pending/Approved/Declined) should appear
expect(wrapper.find('.library__subtabs').exists()).toBe(true)
})
// LV-03: Lock chip shown for device when image is approved for it
it('renders lock chip for device when image is approved for that device', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Lock chips are rendered only for approved devices
const lockChips = wrapper.findAll('.library__lock-chip')
expect(lockChips.length).toBeGreaterThan(0)
expect(lockChips[0].text()).toContain('Bedroom')
})
// LV-06: Share button click renders the ShareSheet
it('clicking share button renders the ShareSheet', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 5 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Find the share action button (aria-label contains "Share")
const shareBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.includes('Share'))
expect(shareBtn).toBeTruthy()
await shareBtn!.trigger('click')
await wrapper.vm.$nextTick()
// After clicking, the ShareSheet stub should be rendered
expect(wrapper.find('.share-sheet-stub').exists()).toBe(true)
})
// LV-07: Empty state shown when no images (All tab)
it('shows empty state when no images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('No photos yet')
})
// LV-07b: Empty state on shared sub-tab (pending)
it('shows shared empty state when no shared items exist', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Switch to Shared tab
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
// Wait for async loadShared to complete
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
})
})
+39 -8
View File
@@ -3,6 +3,7 @@ export interface User {
email: string
roles: string[]
theme: string | null
timezone: string
}
export interface Device {
@@ -10,18 +11,20 @@ export interface Device {
mac: string
name: string
orientation: 'landscape' | 'portrait'
rotationIntervalHours: number
rotationIntervalMinutes: number
wakeHour: number | null
timezone: string
uniquenessWindow: number
linkedAt: string
lastSeenAt: string | null
lockedImageId: number | null
}
export interface Image {
id: number
source: 'uploaded' | 'shared'
filename: string
thumbnailUrl: string
deletedAt: string | null
approvedDeviceIds: number[]
export interface CropParams {
natX: number
natY: number
natW: number
natH: number
}
export interface StickerLayer {
@@ -33,6 +36,17 @@ export interface StickerLayer {
rotation: number
}
export interface Image {
id: number
originalFilename: string
thumbnailUrl: string
originalUrl: string
uploadedAt: string
approvedDeviceIds: number[]
cropParams: CropParams | null
stickerState: StickerLayer[] | null
}
export interface RenderedAsset {
id: number
imageId: number
@@ -41,6 +55,23 @@ export interface RenderedAsset {
status: 'pending' | 'processing' | 'ready' | 'failed'
}
export interface SharedImage {
id: number
imageId: number
thumbnailUrl: string
sharedBy: string
sharedAt: string
status: 'pending' | 'approved' | 'declined'
}
export interface SharedImagePage {
items: SharedImage[]
total: number
page: number
limit: number
totalPages: number
}
export interface Token {
uuid: string
type: 'share_approve' | 'share_decline' | 'hard_delete_confirm'
+162 -5
View File
@@ -27,7 +27,7 @@
:deviceId="devicesStore.devices[0].id"
:name="devicesStore.devices[0].name"
size="large"
status="ok"
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
@@ -42,7 +42,7 @@
:deviceId="device.id"
:name="device.name"
size="compact"
status="ok"
:status="deviceStatus(device)"
:orientation="device.orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
@@ -67,6 +67,24 @@
<OrientationPicker v-model="editOrientation" />
</div>
<div class="home-view__sheet-field">
<p class="home-view__sheet-label">Update time</p>
<div class="home-view__interval-grid">
<button
v-for="opt in WAKE_TIME_OPTIONS"
:key="opt.hour"
type="button"
:class="['home-view__interval-chip', { 'home-view__interval-chip--on': editWakeHour === opt.hour }]"
@click="editWakeHour = opt.hour"
>{{ opt.label }}</button>
</div>
<select class="home-view__tz-select" v-model="editTimezone">
<optgroup v-for="group in TIMEZONE_GROUPS" :key="group.label" :label="group.label">
<option v-for="tz in group.zones" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
</optgroup>
</select>
</div>
<BaseButton
variant="primary"
class="home-view__sheet-save"
@@ -80,30 +98,120 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import type { Device } from '@/types'
function deviceStatus(device: Device): 'ok' | 'offline' {
if (!device.lastSeenAt) return 'offline'
const seenMs = Date.now() - new Date(device.lastSeenAt).getTime()
const windowMs = Math.max(device.rotationIntervalMinutes * 2 * 60_000, 30 * 60_000)
return seenMs <= windowMs ? 'ok' : 'offline'
}
import FrameCard from '@/components/FrameCard.vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import BaseInput from '@/components/BaseInput.vue'
import OrientationPicker from '@/components/OrientationPicker.vue'
const router = useRouter()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
onMounted(() => devicesStore.fetchDevices())
onMounted(() => {
devicesStore.fetchDevices()
})
function onAddPhoto(deviceId: number) {
// Photo upload flow — Epic 3
console.log('add-photo', deviceId)
// File picker must be triggered in the user-gesture context (the click handler)
// before navigating, otherwise browsers block it as a popup.
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
uploadStore.init(file, deviceId)
router.push('/upload')
}
input.click()
}
// ── Settings sheet ────────────────────────────────────────────────────────────
const WAKE_TIME_OPTIONS = [
{ hour: 0, label: '12 AM' },
{ hour: 2, label: '2 AM' },
{ hour: 4, label: '4 AM' },
{ hour: 6, label: '6 AM' },
{ hour: 8, label: '8 AM' },
{ hour: 10, label: '10 AM' },
{ hour: 12, label: '12 PM' },
{ hour: 18, label: '6 PM' },
{ hour: 20, label: '8 PM' },
{ hour: 22, label: '10 PM' },
]
const TIMEZONE_GROUPS = [
{ label: 'Americas', zones: [
{ value: 'America/New_York', label: 'Eastern — New York, Toronto' },
{ value: 'America/Chicago', label: 'Central — Chicago, Mexico City' },
{ value: 'America/Denver', label: 'Mountain — Denver, Calgary' },
{ value: 'America/Phoenix', label: 'Mountain (no DST) — Phoenix' },
{ value: 'America/Los_Angeles', label: 'Pacific — Los Angeles, Vancouver' },
{ value: 'America/Anchorage', label: 'Alaska — Anchorage' },
{ value: 'Pacific/Honolulu', label: 'Hawaii — Honolulu' },
{ value: 'America/Sao_Paulo', label: 'Brasília — São Paulo' },
{ value: 'America/Argentina/Buenos_Aires', label: 'Argentina — Buenos Aires' },
{ value: 'America/Bogota', label: 'Colombia — Bogotá' },
]},
{ label: 'Europe', zones: [
{ value: 'Europe/London', label: 'GMT/BST — London, Dublin' },
{ value: 'Europe/Lisbon', label: 'WET/WEST — Lisbon' },
{ value: 'Europe/Paris', label: 'CET/CEST — Paris, Brussels, Amsterdam' },
{ value: 'Europe/Berlin', label: 'CET/CEST — Berlin, Vienna, Zurich' },
{ value: 'Europe/Stockholm', label: 'CET/CEST — Stockholm, Oslo, Copenhagen'},
{ value: 'Europe/Helsinki', label: 'EET/EEST — Helsinki, Tallinn, Riga' },
{ value: 'Europe/Warsaw', label: 'CET/CEST — Warsaw, Prague, Budapest' },
{ value: 'Europe/Rome', label: 'CET/CEST — Rome, Madrid' },
{ value: 'Europe/Athens', label: 'EET/EEST — Athens, Bucharest' },
{ value: 'Europe/Istanbul', label: 'TRT — Istanbul' },
{ value: 'Europe/Moscow', label: 'MSK — Moscow' },
]},
{ label: 'Asia & Pacific', zones: [
{ value: 'Asia/Dubai', label: 'GST — Dubai, Abu Dhabi' },
{ value: 'Asia/Karachi', label: 'PKT — Karachi, Islamabad' },
{ value: 'Asia/Kolkata', label: 'IST — India' },
{ value: 'Asia/Dhaka', label: 'BST — Dhaka, Bangladesh' },
{ value: 'Asia/Bangkok', label: 'ICT — Bangkok, Jakarta, Hanoi' },
{ value: 'Asia/Singapore', label: 'SGT — Singapore, Kuala Lumpur' },
{ value: 'Asia/Shanghai', label: 'CST — Beijing, Shanghai, Taipei' },
{ value: 'Asia/Seoul', label: 'KST — Seoul' },
{ value: 'Asia/Tokyo', label: 'JST — Tokyo' },
{ value: 'Australia/Sydney', label: 'AEDT/AEST — Sydney, Melbourne' },
{ value: 'Australia/Brisbane',label: 'AEST (no DST) — Brisbane' },
{ value: 'Australia/Perth', label: 'AWST — Perth' },
{ value: 'Pacific/Auckland', label: 'NZDT/NZST — Auckland' },
]},
{ label: 'Africa & Middle East', zones: [
{ value: 'Africa/Cairo', label: 'EET — Cairo' },
{ value: 'Africa/Nairobi', label: 'EAT — Nairobi, East Africa'},
{ value: 'Africa/Johannesburg', label: 'SAST — Johannesburg' },
{ value: 'Africa/Lagos', label: 'WAT — Lagos, West Africa' },
]},
{ label: 'UTC', zones: [
{ value: 'UTC', label: 'UTC — Coordinated Universal Time' },
]},
]
const sheetOpen = ref(false)
const saving = ref(false)
const editingDevice = ref<Device | null>(null)
const editName = ref('')
const editOrientation = ref<Device['orientation']>('landscape')
const editWakeHour = ref<number>(4)
const editTimezone = ref('UTC')
function onEdit(deviceId: number) {
const device = devicesStore.devices.find(d => d.id === deviceId)
@@ -111,6 +219,8 @@ function onEdit(deviceId: number) {
editingDevice.value = device
editName.value = device.name
editOrientation.value = device.orientation
editWakeHour.value = device.wakeHour ?? 4
editTimezone.value = device.timezone ?? 'UTC'
sheetOpen.value = true
}
@@ -121,6 +231,8 @@ async function saveSettings() {
await devicesStore.updateDevice(editingDevice.value.id, {
name: editName.value.trim() || editingDevice.value.name,
orientation: editOrientation.value,
wakeHour: editWakeHour.value,
timezone: editTimezone.value,
})
sheetOpen.value = false
} finally {
@@ -208,6 +320,51 @@ async function saveSettings() {
margin-bottom: var(--space-2);
}
&__interval-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
&__tz-select {
width: 100%;
margin-top: var(--space-3);
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;
cursor: pointer;
appearance: auto;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&__interval-chip {
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
min-height: var(--touch-min);
&--on {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-fg);
}
}
&__sheet-save {
width: 100%;
margin-top: var(--space-2);
+581 -3
View File
@@ -1,9 +1,587 @@
<template>
<main class="view">
<h1>Library</h1>
<main class="library">
<!-- Tabs -->
<div class="library__tabs" role="tablist">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
role="tab"
:aria-selected="activeTab === tab.id"
:class="['library__tab', { 'library__tab--active': activeTab === tab.id }]"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<!-- Loading -->
<div v-if="imagesStore.loading" class="library__loading">Loading</div>
<!-- All / Mine tab -->
<template v-else-if="activeTab !== 'shared'">
<div v-if="visibleImages.length === 0" class="library__empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="library__empty-title">No photos yet</p>
<p class="library__empty-sub">Tap "+ Add Photo" on the home screen to get started.</p>
</div>
<div v-else class="library__grid">
<div v-for="image in visibleImages" :key="image.id" class="library__item">
<div class="library__thumb">
<img
:src="image.thumbnailUrl"
:alt="image.originalFilename"
class="library__img"
loading="lazy"
/>
<div class="library__thumb-actions">
<button
class="library__action-btn"
type="button"
:aria-label="`Edit ${image.originalFilename}`"
:disabled="editingId === image.id"
@click="startEdit(image)"
>
<svg v-if="editingId !== image.id" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<svg v-else width="13" height="13" 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="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</button>
<button
class="library__action-btn"
type="button"
:aria-label="`Share ${image.originalFilename}`"
@click="openShare(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<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>
</button>
<button
class="library__action-btn library__action-btn--danger"
type="button"
aria-label="Delete photo"
@click="confirmDelete(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
</div>
<div v-if="devicesStore.devices.length > 0" class="library__approvals">
<button
v-for="device in devicesStore.devices"
:key="device.id"
:class="['library__approval-chip', { 'library__approval-chip--on': image.approvedDeviceIds.includes(device.id) }]"
type="button"
:aria-label="`${image.approvedDeviceIds.includes(device.id) ? 'Remove from' : 'Add to'} ${device.name}`"
@click="toggleApproval(image.id, device.id, !image.approvedDeviceIds.includes(device.id))"
>
{{ device.name }}
</button>
</div>
<div v-if="devicesStore.devices.length > 0" class="library__locks">
<button
v-for="device in devicesStore.devices.filter(d => image.approvedDeviceIds.includes(d.id))"
:key="device.id"
:class="['library__lock-chip', { 'library__lock-chip--on': device.lockedImageId === image.id }]"
type="button"
:aria-label="`${device.lockedImageId === image.id ? 'Unlock from' : 'Lock to'} ${device.name}`"
@click="toggleLock(image.id, device)"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path v-if="device.lockedImageId === image.id" d="M7 11V7a5 5 0 0 1 10 0v4"/>
<path v-else d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>
{{ device.name }}
</button>
</div>
</div>
</div>
</template>
<!-- Shared tab -->
<template v-else>
<!-- Sub-tabs -->
<div class="library__subtabs" role="tablist">
<button
v-for="st in SHARED_TABS"
:key="st.id"
type="button"
role="tab"
:aria-selected="sharedTab === st.id"
:class="['library__subtab', { 'library__subtab--active': sharedTab === st.id }]"
@click="switchSharedTab(st.id)"
>{{ st.label }}</button>
</div>
<div v-if="sharedLoading" class="library__loading">Loading</div>
<div v-else-if="sharedItems.length === 0" class="library__shared-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<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>
<p class="library__empty-title">
{{ sharedTab === 'pending' ? 'No pending photos' : sharedTab === 'approved' ? 'No approved photos' : 'No declined photos' }}
</p>
<p class="library__empty-sub">
{{ sharedTab === 'pending' ? 'Photos shared with you will appear here.' : 'Photos you\'ve added to a frame will appear here.' }}
</p>
</div>
<div v-else class="library__shared-list">
<ApproveCard
v-for="item in sharedItems"
:key="item.id"
:item="item"
@updated="onSharedUpdated"
/>
</div>
<!-- Pagination -->
<div v-if="sharedTotalPages > 1" class="library__pagination">
<button
class="library__page-btn"
:disabled="sharedPage <= 1"
@click="goSharedPage(sharedPage - 1)"
> Prev</button>
<span class="library__page-info">{{ sharedPage }} / {{ sharedTotalPages }}</span>
<button
class="library__page-btn"
:disabled="sharedPage >= sharedTotalPages"
@click="goSharedPage(sharedPage + 1)"
>Next </button>
</div>
</template>
<!-- Share sheet -->
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
<!-- Confirm delete sheet -->
<BaseBottomSheet v-model="deleteSheetOpen" label="Delete photo">
<h2 class="library__sheet-title">Delete this photo?</h2>
<p class="library__sheet-sub">It will be removed from all frames.</p>
<div class="library__sheet-actions">
<BaseButton variant="secondary" @click="deleteSheetOpen = false">Cancel</BaseButton>
<BaseButton variant="destructive" :disabled="deleting" @click="doDelete">
{{ deleting ? 'Deleting' : 'Delete' }}
</BaseButton>
</div>
</BaseBottomSheet>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import { useToastStore } from '@/stores/toast'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import ApproveCard from '@/components/ApproveCard.vue'
import ShareSheet from '@/components/ShareSheet.vue'
import type { Device, Image, SharedImage } from '@/types'
const router = useRouter()
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
const toast = useToastStore()
const route = useRoute()
const TABS = [
{ id: 'all', label: 'All' },
{ id: 'mine', label: 'Mine' },
{ id: 'shared', label: 'Shared' },
] as const
type Tab = typeof TABS[number]['id']
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all')
const SHARED_TABS = [
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'declined', label: 'Declined' },
] as const
type SharedTab = typeof SHARED_TABS[number]['id']
const sharedTab = ref<SharedTab>('pending')
const sharedItems = ref<SharedImage[]>([])
const sharedLoading = ref(false)
const sharedPage = ref(1)
const sharedTotalPages = ref(1)
async function loadShared(tab: SharedTab, page = 1) {
sharedLoading.value = true
try {
const result = await imagesStore.fetchSharedImages(tab, page)
sharedItems.value = result.items
sharedPage.value = result.page
sharedTotalPages.value = result.totalPages
} finally {
sharedLoading.value = false
}
}
function switchSharedTab(tab: SharedTab) {
sharedTab.value = tab
loadShared(tab, 1)
}
function goSharedPage(page: number) {
loadShared(sharedTab.value, page)
}
function onSharedUpdated(updated: SharedImage) {
const idx = sharedItems.value.findIndex(i => i.id === updated.id)
if (idx !== -1) sharedItems.value[idx] = updated
}
onMounted(() => {
imagesStore.fetchImages()
devicesStore.fetchDevices()
imagesStore.fetchPendingCount()
if (activeTab.value === 'shared') loadShared(sharedTab.value)
})
// For now "mine" and "all" show the same list; shared is a placeholder
const visibleImages = computed(() => imagesStore.images)
// ── Share ─────────────────────────────────────────────────────────────────────
const shareSheetOpen = ref(false)
const shareImageId = ref<number | null>(null)
function openShare(id: number) {
shareImageId.value = id
shareSheetOpen.value = true
}
// ── Edit ──────────────────────────────────────────────────────────────────────
const editingId = ref<number | null>(null)
async function startEdit(image: Image) {
if (editingId.value) return
editingId.value = image.id
try {
await uploadStore.initEdit(image)
router.push('/upload')
} catch {
toast.show('Could not load photo for editing', 'error')
} finally {
editingId.value = null
}
}
// ── Lock ──────────────────────────────────────────────────────────────────────
async function toggleLock(imageId: number, device: Device) {
try {
if (device.lockedImageId === imageId) {
await devicesStore.unlockImage(device.id)
} else {
await devicesStore.lockImage(device.id, imageId)
}
} catch {
toast.show('Failed to update lock', 'error')
}
}
// ── Approval toggles ──────────────────────────────────────────────────────────
async function toggleApproval(imageId: number, deviceId: number, approved: boolean) {
try {
await imagesStore.setApproval(imageId, deviceId, approved)
} catch {
toast.show('Failed to update frame approval', 'error')
}
}
// ── Delete ────────────────────────────────────────────────────────────────────
const deleteSheetOpen = ref(false)
const deletingId = ref<number | null>(null)
const deleting = ref(false)
function confirmDelete(id: number) {
deletingId.value = id
deleteSheetOpen.value = true
}
async function doDelete() {
if (!deletingId.value) return
deleting.value = true
try {
await imagesStore.deleteImage(deletingId.value)
deleteSheetOpen.value = false
toast.show('Photo deleted', 'success')
} catch {
toast.show('Delete failed', 'error')
} finally {
deleting.value = false
}
}
</script>
<style scoped lang="scss">
.view { padding: var(--space-4); }
.library {
padding-bottom: calc(64px + var(--space-4));
&__tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--color-bg);
z-index: 5;
}
&__tab {
flex: 1;
padding: var(--space-3) 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__loading {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4);
text-align: center;
}
&__subtabs {
display: flex;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
&__subtab {
flex: 1;
padding: var(--space-2) 0;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__shared-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
&__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-4);
}
&__page-btn {
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: none;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
cursor: pointer;
&:disabled { opacity: .4; cursor: default; }
}
&__page-info {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
&__empty, &__shared-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
&__empty-title {
font-size: var(--text-md);
font-weight: 700;
color: var(--color-text);
}
&__empty-sub {
font-size: var(--text-sm);
max-width: 280px;
line-height: 1.5;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
padding: var(--space-4);
}
&__item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__thumb {
position: relative;
aspect-ratio: 4/3;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-2);
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&__thumb-actions {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: 4px;
}
&__action-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background var(--duration-fast);
flex-shrink: 0;
&:disabled { opacity: 0.5; cursor: default; }
&:hover:not(:disabled) { background: rgba(0, 0, 0, 0.75); }
&--danger:hover:not(:disabled) { background: rgba(180, 0, 0, 0.8); }
}
&__approvals {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__approval-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-xs, 11px);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--on {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
}
&__locks {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__lock-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
border: 1.5px dashed var(--color-border);
font-size: var(--text-xs, 11px);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--on {
background: var(--color-warning, #f59e0b);
border-color: var(--color-warning, #f59e0b);
border-style: solid;
color: #fff;
}
}
&__sheet-title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__sheet-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-5);
}
&__sheet-actions {
display: flex;
gap: var(--space-3);
> * { flex: 1; }
}
}
</style>
-9
View File
@@ -1,9 +0,0 @@
<template>
<main class="view">
<h1>Shared</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+308
View File
@@ -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>