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:
@@ -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
|
||||
|
||||
@@ -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: '🌙' },
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Shared</h1>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="upload-view">
|
||||
<!-- Header -->
|
||||
<header class="upload-view__header">
|
||||
<button
|
||||
v-if="step !== 'done'"
|
||||
class="upload-view__back"
|
||||
type="button"
|
||||
:aria-label="step === 'crop' ? 'Cancel' : 'Back'"
|
||||
@click="goBack"
|
||||
>
|
||||
<svg v-if="step === 'crop'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="upload-view__step-label">{{ stepLabel }}</span>
|
||||
<button
|
||||
v-if="step === 'stickers'"
|
||||
class="upload-view__skip"
|
||||
type="button"
|
||||
@click="skipStickers"
|
||||
>Skip</button>
|
||||
</header>
|
||||
|
||||
<!-- Crop step -->
|
||||
<CropEditor
|
||||
v-if="step === 'crop' && uploadStore.originalUrl"
|
||||
:src="uploadStore.originalUrl"
|
||||
:orientation="contextOrientation"
|
||||
:device-name="contextDeviceName"
|
||||
:initial-params="uploadStore.cropParams"
|
||||
class="upload-view__stage"
|
||||
@crop="onCrop"
|
||||
/>
|
||||
|
||||
<!-- Stickers step -->
|
||||
<StickerCanvas
|
||||
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
|
||||
:cropped-url="uploadStore.croppedUrl"
|
||||
:orientation="contextOrientation"
|
||||
:stickers="uploadStore.stickers"
|
||||
class="upload-view__stage"
|
||||
@add-sticker="uploadStore.addSticker"
|
||||
@update-sticker="uploadStore.updateSticker"
|
||||
@remove-sticker="uploadStore.removeSticker"
|
||||
@done="onStickersDone"
|
||||
/>
|
||||
|
||||
<!-- Done -->
|
||||
<div v-else-if="step === 'done'" class="upload-view__done">
|
||||
<div class="upload-view__done-icon" aria-hidden="true">🎉</div>
|
||||
<p class="upload-view__done-title">{{ isEdit ? 'Photo updated!' : 'Photo added!' }}</p>
|
||||
<p class="upload-view__done-sub">It'll appear on your frame at the next update.</p>
|
||||
<BaseButton variant="primary" class="upload-view__done-btn" @click="finish">Done</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Device picker (only on new uploads, not edits) -->
|
||||
<DevicePicker
|
||||
v-if="!isEdit"
|
||||
v-model="devicePickerOpen"
|
||||
:devices="devicesStore.devices"
|
||||
:selected="uploadStore.selectedDeviceIds"
|
||||
:uploading="uploading"
|
||||
@update:selected="uploadStore.selectedDeviceIds = $event"
|
||||
@confirm="doUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { CropParams } from '@/types'
|
||||
import CropEditor from '@/components/CropEditor.vue'
|
||||
import StickerCanvas from '@/components/StickerCanvas.vue'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const uploadStore = useUploadStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
const imagesStore = useImagesStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
type Step = 'crop' | 'stickers' | 'done'
|
||||
const step = ref<Step>('crop')
|
||||
const uploading = ref(false)
|
||||
const devicePickerOpen = ref(false)
|
||||
let finalBlob: Blob | null = null
|
||||
|
||||
const isEdit = computed(() => uploadStore.editingImageId !== null)
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
await devicesStore.fetchDevices()
|
||||
|
||||
if (!uploadStore.originalFile) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
|
||||
// When opening for edit, jump straight to crop (state already loaded by caller)
|
||||
step.value = 'crop'
|
||||
})
|
||||
|
||||
// ── Context device ────────────────────────────────────────────────────────────
|
||||
|
||||
const contextDevice = computed(() =>
|
||||
uploadStore.contextDeviceId
|
||||
? devicesStore.devices.find(d => d.id === uploadStore.contextDeviceId)
|
||||
: devicesStore.devices[0]
|
||||
)
|
||||
|
||||
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
|
||||
contextDevice.value?.orientation ?? 'landscape'
|
||||
)
|
||||
|
||||
const contextDeviceName = computed(() => contextDevice.value?.name)
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stepLabel = computed(() => {
|
||||
if (step.value === 'crop') return isEdit.value ? 'Edit crop' : 'Crop photo'
|
||||
if (step.value === 'stickers') return 'Add stickers'
|
||||
return isEdit.value ? 'Updated' : 'Added'
|
||||
})
|
||||
|
||||
function onCrop({ blob, params }: { blob: Blob; params: CropParams }) {
|
||||
uploadStore.setCrop(blob, params)
|
||||
step.value = 'stickers'
|
||||
}
|
||||
|
||||
function skipStickers() {
|
||||
if (!uploadStore.croppedBlob) return
|
||||
finalBlob = uploadStore.croppedBlob
|
||||
if (isEdit.value) {
|
||||
doUpload()
|
||||
} else {
|
||||
devicePickerOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onStickersDone(blob: Blob) {
|
||||
finalBlob = blob
|
||||
if (isEdit.value) {
|
||||
doUpload()
|
||||
} else {
|
||||
devicePickerOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (step.value === 'crop') {
|
||||
uploadStore.cleanup()
|
||||
router.replace('/library')
|
||||
return
|
||||
}
|
||||
if (step.value === 'stickers') {
|
||||
step.value = 'crop'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload / reprocess ────────────────────────────────────────────────────────
|
||||
|
||||
async function doUpload() {
|
||||
if (!finalBlob) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const composited = new File([finalBlob], 'photo.jpg', { type: 'image/jpeg' })
|
||||
|
||||
if (isEdit.value) {
|
||||
await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, {
|
||||
cropParams: uploadStore.cropParams ?? undefined,
|
||||
stickerState: uploadStore.stickers,
|
||||
})
|
||||
devicePickerOpen.value = false
|
||||
step.value = 'done'
|
||||
return
|
||||
}
|
||||
|
||||
const image = await imagesStore.uploadImage(composited, {
|
||||
original: uploadStore.originalFile ?? undefined,
|
||||
cropParams: uploadStore.cropParams ?? undefined,
|
||||
stickerState: uploadStore.stickers,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
uploadStore.selectedDeviceIds.map(deviceId =>
|
||||
imagesStore.setApproval(image.id, deviceId, true)
|
||||
)
|
||||
)
|
||||
devicePickerOpen.value = false
|
||||
step.value = 'done'
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : 'Upload failed', 'error')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
uploadStore.cleanup()
|
||||
router.replace('/library')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.upload-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__back {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
&__step-label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__skip {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
&__stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__done {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) var(--space-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__done-icon {
|
||||
font-size: 64px;
|
||||
line-height: 1;
|
||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
||||
}
|
||||
|
||||
&__done-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__done-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
max-width: 260px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__done-btn {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user