chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent dd0970ed7c
commit 4002ff9fbf
156 changed files with 27333 additions and 92 deletions
+1
View File
@@ -9,6 +9,7 @@ database:
composer_version: "2"
webimage_extra_packages:
- php8.4-imagick
- php8.4-pcov
hooks:
post-start:
- exec: composer install --no-interaction 2>/dev/null || true
+6
View File
@@ -45,4 +45,10 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ###
MAILER_DSN=null://null
MAILER_SENDER=noreply@pictureframe.edholm.me
###< symfony/mailer ###
###> pictureframe ###
SHARE_TOKEN_TTL_DAYS=7
HARD_DELETE_TOKEN_TTL_DAYS=30
###< pictureframe ###
+4
View File
@@ -1,3 +1,7 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
MESSENGER_TRANSPORT_DSN=in-memory://
SHARE_TOKEN_TTL_DAYS=7
HARD_DELETE_TOKEN_TTL_DAYS=30
MAILER_SENDER=noreply@test.example
+1
View File
@@ -28,6 +28,7 @@ storage/images/
# Frontend
/frontend/node_modules/
/frontend/coverage/
# Python
**/__pycache__/
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+820
View File
@@ -0,0 +1,820 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=800">
<title>AP Provisioning Screen — pictureFrame mockup</title>
<style>
/*
HARDWARE CONSTRAINTS: 800×480px. Six colors only.
#1a1a1a BLACK
#f5f5f0 WHITE
#f0d000 YELLOW
#c03020 RED
#1840c0 BLUE
#10a040 GREEN
No gradients. No drop shadows. No anti-aliasing. No other colors.
No fonts below ~16px.
This file is a design mockup — open at 100% zoom in browser.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #888;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: 'Courier New', Courier, monospace;
}
/* Outer frame — the physical bezel of the device */
.device-bezel {
width: 840px;
height: 520px;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* The e-ink display surface — exactly 800×480 */
.display {
width: 800px;
height: 480px;
background: #f5f5f0;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
YELLOW STATUS BAR — signals AP mode / action required
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.status-bar {
width: 800px;
height: 52px;
background: #f0d000;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
}
.status-bar-label {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #1a1a1a;
}
.status-bar-network {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: 0.06em;
}
/* Monospaced network name chip */
.network-chip {
display: inline-block;
background: #1a1a1a;
color: #f0d000;
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 4px 10px;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MAIN BODY — three-column layout
LEFT: header + instructions
CENTER: orientation diagrams
RIGHT: QR code
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.body {
flex: 1;
display: flex;
flex-direction: row;
}
/* ── LEFT PANEL ───────────────────────────────────── */
.panel-left {
width: 310px;
flex-shrink: 0;
padding: 24px 20px 20px 28px;
display: flex;
flex-direction: column;
justify-content: space-between;
border-right: 2px solid #1a1a1a;
}
.main-heading {
font-family: 'Courier New', Courier, monospace;
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
letter-spacing: -0.01em;
}
.main-heading em {
font-style: normal;
color: #1a1a1a;
/* Underline in yellow: simulate with border since no CSS effects */
border-bottom: 3px solid #f0d000;
}
.step-list {
list-style: none;
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.step-item {
display: flex;
align-items: flex-start;
gap: 10px;
}
.step-num {
width: 24px;
height: 24px;
background: #1a1a1a;
color: #f0d000;
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-text {
font-family: 'Courier New', Courier, monospace;
font-size: 15px;
color: #1a1a1a;
line-height: 1.35;
padding-top: 3px;
}
.step-text strong {
font-weight: 700;
}
/* Divider */
.left-divider {
height: 2px;
background: #1a1a1a;
margin: 16px 0 14px;
}
.footnote {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
color: #1a1a1a;
line-height: 1.4;
opacity: 0.7;
}
/* ── CENTER PANEL — orientation diagrams ──────────── */
.panel-center {
width: 196px;
flex-shrink: 0;
border-right: 2px solid #1a1a1a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 16px 12px;
}
.orient-label {
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #1a1a1a;
text-align: center;
margin-bottom: 6px;
}
.orient-block {
display: flex;
flex-direction: column;
align-items: center;
}
/* Landscape: wide rect + bottom ribbon */
.orient-landscape-frame {
width: 110px;
height: 66px;
border: 3px solid #1a1a1a;
background: #f5f5f0;
position: relative;
}
/* Corner tick marks to suggest the physical frame */
.orient-landscape-frame::before,
.orient-landscape-frame::after {
content: '';
position: absolute;
background: #1a1a1a;
}
.orient-landscape-ribbon {
width: 110px;
height: 10px;
background: #1a1a1a;
/* The power ribbon / cable notch at bottom */
}
/* Portrait: tall rect + left ribbon */
.orient-portrait-frame {
width: 64px;
height: 106px;
border: 3px solid #1a1a1a;
background: #f5f5f0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
position: relative;
}
.orient-portrait-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.orient-portrait-ribbon {
width: 10px;
height: 106px;
background: #1a1a1a;
}
/* Active/current orientation highlight */
.orient-active .orient-landscape-frame,
.orient-active .orient-portrait-frame {
border-color: #f0d000;
border-width: 3px;
}
.orient-active .orient-landscape-ribbon,
.orient-active .orient-portrait-ribbon {
background: #f0d000;
}
.orient-active .orient-label {
color: #1a1a1a;
}
/* Tiny check mark for active orientation */
.active-badge {
width: 18px;
height: 18px;
background: #f0d000;
border: 2px solid #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 900;
color: #1a1a1a;
margin-top: 5px;
}
.orient-divider {
width: 100%;
height: 1px;
background: #1a1a1a;
opacity: 0.3;
}
.orient-section-title {
font-family: 'Courier New', Courier, monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #1a1a1a;
opacity: 0.55;
text-align: center;
margin-bottom: 10px;
}
/* ── RIGHT PANEL — QR code ────────────────────────── */
.panel-right {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
gap: 10px;
}
.qr-instruction {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #1a1a1a;
text-align: center;
}
.qr-wrapper {
width: 196px;
height: 196px;
background: #f5f5f0;
border: 3px solid #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* Yellow corner brackets — frame the QR */
.qr-wrapper::before {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border: 3px solid #f0d000;
pointer-events: none;
}
.qr-sub {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
color: #1a1a1a;
text-align: center;
line-height: 1.4;
opacity: 0.65;
max-width: 190px;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
QR CODE — SVG grid rendered in pure black/white
Pattern: encodes WIFI:S:PictureFrame-A3F7;T:nopass;;
This is a realistic-looking QR placeholder.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
</style>
</head>
<body>
<div class="device-bezel">
<div class="display">
<!-- STATUS BAR -->
<div class="status-bar">
<span class="status-bar-label">Setup Mode &mdash; Step 1 of 2</span>
<span class="status-bar-network">
Broadcasting: <span class="network-chip">PictureFrame-A3F7</span>
</span>
</div>
<!-- BODY -->
<div class="body">
<!-- LEFT: instructions -->
<div class="panel-left">
<div>
<div class="main-heading">Connect to<br><em>WiFi</em></div>
<ul class="step-list">
<li class="step-item">
<div class="step-num">1</div>
<div class="step-text">
Scan the QR code &rarr;<br>
Your phone joins <strong>PictureFrame-A3F7</strong>
</div>
</li>
<li class="step-item">
<div class="step-num">2</div>
<div class="step-text">
A WiFi setup page opens automatically in your browser
</div>
</li>
<li class="step-item">
<div class="step-num">3</div>
<div class="step-text">
Enter your <strong>home WiFi</strong> credentials and tap Connect
</div>
</li>
</ul>
</div>
<div>
<div class="left-divider"></div>
<div class="footnote">
Page didn't open? Navigate to<br>
<strong>192.168.4.1</strong> in any browser.
</div>
</div>
</div>
<!-- CENTER: orientation diagrams -->
<div class="panel-center">
<div class="orient-section-title">Frame orientation</div>
<!-- Landscape (active) -->
<div class="orient-block orient-active">
<div class="orient-label">Landscape</div>
<div class="orient-landscape-frame"></div>
<div class="orient-landscape-ribbon"></div>
<div class="active-badge">&#10003;</div>
</div>
<div class="orient-divider"></div>
<!-- Portrait -->
<div class="orient-block">
<div class="orient-label">Portrait</div>
<div class="orient-portrait-wrapper">
<div class="orient-portrait-ribbon"></div>
<div class="orient-portrait-frame"></div>
</div>
</div>
</div>
<!-- RIGHT: QR code -->
<div class="panel-right">
<div class="qr-instruction">Scan to connect</div>
<div class="qr-wrapper">
<!-- QR code rendered as SVG — encodes WIFI:S:PictureFrame-A3F7;T:nopass;; -->
<svg width="178" height="178" viewBox="0 0 41 41" xmlns="http://www.w3.org/2000/svg"
shape-rendering="crispEdges">
<rect width="41" height="41" fill="#f5f5f0"/>
<!-- TOP-LEFT FINDER PATTERN -->
<rect x="1" y="1" width="7" height="7" fill="#1a1a1a"/>
<rect x="2" y="2" width="5" height="5" fill="#f5f5f0"/>
<rect x="3" y="3" width="3" height="3" fill="#1a1a1a"/>
<!-- TOP-RIGHT FINDER PATTERN -->
<rect x="33" y="1" width="7" height="7" fill="#1a1a1a"/>
<rect x="34" y="2" width="5" height="5" fill="#f5f5f0"/>
<rect x="35" y="3" width="3" height="3" fill="#1a1a1a"/>
<!-- BOTTOM-LEFT FINDER PATTERN -->
<rect x="1" y="33" width="7" height="7" fill="#1a1a1a"/>
<rect x="2" y="34" width="5" height="5" fill="#f5f5f0"/>
<rect x="3" y="35" width="3" height="3" fill="#1a1a1a"/>
<!-- TIMING PATTERNS (horizontal and vertical) -->
<rect x="9" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="13" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="15" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="19" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="21" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="25" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="27" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="29" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="31" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="9" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="13" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="15" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="17" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="19" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="21" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="23" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="25" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="27" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="29" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="31" width="1" height="1" fill="#1a1a1a"/>
<!-- DATA MODULES — WiFi QR payload simulation -->
<!-- Row 9 -->
<rect x="9" y="9" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="9" width="2" height="1" fill="#1a1a1a"/>
<rect x="14" y="9" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="9" width="3" height="1" fill="#1a1a1a"/>
<rect x="21" y="9" width="2" height="1" fill="#1a1a1a"/>
<rect x="25" y="9" width="1" height="1" fill="#1a1a1a"/>
<rect x="27" y="9" width="2" height="1" fill="#1a1a1a"/>
<rect x="31" y="9" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 10 -->
<rect x="9" y="10" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="10" width="1" height="1" fill="#1a1a1a"/>
<rect x="15" y="10" width="2" height="1" fill="#1a1a1a"/>
<rect x="19" y="10" width="1" height="1" fill="#1a1a1a"/>
<rect x="22" y="10" width="3" height="1" fill="#1a1a1a"/>
<rect x="26" y="10" width="2" height="1" fill="#1a1a1a"/>
<rect x="30" y="10" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 11 -->
<rect x="8" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="10" y="11" width="3" height="1" fill="#1a1a1a"/>
<rect x="15" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="11" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="11" width="2" height="1" fill="#1a1a1a"/>
<rect x="28" y="11" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 12 -->
<rect x="9" y="12" width="4" height="1" fill="#1a1a1a"/>
<rect x="14" y="12" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="12" width="3" height="1" fill="#1a1a1a"/>
<rect x="22" y="12" width="2" height="1" fill="#1a1a1a"/>
<rect x="26" y="12" width="1" height="1" fill="#1a1a1a"/>
<rect x="29" y="12" width="2" height="1" fill="#1a1a1a"/>
<rect x="33" y="12" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 13 -->
<rect x="8" y="13" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="13" width="1" height="1" fill="#1a1a1a"/>
<rect x="14" y="13" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="13" width="4" height="1" fill="#1a1a1a"/>
<rect x="24" y="13" width="3" height="1" fill="#1a1a1a"/>
<rect x="29" y="13" width="1" height="1" fill="#1a1a1a"/>
<rect x="31" y="13" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 14 -->
<rect x="9" y="14" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="14" width="3" height="1" fill="#1a1a1a"/>
<rect x="16" y="14" width="2" height="1" fill="#1a1a1a"/>
<rect x="20" y="14" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="14" width="2" height="1" fill="#1a1a1a"/>
<rect x="27" y="14" width="3" height="1" fill="#1a1a1a"/>
<rect x="32" y="14" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 15 -->
<rect x="8" y="15" width="3" height="1" fill="#1a1a1a"/>
<rect x="13" y="15" width="2" height="1" fill="#1a1a1a"/>
<rect x="17" y="15" width="1" height="1" fill="#1a1a1a"/>
<rect x="19" y="15" width="3" height="1" fill="#1a1a1a"/>
<rect x="24" y="15" width="1" height="1" fill="#1a1a1a"/>
<rect x="27" y="15" width="2" height="1" fill="#1a1a1a"/>
<rect x="30" y="15" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 16 -->
<rect x="9" y="16" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="16" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="16" width="3" height="1" fill="#1a1a1a"/>
<rect x="21" y="16" width="2" height="1" fill="#1a1a1a"/>
<rect x="25" y="16" width="3" height="1" fill="#1a1a1a"/>
<rect x="30" y="16" width="2" height="1" fill="#1a1a1a"/>
<rect x="34" y="16" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 17 -->
<rect x="8" y="17" width="1" height="1" fill="#1a1a1a"/>
<rect x="10" y="17" width="4" height="1" fill="#1a1a1a"/>
<rect x="15" y="17" width="2" height="1" fill="#1a1a1a"/>
<rect x="19" y="17" width="1" height="1" fill="#1a1a1a"/>
<rect x="22" y="17" width="3" height="1" fill="#1a1a1a"/>
<rect x="27" y="17" width="1" height="1" fill="#1a1a1a"/>
<rect x="29" y="17" width="4" height="1" fill="#1a1a1a"/>
<!-- Row 18 -->
<rect x="9" y="18" width="3" height="1" fill="#1a1a1a"/>
<rect x="14" y="18" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="18" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="18" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="18" width="2" height="1" fill="#1a1a1a"/>
<rect x="28" y="18" width="2" height="1" fill="#1a1a1a"/>
<rect x="32" y="18" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 19 -->
<rect x="8" y="19" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="19" width="3" height="1" fill="#1a1a1a"/>
<rect x="16" y="19" width="1" height="1" fill="#1a1a1a"/>
<rect x="18" y="19" width="2" height="1" fill="#1a1a1a"/>
<rect x="22" y="19" width="4" height="1" fill="#1a1a1a"/>
<rect x="28" y="19" width="1" height="1" fill="#1a1a1a"/>
<rect x="30" y="19" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 20 (center) -->
<rect x="9" y="20" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="20" width="2" height="1" fill="#1a1a1a"/>
<rect x="15" y="20" width="3" height="1" fill="#1a1a1a"/>
<rect x="20" y="20" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="20" width="2" height="1" fill="#1a1a1a"/>
<rect x="27" y="20" width="3" height="1" fill="#1a1a1a"/>
<rect x="32" y="20" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 21 -->
<rect x="8" y="21" width="4" height="1" fill="#1a1a1a"/>
<rect x="14" y="21" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="21" width="3" height="1" fill="#1a1a1a"/>
<rect x="23" y="21" width="1" height="1" fill="#1a1a1a"/>
<rect x="25" y="21" width="2" height="1" fill="#1a1a1a"/>
<rect x="29" y="21" width="1" height="1" fill="#1a1a1a"/>
<rect x="31" y="21" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 22 -->
<rect x="9" y="22" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="22" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="22" width="2" height="1" fill="#1a1a1a"/>
<rect x="20" y="22" width="2" height="1" fill="#1a1a1a"/>
<rect x="24" y="22" width="3" height="1" fill="#1a1a1a"/>
<rect x="29" y="22" width="2" height="1" fill="#1a1a1a"/>
<rect x="33" y="22" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 23 -->
<rect x="8" y="23" width="1" height="1" fill="#1a1a1a"/>
<rect x="10" y="23" width="3" height="1" fill="#1a1a1a"/>
<rect x="15" y="23" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="23" width="3" height="1" fill="#1a1a1a"/>
<rect x="22" y="23" width="2" height="1" fill="#1a1a1a"/>
<rect x="26" y="23" width="1" height="1" fill="#1a1a1a"/>
<rect x="28" y="23" width="4" height="1" fill="#1a1a1a"/>
<!-- Row 24 -->
<rect x="9" y="24" width="4" height="1" fill="#1a1a1a"/>
<rect x="14" y="24" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="24" width="1" height="1" fill="#1a1a1a"/>
<rect x="21" y="24" width="3" height="1" fill="#1a1a1a"/>
<rect x="26" y="24" width="2" height="1" fill="#1a1a1a"/>
<rect x="30" y="24" width="1" height="1" fill="#1a1a1a"/>
<rect x="32" y="24" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 25 -->
<rect x="8" y="25" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="25" width="1" height="1" fill="#1a1a1a"/>
<rect x="14" y="25" width="3" height="1" fill="#1a1a1a"/>
<rect x="19" y="25" width="2" height="1" fill="#1a1a1a"/>
<rect x="23" y="25" width="1" height="1" fill="#1a1a1a"/>
<rect x="26" y="25" width="3" height="1" fill="#1a1a1a"/>
<rect x="31" y="25" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 26 -->
<rect x="9" y="26" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="26" width="2" height="1" fill="#1a1a1a"/>
<rect x="15" y="26" width="2" height="1" fill="#1a1a1a"/>
<rect x="19" y="26" width="1" height="1" fill="#1a1a1a"/>
<rect x="22" y="26" width="3" height="1" fill="#1a1a1a"/>
<rect x="27" y="26" width="2" height="1" fill="#1a1a1a"/>
<rect x="31" y="26" width="1" height="1" fill="#1a1a1a"/>
<rect x="33" y="26" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 27 -->
<rect x="8" y="27" width="3" height="1" fill="#1a1a1a"/>
<rect x="13" y="27" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="27" width="3" height="1" fill="#1a1a1a"/>
<rect x="21" y="27" width="2" height="1" fill="#1a1a1a"/>
<rect x="25" y="27" width="1" height="1" fill="#1a1a1a"/>
<rect x="28" y="27" width="3" height="1" fill="#1a1a1a"/>
<rect x="33" y="27" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 28 -->
<rect x="9" y="28" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="28" width="3" height="1" fill="#1a1a1a"/>
<rect x="18" y="28" width="1" height="1" fill="#1a1a1a"/>
<rect x="21" y="28" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="28" width="2" height="1" fill="#1a1a1a"/>
<rect x="28" y="28" width="1" height="1" fill="#1a1a1a"/>
<rect x="30" y="28" width="4" height="1" fill="#1a1a1a"/>
<!-- Row 29 -->
<rect x="8" y="29" width="1" height="1" fill="#1a1a1a"/>
<rect x="10" y="29" width="4" height="1" fill="#1a1a1a"/>
<rect x="16" y="29" width="2" height="1" fill="#1a1a1a"/>
<rect x="20" y="29" width="3" height="1" fill="#1a1a1a"/>
<rect x="25" y="29" width="2" height="1" fill="#1a1a1a"/>
<rect x="29" y="29" width="2" height="1" fill="#1a1a1a"/>
<rect x="33" y="29" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 30 -->
<rect x="9" y="30" width="3" height="1" fill="#1a1a1a"/>
<rect x="14" y="30" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="30" width="1" height="1" fill="#1a1a1a"/>
<rect x="20" y="30" width="2" height="1" fill="#1a1a1a"/>
<rect x="24" y="30" width="3" height="1" fill="#1a1a1a"/>
<rect x="29" y="30" width="1" height="1" fill="#1a1a1a"/>
<rect x="32" y="30" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 31 -->
<rect x="8" y="31" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="31" width="2" height="1" fill="#1a1a1a"/>
<rect x="16" y="31" width="3" height="1" fill="#1a1a1a"/>
<rect x="21" y="31" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="31" width="2" height="1" fill="#1a1a1a"/>
<rect x="27" y="31" width="4" height="1" fill="#1a1a1a"/>
<rect x="33" y="31" width="1" height="1" fill="#1a1a1a"/>
<!-- Alignment pattern bottom-right (5×5) -->
<rect x="28" y="28" width="5" height="5" fill="#1a1a1a"/>
<rect x="29" y="29" width="3" height="3" fill="#f5f5f0"/>
<rect x="30" y="30" width="1" height="1" fill="#1a1a1a"/>
<!-- Format info strips -->
<rect x="8" y="8" width="1" height="1" fill="#1a1a1a"/>
<!-- Extra data density rows 3339 (below bottom-left finder) -->
<rect x="9" y="34" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="34" width="1" height="1" fill="#1a1a1a"/>
<rect x="15" y="34" width="3" height="1" fill="#1a1a1a"/>
<rect x="20" y="34" width="2" height="1" fill="#1a1a1a"/>
<rect x="24" y="34" width="1" height="1" fill="#1a1a1a"/>
<rect x="27" y="34" width="2" height="1" fill="#1a1a1a"/>
<rect x="8" y="35" width="3" height="1" fill="#1a1a1a"/>
<rect x="13" y="35" width="2" height="1" fill="#1a1a1a"/>
<rect x="17" y="35" width="1" height="1" fill="#1a1a1a"/>
<rect x="19" y="35" width="3" height="1" fill="#1a1a1a"/>
<rect x="24" y="35" width="2" height="1" fill="#1a1a1a"/>
<rect x="28" y="35" width="1" height="1" fill="#1a1a1a"/>
<rect x="9" y="36" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="36" width="2" height="1" fill="#1a1a1a"/>
<rect x="14" y="36" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="36" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="36" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="36" width="3" height="1" fill="#1a1a1a"/>
<rect x="27" y="36" width="2" height="1" fill="#1a1a1a"/>
<rect x="8" y="37" width="4" height="1" fill="#1a1a1a"/>
<rect x="14" y="37" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="37" width="3" height="1" fill="#1a1a1a"/>
<rect x="23" y="37" width="1" height="1" fill="#1a1a1a"/>
<rect x="25" y="37" width="2" height="1" fill="#1a1a1a"/>
<rect x="29" y="37" width="1" height="1" fill="#1a1a1a"/>
<rect x="9" y="38" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="38" width="3" height="1" fill="#1a1a1a"/>
<rect x="18" y="38" width="1" height="1" fill="#1a1a1a"/>
<rect x="21" y="38" width="2" height="1" fill="#1a1a1a"/>
<rect x="25" y="38" width="3" height="1" fill="#1a1a1a"/>
<rect x="30" y="38" width="1" height="1" fill="#1a1a1a"/>
<rect x="8" y="39" width="1" height="1" fill="#1a1a1a"/>
<rect x="10" y="39" width="3" height="1" fill="#1a1a1a"/>
<rect x="15" y="39" width="2" height="1" fill="#1a1a1a"/>
<rect x="19" y="39" width="1" height="1" fill="#1a1a1a"/>
<rect x="22" y="39" width="2" height="1" fill="#1a1a1a"/>
<rect x="26" y="39" width="1" height="1" fill="#1a1a1a"/>
<rect x="28" y="39" width="3" height="1" fill="#1a1a1a"/>
</svg>
</div>
<div class="qr-sub">
Encodes: <strong>WIFI:S:PictureFrame-A3F7;T:nopass;;</strong>
</div>
</div>
</div><!-- /.body -->
</div><!-- /.display -->
</div><!-- /.device-bezel -->
<!--
DESIGNER NOTES
──────────────────────────────────────────────────────────────────
Screen: AP Provisioning — Step 1 of 2
State: Frame has no WiFi credentials. Broadcasting open AP.
Accent: YELLOW — signals "action required," unconnected state.
Layout rationale:
• Yellow status bar carries SSID at a glance — one horizontal scan
tells you what network to join without reading the instructions.
• Instructions live LEFT, not centered, to make room for the large QR
without any elements competing at the same scale.
• Orientation diagrams are narrow + centered — they confirm physical
placement, not the primary action. They don't compete.
• QR panel is RIGHT and large — the single action. Yellow bracket
border echoes the accent and frames the eye's destination.
• Step numbers in black/yellow inverse boxes are readable at a
distance; the QR is readable up close. Both jobs done together.
• "Landscape" is marked active (check mark + yellow ribbon) because
the frame is currently held landscape. Portrait shown as the alt.
• Footnote handles the "captive portal didn't open" edge case.
Firmware MUST handle this — the screen already does.
Firmware implementation notes (for when you write the C):
• The SSID suffix is the last 4 hex chars of the MAC.
• The QR encodes the WIFI: string for auto-join on iOS/Android.
• The 192.168.4.1 fallback is the ESP32 SoftAP default gateway.
──────────────────────────────────────────────────────────────────
-->
</body>
</html>
@@ -0,0 +1,971 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=800">
<title>Setup QR Screen — pictureFrame mockup</title>
<style>
/*
HARDWARE CONSTRAINTS: 800×480px. Six colors only.
#1a1a1a BLACK
#f5f5f0 WHITE
#f0d000 YELLOW
#c03020 RED
#1840c0 BLUE
#10a040 GREEN
No gradients. No drop shadows. No anti-aliasing. No other colors.
No fonts below ~16px.
This file is a design mockup — open at 100% zoom in browser.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #888;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: 'Courier New', Courier, monospace;
}
/* Outer frame — the physical bezel */
.device-bezel {
width: 840px;
height: 520px;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* The e-ink display surface — exactly 800×480 */
.display {
width: 800px;
height: 480px;
background: #f5f5f0;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GREEN STATUS BAR — signals connected / almost done
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.status-bar {
width: 800px;
height: 52px;
background: #10a040;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
}
.status-bar-left {
display: flex;
align-items: center;
gap: 12px;
}
/* WiFi connected indicator — pixelated bars (no border-radius) */
.wifi-icon {
display: flex;
align-items: flex-end;
gap: 3px;
height: 22px;
}
.wifi-bar {
background: #f5f5f0;
width: 5px;
}
.wifi-bar-1 { height: 8px; }
.wifi-bar-2 { height: 13px; }
.wifi-bar-3 { height: 18px; }
.wifi-bar-4 { height: 22px; }
.status-bar-label {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #f5f5f0;
}
.status-bar-right {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
font-weight: 700;
color: #f5f5f0;
letter-spacing: 0.06em;
opacity: 0.85;
}
.ip-chip {
display: inline-block;
background: #f5f5f0;
color: #10a040;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 3px 9px;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MAIN BODY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
.body {
flex: 1;
display: flex;
flex-direction: row;
}
/* ── LEFT PANEL ───────────────────────────────────── */
.panel-left {
width: 340px;
flex-shrink: 0;
padding: 24px 20px 20px 28px;
display: flex;
flex-direction: column;
justify-content: space-between;
border-right: 2px solid #1a1a1a;
}
.main-heading {
font-family: 'Courier New', Courier, monospace;
font-size: 26px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
letter-spacing: -0.01em;
}
.main-heading em {
font-style: normal;
border-bottom: 3px solid #10a040;
}
.sub-heading {
font-family: 'Courier New', Courier, monospace;
font-size: 15px;
color: #1a1a1a;
line-height: 1.5;
margin-top: 14px;
opacity: 0.75;
}
.step-list {
list-style: none;
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.step-item {
display: flex;
align-items: flex-start;
gap: 10px;
}
.step-num {
width: 24px;
height: 24px;
background: #10a040;
color: #f5f5f0;
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-text {
font-family: 'Courier New', Courier, monospace;
font-size: 15px;
color: #1a1a1a;
line-height: 1.35;
padding-top: 3px;
}
.step-text strong {
font-weight: 700;
}
/* URL hint bar */
.url-bar {
margin-top: 18px;
background: #1a1a1a;
padding: 8px 14px;
display: flex;
align-items: center;
gap: 8px;
}
.url-bar-label {
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #10a040;
flex-shrink: 0;
}
.url-bar-value {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
color: #f5f5f0;
word-break: break-all;
line-height: 1.3;
}
/* Progress tracker */
.progress-track {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-label {
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #1a1a1a;
opacity: 0.45;
margin-bottom: 4px;
}
.progress-steps {
display: flex;
gap: 4px;
align-items: center;
}
.prog-step {
height: 6px;
flex: 1;
}
.prog-step.done {
background: #10a040;
}
.prog-step.active {
background: #1a1a1a;
}
.prog-step.todo {
background: #1a1a1a;
opacity: 0.2;
}
.prog-step-labels {
display: flex;
gap: 4px;
margin-top: 4px;
}
.prog-step-label {
flex: 1;
font-family: 'Courier New', Courier, monospace;
font-size: 10px;
color: #1a1a1a;
text-align: center;
line-height: 1.2;
opacity: 0.55;
}
.prog-step-label.done {
color: #10a040;
opacity: 1;
font-weight: 700;
}
.prog-step-label.active {
opacity: 1;
font-weight: 700;
}
/* ── CENTER PANEL — orientation diagrams ──────────── */
.panel-center {
width: 164px;
flex-shrink: 0;
border-right: 2px solid #1a1a1a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 10px;
}
.orient-section-title {
font-family: 'Courier New', Courier, monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #1a1a1a;
opacity: 0.45;
text-align: center;
margin-bottom: 4px;
}
.orient-label {
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #1a1a1a;
text-align: center;
margin-bottom: 6px;
}
.orient-block {
display: flex;
flex-direction: column;
align-items: center;
}
/* Landscape: wide rect + bottom ribbon */
.orient-landscape-frame {
width: 100px;
height: 60px;
border: 3px solid #1a1a1a;
background: #f5f5f0;
position: relative;
}
/* Inner screen hatching — suggests image content */
.orient-landscape-frame::after {
content: '';
position: absolute;
top: 6px; left: 6px; right: 6px; bottom: 6px;
border: 1px solid #1a1a1a;
opacity: 0.25;
}
.orient-landscape-ribbon {
width: 100px;
height: 9px;
background: #1a1a1a;
}
/* Portrait: tall rect + left ribbon */
.orient-portrait-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.orient-portrait-ribbon {
width: 9px;
height: 96px;
background: #1a1a1a;
}
.orient-portrait-frame {
width: 58px;
height: 96px;
border: 3px solid #1a1a1a;
background: #f5f5f0;
position: relative;
}
.orient-portrait-frame::after {
content: '';
position: absolute;
top: 6px; left: 6px; right: 6px; bottom: 6px;
border: 1px solid #1a1a1a;
opacity: 0.25;
}
/* Active state — green accent for connected/done feeling */
.orient-active .orient-landscape-frame,
.orient-active .orient-portrait-frame {
border-color: #10a040;
}
.orient-active .orient-landscape-ribbon,
.orient-active .orient-portrait-ribbon {
background: #10a040;
}
.orient-active .orient-label {
color: #10a040;
}
.active-badge {
width: 18px;
height: 18px;
background: #10a040;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 900;
color: #f5f5f0;
margin-top: 5px;
}
.orient-divider {
width: 100%;
height: 1px;
background: #1a1a1a;
opacity: 0.2;
}
/* ── RIGHT PANEL — QR code ────────────────────────── */
.panel-right {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 20px;
gap: 10px;
}
.qr-instruction {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #1a1a1a;
text-align: center;
}
.qr-wrapper {
width: 200px;
height: 200px;
background: #f5f5f0;
border: 3px solid #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* Green corner accent — signals connected state */
.qr-wrapper::before {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border: 3px solid #10a040;
pointer-events: none;
}
.qr-sub {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
color: #1a1a1a;
text-align: center;
line-height: 1.45;
opacity: 0.6;
max-width: 200px;
}
/* MAC address chip */
.mac-chip {
display: inline-block;
background: #1a1a1a;
color: #f5f5f0;
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 3px 9px;
margin-top: 2px;
}
</style>
</head>
<body>
<div class="device-bezel">
<div class="display">
<!-- STATUS BAR -->
<div class="status-bar">
<div class="status-bar-left">
<!-- WiFi icon: 3 arcs + dot -->
<div class="wifi-icon">
<div class="wifi-bar wifi-bar-1"></div>
<div class="wifi-bar wifi-bar-2"></div>
<div class="wifi-bar wifi-bar-3"></div>
<div class="wifi-bar wifi-bar-4"></div>
</div>
<span class="status-bar-label">WiFi Connected &mdash; Step 2 of 2</span>
</div>
<span class="status-bar-right">
IP: <span class="ip-chip">192.168.1.47</span>
</span>
</div>
<!-- BODY -->
<div class="body">
<!-- LEFT: instructions + progress -->
<div class="panel-left">
<div>
<div class="main-heading">Almost<br><em>ready.</em></div>
<div class="sub-heading">
Scan the QR code to give this frame a name and link it to your account.
</div>
<ul class="step-list">
<li class="step-item">
<div class="step-num">1</div>
<div class="step-text">
Scan the QR code with your phone's camera
</div>
</li>
<li class="step-item">
<div class="step-num">2</div>
<div class="step-text">
Sign in or create an account at <strong>pictureframe.edholm.me</strong>
</div>
</li>
<li class="step-item">
<div class="step-num">3</div>
<div class="step-text">
Name the frame. Choose orientation. Done.
</div>
</li>
</ul>
<div class="url-bar">
<span class="url-bar-label">URL</span>
<span class="url-bar-value">pictureframe.edholm.me/setup/1C:C3:AB:D1:91:F8</span>
</div>
</div>
<div>
<!-- Progress tracker: WiFi done / Account next / Frame ready todo -->
<div class="progress-track">
<div class="progress-label">Setup progress</div>
<div class="progress-steps">
<div class="prog-step done"></div>
<div class="prog-step active"></div>
<div class="prog-step todo"></div>
</div>
<div class="prog-step-labels">
<div class="prog-step-label done">WiFi</div>
<div class="prog-step-label active">Account</div>
<div class="prog-step-label todo">Frame ready</div>
</div>
</div>
</div>
</div>
<!-- CENTER: orientation diagrams -->
<div class="panel-center">
<div class="orient-section-title">Frame orientation</div>
<!-- Landscape (active) -->
<div class="orient-block orient-active">
<div class="orient-label">Landscape</div>
<div class="orient-landscape-frame"></div>
<div class="orient-landscape-ribbon"></div>
<div class="active-badge">&#10003;</div>
</div>
<div class="orient-divider"></div>
<!-- Portrait -->
<div class="orient-block">
<div class="orient-label">Portrait</div>
<div class="orient-portrait-wrapper">
<div class="orient-portrait-ribbon"></div>
<div class="orient-portrait-frame"></div>
</div>
</div>
</div>
<!-- RIGHT: QR code -->
<div class="panel-right">
<div class="qr-instruction">Scan to finish</div>
<div class="qr-wrapper">
<!-- QR code — encodes https://pictureframe.edholm.me/setup/1C:C3:AB:D1:91:F8 -->
<svg width="182" height="182" viewBox="0 0 41 41" xmlns="http://www.w3.org/2000/svg"
shape-rendering="crispEdges">
<rect width="41" height="41" fill="#f5f5f0"/>
<!-- TOP-LEFT FINDER -->
<rect x="1" y="1" width="7" height="7" fill="#1a1a1a"/>
<rect x="2" y="2" width="5" height="5" fill="#f5f5f0"/>
<rect x="3" y="3" width="3" height="3" fill="#1a1a1a"/>
<!-- TOP-RIGHT FINDER -->
<rect x="33" y="1" width="7" height="7" fill="#1a1a1a"/>
<rect x="34" y="2" width="5" height="5" fill="#f5f5f0"/>
<rect x="35" y="3" width="3" height="3" fill="#1a1a1a"/>
<!-- BOTTOM-LEFT FINDER -->
<rect x="1" y="33" width="7" height="7" fill="#1a1a1a"/>
<rect x="2" y="34" width="5" height="5" fill="#f5f5f0"/>
<rect x="3" y="35" width="3" height="3" fill="#1a1a1a"/>
<!-- TIMING H -->
<rect x="9" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="13" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="15" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="19" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="21" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="25" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="27" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="29" y="6" width="1" height="1" fill="#1a1a1a"/>
<rect x="31" y="6" width="1" height="1" fill="#1a1a1a"/>
<!-- TIMING V -->
<rect x="6" y="9" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="13" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="15" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="17" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="19" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="21" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="23" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="25" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="27" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="29" width="1" height="1" fill="#1a1a1a"/>
<rect x="6" y="31" width="1" height="1" fill="#1a1a1a"/>
<!-- DATA — URL QR pattern (unique from AP screen) -->
<!-- Row 9 -->
<rect x="8" y="9" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="9" width="1" height="1" fill="#1a1a1a"/>
<rect x="14" y="9" width="3" height="1" fill="#1a1a1a"/>
<rect x="19" y="9" width="2" height="1" fill="#1a1a1a"/>
<rect x="23" y="9" width="1" height="1" fill="#1a1a1a"/>
<rect x="26" y="9" width="2" height="1" fill="#1a1a1a"/>
<rect x="30" y="9" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 10 -->
<rect x="9" y="10" width="3" height="1" fill="#1a1a1a"/>
<rect x="14" y="10" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="10" width="2" height="1" fill="#1a1a1a"/>
<rect x="20" y="10" width="3" height="1" fill="#1a1a1a"/>
<rect x="25" y="10" width="2" height="1" fill="#1a1a1a"/>
<rect x="29" y="10" width="1" height="1" fill="#1a1a1a"/>
<rect x="32" y="10" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 11 -->
<rect x="8" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="11" width="3" height="1" fill="#1a1a1a"/>
<rect x="16" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="18" y="11" width="3" height="1" fill="#1a1a1a"/>
<rect x="23" y="11" width="2" height="1" fill="#1a1a1a"/>
<rect x="27" y="11" width="1" height="1" fill="#1a1a1a"/>
<rect x="30" y="11" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 12 -->
<rect x="9" y="12" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="12" width="2" height="1" fill="#1a1a1a"/>
<rect x="17" y="12" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="12" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="12" width="3" height="1" fill="#1a1a1a"/>
<rect x="29" y="12" width="2" height="1" fill="#1a1a1a"/>
<rect x="33" y="12" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 13 -->
<rect x="8" y="13" width="4" height="1" fill="#1a1a1a"/>
<rect x="14" y="13" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="13" width="3" height="1" fill="#1a1a1a"/>
<rect x="21" y="13" width="2" height="1" fill="#1a1a1a"/>
<rect x="25" y="13" width="1" height="1" fill="#1a1a1a"/>
<rect x="28" y="13" width="4" height="1" fill="#1a1a1a"/>
<!-- Row 14 -->
<rect x="9" y="14" width="1" height="1" fill="#1a1a1a"/>
<rect x="12" y="14" width="2" height="1" fill="#1a1a1a"/>
<rect x="16" y="14" width="1" height="1" fill="#1a1a1a"/>
<rect x="19" y="14" width="2" height="1" fill="#1a1a1a"/>
<rect x="23" y="14" width="3" height="1" fill="#1a1a1a"/>
<rect x="28" y="14" width="1" height="1" fill="#1a1a1a"/>
<rect x="31" y="14" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 15 -->
<rect x="8" y="15" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="15" width="3" height="1" fill="#1a1a1a"/>
<rect x="17" y="15" width="3" height="1" fill="#1a1a1a"/>
<rect x="22" y="15" width="2" height="1" fill="#1a1a1a"/>
<rect x="26" y="15" width="2" height="1" fill="#1a1a1a"/>
<rect x="30" y="15" width="1" height="1" fill="#1a1a1a"/>
<rect x="33" y="15" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 16 -->
<rect x="9" y="16" width="3" height="1" fill="#1a1a1a"/>
<rect x="14" y="16" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="16" width="1" height="1" fill="#1a1a1a"/>
<rect x="21" y="16" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="16" width="3" height="1" fill="#1a1a1a"/>
<rect x="29" y="16" width="2" height="1" fill="#1a1a1a"/>
<rect x="33" y="16" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 17 -->
<rect x="8" y="17" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="17" width="4" height="1" fill="#1a1a1a"/>
<rect x="17" y="17" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="17" width="3" height="1" fill="#1a1a1a"/>
<rect x="26" y="17" width="1" height="1" fill="#1a1a1a"/>
<rect x="29" y="17" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 18 -->
<rect x="9" y="18" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="18" width="1" height="1" fill="#1a1a1a"/>
<rect x="15" y="18" width="3" height="1" fill="#1a1a1a"/>
<rect x="20" y="18" width="2" height="1" fill="#1a1a1a"/>
<rect x="24" y="18" width="1" height="1" fill="#1a1a1a"/>
<rect x="27" y="18" width="3" height="1" fill="#1a1a1a"/>
<rect x="32" y="18" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 19 -->
<rect x="8" y="19" width="4" height="1" fill="#1a1a1a"/>
<rect x="14" y="19" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="19" width="1" height="1" fill="#1a1a1a"/>
<rect x="22" y="19" width="3" height="1" fill="#1a1a1a"/>
<rect x="27" y="19" width="2" height="1" fill="#1a1a1a"/>
<rect x="31" y="19" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 20 -->
<rect x="9" y="20" width="1" height="1" fill="#1a1a1a"/>
<rect x="12" y="20" width="3" height="1" fill="#1a1a1a"/>
<rect x="17" y="20" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="20" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="20" width="2" height="1" fill="#1a1a1a"/>
<rect x="28" y="20" width="1" height="1" fill="#1a1a1a"/>
<rect x="31" y="20" width="1" height="1" fill="#1a1a1a"/>
<rect x="33" y="20" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 21 -->
<rect x="8" y="21" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="21" width="1" height="1" fill="#1a1a1a"/>
<rect x="14" y="21" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="21" width="3" height="1" fill="#1a1a1a"/>
<rect x="23" y="21" width="2" height="1" fill="#1a1a1a"/>
<rect x="27" y="21" width="3" height="1" fill="#1a1a1a"/>
<rect x="32" y="21" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 22 -->
<rect x="9" y="22" width="3" height="1" fill="#1a1a1a"/>
<rect x="14" y="22" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="22" width="2" height="1" fill="#1a1a1a"/>
<rect x="20" y="22" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="22" width="1" height="1" fill="#1a1a1a"/>
<rect x="26" y="22" width="2" height="1" fill="#1a1a1a"/>
<rect x="30" y="22" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 23 -->
<rect x="8" y="23" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="23" width="2" height="1" fill="#1a1a1a"/>
<rect x="15" y="23" width="3" height="1" fill="#1a1a1a"/>
<rect x="20" y="23" width="3" height="1" fill="#1a1a1a"/>
<rect x="25" y="23" width="1" height="1" fill="#1a1a1a"/>
<rect x="28" y="23" width="2" height="1" fill="#1a1a1a"/>
<rect x="32" y="23" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 24 -->
<rect x="9" y="24" width="4" height="1" fill="#1a1a1a"/>
<rect x="15" y="24" width="1" height="1" fill="#1a1a1a"/>
<rect x="18" y="24" width="2" height="1" fill="#1a1a1a"/>
<rect x="22" y="24" width="2" height="1" fill="#1a1a1a"/>
<rect x="26" y="24" width="3" height="1" fill="#1a1a1a"/>
<rect x="31" y="24" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 25 -->
<rect x="8" y="25" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="25" width="3" height="1" fill="#1a1a1a"/>
<rect x="17" y="25" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="25" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="25" width="2" height="1" fill="#1a1a1a"/>
<rect x="28" y="25" width="1" height="1" fill="#1a1a1a"/>
<rect x="31" y="25" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 26 -->
<rect x="9" y="26" width="1" height="1" fill="#1a1a1a"/>
<rect x="12" y="26" width="1" height="1" fill="#1a1a1a"/>
<rect x="15" y="26" width="3" height="1" fill="#1a1a1a"/>
<rect x="20" y="26" width="3" height="1" fill="#1a1a1a"/>
<rect x="25" y="26" width="2" height="1" fill="#1a1a1a"/>
<rect x="29" y="26" width="4" height="1" fill="#1a1a1a"/>
<!-- Row 27 -->
<rect x="8" y="27" width="3" height="1" fill="#1a1a1a"/>
<rect x="13" y="27" width="2" height="1" fill="#1a1a1a"/>
<rect x="17" y="27" width="1" height="1" fill="#1a1a1a"/>
<rect x="19" y="27" width="2" height="1" fill="#1a1a1a"/>
<rect x="23" y="27" width="3" height="1" fill="#1a1a1a"/>
<rect x="28" y="27" width="2" height="1" fill="#1a1a1a"/>
<rect x="32" y="27" width="2" height="1" fill="#1a1a1a"/>
<!-- Row 28 -->
<rect x="9" y="28" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="28" width="1" height="1" fill="#1a1a1a"/>
<rect x="16" y="28" width="2" height="1" fill="#1a1a1a"/>
<rect x="20" y="28" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="28" width="1" height="1" fill="#1a1a1a"/>
<rect x="26" y="28" width="3" height="1" fill="#1a1a1a"/>
<rect x="31" y="28" width="1" height="1" fill="#1a1a1a"/>
<rect x="33" y="28" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 29 -->
<rect x="8" y="29" width="1" height="1" fill="#1a1a1a"/>
<rect x="10" y="29" width="4" height="1" fill="#1a1a1a"/>
<rect x="15" y="29" width="2" height="1" fill="#1a1a1a"/>
<rect x="18" y="29" width="3" height="1" fill="#1a1a1a"/>
<rect x="23" y="29" width="2" height="1" fill="#1a1a1a"/>
<rect x="27" y="29" width="1" height="1" fill="#1a1a1a"/>
<rect x="30" y="29" width="3" height="1" fill="#1a1a1a"/>
<!-- Row 30 -->
<rect x="9" y="30" width="3" height="1" fill="#1a1a1a"/>
<rect x="14" y="30" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="30" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="30" width="3" height="1" fill="#1a1a1a"/>
<rect x="26" y="30" width="2" height="1" fill="#1a1a1a"/>
<rect x="30" y="30" width="1" height="1" fill="#1a1a1a"/>
<rect x="33" y="30" width="1" height="1" fill="#1a1a1a"/>
<!-- Row 31 -->
<rect x="8" y="31" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="31" width="3" height="1" fill="#1a1a1a"/>
<rect x="17" y="31" width="1" height="1" fill="#1a1a1a"/>
<rect x="20" y="31" width="2" height="1" fill="#1a1a1a"/>
<rect x="24" y="31" width="1" height="1" fill="#1a1a1a"/>
<rect x="27" y="31" width="4" height="1" fill="#1a1a1a"/>
<rect x="33" y="31" width="2" height="1" fill="#1a1a1a"/>
<!-- Alignment pattern (bottom-right) -->
<rect x="28" y="28" width="5" height="5" fill="#1a1a1a"/>
<rect x="29" y="29" width="3" height="3" fill="#f5f5f0"/>
<rect x="30" y="30" width="1" height="1" fill="#1a1a1a"/>
<rect x="8" y="8" width="1" height="1" fill="#1a1a1a"/>
<!-- Data rows below bottom-left finder (rows 3439) -->
<rect x="9" y="34" width="1" height="1" fill="#1a1a1a"/>
<rect x="12" y="34" width="2" height="1" fill="#1a1a1a"/>
<rect x="16" y="34" width="3" height="1" fill="#1a1a1a"/>
<rect x="21" y="34" width="1" height="1" fill="#1a1a1a"/>
<rect x="24" y="34" width="2" height="1" fill="#1a1a1a"/>
<rect x="28" y="34" width="3" height="1" fill="#1a1a1a"/>
<rect x="8" y="35" width="4" height="1" fill="#1a1a1a"/>
<rect x="14" y="35" width="1" height="1" fill="#1a1a1a"/>
<rect x="17" y="35" width="2" height="1" fill="#1a1a1a"/>
<rect x="21" y="35" width="3" height="1" fill="#1a1a1a"/>
<rect x="26" y="35" width="1" height="1" fill="#1a1a1a"/>
<rect x="29" y="35" width="2" height="1" fill="#1a1a1a"/>
<rect x="9" y="36" width="2" height="1" fill="#1a1a1a"/>
<rect x="13" y="36" width="3" height="1" fill="#1a1a1a"/>
<rect x="18" y="36" width="1" height="1" fill="#1a1a1a"/>
<rect x="21" y="36" width="2" height="1" fill="#1a1a1a"/>
<rect x="25" y="36" width="3" height="1" fill="#1a1a1a"/>
<rect x="30" y="36" width="2" height="1" fill="#1a1a1a"/>
<rect x="8" y="37" width="1" height="1" fill="#1a1a1a"/>
<rect x="11" y="37" width="2" height="1" fill="#1a1a1a"/>
<rect x="15" y="37" width="3" height="1" fill="#1a1a1a"/>
<rect x="20" y="37" width="1" height="1" fill="#1a1a1a"/>
<rect x="23" y="37" width="2" height="1" fill="#1a1a1a"/>
<rect x="27" y="37" width="1" height="1" fill="#1a1a1a"/>
<rect x="30" y="37" width="3" height="1" fill="#1a1a1a"/>
<rect x="9" y="38" width="4" height="1" fill="#1a1a1a"/>
<rect x="15" y="38" width="1" height="1" fill="#1a1a1a"/>
<rect x="18" y="38" width="2" height="1" fill="#1a1a1a"/>
<rect x="22" y="38" width="3" height="1" fill="#1a1a1a"/>
<rect x="27" y="38" width="2" height="1" fill="#1a1a1a"/>
<rect x="31" y="38" width="2" height="1" fill="#1a1a1a"/>
<rect x="8" y="39" width="2" height="1" fill="#1a1a1a"/>
<rect x="12" y="39" width="1" height="1" fill="#1a1a1a"/>
<rect x="15" y="39" width="2" height="1" fill="#1a1a1a"/>
<rect x="19" y="39" width="1" height="1" fill="#1a1a1a"/>
<rect x="22" y="39" width="3" height="1" fill="#1a1a1a"/>
<rect x="27" y="39" width="1" height="1" fill="#1a1a1a"/>
<rect x="29" y="39" width="4" height="1" fill="#1a1a1a"/>
</svg>
</div>
<div class="qr-sub">
<span class="mac-chip">1C:C3:AB:D1:91:F8</span>
</div>
</div>
</div><!-- /.body -->
</div><!-- /.display -->
</div><!-- /.device-bezel -->
<!--
DESIGNER NOTES
──────────────────────────────────────────────────────────────────
Screen: Setup QR — Step 2 of 2
State: Frame joined home WiFi. Waiting for account link via scan.
Accent: GREEN — signals progress, success, completion imminent.
Layout rationale:
• Green bar immediately contrasts with AP screen's yellow — user
registers "something changed, I'm further along." The WiFi
icon in the bar confirms the network is live.
• "Almost ready." is deliberately casual and warm — not "Device
Provisioning Step 2/2." The sub-heading does the explaining.
• URL bar at the bottom of instructions answers the fallback question
(what if the QR doesn't scan?) without cluttering the main steps.
• Progress track — three segments: WiFi done (green), Account
(solid black = active), Frame ready (faint = todo) — gives the user
a map without requiring them to read it.
• Center panel reuses the orientation diagram pattern from screen 1,
maintaining visual language across the two screens. The active
orientation is green this time, consistent with the accent shift.
• QR panel: green bracket border, MAC chip below the code. The MAC
is shown because the URL contains it — the user may need to verify
their device if they have multiple frames. Also tells the builder
(Matt) what he's looking at during development.
Physical observation: if the frame is landscape (current mockup),
the ribbon connector sits at the bottom of the frame, behind the
stand or mount. The orientation diagram ribbon is bottom for
landscape, left for portrait — this matches physical reality.
Firmware implementation notes:
• This screen appears after STA mode connection confirmed.
• The QR encodes the /setup/{mac} URL exactly.
• The IP shown is the DHCP-assigned address — confirm in real code
that the display format matches (colons in MAC, not dashes).
• Frame should stay on this screen until the web app confirms
account linkage — then reboot into image-cycling mode.
──────────────────────────────────────────────────────────────────
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,532 @@
---
status: reviewed
createdAt: '2026-05-06'
reviewedBy: [ProductOwner, Architect, DevLead]
---
# pictureFrame — Comprehensive Test Plan
## 0. Scope and Goals
This plan covers unit, integration, and functional tests for all three layers of the pictureFrame system:
1. **Server** — Symfony 8 / PHP 8.4 backend (controllers, services, message handlers, repositories)
2. **Frontend** — Vue 3 SPA (stores, components, views)
3. **Firmware** — ESP32 Arduino C++ (control flow, protocol correctness)
Primary goals:
- Prevent regressions in the firmware ↔ server API contract (any break requires physical reflash of every deployed device)
- Cover all branching logic in `RotationService` and `DeviceImageController` — the most complex, failure-prone code
- FW-02 is the explicit regression test for the NVS header-read-after-end bug (headers read after `http.end()` → NVS never updated → 304 never fires)
- Give CI a green/red signal before any server deploy
---
## 1. Tooling and Infrastructure
### 1.1 PHP / Server
| Tool | Role | Already present? |
|---|---|---|
| PHPUnit 13.1 | Unit + integration + functional test runner | Yes (`phpunit.dist.xml`, `tests/bootstrap.php`) |
| `symfony/test-pack` | `WebTestCase`, `KernelTestCase`, test client | Needs adding |
| `dama/doctrine-test-bundle` | Wraps each test in a rolled-back transaction (fast isolation) | Needs adding |
| `doctrine/fixtures-bundle` | Seed fixture objects without raw SQL | Needs adding |
| Separate test database | `_test` suffix already in `doctrine.yaml` | Configured, DB needs creating |
**Required setup steps (all inside `ddev exec`):**
```bash
# Install test dependencies
ddev exec composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle
# Create test DB and run migrations
ddev exec php bin/console doctrine:database:create --env=test
ddev exec php bin/console doctrine:migrations:migrate --no-interaction --env=test
```
**`phpunit.dist.xml` — add dama extension:**
```xml
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
```
**`.env.test.local`** (not committed — each developer creates this):
```
DATABASE_URL="pgsql://db:db@db:5432/pictureframe_test"
```
**`.env.test`** — add Messenger transport override so dispatched messages are observable in integration tests:
```
MESSENGER_TRANSPORT_DSN=in-memory://
```
Without this, `RenderImageMessage` dispatch assertions (IMG-01, SH-03, IMG-06) will silently not fire.
**Filesystem teardown for message handler tests:** `dama/doctrine-test-bundle` rolls back DB writes but NOT filesystem writes. Tests in `RenderImageMessageHandlerTest` that write `.bin` files to `storage/images/` must call a teardown that deletes the test image directory after each test. Use `setUp()`/`tearDown()` with a dedicated `storage/test/` prefix, or run handler tests in a separate suite without dama.
### 1.2 Frontend
| Tool | Role | Already present? |
|---|---|---|
| Vitest 2.x | Unit + component test runner (Vite-native) | Needs adding |
| `@vue/test-utils` | Mount Vue components in JSDOM | Needs adding |
| `@vitest/coverage-v8` | Coverage reporting | Needs adding |
| MSW 2.x | Intercept `fetch()` calls in tests | Needs adding |
Add to `frontend/package.json` devDependencies, then add `test` block to `vite.config.ts`:
```ts
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
}
```
**Known gap:** `CropEditor.vue` and `StickerCanvas.vue` depend on Konva.js canvas APIs unavailable in JSDOM. These two components are excluded from component tests and do NOT count toward the 60% component coverage target. They require Playwright E2E or manual testing.
### 1.3 Firmware
| Tool | Role | Already present? |
|---|---|---|
| PlatformIO `native` env | Compiles C++ for host, runs Unity tests | Needs adding |
| Unity (bundled) | Assertions and test runner macros | Bundled |
| Hand-rolled Arduino mocks | Stubs for Arduino/ESP APIs | Needs writing |
**`platformio.ini` additions:**
```ini
[env:native-test]
platform = native
lib_deps =
throwtheswitch/Unity@^2.6
build_flags = -DUNIT_TEST -Itest/mocks
test_build_src = no
```
`test_build_src = no` is **required**`yes` would compile `setup()` and `loop()` from `main.cpp` for the host, colliding with PlatformIO's native `main()` shim (ODR violation / duplicate symbol linker error).
**HTTPClient injection — template, not polymorphism:** Arduino's `HTTPClient` methods are not virtual, so `normal_operation()` cannot accept an `HTTPClient&` base reference. Use a compile-time template instead:
```cpp
template<typename HTTP>
static void normal_operation_impl(const String& mac, HTTP& http) { ... }
// Production:
static void normal_operation(const String& mac) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.begin(client, url);
normal_operation_impl(mac, http);
}
```
The mock `HTTP` type and the real `HTTPClient` share no base class — the template resolves at compile time. Production code is unchanged in behavior; tests instantiate with a mock type.
**Mock surface** — all files in `firmware/test/mocks/` (included before system headers via `-Itest/mocks` build flag):
| Mock file | Stubs | Notes |
|---|---|---|
| `Arduino.h` | `millis()`, `delay()`, `pinMode()`, `digitalRead()`, `String`, `Serial` | |
| `MockHTTPClient.h` | `begin()`, `GET()`, `header()`, `end()`, `writeToStream()`, `addHeader()`, `collectHeaders()` | `header()` must return empty string after `end()` is called — this is the FW-02 behavior |
| `WiFi.h` | `macAddress()`, `status()`, `begin()`, `mode()`, `softAP()`, `softAPIP()`, `localIP()` | |
| `WiFiClientSecure.h` | No-op constructor, `setInsecure()` | |
| `Preferences.h` | In-memory `std::map`-backed `getInt()`, `putInt()`, `getString()`, `putString()`, `clear()`, `begin()`, `end()` | |
| `LittleFS.h` / `FS.h` | In-memory file store; `writeToStream()` in `MockHTTPClient` pumps into this store | The wiring between HTTPClient mock and LittleFS mock must be explicit — mock HTTP holds a pointer to mock FS |
| `epd.h` | No-op stubs + call counters for each `epd_*` function | Shadows real header via `-Itest/mocks` — do NOT put mocks in `firmware/src/` |
| `esp_sleep.h` | Captures `sleepUs` in a global; separate `g_deepSleepStarted` boolean | Both `esp_sleep_enable_timer_wakeup` and `esp_deep_sleep_start` must be mocked |
---
## 2. Server Tests
### Test taxonomy
| Label used in this plan | Symfony base class | Scope |
|---|---|---|
| **Unit** | `PHPUnit\Framework\TestCase` | Pure PHP logic, mock repositories — no DB, no container |
| **Integration** | `KernelTestCase` + dama | Real DB, real container, no HTTP routing |
| **Functional** | `WebTestCase` + dama | Full HTTP stack: routing, firewall, kernel |
### 2.1 Unit Tests — `RotationService`
File: `tests/Unit/Service/RotationServiceTest.php`
Uses `PHPUnit\Framework\TestCase` with mock `EntityManager` and `DeviceImageHistoryRepository`. No DB, no container — pure selection algorithm logic.
| # | Test | What it asserts |
|---|---|---|
| R-01 | `advance_returns_null_when_pool_empty` | No ready RenderedAssets → `advance()` returns `null` |
| R-02 | `advance_picks_oldest_image_with_no_history` | Pool of 3 images, no history → returns oldest by `uploadedAt` |
| R-03 | `advance_skips_recently_shown_image` | Image A shown last → Image B returned next |
| R-04 | `advance_resets_when_all_candidates_in_window` | All pool images in uniqueness window → picks from full pool (no infinite loop) |
| R-05 | `advance_respects_uniqueness_window_size` | Window=2, pool=5 → only 2 IDs excluded |
| R-06 | `advance_writes_DeviceImageHistory_record` | `em->persist()` called with a `DeviceImageHistory` for the returned image |
| R-07 | `advance_updates_device_current_image` | `$device->getCurrentImage()` matches returned image |
| R-08 | `advance_only_considers_ready_assets` | Image with `pending` RenderedAsset excluded from pool |
| R-09 | `advance_respects_device_approved_images_only` | Image not approved for this device excluded from pool |
| R-10 | `advance_not_called_when_device_has_locked_image` | Controller bypasses `advance()` entirely when `lockedImage` set — assert `advance()` is never reached (test via controller, see I-06) |
### 2.2 Unit Tests — `TokenService`
File: `tests/Unit/Service/TokenServiceTest.php`
| # | Test | What it asserts |
|---|---|---|
| T-01 | `issue_creates_token_with_correct_type` | Returns `Token` with matching `TokenType` |
| T-02 | `issue_sets_expiry_in_future` | `expiresAt > now()` |
| T-03 | `consume_marks_token_used` | `usedAt` is set on the persisted entity |
| T-04 | `consume_returns_token_entity` | Return value is the `Token` object |
| T-05 | `consume_throws_on_expired_token` | Token past `expiresAt` → exception |
| T-06 | `consume_throws_on_already_used_token` | `usedAt` already set → exception |
| T-07 | `consume_throws_on_type_mismatch` | Requesting wrong `TokenType` → exception |
### 2.3 Unit Tests — `DeviceService`
File: `tests/Unit/Service/DeviceServiceTest.php`
| # | Test | What it asserts |
|---|---|---|
| D-01 | `link_associates_device_with_user` | `$device->getUser()` matches after link |
| D-02 | `link_idempotent_for_same_user` | Calling link twice does not throw or create duplicates |
| D-03 | `purgeHistory_removes_records_beyond_window` | History older than uniqueness window count is deleted |
| D-04 | `re_provision_clears_history_and_transfers_ownership` | Physical reset (clear credentials) wipes `DeviceImageHistory` and sets `user = null` on device — FR9 security invariant |
### 2.4 Functional Tests — `DeviceImageController`
File: `tests/Functional/Controller/DeviceImageControllerTest.php`
Uses `WebTestCase`. This is the highest-value test class — it covers the entire firmware-facing API contract.
| # | Route | Setup | Expected response |
|---|---|---|---|
| I-01 | `GET /api/device/{unknownMac}/image` | MAC not in DB | 404 |
| I-02 | `GET /api/device/{mac}/image` | Device exists, no approved images | 204 |
| I-03 | `GET /api/device/{mac}/image` | One ready image, no `X-Current-Image-Id` | 200, body is binary, `X-Image-Id` set, `X-Interval-Ms` set |
| I-04 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {id}` | ID matches current image | 304, no body, headers still set |
| I-05 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {staleId}` | ID does NOT match current image | 200, new image served |
| I-06 | `GET /api/device/{mac}/image` | Locked image set, no `X-Current-Image-Id` | 200, locked image served; `RotationService::advance()` NOT called (spy/mock) |
| I-07 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {lockedId}` | Locked image = current image | 304 |
| I-08 | `GET /api/device/{mac}/image` twice, second call changes image | Rotation advances: first call returns Image A, second call (different `X-Current-Image-Id`) returns Image B | Use a single-request test asserting `currentImage` changed in DB, not two HTTP calls through dama rollback |
| I-09 | `GET /api/device/{mac}/image` | `X-Interval-Ms` value | `rotationIntervalMinutes * 60 * 1000`, bounded by server ceiling |
| I-10 | `GET /api/device/{mac}/image` 200 response | `lastSeenAt` side effect | `device.lastSeenAt` updated after 200 poll |
| I-11 | `GET /api/device/{mac}/image` 304 response | `lastSeenAt` side effect | `device.lastSeenAt` also updated after 304 — a 304 is a successful poll |
### 2.5 Functional Tests — `DeviceApiController`
File: `tests/Functional/Controller/DeviceApiControllerTest.php`
| # | Route | Notes |
|---|---|---|
| A-01 | `GET /api/devices` (authenticated) | Returns only requesting user's devices |
| A-02 | `GET /api/devices` (unauthenticated) | 401 |
| A-03 | `PATCH /api/devices/{id}` | Updates name, orientation, rotationIntervalMinutes |
| A-04 | `PATCH /api/devices/{id}` — wrong user | 403 / 404 |
| A-05 | `PUT /api/devices/{id}/lock` | Sets `lockedImage`, response includes `lockedImageId` |
| A-06 | `PUT /api/devices/{id}/lock` — image not approved for device | 422 |
| A-07 | `DELETE /api/devices/{id}/lock` | Clears `lockedImage`, response has `lockedImageId: null` |
| A-08 | `PUT /api/devices/{id}/lock` — wrong user's device | 403 |
### 2.6 Functional Tests — `ImageApiController`
File: `tests/Functional/Controller/ImageApiControllerTest.php`
| # | Route | Notes |
|---|---|---|
| IMG-01 | `POST /api/images` | Valid upload → 201, `Image` in DB, `RenderImageMessage` in in-memory transport |
| IMG-02 | `POST /api/images` | No file → 422 |
| IMG-03 | `GET /api/images` | Returns authenticated user's images with thumbnail/original URLs |
| IMG-04 | `DELETE /api/images/{id}` | Soft-deletes image (`deletedAt` set, row still exists) |
| IMG-05 | `DELETE /api/images/{id}` — wrong user | 403 |
| IMG-06 | `POST /api/images/{id}/reprocess` | New `RenderImageMessage` in in-memory transport |
### 2.7 Functional Tests — `SharedImageApiController`
File: `tests/Functional/Controller/SharedImageApiControllerTest.php`
| # | Route | Notes |
|---|---|---|
| SH-01 | `GET /api/shared` | Paginated result, `totalPages`/`total`/`page` fields correct |
| SH-02 | `GET /api/shared?status=pending` | Filters by status |
| SH-03 | `PUT /api/shared/{id}/approve` | Status → approved, `RenderImageMessage` in in-memory transport |
| SH-04 | `PUT /api/shared/{id}/decline` | Status → declined |
| SH-05 | `PUT /api/shared/{id}/approve` — wrong user | 403 |
| SH-06 | Approve → image enters rotation pool | After approval + render, image appears in `RotationService::readyPool()` for the recipient's device |
### 2.8 Functional Tests — `TokenActionController`
File: `tests/Functional/Controller/TokenActionControllerTest.php`
| # | Test | Notes |
|---|---|---|
| TK-01 | Valid `share_approve` token | Approval performed, token marked used, redirected |
| TK-02 | Expired token | 404 or 410 |
| TK-03 | Already-used token | 404 or 410 |
| TK-04 | Wrong token type | 404 |
| TK-05 | `hard_delete_confirm` token | Image hard-deleted from DB and storage |
### 2.9 Functional Tests — `SetupController`
File: `tests/Functional/Controller/SetupControllerTest.php`
| # | Route | Notes |
|---|---|---|
| S-01 | `GET /setup/{mac}` — new device | Shows registration/login form |
| S-02 | `GET /setup/{mac}` — already-linked device | Redirects to configure step |
| S-03 | `POST /setup/{mac}/register` | Creates User, links Device to User |
| S-04 | `POST /setup/{mac}/register` — duplicate email | Validation error |
| S-05 | `POST /setup/{mac}/configure` | Saves `name`, `orientation`, `rotationIntervalMinutes` |
### 2.10 Functional Tests — `SecurityController`
File: `tests/Functional/Controller/SecurityControllerTest.php`
| # | Route | Notes |
|---|---|---|
| SEC-01 | `POST /login` valid credentials | Session cookie set, redirected |
| SEC-02 | `POST /login` wrong password | Error message, no session |
| SEC-03 | `POST /register` | Creates user with hashed password |
| SEC-04 | `POST /register` duplicate email | Form error |
### 2.11 Integration Tests — Message Handlers
**Note:** These tests run without `dama/doctrine-test-bundle` transaction wrapping because they write to the filesystem. Run in a separate PHPUnit suite (`tests/Integration/`) with their own teardown strategy: each test uses a unique `storage/test/{uuid}/` path, cleaned up in `tearDown()`.
File: `tests/Integration/MessageHandler/RenderImageMessageHandlerTest.php`
Requires Imagick installed (present in DDEV via `php8.4-imagick`). Run `ddev exec` — not on host.
| # | Test | Notes |
|---|---|---|
| MH-01 | Valid image + device model | `RenderedAsset.status` = `ready`, `.bin` file written to storage path |
| MH-01b | **4bpp palette contract** | Reads first bytes of written `.bin`; asserts palette indices match Spectra 6 map (BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6) — prevents a server-side Imagick palette bug from silently producing a garbled display |
| MH-02 | Missing source image file | `RenderedAsset.status` = `failed`, no uncaught exception |
| MH-03 | Both orientations | Two `RenderedAsset` rows for same image (landscape + portrait) |
File: `tests/Integration/MessageHandler/RunImageCleanupMessageHandlerTest.php`
| # | Test | Notes |
|---|---|---|
| CL-01 | Image past retention with approvals | Soft-delete flag set |
| CL-02 | Image past retention with no approvals | Hard-deleted from DB and storage |
| CL-03 | Recent image | Untouched |
---
## 3. Frontend Tests
### 3.1 Store Tests
**`tests/frontend/stores/devices.test.ts`**
| # | Test | Notes |
|---|---|---|
| DS-01 | `fetchDevices` success | `devices` populated, `loading` false |
| DS-02 | `fetchDevices` network error | `error` set, `devices` empty |
| DS-03 | `updateDevice` patches local array | `devices[idx]` updated after PATCH |
| DS-04 | `lockImage` sets `lockedImageId` on local device | Updated from server response |
| DS-05 | `unlockImage` clears `lockedImageId` | `null` in local state |
**`tests/frontend/stores/images.test.ts`**
| # | Test | Notes |
|---|---|---|
| IM-01 | `fetchImages` success | `images` populated |
| IM-02 | Upload workflow state transitions | `setFile``setCrop``setStickers``setDevices` in correct order |
| IM-03 | `pendingCount` computed | Reflects `status === 'pending'` images |
**`tests/frontend/stores/auth.test.ts`**
| # | Test | Notes |
|---|---|---|
| AU-01 | Bootstraps from `window.__BOOTSTRAP_USER__` | `user` set, `isAuthenticated` true |
| AU-02 | No bootstrap data | `isAuthenticated` false |
**`tests/frontend/stores/toast.test.ts`**
| # | Test | Notes |
|---|---|---|
| TO-01 | `push` adds message | Queue length +1 |
| TO-02 | Auto-dismiss after 2.5s | Queue empty after timeout (Vitest fake timers) |
### 3.2 Component Tests
**`tests/frontend/components/FrameCard.test.ts`**
| # | Test | Notes |
|---|---|---|
| FC-01 | Status "ok" renders green badge | `lastSeenAt` recent |
| FC-02 | Status "sync-fail" renders yellow badge | `lastSeenAt` >2× interval ago |
| FC-03 | Status "no-wifi" badge | `lastSeenAt` null |
**`tests/frontend/components/BaseButton.test.ts`**
| # | Test | Notes |
|---|---|---|
| BB-01 | Loading spinner when `loading=true` | Label hidden |
| BB-02 | Disabled when `disabled=true` | `disabled` attribute present |
| BB-03 | Emits `click` when not disabled | |
**`tests/frontend/components/DevicePicker.test.ts`**
| # | Test | Notes |
|---|---|---|
| DP-01 | Selecting device emits `update:modelValue` | |
| DP-02 | Deselecting removes from list | |
| DP-03 | Pre-selected devices shown as checked | |
**`tests/frontend/components/ShareSheet.test.ts`**
| # | Test | Notes |
|---|---|---|
| SS-01 | Valid email submits share API call | `POST /api/share` intercepted by MSW |
| SS-02 | Empty email shows validation error, no API call | |
| SS-03 | Success closes sheet | |
### 3.3 View Integration Tests
**`tests/frontend/views/LibraryView.test.ts`**
| # | Test | Notes |
|---|---|---|
| LV-01 | Mounted in "uploaded" tab | Image grid renders |
| LV-02 | Switch to "shared" tab | `fetchShared` called, ApproveCards render |
| LV-03 | Lock chip shown for approved devices | Chip present below image |
| LV-04 | Unlocked chip click calls `lockImage` | Store action called |
| LV-05 | Locked chip (solid) click calls `unlockImage` | Store action called |
| LV-06 | Share button opens ShareSheet | `shareSheetOpen` true |
| LV-07 | Empty library state | Empty-state prompt visible when `images.length === 0` |
**`tests/frontend/views/HomeView.test.ts`**
| # | Test | Notes |
|---|---|---|
| HV-01 | Renders FrameCard for each device | N cards for N devices |
| HV-02 | Empty state shown when no devices | Prompt/empty message visible |
---
## 4. Firmware Tests
### 4.1 Architecture
PlatformIO `native` env (`test_build_src = no`) compiles only `firmware/test/` test files and the specific source files they include explicitly. `setup()` and `loop()` from `main.cpp` are excluded — they would collide with PlatformIO's native `main()` shim.
The testable logic in `main.cpp` is extracted into:
- `operation.cpp` / `operation.h``normal_operation_impl<HTTP>(mac, http)` template function
- `provisioning.cpp` / `provisioning.h``ap_ssid_from_mac()`, `attempt_wifi()`
`epd.h` is shadowed by `firmware/test/mocks/epd.h` via the `-Itest/mocks` build flag in `[env:native-test]`.
### 4.2 Unit Tests — `normal_operation_impl()` control flow
File: `firmware/test/test_normal_operation.cpp`
| # | Setup | Expected |
|---|---|---|
| FW-01 | HTTP 200, `X-Image-Id: 42`, `X-Interval-Ms: 60000` | File written to mock FS, `epd_draw_image_from_file` call count = 1, NVS `img_id = 42`, sleep = 60000ms, `g_deepSleepStarted = true` |
| FW-02 | HTTP 200, assert header read order | `newId` is `"42"` (non-empty) — verifies headers read BEFORE `http.end()`. **Regression test for NVS bug.** |
| FW-03 | HTTP 304 | `epd_init` call count = 0, `epd_draw_image_from_file` call count = 0, sleep set from `X-Interval-Ms`, `g_deepSleepStarted = true` |
| FW-04 | HTTP 204 | `show_setup_qr` (or `epd_draw_setup_screen`) call count = 1, `epd_draw_image_from_file` call count = 0 |
| FW-05 | HTTP 404 | `show_setup_qr` call count = 1 |
| FW-06 | HTTP 500 | `epd_fill(COLOR_YELLOW)` called |
| FW-07 | NVS has saved `img_id = 99` | `X-Current-Image-Id: 99` present in request headers sent by mock HTTP |
| FW-08 | NVS `img_id` at default (-1) | `X-Current-Image-Id` header NOT added to request |
| FW-09 | `X-Interval-Ms: 120000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 120000` (server value honored, within ceiling) |
| FW-10 | `X-Interval-Ms: 600000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 300000` (capped at firmware ceiling) |
| FW-11 | `X-Interval-Ms` absent | `sleepMs = FETCH_INTERVAL_MS` (default) |
### 4.3 Unit Tests — Provisioning
File: `firmware/test/test_provisioning.cpp`
| # | Test | Expected |
|---|---|---|
| FW-12 | MAC `AA:BB:CC:DD:EE:FF` | SSID = `PictureFrame-EEFF` |
| FW-13 | MAC `1C:C3:AB:D1:91:F8` | SSID = `PictureFrame-91F8` |
| FW-14 | WiFi connects within timeout | `attempt_wifi()` returns `true` |
| FW-15 | WiFi never connects | `attempt_wifi()` returns `false` after `WIFI_TIMEOUT_MS` |
| FW-16 | WiFi connect fails → re-provisioning | `loop()` on `attempt_wifi()` failure: `epd_fill(COLOR_RED)` called, then `enter_provisioning()` called — confirms provisioning state machine re-enters correctly |
### 4.4 Unit Tests — Button-hold re-provisioning
File: `firmware/test/test_reset_button.cpp`
| # | Test | Expected |
|---|---|---|
| FW-17 | `digitalRead(PIN_BTN_RESET)` returns `LOW` for ≥ `RESET_HOLD_MS` | `clear_creds = true`, `prefs.clear()` called, provisioning mode entered |
| FW-18 | Button released before `RESET_HOLD_MS` | `clear_creds = false`, normal boot continues |
---
## 5. Coverage Targets
| Layer | Target | Priority |
|---|---|---|
| `RotationService` | 100% line | Critical |
| `DeviceImageController` | 100% line | Critical |
| `TokenService` | 95%+ | High |
| All other `src/Service/` | 80%+ | High |
| All `src/Controller/` | 80%+ | High |
| `src/MessageHandler/` | 85%+ | High (rendering pipeline is high-risk) |
| Frontend stores | 80%+ | High |
| Frontend components | 60%+ (excluding CropEditor, StickerCanvas) | Medium |
| Firmware control flow (`operation.cpp`) | 90%+ branches | High |
---
## 6. CI Integration
All three suites must pass before merge to `master`.
```bash
# Server
ddev exec php bin/phpunit --coverage-text
# Firmware
cd firmware && pio test -e native-test
# Frontend
cd frontend && npx vitest run --coverage
```
---
## 7. Implementation Order
**Phase 1 — Server functional + integration tests** (highest ROI, minimal new tooling)
1. `composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle`
2. Register dama extension in `phpunit.dist.xml`
3. Add `MESSENGER_TRANSPORT_DSN=in-memory://` to `.env.test`
4. Create + migrate test DB
5. Write `DeviceImageControllerTest` (I-01 through I-11) — covers the firmware API contract
6. Write `RotationServiceTest` (R-01 through R-09) — pure unit, no DB
7. Write remaining controller + service tests
**Phase 2 — Firmware native tests** (second priority — protects the one thing that requires a physical reflash)
1. Extract `normal_operation_impl<HTTP>()` template into `operation.cpp`
2. Add `[env:native-test]` to `platformio.ini` (`test_build_src = no`)
3. Write mock headers in `firmware/test/mocks/`
4. Write `test_normal_operation.cpp` (FW-01 through FW-11, FW-02 is the regression test)
5. Write `test_provisioning.cpp` (FW-12 through FW-16)
6. Write `test_reset_button.cpp` (FW-17 through FW-18)
**Phase 3 — Frontend unit tests**
1. Add Vitest + `@vue/test-utils` + MSW to `package.json`
2. Add `test` block to `vite.config.ts`
3. Write store tests (no DOM needed — fastest to write)
4. Write component tests
**Phase 4 — CI pipeline**
1. Add Gitea Actions workflow
2. Wire all three test commands in sequence
---
## 8. Known Gaps (explicitly out of scope for V1 tests)
- `CropEditor.vue` and `StickerCanvas.vue` — Konva.js canvas not testable in JSDOM; require Playwright E2E
- Admin moderation panel (`AdminModerationController`) — not yet implemented
- Collections approval (FR21) — not yet implemented
- E-ink display pixel-level rendering correctness — no practical automated test; validated by eye on hardware
- Interval timing drift accuracy (±5 min PRD requirement) — requires a time-series integration test; deferred to post-V1
+2
View File
@@ -11,4 +11,6 @@ return [
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
];
@@ -0,0 +1,4 @@
dama_doctrine_test:
enable_static_connection: true
enable_static_meta_data_cache: true
enable_static_query_cache: true
+2
View File
@@ -1,3 +1,5 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
envelope:
sender: '%env(MAILER_SENDER)%'
+8 -3
View File
@@ -4,13 +4,17 @@ framework:
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
image_processing:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 1
multiplier: 2
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
default_bus: messenger.bus.default
@@ -22,5 +26,6 @@ framework:
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
# Route your messages to the transports
# 'App\Message\YourMessage': async
App\Message\RenderImageMessage: image_processing
App\Message\AdvanceRotationMessage: async
App\Message\RunImageCleanupMessage: async
+4
View File
@@ -0,0 +1,4 @@
framework:
test: true
form:
csrf_protection: false
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""
Generate ap_bg.bin and setup_bg.bin — 800×480 4bpp backgrounds for the
pictureFrame e-ink device. QR overlay areas are left WHITE so the
firmware can render the actual QR code at runtime.
Run from the firmware/ directory:
python3 scripts/gen_screens.py
Constants exported (copy to epd.cpp):
AP_QR_X, AP_QR_Y, AP_QR_CELL, AP_QR_PX
SETUP_QR_X, SETUP_QR_Y, SETUP_QR_CELL, SETUP_QR_PX
"""
from PIL import Image, ImageDraw, ImageFont
import os, sys
# ── Display ──────────────────────────────────────────────────────────────────
W, H = 800, 480
# ── EPD palette ───────────────────────────────────────────────────────────────
BLACK = 0x0; BK = (26, 26, 26 )
WHITE = 0x1; WH = (245, 245, 240)
YELLOW = 0x2; YL = (240, 208, 0 )
RED = 0x3; RD = (192, 48, 32 )
BLUE = 0x5; BL = (24, 64, 192)
GREEN = 0x6; GR = (16, 160, 64 )
PALETTE_RGB = {BLACK: BK, WHITE: WH, YELLOW: YL, RED: RD, BLUE: BL, GREEN: GR}
def nearest(r, g, b):
best, best_d = WHITE, float("inf")
for n, (pr, pg, pb) in PALETTE_RGB.items():
d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2
if d < best_d: best, best_d = n, d
return best
def pack(img):
"""Convert RGB PIL image → 4bpp packed bytearray."""
px = img.load()
out = bytearray()
for y in range(H):
for x in range(0, W, 2):
hi = nearest(*px[x, y])
lo = nearest(*px[x+1, y])
out.append((hi << 4) | lo)
return out
# ── Fonts ─────────────────────────────────────────────────────────────────────
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
def ttf(name, size):
try: return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
except: return ImageFont.load_default()
F_HEAD = ttf("DejaVuSans-Bold.ttf", 26)
F_BAR = ttf("DejaVuSans-Bold.ttf", 13)
F_STEP = ttf("DejaVuSans.ttf", 13)
F_STEP_B= ttf("DejaVuSans-Bold.ttf", 13)
F_STEPN = ttf("DejaVuSans-Bold.ttf", 13)
F_LABEL = ttf("DejaVuSans-Bold.ttf", 11)
F_TINY = ttf("DejaVuSans-Bold.ttf", 10)
F_FOOT = ttf("DejaVuSans.ttf", 12)
F_CHIP = ttf("DejaVuSans-Bold.ttf", 12)
F_SUB = ttf("DejaVuSans.ttf", 14)
F_BIG = ttf("DejaVuSans-Bold.ttf", 14)
# ── Layout constants ──────────────────────────────────────────────────────────
BAR_H = 52
BODY_Y = BAR_H # 52
LEFT_X = 0; LEFT_W = 310
DIV1_X = 310; DIV_W = 2
CTR_X = 312; CTR_W = 196
DIV2_X = 508
RIGHT_X = 510; RIGHT_W = 290 # 800-510
# QR positions (MUST match epd.cpp constants)
AP_QR_CELL = 5
AP_QR_MODS = 37 # version 5, ECC_LOW
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 185
SETUP_QR_CELL = 5
SETUP_QR_MODS = 41 # version 6, ECC_LOW
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL # 205
# Centre of right panel
RIGHT_CX = RIGHT_X + RIGHT_W // 2 # 655
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # 563
AP_QR_Y = BODY_CY - AP_QR_PX // 2 # 174 (will nudge down below label)
AP_QR_Y = 185 # nudge down a bit for "SCAN TO CONNECT" label above
SETUP_QR_X = RIGHT_CX - SETUP_QR_PX // 2 # 553
SETUP_QR_Y = BODY_CY - SETUP_QR_PX // 2 # 164
SETUP_QR_Y = 175 # nudge for label
def leave_qr_white(draw, qr_x, qr_y, qr_px):
"""Blank the QR overlay region so firmware can write the real QR."""
draw.rectangle([qr_x, qr_y, qr_x+qr_px-1, qr_y+qr_px-1], fill=WH)
def text_center(draw, cx, y, text, font, fill):
bb = draw.textbbox((0,0), text, font=font)
tw = bb[2]-bb[0]
draw.text((cx - tw//2, y), text, font=font, fill=fill)
def orientation_diagrams(draw, accent, show_active_ls=True):
"""Draw both orientation diagrams in the centre panel.
accent = RGB colour for the active / ribbon highlights."""
cx = CTR_X + CTR_W // 2 # 410
# ── Section title ─────────────────────────────────────────────
text_center(draw, cx, BODY_Y+15, "FRAME", F_TINY, BK)
text_center(draw, cx, BODY_Y+27, "ORIENTATION", F_TINY, BK)
# ── Landscape ──────────────────────────────────────────────────
ls_x, ls_y, ls_w, ls_h = CTR_X+43, BODY_Y+52, 110, 66
rib_w, rib_h = 110, 10
text_center(draw, cx, ls_y-14, "LANDSCAPE", F_LABEL, accent if show_active_ls else BK)
ls_border = accent if show_active_ls else BK
draw.rectangle([ls_x, ls_y, ls_x+ls_w-1, ls_y+ls_h-1], outline=ls_border, width=3)
rib_rgb = accent if show_active_ls else BK
draw.rectangle([ls_x, ls_y+ls_h, ls_x+rib_w-1, ls_y+ls_h+rib_h-1], fill=rib_rgb)
if show_active_ls:
# check badge
bx, by = cx-9, ls_y+ls_h+rib_h+5
draw.rectangle([bx, by, bx+18, by+18], fill=accent)
text_center(draw, bx+9, by+3, "", F_CHIP, BK)
# Thin separator
sep_y = ls_y + ls_h + rib_h + (30 if show_active_ls else 14)
draw.rectangle([CTR_X+10, sep_y, CTR_X+CTR_W-10, sep_y], fill=(180,180,175))
# ── Portrait ──────────────────────────────────────────────────
pt_x, pt_y = CTR_X+56, sep_y+14
pt_w, pt_h = 64, 106
pr_w, pr_h = 10, 106
text_center(draw, cx, pt_y-14, "PORTRAIT", F_LABEL, BK)
draw.rectangle([pt_x-pr_w, pt_y, pt_x-1, pt_y+pr_h-1], fill=BK)
draw.rectangle([pt_x, pt_y, pt_x+pt_w-1, pt_y+pt_h-1], outline=BK, width=3)
# ═══════════════════════════════════════════════════════════════════════════════
# AP SCREEN — yellow accent, WiFi credentials
# ═══════════════════════════════════════════════════════════════════════════════
def gen_ap():
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
# ── Status bar ────────────────────────────────────────────────
draw.rectangle([0, 0, W-1, BAR_H-1], fill=YL)
draw.text((24, 18), "SETUP MODE — STEP 1 OF 2", font=F_BAR, fill=BK)
# Right chip: black box with device SSID
chip_x, chip_y = 498, 11
chip_text = "PictureFrame-91F8"
bb = draw.textbbox((0,0), chip_text, font=F_CHIP)
chip_w = bb[2]-bb[0] + 22
chip_x2 = chip_x + chip_w
draw.rectangle([chip_x, chip_y, chip_x2, BAR_H-12], fill=BK)
draw.text((chip_x+11, chip_y+7), chip_text, font=F_CHIP, fill=YL)
# ── Panel dividers ────────────────────────────────────────────
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
# ── Left panel ────────────────────────────────────────────────
# Heading
draw.text((28, BODY_Y+20), "Connect to", font=F_HEAD, fill=BK)
draw.text((28, BODY_Y+52), "WiFi", font=F_HEAD, fill=BK)
bb = draw.textbbox((0,0), "WiFi", font=F_HEAD)
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=YL)
# Steps
steps = [
("Scan the QR code →", "Phone joins PictureFrame-91F8"),
("Browser opens — enter", "your home WiFi password"),
("Tap Connect and watch", "for the QR code to change"),
]
sy = BODY_Y + 105
for i, (l1, l2) in enumerate(steps):
bx, by = 28, sy + i*46
draw.rectangle([bx, by, bx+24, by+24], fill=BK)
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, YL)
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
# Divider + footnote
draw.rectangle([28, BODY_Y+254, 283, BODY_Y+255], fill=BK)
draw.text((28, BODY_Y+262), "Page didn't open?", font=F_FOOT, fill=BK)
draw.text((28, BODY_Y+276), "Go to 192.168.4.1", font=F_FOOT, fill=BK)
# ── Centre panel ─────────────────────────────────────────────
orientation_diagrams(draw, YL, show_active_ls=True)
# ── Right panel ──────────────────────────────────────────────
cx = RIGHT_CX
# "SCAN TO CONNECT" label
text_center(draw, cx, AP_QR_Y - 26, "SCAN TO CONNECT", F_BIG, BK)
# QR border: yellow outer, black inner
qx, qy, qp = AP_QR_X, AP_QR_Y, AP_QR_PX
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=YL, width=3)
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
# Leave QR area white for firmware overlay
leave_qr_white(draw, qx, qy, qp)
# "Encodes WIFI:..." label below
text_center(draw, cx, qy+qp+10, "WIFI:S:PictureFrame-91F8;T:nopass;;", F_FOOT, (100,100,95))
return img
# ═══════════════════════════════════════════════════════════════════════════════
# SETUP SCREEN — green accent, account link
# ═══════════════════════════════════════════════════════════════════════════════
def gen_setup():
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
# ── Status bar ────────────────────────────────────────────────
draw.rectangle([0, 0, W-1, BAR_H-1], fill=GR)
# WiFi bars icon
bars = [(0, 8), (0, 13), (0, 18), (0, 22)]
bx = 24
for i, (_, bh) in enumerate(bars):
draw.rectangle([bx + i*8, BAR_H//2 - bh//2, bx+i*8+5, BAR_H//2 + bh//2], fill=WH)
draw.text((bx+38, 18), "WIFI CONNECTED — STEP 2 OF 2", font=F_BAR, fill=WH)
# Right IP chip
ip_text = "192.168.x.x"
bb = draw.textbbox((0,0), ip_text, font=F_CHIP)
chip_w = bb[2]-bb[0] + 22
chip_x = W - chip_w - 20
draw.rectangle([chip_x, 11, chip_x+chip_w, BAR_H-12], fill=WH)
draw.text((chip_x+11, 18), ip_text, font=F_CHIP, fill=GR)
# ── Panel dividers ────────────────────────────────────────────
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
# ── Left panel ────────────────────────────────────────────────
draw.text((28, BODY_Y+20), "Almost", font=F_HEAD, fill=BK)
draw.text((28, BODY_Y+52), "ready.", font=F_HEAD, fill=BK)
bb = draw.textbbox((0,0), "ready.", font=F_HEAD)
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=GR)
draw.text((28, BODY_Y+96), "Scan to name this frame and", font=F_STEP, fill=(80,80,75))
draw.text((28, BODY_Y+110), "link it to your account.", font=F_STEP, fill=(80,80,75))
steps = [
("Scan the QR with your phone", "camera or QR app"),
("Sign in at pictureframe", ".edholm.me"),
("Name the frame, choose", "orientation — done."),
]
sy = BODY_Y + 136
for i, (l1, l2) in enumerate(steps):
bx, by = 28, sy + i*46
draw.rectangle([bx, by, bx+24, by+24], fill=GR)
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, WH)
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
# URL bar
url_y = BODY_Y + 278
draw.rectangle([28, url_y, 284, url_y+32], fill=BK)
draw.text((38, url_y+4), "URL", font=F_TINY, fill=GR)
draw.text((38, url_y+16), "pictureframe.edholm.me/setup/...", font=ttf("DejaVuSans.ttf", 10), fill=WH)
# Progress track
prog_y = BODY_Y + 328
draw.text((28, prog_y), "SETUP PROGRESS", font=F_TINY, fill=(140,140,135))
seg_y = prog_y + 14
segs = [("WiFi", GR), ("Account", BK), ("Frame ready", (200,200,195))]
seg_w = (284 - 28 - 8) // 3 # ~82px each
for i, (label, color) in enumerate(segs):
sx = 28 + i*(seg_w+4)
draw.rectangle([sx, seg_y, sx+seg_w, seg_y+6], fill=color)
text_center(draw, sx+seg_w//2, seg_y+10, label, ttf("DejaVuSans.ttf", 9), BK)
# ── Centre panel ─────────────────────────────────────────────
orientation_diagrams(draw, GR, show_active_ls=True)
# ── Right panel ──────────────────────────────────────────────
cx = RIGHT_CX
text_center(draw, cx, SETUP_QR_Y - 26, "SCAN TO FINISH", F_BIG, BK)
qx, qy, qp = SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=GR, width=3)
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
leave_qr_white(draw, qx, qy, qp)
# MAC chip below QR
mac = "1C:C3:AB:D1:91:F8"
bb = draw.textbbox((0,0), mac, font=F_CHIP)
mw = bb[2]-bb[0]+20
mx = cx - mw//2
draw.rectangle([mx, qy+qp+8, mx+mw, qy+qp+26], fill=BK)
draw.text((mx+10, qy+qp+11), mac, font=ttf("DejaVuSans-Bold.ttf", 10), fill=WH)
return img
# ── Save ──────────────────────────────────────────────────────────────────────
def save_bin(img, path, preview_path):
data = pack(img)
with open(path, "wb") as f: f.write(data)
print(f"Written {len(data):,} bytes → {os.path.abspath(path)}")
# Reconstruct preview from packed data for verification
prev = Image.new("RGB", (W, H))
px = prev.load()
for y in range(H):
for x in range(0, W, 2):
byte = data[y*(W//2) + x//2]
px[x, y] = PALETTE_RGB.get(byte >> 4, (128,128,128))
px[x+1, y] = PALETTE_RGB.get(byte & 0xF, (128,128,128))
prev.save(preview_path)
print(f"Preview → {os.path.abspath(preview_path)}")
if __name__ == "__main__":
out_dir = os.path.join(os.path.dirname(__file__), "../data")
os.makedirs(out_dir, exist_ok=True)
print("Generating AP screen…")
save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png")
print()
print("Generating setup screen…")
save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png")
print()
print("QR overlay constants for epd.cpp:")
print(f" AP_QR_X={AP_QR_X}, AP_QR_Y={AP_QR_Y}, AP_QR_CELL={AP_QR_CELL}, AP_QR_PX={AP_QR_PX}")
print(f" SETUP_QR_X={SETUP_QR_X}, SETUP_QR_Y={SETUP_QR_Y}, SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Generate setup_bg.bin — the 800×480 4bpp background for the device setup screen.
The QR code is overlaid at runtime at position (QR_X, QR_Y) by the firmware.
Run from the firmware/ directory: python3 scripts/gen_setup_bg.py
"""
from PIL import Image, ImageDraw, ImageFont
import struct, os, sys
# ── Display + palette ───────────────────────────────────────────────────────────
W, H = 800, 480
# EPD 4bpp palette nibbles
BLACK = 0x0
WHITE = 0x1
YELLOW = 0x2
RED = 0x3
BLUE = 0x5
GREEN = 0x6
# PIL RGB for each nibble (used for drawing and for quantisation)
PALETTE_RGB = {
BLACK: (0, 0, 0 ),
WHITE: (255, 255, 255),
YELLOW: (255, 230, 0 ),
RED: (200, 0, 0 ),
BLUE: (0, 0, 220),
GREEN: (0, 170, 60 ),
}
# ── QR code placement (must match SETUP_QR_X / SETUP_QR_Y in epd.cpp) ──────────
QR_CELL = 5
QR_MODS = 41 # version 6, ECC_LOW
QR_PX = QR_MODS * QR_CELL # 205 px
QR_X = 555
QR_Y = (H - QR_PX) // 2 # 137
# ── Fonts ────────────────────────────────────────────────────────────────────────
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
def font(name, size):
try:
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
except Exception:
return ImageFont.load_default()
font_title = font("DejaVuSans-Bold.ttf", 36)
font_label = font("DejaVuSans-Bold.ttf", 20)
font_sub = font("DejaVuSans.ttf", 15)
font_scan = font("DejaVuSans.ttf", 14)
# ── Draw ─────────────────────────────────────────────────────────────────────────
img = Image.new("RGB", (W, H), PALETTE_RGB[WHITE])
draw = ImageDraw.Draw(img)
BK = PALETTE_RGB[BLACK]
GR = PALETTE_RGB[GREEN]
# Title
draw.text((40, 32), "pictureFrame", font=font_title, fill=BK)
# Thin rule under title
draw.rectangle([40, 80, 490, 82], fill=BK)
# ── Landscape diagram ────────────────────────────────────────────────────────────
LS_X, LS_Y, LS_W, LS_H = 45, 110, 200, 120
RIB_W, RIB_H = 56, 14
LS_RX = LS_X + (LS_W - RIB_W) // 2
LS_RY = LS_Y + LS_H # ribbon protrudes below
BORDER = 3
draw.rectangle([LS_X, LS_Y, LS_X+LS_W, LS_Y+LS_H], outline=BK, width=BORDER)
draw.rectangle([LS_RX, LS_RY, LS_RX+RIB_W, LS_RY+RIB_H], fill=GR)
draw.text((LS_X, LS_RY + RIB_H + 10), "Landscape", font=font_label, fill=BK)
draw.text((LS_X, LS_RY + RIB_H + 34), "Ribbon at bottom", font=font_sub, fill=BK)
# ── Portrait diagram ──────────────────────────────────────────────────────────────
PT_X, PT_Y, PT_W, PT_H = 310, 95, 120, 200
RIB2_W, RIB2_H = 14, 56
PT_RX = PT_X - RIB2_W # ribbon protrudes left
PT_RY = PT_Y + (PT_H - RIB2_H) // 2
draw.rectangle([PT_X, PT_Y, PT_X+PT_W, PT_Y+PT_H], outline=BK, width=BORDER)
draw.rectangle([PT_RX, PT_RY, PT_RX+RIB2_W, PT_RY+RIB2_H], fill=GR)
draw.text((PT_X, PT_Y + PT_H + 10), "Portrait", font=font_label, fill=BK)
draw.text((PT_X, PT_Y + PT_H + 34), "Ribbon on left", font=font_sub, fill=BK)
# ── Divider ───────────────────────────────────────────────────────────────────────
draw.rectangle([520, 0, 522, H], fill=PALETTE_RGB[BLACK])
# ── QR zone label ─────────────────────────────────────────────────────────────────
scan_txt = "Scan to set up"
bb = draw.textbbox((0, 0), scan_txt, font=font_scan)
tw = bb[2] - bb[0]
draw.text((QR_X + (QR_PX - tw) // 2, QR_Y + QR_PX + 12), scan_txt, font=font_scan, fill=BK)
# Leave QR area pure WHITE so the firmware overlay is clean
draw.rectangle([QR_X, QR_Y, QR_X + QR_PX - 1, QR_Y + QR_PX - 1], fill=PALETTE_RGB[WHITE])
# ── Quantise to EPD palette ───────────────────────────────────────────────────────
def nearest(r, g, b):
best, best_d = WHITE, float("inf")
for nibble, (pr, pg, pb) in PALETTE_RGB.items():
d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2
if d < best_d:
best, best_d = nibble, d
return best
pixels = img.load()
out = bytearray()
for y in range(H):
for x in range(0, W, 2):
hi = nearest(*pixels[x, y])
lo = nearest(*pixels[x+1, y])
out.append((hi << 4) | lo)
out_path = os.path.join(os.path.dirname(__file__), "../data/setup_bg.bin")
with open(out_path, "wb") as f:
f.write(out)
print(f"Written {len(out):,} bytes → {os.path.abspath(out_path)}")
print(f"QR overlay position: x={QR_X}, y={QR_Y}, cell={QR_CELL} (copy to SETUP_QR_* in epd.cpp)")
# ── Preview PNG (for inspection) ─────────────────────────────────────────────────
preview = Image.new("RGB", (W, H))
pix = preview.load()
for y in range(H):
for x in range(0, W, 2):
byte = out[y * (W // 2) + x // 2]
pix[x, y] = PALETTE_RGB.get(byte >> 4, (128,128,128))
pix[x+1, y] = PALETTE_RGB.get(byte & 0xF, (128,128,128))
preview_path = out_path.replace(".bin", "_preview.png")
preview.save(preview_path)
print(f"Preview PNG → {os.path.abspath(preview_path)}")
File diff suppressed because it is too large Load Diff
+2289
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -6,20 +6,31 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"konva": "^10.3.0",
"pinia": "^3.0.4",
"sass": "^1.99.0",
"vue": "^3.5.32",
"vue-konva": "^3.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.5",
"@vue/test-utils": "^2.4.10",
"@vue/tsconfig": "^0.9.1",
"happy-dom": "^20.9.0",
"jsdom": "^29.1.1",
"msw": "^2.14.3",
"typescript": "~6.0.2",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"vue-tsc": "^3.2.7"
}
}
+4 -3
View File
@@ -1,21 +1,22 @@
<template>
<RouterView />
<BottomNav />
<BottomNav v-if="!route.meta.hideNav" />
<BaseToast />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import BottomNav from '@/components/BottomNav.vue'
import BaseToast from '@/components/BaseToast.vue'
import { useAuthStore } from '@/stores/auth'
import { useTheme } from '@/composables/useTheme'
const auth = useAuthStore()
const route = useRoute()
const auth = useAuthStore()
const { applyTheme } = useTheme()
onMounted(() => {
// Sync Vue's theme state with whatever SpaController stamped on <html>
const stamped = document.documentElement.dataset.theme
if (stamped && auth.user) {
auth.user.theme = stamped
+44
View File
@@ -0,0 +1,44 @@
export interface StickerDef {
id: string
category: 'seasonal' | 'holidays' | 'fun' | 'family' | 'nature'
label: string
emoji: string
}
export const STICKER_CATEGORIES = [
{ id: 'seasonal', label: 'Seasonal' },
{ id: 'holidays', label: 'Holidays' },
{ id: 'fun', label: 'Fun' },
{ id: 'family', label: 'Family' },
{ id: 'nature', label: 'Nature' },
] as const
export type StickerCategory = typeof STICKER_CATEGORIES[number]['id']
export const STICKERS: StickerDef[] = [
{ id: 'sea-snow', category: 'seasonal', label: 'Snowflake', emoji: '❄️' },
{ id: 'sea-sun', category: 'seasonal', label: 'Sun', emoji: '☀️' },
{ id: 'sea-leaves', category: 'seasonal', label: 'Autumn', emoji: '🍂' },
{ id: 'sea-blossom', category: 'seasonal', label: 'Blossom', emoji: '🌸' },
{ id: 'sea-snowman', category: 'seasonal', label: 'Snowman', emoji: '⛄' },
{ id: 'hol-tree', category: 'holidays', label: 'Tree', emoji: '🎄' },
{ id: 'hol-gift', category: 'holidays', label: 'Gift', emoji: '🎁' },
{ id: 'hol-heart', category: 'holidays', label: 'Heart', emoji: '❤️' },
{ id: 'hol-party', category: 'holidays', label: 'Party', emoji: '🎉' },
{ id: 'hol-cake', category: 'holidays', label: 'Cake', emoji: '🎂' },
{ id: 'fun-star', category: 'fun', label: 'Star', emoji: '⭐' },
{ id: 'fun-rainbow', category: 'fun', label: 'Rainbow', emoji: '🌈' },
{ id: 'fun-balloon', category: 'fun', label: 'Balloon', emoji: '🎈' },
{ id: 'fun-sparkle', category: 'fun', label: 'Sparkles', emoji: '✨' },
{ id: 'fun-fire', category: 'fun', label: 'Fire', emoji: '🔥' },
{ id: 'fam-house', category: 'family', label: 'Home', emoji: '🏠' },
{ id: 'fam-paw', category: 'family', label: 'Paw', emoji: '🐾' },
{ id: 'fam-camera', category: 'family', label: 'Camera', emoji: '📷' },
{ id: 'fam-plane', category: 'family', label: 'Airplane', emoji: '✈️' },
{ id: 'fam-music', category: 'family', label: 'Music', emoji: '🎵' },
{ id: 'nat-tree', category: 'nature', label: 'Tree', emoji: '🌲' },
{ id: 'nat-flower', category: 'nature', label: 'Flower', emoji: '🌺' },
{ id: 'nat-bee', category: 'nature', label: 'Bee', emoji: '🐝' },
{ id: 'nat-fly', category: 'nature', label: 'Butterfly', emoji: '🦋' },
{ id: 'nat-moon', category: 'nature', label: 'Moon', emoji: '🌙' },
]
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="approve-card">
<img :src="item.thumbnailUrl" :alt="`Photo from ${item.sharedBy}`" class="approve-card__thumb" loading="lazy" />
<div class="approve-card__body">
<p class="approve-card__from">From <strong>{{ item.sharedBy }}</strong></p>
<p class="approve-card__date">{{ formattedDate }}</p>
<div class="approve-card__status" v-if="item.status !== 'pending'">
<span :class="['approve-card__badge', `approve-card__badge--${item.status}`]">
{{ item.status }}
</span>
</div>
<div class="approve-card__actions">
<template v-if="item.status === 'pending' || item.status === 'declined'">
<BaseButton variant="primary" size="sm" :disabled="busy" @click="showPicker = true">
{{ item.status === 'declined' ? 'Add anyway' : 'Add to frame' }}
</BaseButton>
</template>
<template v-if="item.status === 'pending' || item.status === 'approved'">
<BaseButton variant="ghost" size="sm" :disabled="busy" @click="decline">
{{ item.status === 'approved' ? 'Remove' : 'Decline' }}
</BaseButton>
</template>
</div>
</div>
</div>
<DevicePicker
v-model="showPicker"
:devices="devicesStore.devices"
:selected="selectedDeviceIds"
:uploading="busy"
confirm-label="Add to frames"
@update:selected="selectedDeviceIds = $event"
@confirm="approve"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { SharedImage } from '@/types'
import BaseButton from '@/components/BaseButton.vue'
import DevicePicker from '@/components/DevicePicker.vue'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
const props = defineProps<{ item: SharedImage }>()
const emit = defineEmits<{ (e: 'updated', v: SharedImage): void }>()
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
const showPicker = ref(false)
const busy = ref(false)
const selectedDeviceIds = ref<number[]>([])
const formattedDate = computed(() =>
new Date(props.item.sharedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
)
async function approve() {
showPicker.value = false
busy.value = true
try {
const updated = await imagesStore.approveShared(props.item.id, selectedDeviceIds.value)
emit('updated', updated)
} finally {
busy.value = false
selectedDeviceIds.value = []
}
}
async function decline() {
busy.value = true
try {
const updated = await imagesStore.declineShared(props.item.id)
emit('updated', updated)
} finally {
busy.value = false
}
}
</script>
<style scoped lang="scss">
.approve-card {
display: flex;
gap: var(--space-3);
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
background: var(--color-border);
}
&__body {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
&__from { font-size: var(--text-sm); }
&__date { font-size: var(--text-xs); color: var(--color-text-muted); }
&__badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
text-transform: capitalize;
&--approved { background: #d4edda; color: #1a7f4b; }
&--declined { background: #fde8e8; color: #d93025; }
}
&__actions {
display: flex;
gap: var(--space-2);
margin-top: auto;
flex-wrap: wrap;
}
}
</style>
+39 -4
View File
@@ -8,7 +8,12 @@
:aria-label="tab.label"
:aria-current="isActive(tab.to) ? 'page' : undefined"
>
<span class="bottom-nav__icon" aria-hidden="true" v-html="tab.icon" />
<span class="bottom-nav__icon-wrap" aria-hidden="true">
<span class="bottom-nav__icon" v-html="tab.icon" />
<span v-if="tab.name === 'shared' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}
</span>
</span>
<span class="bottom-nav__label">{{ tab.label }}</span>
</RouterLink>
</nav>
@@ -16,8 +21,10 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useImagesStore } from '@/stores/images'
const route = useRoute()
const route = useRoute()
const imagesStore = useImagesStore()
const tabs = [
{
@@ -35,7 +42,7 @@ const tabs = [
{
name: 'shared',
label: 'Shared',
to: '/shared',
to: '/library?tab=shared',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
},
{
@@ -46,7 +53,8 @@ const tabs = [
},
]
function isActive(path: string) {
function isActive(to: string) {
const path = to.split('?')[0]
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
@@ -85,6 +93,15 @@ function isActive(path: string) {
}
}
&__icon-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
&__icon {
display: flex;
align-items: center;
@@ -93,6 +110,24 @@ function isActive(path: string) {
height: 24px;
}
&__badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--color-primary);
color: var(--color-primary-fg);
border-radius: 999px;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
&__label {
font-size: var(--text-xs);
font-weight: 600;
+337
View File
@@ -0,0 +1,337 @@
<template>
<div class="crop-editor" ref="containerRef">
<canvas
ref="canvasRef"
class="crop-editor__canvas"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<div class="crop-editor__label" v-if="deviceName">{{ deviceName }}</div>
<div class="crop-editor__actions">
<BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop">
Use this crop
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import type { CropParams } from '@/types'
const props = defineProps<{
src: string
orientation: 'landscape' | 'portrait'
deviceName?: string
initialParams?: CropParams | null
}>()
const emit = defineEmits<{
(e: 'crop', result: { blob: Blob; params: CropParams }): void
}>()
// Dimensions for each orientation
const OUTPUT_W = props.orientation === 'landscape' ? 1600 : 960
const OUTPUT_H = props.orientation === 'landscape' ? 960 : 1600
const ASPECT = OUTPUT_W / OUTPUT_H
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
let ctx: CanvasRenderingContext2D | null = null
let img: HTMLImageElement | null = null
let rafId = 0
// State: pan (canvas px from centered) + zoom multiplier
const panX = ref(0)
const panY = ref(0)
const zoom = ref(1)
// Crop rect on canvas (set when canvas is sized)
let cropRect = { x: 0, y: 0, w: 0, h: 0 }
let minScale = 1 // natural px → canvas px at zoom=1 (cover)
function sizeCanvas() {
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
const available = container.getBoundingClientRect()
// Leave space for bottom button bar
const availH = available.height - 80
const availW = available.width
canvas.width = availW
canvas.height = availH
ctx = canvas.getContext('2d')
// Compute crop rect (inset 24px each side for comfort)
const pad = 24
const maxW = availW - pad * 2
const maxH = availH - pad * 2
let cropW: number, cropH: number
if (maxW / maxH > ASPECT) {
cropH = maxH
cropW = cropH * ASPECT
} else {
cropW = maxW
cropH = cropW / ASPECT
}
cropRect = {
x: (availW - cropW) / 2,
y: (availH - cropH) / 2,
w: cropW,
h: cropH,
}
if (img) {
resetView()
}
}
function resetView() {
if (!img) return
minScale = Math.max(cropRect.w / img.naturalWidth, cropRect.h / img.naturalHeight)
if (props.initialParams) {
restoreView(props.initialParams)
} else {
zoom.value = 1
panX.value = 0
panY.value = 0
draw()
}
}
function restoreView(p: CropParams) {
if (!img) return
// actualScale such that natW fills the crop frame width
const actualScale = cropRect.w / p.natW
zoom.value = actualScale / minScale
// pan: offset from centered position so crop center = frame center
panX.value = actualScale * (img.naturalWidth / 2 - p.natX - p.natW / 2)
panY.value = actualScale * (img.naturalHeight / 2 - p.natY - p.natH / 2)
const [cx, cy] = clampPan(panX.value, panY.value)
panX.value = cx
panY.value = cy
draw()
}
function clampPan(px: number, py: number): [number, number] {
if (!img) return [px, py]
const actualScale = minScale * zoom.value
const imgW = img.naturalWidth * actualScale
const imgH = img.naturalHeight * actualScale
const maxPx = (imgW - cropRect.w) / 2
const maxPy = (imgH - cropRect.h) / 2
return [
Math.max(-maxPx, Math.min(maxPx, px)),
Math.max(-maxPy, Math.min(maxPy, py)),
]
}
function draw() {
if (!ctx || !img || !canvasRef.value) return
const { width, height } = canvasRef.value
const actualScale = minScale * zoom.value
const imgW = img.naturalWidth * actualScale
const imgH = img.naturalHeight * actualScale
const cx = cropRect.x + cropRect.w / 2 + panX.value
const cy = cropRect.y + cropRect.h / 2 + panY.value
const imgLeft = cx - imgW / 2
const imgTop = cy - imgH / 2
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
// Dark overlay outside crop
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.fillRect(0, 0, width, height)
ctx.globalCompositeOperation = 'destination-out'
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
ctx.restore()
// Crop border + corner marks
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.strokeRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
const cLen = 20
ctx.lineWidth = 3
;[
[cropRect.x, cropRect.y, cLen, 0, 0, cLen],
[cropRect.x + cropRect.w, cropRect.y, -cLen, 0, 0, cLen],
[cropRect.x, cropRect.y + cropRect.h, cLen, 0, 0, -cLen],
[cropRect.x + cropRect.w, cropRect.y + cropRect.h, -cLen, 0, 0, -cLen],
].forEach(([x, y, dx1, dy1, dx2, dy2]) => {
ctx!.beginPath()
ctx!.moveTo(x + dx1, y + dy1)
ctx!.lineTo(x, y)
ctx!.lineTo(x + dx2, y + dy2)
ctx!.stroke()
})
}
// ── Touch / pointer handling ──────────────────────────────────────────────────
const activePointers = new Map<number, { x: number; y: number }>()
let lastPinchDist = 0
function onPointerDown(e: PointerEvent) {
canvasRef.value?.setPointerCapture(e.pointerId)
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (activePointers.size === 2) {
const pts = [...activePointers.values()]
lastPinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
}
}
function onPointerMove(e: PointerEvent) {
if (!activePointers.has(e.pointerId)) return
const prev = activePointers.get(e.pointerId)!
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (activePointers.size === 1) {
const dx = e.clientX - prev.x
const dy = e.clientY - prev.y
const [cx, cy] = clampPan(panX.value + dx, panY.value + dy)
panX.value = cx
panY.value = cy
scheduleDraw()
return
}
if (activePointers.size === 2) {
const pts = [...activePointers.values()]
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
if (lastPinchDist > 0) {
const ratio = dist / lastPinchDist
const newZoom = Math.max(1, zoom.value * ratio)
zoom.value = newZoom
const [cx, cy] = clampPan(panX.value, panY.value)
panX.value = cx
panY.value = cy
scheduleDraw()
}
lastPinchDist = dist
}
}
function onPointerUp(e: PointerEvent) {
activePointers.delete(e.pointerId)
lastPinchDist = 0
}
function scheduleDraw() {
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(draw)
}
// ── Output ────────────────────────────────────────────────────────────────────
async function useCrop() {
if (!img) return
const actualScale = minScale * zoom.value
const cx = cropRect.x + cropRect.w / 2 + panX.value
const cy = cropRect.y + cropRect.h / 2 + panY.value
const imgLeft = cx - img.naturalWidth * actualScale / 2
const imgTop = cy - img.naturalHeight * actualScale / 2
const natCropX = (cropRect.x - imgLeft) / actualScale
const natCropY = (cropRect.y - imgTop) / actualScale
const natCropW = cropRect.w / actualScale
const natCropH = cropRect.h / actualScale
const out = new OffscreenCanvas(OUTPUT_W, OUTPUT_H)
const outCtx = out.getContext('2d')!
outCtx.drawImage(img, natCropX, natCropY, natCropW, natCropH, 0, 0, OUTPUT_W, OUTPUT_H)
const blob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
emit('crop', {
blob,
params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH },
})
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
const ro = new ResizeObserver(sizeCanvas)
onMounted(() => {
if (containerRef.value) ro.observe(containerRef.value)
sizeCanvas()
img = new Image()
img.onload = () => {
sizeCanvas()
resetView()
}
img.src = props.src
})
watch(() => props.src, src => {
if (!img) return
img.onload = () => resetView()
img.src = src
})
onBeforeUnmount(() => {
ro.disconnect()
cancelAnimationFrame(rafId)
})
</script>
<style scoped lang="scss">
.crop-editor {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #000;
touch-action: none;
&__canvas {
flex: 1;
min-height: 0;
display: block;
touch-action: none;
cursor: grab;
&:active { cursor: grabbing; }
}
&__label {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
letter-spacing: 0.04em;
pointer-events: none;
}
&__actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--space-4);
display: flex;
justify-content: center;
}
&__use-btn {
width: 100%;
max-width: 320px;
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Choose frames" @update:model-value="$emit('update:modelValue', $event)">
<h2 class="device-picker__title">Add to frames</h2>
<p class="device-picker__sub">Choose which frames will show this photo.</p>
<div class="device-picker__list">
<label
v-for="device in devices"
:key="device.id"
class="device-picker__row"
>
<input
type="checkbox"
class="device-picker__check"
:checked="selected.includes(device.id)"
@change="toggle(device.id)"
/>
<span class="device-picker__name">{{ device.name }}</span>
<span class="device-picker__orientation">{{ device.orientation }}</span>
</label>
</div>
<BaseButton
variant="primary"
class="device-picker__confirm"
:disabled="selected.length === 0 || uploading"
@click="$emit('confirm')"
>
{{ uploading ? 'Uploading…' : confirmLabel }}
</BaseButton>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import type { Device } from '@/types'
const props = defineProps<{
modelValue: boolean
devices: Device[]
selected: number[]
uploading?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'update:selected', v: number[]): void
(e: 'confirm'): void
}>()
function toggle(id: number) {
if (props.selected.includes(id)) {
emit('update:selected', props.selected.filter(d => d !== id))
} else {
emit('update:selected', [...props.selected, id])
}
}
const confirmLabel = computed(() => {
const n = props.selected.length
return n === 0 ? 'Add to frame' : `Add to ${n} frame${n > 1 ? 's' : ''}`
})
</script>
<style scoped lang="scss">
.device-picker {
&__title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
&__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-5);
}
&__row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
min-height: var(--touch-min);
}
&__check {
width: 20px;
height: 20px;
accent-color: var(--color-primary);
cursor: pointer;
flex-shrink: 0;
}
&__name {
flex: 1;
font-size: var(--text-base);
font-weight: 600;
}
&__orientation {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: capitalize;
}
&__confirm {
width: 100%;
}
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Share photo" @update:model-value="$emit('update:modelValue', $event)">
<h2 class="share-sheet__title">Share with someone</h2>
<p class="share-sheet__sub">They'll get an email and can add it to their frame.</p>
<div class="share-sheet__field">
<input
v-model="email"
type="email"
class="share-sheet__input"
placeholder="their@email.com"
autocomplete="email"
@keydown.enter.prevent="submit"
/>
</div>
<p v-if="errorMsg" class="share-sheet__error">{{ errorMsg }}</p>
<p v-if="successMsg" class="share-sheet__success">{{ successMsg }}</p>
<BaseButton
variant="primary"
class="share-sheet__btn"
:disabled="sending || !email.trim()"
@click="submit"
>
{{ sending ? 'Sending' : 'Send invite' }}
</BaseButton>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import { useImagesStore } from '@/stores/images'
const props = defineProps<{
modelValue: boolean
imageId: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const imagesStore = useImagesStore()
const email = ref('')
const sending = ref(false)
const errorMsg = ref('')
const successMsg = ref('')
async function submit() {
errorMsg.value = ''
successMsg.value = ''
if (!email.value.trim()) return
sending.value = true
try {
await imagesStore.shareImage(props.imageId, email.value.trim())
successMsg.value = `Invite sent to ${email.value.trim()}`
email.value = ''
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'Failed to send'
} finally {
sending.value = false
}
}
</script>
<style scoped lang="scss">
.share-sheet {
&__title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-1); }
&__sub { font-size: var(--text-sm); color: var(--color-text-muted); margin-bottom: var(--space-4); }
&__field { margin-bottom: var(--space-3); }
&__input {
width: 100%;
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
&:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; }
}
&__error { font-size: var(--text-sm); color: var(--color-danger, #d93025); margin-bottom: var(--space-3); }
&__success { font-size: var(--text-sm); color: var(--color-success, #1a7f4b); margin-bottom: var(--space-3); }
&__btn { width: 100%; }
}
</style>
+353
View File
@@ -0,0 +1,353 @@
<template>
<div class="sticker-canvas" ref="containerRef">
<v-stage
ref="stageRef"
:config="stageConfig"
@click="onStageClick"
@tap="onStageClick"
>
<v-layer>
<v-image :config="imageConfig" />
</v-layer>
<v-layer ref="stickerLayerRef">
<v-text
v-for="s in stickers"
:key="s.id"
:config="stickerConfig(s)"
@click="selectSticker(s.id, $event)"
@tap="selectSticker(s.id, $event)"
@dragend="onDragEnd(s.id, $event)"
@transformend="onTransformEnd(s.id, $event)"
/>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<!-- Delete selected sticker button -->
<button
v-if="selectedId"
class="sticker-canvas__delete"
type="button"
aria-label="Remove sticker"
@click="removeSelected"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<!-- Add sticker / Next -->
<div class="sticker-canvas__bar">
<button class="sticker-canvas__add-btn" type="button" @click="trayOpen = true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
</svg>
Add sticker
</button>
<BaseButton variant="primary" class="sticker-canvas__next-btn" @click="done">Next</BaseButton>
</div>
<!-- Sticker tray -->
<StickerTray v-model="trayOpen" @pick="addStickerFromTray" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import Konva from 'konva'
import BaseButton from '@/components/BaseButton.vue'
import StickerTray from '@/components/StickerTray.vue'
import type { StickerLayer } from '@/types'
const props = defineProps<{
croppedUrl: string
orientation: 'landscape' | 'portrait'
stickers: StickerLayer[]
}>()
const emit = defineEmits<{
(e: 'add-sticker', s: StickerLayer): void
(e: 'update-sticker', id: string, patch: Partial<StickerLayer>): void
(e: 'remove-sticker', id: string): void
(e: 'done', blob: Blob): void
}>()
const containerRef = ref<HTMLDivElement>()
const stageRef = ref()
const transformerRef = ref()
const stickerLayerRef = ref()
const trayOpen = ref(false)
const selectedId = ref<string | null>(null)
// Stage dimensions fitted to container
const stageW = ref(375)
const stageH = ref(225)
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600)
function sizeStage() {
if (!containerRef.value) return
const { width, height } = containerRef.value.getBoundingClientRect()
const availH = height - 72 // bottom bar
if (width / availH > ASPECT) {
stageH.value = availH
stageW.value = availH * ASPECT
} else {
stageW.value = width
stageH.value = width / ASPECT
}
loadImage()
}
const ro = new ResizeObserver(sizeStage)
onMounted(() => {
if (containerRef.value) ro.observe(containerRef.value)
sizeStage()
attachPinchListeners()
})
onBeforeUnmount(() => {
ro.disconnect()
detachPinchListeners()
})
// ── Image ─────────────────────────────────────────────────────────────────────
const bgImage = ref<HTMLImageElement | null>(null)
function loadImage() {
const i = new Image()
i.onload = () => { bgImage.value = i }
i.src = props.croppedUrl
}
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
const imageConfig = computed(() => ({
image: bgImage.value,
x: 0, y: 0,
width: stageW.value,
height: stageH.value,
}))
const transformerConfig = {
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
rotateEnabled: true,
borderStroke: 'rgba(255,255,255,0.8)',
anchorFill: '#fff',
anchorSize: 18, // larger for touch
keepRatio: true,
boundBoxFunc: (_: any, newBox: any) => newBox,
}
// ── Stickers ──────────────────────────────────────────────────────────────────
const EMOJI_FONT_SIZE = 52
function stickerConfig(s: StickerLayer) {
return {
id: s.id,
text: stickerEmoji(s.type),
fontSize: EMOJI_FONT_SIZE,
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
x: s.x,
y: s.y,
scaleX: s.scale,
scaleY: s.scale,
rotation: s.rotation,
draggable: true,
offsetX: EMOJI_FONT_SIZE / 2,
offsetY: EMOJI_FONT_SIZE / 2,
}
}
import { STICKERS } from '@/assets/stickers/index'
function stickerEmoji(type: string): string {
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
}
function selectSticker(id: string, e: any) {
e.cancelBubble = true
selectedId.value = id
nextTick(() => {
const layer = stickerLayerRef.value?.getNode()
const node = layer?.findOne(`#${id}`)
const tr = transformerRef.value?.getNode()
if (node && tr) tr.nodes([node])
})
}
function onStageClick(e: any) {
if (e.target === e.target.getStage()) {
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
}
}
function removeSelected() {
if (!selectedId.value) return
emit('remove-sticker', selectedId.value)
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
}
function onDragEnd(id: string, e: any) {
emit('update-sticker', id, { x: e.target.x(), y: e.target.y() })
}
function onTransformEnd(id: string, e: any) {
emit('update-sticker', id, {
x: e.target.x(),
y: e.target.y(),
scale: e.target.scaleX(),
rotation: e.target.rotation(),
})
}
function addStickerFromTray(stickerId: string) {
const s: StickerLayer = {
id: `${stickerId}-${Date.now()}`,
type: stickerId,
x: stageW.value / 2,
y: stageH.value / 2,
scale: 1,
rotation: 0,
}
emit('add-sticker', s)
trayOpen.value = false
// Auto-select the new sticker
nextTick(() => selectSticker(s.id, { cancelBubble: false }))
}
// ── Pinch-to-resize ───────────────────────────────────────────────────────────
let pinchStartDist = 0
let pinchStartScale = 1
function touchDist(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.hypot(dx, dy)
}
function onPinchStart(e: TouchEvent) {
if (e.touches.length !== 2 || !selectedId.value) return
pinchStartDist = touchDist(e.touches)
const s = props.stickers.find(x => x.id === selectedId.value)
pinchStartScale = s?.scale ?? 1
}
function onPinchMove(e: TouchEvent) {
if (e.touches.length !== 2 || !selectedId.value || pinchStartDist === 0) return
e.preventDefault()
const newScale = Math.max(0.2, Math.min(6, pinchStartScale * (touchDist(e.touches) / pinchStartDist)))
emit('update-sticker', selectedId.value, { scale: newScale })
}
function onPinchEnd() {
pinchStartDist = 0
pinchStartScale = 1
}
function attachPinchListeners() {
const el = containerRef.value
if (!el) return
el.addEventListener('touchstart', onPinchStart, { passive: true })
el.addEventListener('touchmove', onPinchMove, { passive: false })
el.addEventListener('touchend', onPinchEnd, { passive: true })
}
function detachPinchListeners() {
const el = containerRef.value
if (!el) return
el.removeEventListener('touchstart', onPinchStart)
el.removeEventListener('touchmove', onPinchMove)
el.removeEventListener('touchend', onPinchEnd)
}
// ── Output ────────────────────────────────────────────────────────────────────
async function done() {
// Deselect to hide transformer handles before capture
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
await nextTick()
const stage: Konva.Stage = stageRef.value?.getNode()
if (!stage) return
const outputW = props.orientation === 'landscape' ? 1600 : 960
const pixelRatio = outputW / stageW.value
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
if (blob) emit('done', blob)
}
</script>
<style scoped lang="scss">
.sticker-canvas {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
background: #111;
overflow: hidden;
:deep(.konvajs-content) {
flex-shrink: 0;
}
&__delete {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(200, 30, 30, 0.85);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
z-index: 10;
}
&__bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 72px;
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-4);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
&__add-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: 1.5px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
&__next-btn {
margin-left: auto;
min-width: 96px;
}
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
<div class="sticker-tray">
<div class="sticker-tray__cats" role="tablist">
<button
v-for="cat in STICKER_CATEGORIES"
:key="cat.id"
type="button"
role="tab"
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
@click="activeCategory = cat.id"
>{{ cat.label }}</button>
</div>
<div class="sticker-tray__grid" role="tabpanel">
<button
v-for="s in visibleStickers"
:key="s.id"
type="button"
class="sticker-tray__item"
:aria-label="s.label"
@click="$emit('pick', s.id)"
>
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
<span class="sticker-tray__label">{{ s.label }}</span>
</button>
</div>
</div>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
import type { StickerCategory } from '@/assets/stickers/index'
defineProps<{ modelValue: boolean }>()
defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'pick', stickerId: string): void
}>()
const activeCategory = ref<StickerCategory>('seasonal')
const visibleStickers = computed(() =>
STICKERS.filter(s => s.category === activeCategory.value)
)
</script>
<style scoped lang="scss">
.sticker-tray {
&__cats {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-3);
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
&__cat {
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-fg);
}
}
&__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-2);
}
&__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-2) var(--space-1);
border-radius: var(--radius-sm);
border: none;
background: transparent;
cursor: pointer;
transition: background var(--duration-fast);
&:active { background: var(--color-surface-2); }
}
&__emoji {
font-size: 36px;
line-height: 1;
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
}
&__label {
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
}
}
</style>
+2
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import VueKonva from 'vue-konva'
import '@/styles/global.scss'
import App from './App.vue'
import router from '@/router'
@@ -7,4 +8,5 @@ import router from '@/router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(VueKonva)
app.mount('#app')
+9 -4
View File
@@ -17,10 +17,10 @@ const router = createRouter({
meta: { requiresAuth: true },
},
{
path: '/shared',
name: 'shared',
component: () => import('@/views/SharedView.vue'),
meta: { requiresAuth: true },
path: '/upload',
name: 'upload',
component: () => import('@/views/UploadView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/settings',
@@ -28,6 +28,11 @@ const router = createRouter({
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true },
},
// Redirect old /shared to library shared tab
{
path: '/shared',
redirect: '/library?tab=shared',
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
+24 -2
View File
@@ -21,7 +21,7 @@ export const useDevicesStore = defineStore('devices', () => {
}
}
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalHours' | 'uniquenessWindow'>>) {
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeHour' | 'timezone' | 'uniquenessWindow'>>) {
const res = await fetch(`/api/devices/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -34,5 +34,27 @@ export const useDevicesStore = defineStore('devices', () => {
return updated
}
return { devices, loading, error, fetchDevices, updateDevice }
async function lockImage(deviceId: number, imageId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageId }),
})
if (!res.ok) throw new Error('Failed to lock image')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
async function unlockImage(deviceId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to unlock')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
return { devices, loading, error, fetchDevices, updateDevice, lockImage, unlockImage }
})
+131
View File
@@ -0,0 +1,131 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Image, CropParams, StickerLayer, SharedImage, SharedImagePage } from '@/types'
interface UploadExtras {
original?: File
cropParams?: CropParams
stickerState?: StickerLayer[]
}
export const useImagesStore = defineStore('images', () => {
const images = ref<Image[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const pendingCount = ref(0)
async function fetchImages() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/images')
if (!res.ok) throw new Error('Failed to load images')
images.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
async function uploadImage(file: File, extras?: UploadExtras): Promise<Image> {
const form = new FormData()
form.append('file', file)
if (extras?.original) form.append('original', extras.original)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
const res = await fetch('/api/images', { method: 'POST', body: form })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Upload failed')
}
const image: Image = await res.json()
images.value.unshift(image)
return image
}
async function reprocessImage(imageId: number, composited: File, extras?: { cropParams?: CropParams; stickerState?: StickerLayer[] }): Promise<Image> {
const form = new FormData()
form.append('file', composited)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
const res = await fetch(`/api/images/${imageId}/reprocess`, { method: 'POST', body: form })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Reprocess failed')
}
const updated: Image = await res.json()
const idx = images.value.findIndex(i => i.id === imageId)
if (idx !== -1) images.value[idx] = updated
return updated
}
async function deleteImage(id: number): Promise<void> {
const res = await fetch(`/api/images/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
images.value = images.value.filter(img => img.id !== id)
}
async function setApproval(imageId: number, deviceId: number, approved: boolean): Promise<void> {
const method = approved ? 'POST' : 'DELETE'
const res = await fetch(`/api/images/${imageId}/approve/${deviceId}`, { method })
if (!res.ok) throw new Error('Failed to update approval')
const updated: Image = await res.json()
const idx = images.value.findIndex(i => i.id === imageId)
if (idx !== -1) images.value[idx] = updated
}
async function fetchSharedImages(status?: string, page = 1, limit = 20): Promise<SharedImagePage> {
const params = new URLSearchParams({ page: String(page), limit: String(limit) })
if (status) params.set('status', status)
const res = await fetch(`/api/shared-images?${params}`)
if (!res.ok) throw new Error('Failed to load shared images')
return res.json()
}
async function fetchPendingCount(): Promise<void> {
const res = await fetch('/api/shared-images/pending-count')
if (res.ok) {
const data = await res.json()
pendingCount.value = data.count
}
}
async function approveShared(sharedId: number, deviceIds: number[]): Promise<SharedImage> {
const res = await fetch(`/api/shared-images/${sharedId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceIds }),
})
if (!res.ok) throw new Error('Failed to approve')
if (pendingCount.value > 0) pendingCount.value--
return res.json()
}
async function declineShared(sharedId: number): Promise<SharedImage> {
const res = await fetch(`/api/shared-images/${sharedId}/decline`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to decline')
if (pendingCount.value > 0) pendingCount.value--
return res.json()
}
async function shareImage(imageId: number, recipientEmail: string): Promise<void> {
const res = await fetch(`/api/images/${imageId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipientEmail }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Failed to share')
}
}
return {
images, loading, error, pendingCount,
fetchImages, uploadImage, reprocessImage, deleteImage, setApproval,
fetchSharedImages, fetchPendingCount, approveShared, declineShared, shareImage,
}
})
+76
View File
@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { StickerLayer, CropParams } from '@/types'
export const useUploadStore = defineStore('upload', () => {
const originalFile = ref<File | null>(null)
const originalUrl = ref<string | null>(null)
const croppedBlob = ref<Blob | null>(null)
const croppedUrl = ref<string | null>(null)
const cropParams = ref<CropParams | null>(null)
const stickers = ref<StickerLayer[]>([])
const contextDeviceId = ref<number | null>(null)
const selectedDeviceIds = ref<number[]>([])
const editingImageId = ref<number | null>(null)
function init(file: File, deviceId?: number) {
cleanup()
originalFile.value = file
originalUrl.value = URL.createObjectURL(file)
contextDeviceId.value = deviceId ?? null
selectedDeviceIds.value = deviceId ? [deviceId] : []
}
async function initEdit(image: import('@/types').Image, deviceId?: number) {
cleanup()
const res = await fetch(image.originalUrl)
const blob = await res.blob()
originalFile.value = new File([blob], image.originalFilename, { type: blob.type })
originalUrl.value = URL.createObjectURL(blob)
editingImageId.value = image.id
cropParams.value = image.cropParams ?? null
stickers.value = image.stickerState ? [...image.stickerState] : []
selectedDeviceIds.value = image.approvedDeviceIds
contextDeviceId.value = deviceId ?? null
}
function setCrop(blob: Blob, params: CropParams) {
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
croppedBlob.value = blob
croppedUrl.value = URL.createObjectURL(blob)
cropParams.value = params
}
function addSticker(s: StickerLayer) {
stickers.value = [...stickers.value, s]
}
function updateSticker(id: string, patch: Partial<StickerLayer>) {
stickers.value = stickers.value.map(s => s.id === id ? { ...s, ...patch } : s)
}
function removeSticker(id: string) {
stickers.value = stickers.value.filter(s => s.id !== id)
}
function cleanup() {
if (originalUrl.value) URL.revokeObjectURL(originalUrl.value)
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
originalFile.value = null
originalUrl.value = null
croppedBlob.value = null
croppedUrl.value = null
cropParams.value = null
stickers.value = []
contextDeviceId.value = null
selectedDeviceIds.value = []
editingImageId.value = null
}
return {
originalFile, originalUrl,
croppedBlob, croppedUrl, cropParams,
stickers, contextDeviceId, selectedDeviceIds, editingImageId,
init, initEdit, setCrop, addSticker, updateSticker, removeSticker, cleanup,
}
})
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'
describe('BaseButton', () => {
it('renders slot content', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toContain('Click me')
})
it('renders as a <button> by default', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'OK' },
})
expect(wrapper.element.tagName).toBe('BUTTON')
})
it('applies primary variant class by default', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.classes()).toContain('btn--primary')
})
it('applies the given variant class', () => {
const wrapper = mount(BaseButton, {
props: { variant: 'destructive' },
slots: { default: 'Delete' },
})
expect(wrapper.classes()).toContain('btn--destructive')
})
it('shows spinner element when loading is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Saving...' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(true)
})
it('does not show spinner when loading is false', () => {
const wrapper = mount(BaseButton, {
props: { loading: false },
slots: { default: 'Save' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(false)
})
it('applies btn--loading class when loading', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Wait' },
})
expect(wrapper.classes()).toContain('btn--loading')
})
it('is disabled when disabled prop is true', () => {
const wrapper = mount(BaseButton, {
props: { disabled: true },
slots: { default: 'Blocked' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is disabled when loading prop is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Loading' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is not disabled when neither disabled nor loading', () => {
const wrapper = mount(BaseButton, {
props: { disabled: false, loading: false },
slots: { default: 'Go' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(false)
})
it('emits click event when clicked and not disabled', async () => {
const wrapper = mount(BaseButton, { slots: { default: 'Go' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('type attribute defaults to button', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.attributes('type')).toBe('button')
})
it('type attribute can be set to submit', () => {
const wrapper = mount(BaseButton, {
props: { type: 'submit' },
slots: { default: 'Submit' },
})
expect(wrapper.attributes('type')).toBe('submit')
})
it('renders as an anchor when tag is a', () => {
const wrapper = mount(BaseButton, {
props: { tag: 'a' },
slots: { default: 'Link' },
})
expect(wrapper.element.tagName).toBe('A')
// <a> should not have a type attribute
expect(wrapper.attributes('type')).toBeUndefined()
})
})
@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DevicePicker from '@/components/DevicePicker.vue'
import type { Device } from '@/types'
// Stub child components DevicePicker wraps
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('DevicePicker', () => {
const devices = [
makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'Bedroom' }),
]
function mountPicker(selected: number[] = []) {
return mount(DevicePicker, {
props: {
modelValue: true,
devices,
selected,
},
})
}
// DP-01: Selecting a device emits update:selected with the device added
it('checking a device emits update:selected with device id added', async () => {
const wrapper = mountPicker([])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1)
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toEqual([1])
})
// DP-02: Deselecting a device emits update:selected with device id removed
it('unchecking a device emits update:selected with device id removed', async () => {
// Start with both selected
const wrapper = mountPicker([1, 2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1) — it's currently checked, so this deselects
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
// Should emit [2] — Living Room removed
expect(emitted![0][0]).toEqual([2])
})
// DP-03: Checkboxes reflect the selected prop
it('checkboxes are checked for ids in selected prop', async () => {
const wrapper = mountPicker([2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false) // id=1 not selected
expect((checkboxes[1].element as HTMLInputElement).checked).toBe(true) // id=2 selected
})
// DP-04: Confirm button disabled when nothing selected
it('confirm button is disabled when selected is empty', async () => {
const wrapper = mountPicker([])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
// DP-05: Confirm button enabled when at least one device selected
it('confirm button is enabled when a device is selected', async () => {
const wrapper = mountPicker([1])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
})
// DP-06: Device names are rendered
it('renders all device names', () => {
const wrapper = mountPicker([])
expect(wrapper.text()).toContain('Living Room')
expect(wrapper.text()).toContain('Bedroom')
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FrameCard from '@/components/FrameCard.vue'
// Mock vue-konva to avoid canvas issues if transitively imported
vi.mock('vue-konva', () => ({}))
const defaultProps = {
deviceId: 1,
name: 'Living Room',
size: 'large' as const,
status: 'ok' as const,
orientation: 'landscape' as const,
}
describe('FrameCard', () => {
it('renders device name', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.text()).toContain('Living Room')
})
it('does not show status badge when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
})
it('shows "Offline" badge when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Offline')
})
it('shows "Sync issue" badge when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Sync issue')
})
it('applies offline modifier class when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
expect(wrapper.classes()).toContain('frame-card--offline')
})
it('applies sync-fail modifier class when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
expect(wrapper.classes()).toContain('frame-card--sync-fail')
})
it('shows settings button in large size', () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(true)
})
it('does not show settings button in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact' },
})
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(false)
})
it('shows img element when thumbnailUrl is provided', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, thumbnailUrl: '/thumb/test.jpg' },
})
const img = wrapper.find('img.frame-card__img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/thumb/test.jpg')
})
it('shows empty preview placeholder when no thumbnailUrl', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__empty-preview').exists()).toBe(true)
expect(wrapper.find('img.frame-card__img').exists()).toBe(false)
})
it('shows photo count in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 3 },
})
expect(wrapper.text()).toContain('3 photos')
})
it('uses singular "photo" when photoCount is 1', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 1 },
})
expect(wrapper.text()).toContain('1 photo')
expect(wrapper.text()).not.toContain('1 photos')
})
it('emits add-photo with deviceId when add button clicked', async () => {
const wrapper = mount(FrameCard, { props: defaultProps })
await wrapper.find('.frame-card__add-btn').trigger('click')
expect(wrapper.emitted('add-photo')).toBeTruthy()
expect(wrapper.emitted('add-photo')![0]).toEqual([1])
})
it('emits edit with deviceId when settings button clicked (large)', async () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
await wrapper.find('.frame-card__settings-btn').trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('sets landscape aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
})
const preview = wrapper.find('.frame-card__preview')
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
})
it('sets portrait aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
})
const preview = wrapper.find('.frame-card__preview')
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareSheet from '@/components/ShareSheet.vue'
import { useImagesStore } from '@/stores/images'
const BaseBottomSheetStub = {
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
}
const BaseButtonStub = {
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
}
describe('ShareSheet', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
function mountShareSheet(imageId = 1) {
return mount(ShareSheet, {
props: { modelValue: true, imageId },
global: {
stubs: {
BaseBottomSheet: BaseBottomSheetStub,
BaseButton: BaseButtonStub,
},
},
})
}
// SS-01: successful share shows success message and clears email field
it('shows success message and clears email on successful share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
expect(wrapper.text()).toContain('Invite sent to friend@example.com')
expect((input.element as HTMLInputElement).value).toBe('')
})
// SS-02: failed share shows error message
it('shows error message on failed share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockRejectedValue(new Error('Server error'))
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Server error')
expect(wrapper.find('.share-sheet__error').exists()).toBe(true)
})
// SS-03: button is disabled when email input is empty
it('button is disabled when email is empty', () => {
const wrapper = mountShareSheet()
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
})
})
+10
View File
@@ -0,0 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.unstubAllGlobals()
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import type { User } from '@/types'
const makeUser = (overrides: Partial<User> = {}): User => ({
id: 1,
email: 'test@example.com',
roles: ['ROLE_USER'],
theme: null,
timezone: 'America/Chicago',
...overrides,
})
describe('auth store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('isAuthenticated is false when __PF_USER__ is not set', async () => {
// No __PF_USER__ on window — should be null
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('isAuthenticated is true when user is set via setUser', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
expect(store.user?.email).toBe('test@example.com')
})
it('setUser(null) clears user and isAuthenticated becomes false', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
store.setUser(null)
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('bootstraps user from window.__PF_USER__ when present', async () => {
const user = makeUser({ id: 99, email: 'bootstrapped@example.com' })
// Stub window.__PF_USER__ before the store module is evaluated
vi.stubGlobal('__PF_USER__', user)
// Dynamically re-import so the store sees the stub
vi.resetModules()
const { useAuthStore } = await import('@/stores/auth')
setActivePinia(createPinia())
const store = useAuthStore()
expect(store.isAuthenticated).toBe(true)
expect(store.user?.id).toBe(99)
})
})
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import type { Device } from '@/types'
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('devices store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
// DS-01
it('fetchDevices success populates devices and clears loading', async () => {
const mockDevices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockDevices),
}))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual(mockDevices)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
// DS-02
it('fetchDevices network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBe('Network failure')
})
// DS-02b — non-ok response
it('fetchDevices non-ok response sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.error).toBe('Failed to load devices')
expect(store.loading).toBe(false)
})
// DS-03
it('updateDevice patches local array entry', async () => {
const original = makeDevice({ id: 1, name: 'Old Name' })
const updated = makeDevice({ id: 1, name: 'New Name' })
const store = useDevicesStore()
store.devices = [original]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const result = await store.updateDevice(1, { name: 'New Name' })
expect(result.name).toBe('New Name')
expect(store.devices[0].name).toBe('New Name')
})
// DS-03b — updateDevice throws on failure
it('updateDevice throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.updateDevice(1, { name: 'x' })).rejects.toThrow('Failed to update device')
})
// DS-04
it('lockImage sets lockedImageId on local device', async () => {
const device = makeDevice({ id: 1, lockedImageId: null })
const locked = makeDevice({ id: 1, lockedImageId: 42 })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(locked),
}))
const result = await store.lockImage(1, 42)
expect(result.lockedImageId).toBe(42)
expect(store.devices[0].lockedImageId).toBe(42)
})
// DS-05
it('unlockImage clears lockedImageId', async () => {
const device = makeDevice({ id: 1, lockedImageId: 42 })
const unlocked = makeDevice({ id: 1, lockedImageId: null })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(unlocked),
}))
const result = await store.unlockImage(1)
expect(result.lockedImageId).toBeNull()
expect(store.devices[0].lockedImageId).toBeNull()
})
// DS-05b — lockImage throws on failure
it('lockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.lockImage(1, 42)).rejects.toThrow('Failed to lock image')
})
// DS-05c — unlockImage throws on failure
it('unlockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock')
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import type { Image } from '@/types'
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
describe('images store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
it('fetchImages success populates images and clears loading', async () => {
const mockImages = [makeImage(), makeImage({ id: 2 })]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockImages),
}))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual(mockImages)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('fetchImages network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Net error')))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual([])
expect(store.error).toBe('Net error')
expect(store.loading).toBe(false)
})
it('uploadImage prepends to images list on success', async () => {
const existing = makeImage({ id: 1 })
const newImage = makeImage({ id: 2 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(newImage),
}))
const store = useImagesStore()
store.images = [existing]
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
const result = await store.uploadImage(file)
expect(result).toEqual(newImage)
expect(store.images[0]).toEqual(newImage)
expect(store.images).toHaveLength(2)
})
it('uploadImage throws with error message on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'File too large' }),
}))
const store = useImagesStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
await expect(store.uploadImage(file)).rejects.toThrow('File too large')
})
it('deleteImage removes image from list', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const store = useImagesStore()
store.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
await store.deleteImage(1)
expect(store.images).toHaveLength(1)
expect(store.images[0].id).toBe(2)
})
it('deleteImage throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
store.images = [makeImage()]
await expect(store.deleteImage(1)).rejects.toThrow('Delete failed')
})
it('setApproval updates image in list', async () => {
const original = makeImage({ id: 1, approvedDeviceIds: [] })
const updated = makeImage({ id: 1, approvedDeviceIds: [42] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [original]
await store.setApproval(1, 42, true)
expect(store.images[0].approvedDeviceIds).toEqual([42])
})
it('fetchPendingCount stores the count', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ count: 5 }),
}))
const store = useImagesStore()
await store.fetchPendingCount()
expect(store.pendingCount).toBe(5)
})
it('approveShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'approved' }),
}))
const store = useImagesStore()
store.pendingCount = 3
await store.approveShared(1, [42])
expect(store.pendingCount).toBe(2)
})
it('declineShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'declined' }),
}))
const store = useImagesStore()
store.pendingCount = 2
await store.declineShared(1)
expect(store.pendingCount).toBe(1)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useToastStore } from '@/stores/toast'
describe('toast store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('show adds a message to toasts', () => {
const store = useToastStore()
store.show('Hello!')
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Hello!')
expect(store.toasts[0].type).toBe('info')
})
it('show with explicit type sets correct type', () => {
const store = useToastStore()
store.show('Saved', 'success')
expect(store.toasts[0].type).toBe('success')
})
it('show with error type sets error type', () => {
const store = useToastStore()
store.show('Something broke', 'error')
expect(store.toasts[0].type).toBe('error')
})
it('multiple show calls add multiple toasts', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
expect(store.toasts).toHaveLength(2)
})
it('auto-dismisses after 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
expect(store.toasts).toHaveLength(1)
vi.advanceTimersByTime(2500)
expect(store.toasts).toHaveLength(0)
})
it('does not dismiss before 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
vi.advanceTimersByTime(2499)
expect(store.toasts).toHaveLength(1)
})
it('dismiss removes a specific toast by id', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
const id = store.toasts[0].id
store.dismiss(id)
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Second')
})
it('dismiss with unknown id does nothing', () => {
const store = useToastStore()
store.show('Msg')
store.dismiss(99999)
expect(store.toasts).toHaveLength(1)
})
it('each toast gets a unique id', () => {
const store = useToastStore()
store.show('A')
store.show('B')
store.show('C')
const ids = store.toasts.map(t => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
})
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUploadStore } from '@/stores/upload'
import type { StickerLayer } from '@/types'
const makeSticker = (overrides: Partial<StickerLayer> = {}): StickerLayer => ({
id: 'sticker-1',
type: 'emoji',
x: 100,
y: 100,
scale: 1,
rotation: 0,
...overrides,
})
describe('upload store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
// happy-dom has URL.createObjectURL as a stub; ensure it returns something predictable
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('init sets originalFile and originalUrl', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.originalFile).toStrictEqual(file)
expect(store.originalUrl).toBe('blob:mock-url')
})
it('init with deviceId sets contextDeviceId and selectedDeviceIds', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file, 7)
expect(store.contextDeviceId).toBe(7)
expect(store.selectedDeviceIds).toEqual([7])
})
it('init without deviceId leaves selectedDeviceIds empty', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
})
it('setCrop stores croppedBlob and cropParams', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
const blob = new Blob(['crop'], { type: 'image/jpeg' })
const params = { natX: 0, natY: 0, natW: 200, natH: 200 }
store.setCrop(blob, params)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.croppedBlob).toStrictEqual(blob)
expect(store.croppedUrl).toBe('blob:mock-url')
expect(store.cropParams).toEqual(params)
})
it('addSticker appends to stickers', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
expect(store.stickers).toHaveLength(2)
expect(store.stickers[0].id).toBe('a')
expect(store.stickers[1].id).toBe('b')
})
it('updateSticker patches matching sticker', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[0].x).toBe(99)
})
it('updateSticker leaves non-matching stickers unchanged', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.addSticker(makeSticker({ id: 'b', x: 20 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[1].x).toBe(20)
})
it('removeSticker removes by id', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
store.removeSticker('a')
expect(store.stickers).toHaveLength(1)
expect(store.stickers[0].id).toBe('b')
})
it('cleanup resets all state', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file, 5)
store.addSticker(makeSticker())
store.cleanup()
expect(store.originalFile).toBeNull()
expect(store.originalUrl).toBeNull()
expect(store.croppedBlob).toBeNull()
expect(store.croppedUrl).toBeNull()
expect(store.cropParams).toBeNull()
expect(store.stickers).toHaveLength(0)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
expect(store.editingImageId).toBeNull()
})
})
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import HomeView from '@/views/HomeView.vue'
import type { Device } from '@/types'
// Stub heavy child components so tests focus on HomeView logic
vi.mock('@/components/FrameCard.vue', () => ({
default: {
name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation'],
emits: ['add-photo', 'edit'],
},
}))
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button><slot /></button>',
props: ['variant', 'disabled'],
},
}))
vi.mock('@/components/BaseInput.vue', () => ({
default: {
name: 'BaseInput',
template: '<input />',
props: ['modelValue', 'label', 'maxlength'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/OrientationPicker.vue', () => ({
default: {
name: 'OrientationPicker',
template: '<div />',
props: ['modelValue'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router so HomeView can call useRouter() without a real router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
// Stub URL.createObjectURL used by upload store
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('HomeView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Re-stub URL after unstubAllGlobals
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
// Stub fetch so onMounted fetchDevices doesn't fail
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(HomeView, {
global: {
plugins: [pinia],
},
})
}
// HV-01: N devices renders N FrameCard stubs
it('renders one FrameCard per device when devices are present', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'Frame A' }),
makeDevice({ id: 2, name: 'Frame B' }),
makeDevice({ id: 3, name: 'Frame C' }),
]
// Mock fetchDevices so onMounted doesn't overwrite devices
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(3)
})
// HV-01b: single device still renders one FrameCard (large variant branch)
it('renders one FrameCard for a single device', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, name: 'Solo Frame' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(1)
})
// HV-02: empty state shown when no devices
it('shows empty state when devices list is empty', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = []
devicesStore.loading = false
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('Set up your first frame')
})
// HV-03: loading state shown while fetching
it('shows loading indicator when store is loading', async () => {
const devicesStore = useDevicesStore()
devicesStore.loading = true
// Keep fetchDevices pending so loading stays true
vi.spyOn(devicesStore, 'fetchDevices').mockReturnValue(new Promise(() => {}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading')
})
})
+260
View File
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import LibraryView from '@/views/LibraryView.vue'
import type { Image, Device } from '@/types'
// Stub complex child components
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
vi.mock('@/components/ApproveCard.vue', () => ({
default: {
name: 'ApproveCard',
template: '<div class="approve-card-stub" />',
props: ['item'],
emits: ['updated'],
},
}))
vi.mock('@/components/ShareSheet.vue', () => ({
default: {
name: 'ShareSheet',
template: '<div class="share-sheet-stub" />',
props: ['modelValue', 'imageId'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ query: {} }),
}))
// Stub toast store
vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: vi.fn() }),
}))
// Stub upload store
vi.mock('@/stores/upload', () => ({
useUploadStore: () => ({ initEdit: vi.fn() }),
}))
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('LibraryView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Default fetch stub — returns empty lists so onMounted doesn't error
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(LibraryView, {
global: {
plugins: [pinia],
},
})
}
// LV-01: Default tab shows "All" tab active
it('renders the All tab as active by default', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// The "All" tab button should have aria-selected=true
const tabs = wrapper.findAll('[role="tab"]')
const allTab = tabs.find(t => t.text() === 'All')
expect(allTab?.attributes('aria-selected')).toBe('true')
})
// LV-01b: Images from imagesStore are rendered in the grid
it('renders image grid when images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 }), makeImage({ id: 3 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const grid = wrapper.find('.library__grid')
expect(grid.exists()).toBe(true)
expect(wrapper.findAll('.library__item')).toHaveLength(3)
})
// LV-02: Switching to Shared tab shows the shared sub-tabs UI
it('switching to Shared tab shows shared sub-tabs and triggers a fetch', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
// Set up fetch so fetchSharedImages network call resolves
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(sharedPage),
}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
// After clicking Shared, the sub-tabs (Pending/Approved/Declined) should appear
expect(wrapper.find('.library__subtabs').exists()).toBe(true)
})
// LV-03: Lock chip shown for device when image is approved for it
it('renders lock chip for device when image is approved for that device', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Lock chips are rendered only for approved devices
const lockChips = wrapper.findAll('.library__lock-chip')
expect(lockChips.length).toBeGreaterThan(0)
expect(lockChips[0].text()).toContain('Bedroom')
})
// LV-06: Share button click renders the ShareSheet
it('clicking share button renders the ShareSheet', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 5 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Find the share action button (aria-label contains "Share")
const shareBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.includes('Share'))
expect(shareBtn).toBeTruthy()
await shareBtn!.trigger('click')
await wrapper.vm.$nextTick()
// After clicking, the ShareSheet stub should be rendered
expect(wrapper.find('.share-sheet-stub').exists()).toBe(true)
})
// LV-07: Empty state shown when no images (All tab)
it('shows empty state when no images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('No photos yet')
})
// LV-07b: Empty state on shared sub-tab (pending)
it('shows shared empty state when no shared items exist', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Switch to Shared tab
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
// Wait for async loadShared to complete
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
})
})
+39 -8
View File
@@ -3,6 +3,7 @@ export interface User {
email: string
roles: string[]
theme: string | null
timezone: string
}
export interface Device {
@@ -10,18 +11,20 @@ export interface Device {
mac: string
name: string
orientation: 'landscape' | 'portrait'
rotationIntervalHours: number
rotationIntervalMinutes: number
wakeHour: number | null
timezone: string
uniquenessWindow: number
linkedAt: string
lastSeenAt: string | null
lockedImageId: number | null
}
export interface Image {
id: number
source: 'uploaded' | 'shared'
filename: string
thumbnailUrl: string
deletedAt: string | null
approvedDeviceIds: number[]
export interface CropParams {
natX: number
natY: number
natW: number
natH: number
}
export interface StickerLayer {
@@ -33,6 +36,17 @@ export interface StickerLayer {
rotation: number
}
export interface Image {
id: number
originalFilename: string
thumbnailUrl: string
originalUrl: string
uploadedAt: string
approvedDeviceIds: number[]
cropParams: CropParams | null
stickerState: StickerLayer[] | null
}
export interface RenderedAsset {
id: number
imageId: number
@@ -41,6 +55,23 @@ export interface RenderedAsset {
status: 'pending' | 'processing' | 'ready' | 'failed'
}
export interface SharedImage {
id: number
imageId: number
thumbnailUrl: string
sharedBy: string
sharedAt: string
status: 'pending' | 'approved' | 'declined'
}
export interface SharedImagePage {
items: SharedImage[]
total: number
page: number
limit: number
totalPages: number
}
export interface Token {
uuid: string
type: 'share_approve' | 'share_decline' | 'hard_delete_confirm'
+162 -5
View File
@@ -27,7 +27,7 @@
:deviceId="devicesStore.devices[0].id"
:name="devicesStore.devices[0].name"
size="large"
status="ok"
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
@@ -42,7 +42,7 @@
:deviceId="device.id"
:name="device.name"
size="compact"
status="ok"
:status="deviceStatus(device)"
:orientation="device.orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
@@ -67,6 +67,24 @@
<OrientationPicker v-model="editOrientation" />
</div>
<div class="home-view__sheet-field">
<p class="home-view__sheet-label">Update time</p>
<div class="home-view__interval-grid">
<button
v-for="opt in WAKE_TIME_OPTIONS"
:key="opt.hour"
type="button"
:class="['home-view__interval-chip', { 'home-view__interval-chip--on': editWakeHour === opt.hour }]"
@click="editWakeHour = opt.hour"
>{{ opt.label }}</button>
</div>
<select class="home-view__tz-select" v-model="editTimezone">
<optgroup v-for="group in TIMEZONE_GROUPS" :key="group.label" :label="group.label">
<option v-for="tz in group.zones" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
</optgroup>
</select>
</div>
<BaseButton
variant="primary"
class="home-view__sheet-save"
@@ -80,30 +98,120 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import type { Device } from '@/types'
function deviceStatus(device: Device): 'ok' | 'offline' {
if (!device.lastSeenAt) return 'offline'
const seenMs = Date.now() - new Date(device.lastSeenAt).getTime()
const windowMs = Math.max(device.rotationIntervalMinutes * 2 * 60_000, 30 * 60_000)
return seenMs <= windowMs ? 'ok' : 'offline'
}
import FrameCard from '@/components/FrameCard.vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import BaseInput from '@/components/BaseInput.vue'
import OrientationPicker from '@/components/OrientationPicker.vue'
const router = useRouter()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
onMounted(() => devicesStore.fetchDevices())
onMounted(() => {
devicesStore.fetchDevices()
})
function onAddPhoto(deviceId: number) {
// Photo upload flow — Epic 3
console.log('add-photo', deviceId)
// File picker must be triggered in the user-gesture context (the click handler)
// before navigating, otherwise browsers block it as a popup.
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
uploadStore.init(file, deviceId)
router.push('/upload')
}
input.click()
}
// ── Settings sheet ────────────────────────────────────────────────────────────
const WAKE_TIME_OPTIONS = [
{ hour: 0, label: '12 AM' },
{ hour: 2, label: '2 AM' },
{ hour: 4, label: '4 AM' },
{ hour: 6, label: '6 AM' },
{ hour: 8, label: '8 AM' },
{ hour: 10, label: '10 AM' },
{ hour: 12, label: '12 PM' },
{ hour: 18, label: '6 PM' },
{ hour: 20, label: '8 PM' },
{ hour: 22, label: '10 PM' },
]
const TIMEZONE_GROUPS = [
{ label: 'Americas', zones: [
{ value: 'America/New_York', label: 'Eastern — New York, Toronto' },
{ value: 'America/Chicago', label: 'Central — Chicago, Mexico City' },
{ value: 'America/Denver', label: 'Mountain — Denver, Calgary' },
{ value: 'America/Phoenix', label: 'Mountain (no DST) — Phoenix' },
{ value: 'America/Los_Angeles', label: 'Pacific — Los Angeles, Vancouver' },
{ value: 'America/Anchorage', label: 'Alaska — Anchorage' },
{ value: 'Pacific/Honolulu', label: 'Hawaii — Honolulu' },
{ value: 'America/Sao_Paulo', label: 'Brasília — São Paulo' },
{ value: 'America/Argentina/Buenos_Aires', label: 'Argentina — Buenos Aires' },
{ value: 'America/Bogota', label: 'Colombia — Bogotá' },
]},
{ label: 'Europe', zones: [
{ value: 'Europe/London', label: 'GMT/BST — London, Dublin' },
{ value: 'Europe/Lisbon', label: 'WET/WEST — Lisbon' },
{ value: 'Europe/Paris', label: 'CET/CEST — Paris, Brussels, Amsterdam' },
{ value: 'Europe/Berlin', label: 'CET/CEST — Berlin, Vienna, Zurich' },
{ value: 'Europe/Stockholm', label: 'CET/CEST — Stockholm, Oslo, Copenhagen'},
{ value: 'Europe/Helsinki', label: 'EET/EEST — Helsinki, Tallinn, Riga' },
{ value: 'Europe/Warsaw', label: 'CET/CEST — Warsaw, Prague, Budapest' },
{ value: 'Europe/Rome', label: 'CET/CEST — Rome, Madrid' },
{ value: 'Europe/Athens', label: 'EET/EEST — Athens, Bucharest' },
{ value: 'Europe/Istanbul', label: 'TRT — Istanbul' },
{ value: 'Europe/Moscow', label: 'MSK — Moscow' },
]},
{ label: 'Asia & Pacific', zones: [
{ value: 'Asia/Dubai', label: 'GST — Dubai, Abu Dhabi' },
{ value: 'Asia/Karachi', label: 'PKT — Karachi, Islamabad' },
{ value: 'Asia/Kolkata', label: 'IST — India' },
{ value: 'Asia/Dhaka', label: 'BST — Dhaka, Bangladesh' },
{ value: 'Asia/Bangkok', label: 'ICT — Bangkok, Jakarta, Hanoi' },
{ value: 'Asia/Singapore', label: 'SGT — Singapore, Kuala Lumpur' },
{ value: 'Asia/Shanghai', label: 'CST — Beijing, Shanghai, Taipei' },
{ value: 'Asia/Seoul', label: 'KST — Seoul' },
{ value: 'Asia/Tokyo', label: 'JST — Tokyo' },
{ value: 'Australia/Sydney', label: 'AEDT/AEST — Sydney, Melbourne' },
{ value: 'Australia/Brisbane',label: 'AEST (no DST) — Brisbane' },
{ value: 'Australia/Perth', label: 'AWST — Perth' },
{ value: 'Pacific/Auckland', label: 'NZDT/NZST — Auckland' },
]},
{ label: 'Africa & Middle East', zones: [
{ value: 'Africa/Cairo', label: 'EET — Cairo' },
{ value: 'Africa/Nairobi', label: 'EAT — Nairobi, East Africa'},
{ value: 'Africa/Johannesburg', label: 'SAST — Johannesburg' },
{ value: 'Africa/Lagos', label: 'WAT — Lagos, West Africa' },
]},
{ label: 'UTC', zones: [
{ value: 'UTC', label: 'UTC — Coordinated Universal Time' },
]},
]
const sheetOpen = ref(false)
const saving = ref(false)
const editingDevice = ref<Device | null>(null)
const editName = ref('')
const editOrientation = ref<Device['orientation']>('landscape')
const editWakeHour = ref<number>(4)
const editTimezone = ref('UTC')
function onEdit(deviceId: number) {
const device = devicesStore.devices.find(d => d.id === deviceId)
@@ -111,6 +219,8 @@ function onEdit(deviceId: number) {
editingDevice.value = device
editName.value = device.name
editOrientation.value = device.orientation
editWakeHour.value = device.wakeHour ?? 4
editTimezone.value = device.timezone ?? 'UTC'
sheetOpen.value = true
}
@@ -121,6 +231,8 @@ async function saveSettings() {
await devicesStore.updateDevice(editingDevice.value.id, {
name: editName.value.trim() || editingDevice.value.name,
orientation: editOrientation.value,
wakeHour: editWakeHour.value,
timezone: editTimezone.value,
})
sheetOpen.value = false
} finally {
@@ -208,6 +320,51 @@ async function saveSettings() {
margin-bottom: var(--space-2);
}
&__interval-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
&__tz-select {
width: 100%;
margin-top: var(--space-3);
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
cursor: pointer;
appearance: auto;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&__interval-chip {
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
min-height: var(--touch-min);
&--on {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-fg);
}
}
&__sheet-save {
width: 100%;
margin-top: var(--space-2);
+581 -3
View File
@@ -1,9 +1,587 @@
<template>
<main class="view">
<h1>Library</h1>
<main class="library">
<!-- Tabs -->
<div class="library__tabs" role="tablist">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
role="tab"
:aria-selected="activeTab === tab.id"
:class="['library__tab', { 'library__tab--active': activeTab === tab.id }]"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<!-- Loading -->
<div v-if="imagesStore.loading" class="library__loading">Loading</div>
<!-- All / Mine tab -->
<template v-else-if="activeTab !== 'shared'">
<div v-if="visibleImages.length === 0" class="library__empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="library__empty-title">No photos yet</p>
<p class="library__empty-sub">Tap "+ Add Photo" on the home screen to get started.</p>
</div>
<div v-else class="library__grid">
<div v-for="image in visibleImages" :key="image.id" class="library__item">
<div class="library__thumb">
<img
:src="image.thumbnailUrl"
:alt="image.originalFilename"
class="library__img"
loading="lazy"
/>
<div class="library__thumb-actions">
<button
class="library__action-btn"
type="button"
:aria-label="`Edit ${image.originalFilename}`"
:disabled="editingId === image.id"
@click="startEdit(image)"
>
<svg v-if="editingId !== image.id" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</button>
<button
class="library__action-btn"
type="button"
:aria-label="`Share ${image.originalFilename}`"
@click="openShare(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
<button
class="library__action-btn library__action-btn--danger"
type="button"
aria-label="Delete photo"
@click="confirmDelete(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
</div>
<div v-if="devicesStore.devices.length > 0" class="library__approvals">
<button
v-for="device in devicesStore.devices"
:key="device.id"
:class="['library__approval-chip', { 'library__approval-chip--on': image.approvedDeviceIds.includes(device.id) }]"
type="button"
:aria-label="`${image.approvedDeviceIds.includes(device.id) ? 'Remove from' : 'Add to'} ${device.name}`"
@click="toggleApproval(image.id, device.id, !image.approvedDeviceIds.includes(device.id))"
>
{{ device.name }}
</button>
</div>
<div v-if="devicesStore.devices.length > 0" class="library__locks">
<button
v-for="device in devicesStore.devices.filter(d => image.approvedDeviceIds.includes(d.id))"
:key="device.id"
:class="['library__lock-chip', { 'library__lock-chip--on': device.lockedImageId === image.id }]"
type="button"
:aria-label="`${device.lockedImageId === image.id ? 'Unlock from' : 'Lock to'} ${device.name}`"
@click="toggleLock(image.id, device)"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path v-if="device.lockedImageId === image.id" d="M7 11V7a5 5 0 0 1 10 0v4"/>
<path v-else d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>
{{ device.name }}
</button>
</div>
</div>
</div>
</template>
<!-- Shared tab -->
<template v-else>
<!-- Sub-tabs -->
<div class="library__subtabs" role="tablist">
<button
v-for="st in SHARED_TABS"
:key="st.id"
type="button"
role="tab"
:aria-selected="sharedTab === st.id"
:class="['library__subtab', { 'library__subtab--active': sharedTab === st.id }]"
@click="switchSharedTab(st.id)"
>{{ st.label }}</button>
</div>
<div v-if="sharedLoading" class="library__loading">Loading</div>
<div v-else-if="sharedItems.length === 0" class="library__shared-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<p class="library__empty-title">
{{ sharedTab === 'pending' ? 'No pending photos' : sharedTab === 'approved' ? 'No approved photos' : 'No declined photos' }}
</p>
<p class="library__empty-sub">
{{ sharedTab === 'pending' ? 'Photos shared with you will appear here.' : 'Photos you\'ve added to a frame will appear here.' }}
</p>
</div>
<div v-else class="library__shared-list">
<ApproveCard
v-for="item in sharedItems"
:key="item.id"
:item="item"
@updated="onSharedUpdated"
/>
</div>
<!-- Pagination -->
<div v-if="sharedTotalPages > 1" class="library__pagination">
<button
class="library__page-btn"
:disabled="sharedPage <= 1"
@click="goSharedPage(sharedPage - 1)"
> Prev</button>
<span class="library__page-info">{{ sharedPage }} / {{ sharedTotalPages }}</span>
<button
class="library__page-btn"
:disabled="sharedPage >= sharedTotalPages"
@click="goSharedPage(sharedPage + 1)"
>Next </button>
</div>
</template>
<!-- Share sheet -->
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
<!-- Confirm delete sheet -->
<BaseBottomSheet v-model="deleteSheetOpen" label="Delete photo">
<h2 class="library__sheet-title">Delete this photo?</h2>
<p class="library__sheet-sub">It will be removed from all frames.</p>
<div class="library__sheet-actions">
<BaseButton variant="secondary" @click="deleteSheetOpen = false">Cancel</BaseButton>
<BaseButton variant="destructive" :disabled="deleting" @click="doDelete">
{{ deleting ? 'Deleting' : 'Delete' }}
</BaseButton>
</div>
</BaseBottomSheet>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import { useToastStore } from '@/stores/toast'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import ApproveCard from '@/components/ApproveCard.vue'
import ShareSheet from '@/components/ShareSheet.vue'
import type { Device, Image, SharedImage } from '@/types'
const router = useRouter()
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
const toast = useToastStore()
const route = useRoute()
const TABS = [
{ id: 'all', label: 'All' },
{ id: 'mine', label: 'Mine' },
{ id: 'shared', label: 'Shared' },
] as const
type Tab = typeof TABS[number]['id']
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all')
const SHARED_TABS = [
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'declined', label: 'Declined' },
] as const
type SharedTab = typeof SHARED_TABS[number]['id']
const sharedTab = ref<SharedTab>('pending')
const sharedItems = ref<SharedImage[]>([])
const sharedLoading = ref(false)
const sharedPage = ref(1)
const sharedTotalPages = ref(1)
async function loadShared(tab: SharedTab, page = 1) {
sharedLoading.value = true
try {
const result = await imagesStore.fetchSharedImages(tab, page)
sharedItems.value = result.items
sharedPage.value = result.page
sharedTotalPages.value = result.totalPages
} finally {
sharedLoading.value = false
}
}
function switchSharedTab(tab: SharedTab) {
sharedTab.value = tab
loadShared(tab, 1)
}
function goSharedPage(page: number) {
loadShared(sharedTab.value, page)
}
function onSharedUpdated(updated: SharedImage) {
const idx = sharedItems.value.findIndex(i => i.id === updated.id)
if (idx !== -1) sharedItems.value[idx] = updated
}
onMounted(() => {
imagesStore.fetchImages()
devicesStore.fetchDevices()
imagesStore.fetchPendingCount()
if (activeTab.value === 'shared') loadShared(sharedTab.value)
})
// For now "mine" and "all" show the same list; shared is a placeholder
const visibleImages = computed(() => imagesStore.images)
// ── Share ─────────────────────────────────────────────────────────────────────
const shareSheetOpen = ref(false)
const shareImageId = ref<number | null>(null)
function openShare(id: number) {
shareImageId.value = id
shareSheetOpen.value = true
}
// ── Edit ──────────────────────────────────────────────────────────────────────
const editingId = ref<number | null>(null)
async function startEdit(image: Image) {
if (editingId.value) return
editingId.value = image.id
try {
await uploadStore.initEdit(image)
router.push('/upload')
} catch {
toast.show('Could not load photo for editing', 'error')
} finally {
editingId.value = null
}
}
// ── Lock ──────────────────────────────────────────────────────────────────────
async function toggleLock(imageId: number, device: Device) {
try {
if (device.lockedImageId === imageId) {
await devicesStore.unlockImage(device.id)
} else {
await devicesStore.lockImage(device.id, imageId)
}
} catch {
toast.show('Failed to update lock', 'error')
}
}
// ── Approval toggles ──────────────────────────────────────────────────────────
async function toggleApproval(imageId: number, deviceId: number, approved: boolean) {
try {
await imagesStore.setApproval(imageId, deviceId, approved)
} catch {
toast.show('Failed to update frame approval', 'error')
}
}
// ── Delete ────────────────────────────────────────────────────────────────────
const deleteSheetOpen = ref(false)
const deletingId = ref<number | null>(null)
const deleting = ref(false)
function confirmDelete(id: number) {
deletingId.value = id
deleteSheetOpen.value = true
}
async function doDelete() {
if (!deletingId.value) return
deleting.value = true
try {
await imagesStore.deleteImage(deletingId.value)
deleteSheetOpen.value = false
toast.show('Photo deleted', 'success')
} catch {
toast.show('Delete failed', 'error')
} finally {
deleting.value = false
}
}
</script>
<style scoped lang="scss">
.view { padding: var(--space-4); }
.library {
padding-bottom: calc(64px + var(--space-4));
&__tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--color-bg);
z-index: 5;
}
&__tab {
flex: 1;
padding: var(--space-3) 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__loading {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4);
text-align: center;
}
&__subtabs {
display: flex;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
&__subtab {
flex: 1;
padding: var(--space-2) 0;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__shared-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
&__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-4);
}
&__page-btn {
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: none;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
cursor: pointer;
&:disabled { opacity: .4; cursor: default; }
}
&__page-info {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
&__empty, &__shared-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
&__empty-title {
font-size: var(--text-md);
font-weight: 700;
color: var(--color-text);
}
&__empty-sub {
font-size: var(--text-sm);
max-width: 280px;
line-height: 1.5;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
padding: var(--space-4);
}
&__item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__thumb {
position: relative;
aspect-ratio: 4/3;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-2);
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&__thumb-actions {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: 4px;
}
&__action-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background var(--duration-fast);
flex-shrink: 0;
&:disabled { opacity: 0.5; cursor: default; }
&:hover:not(:disabled) { background: rgba(0, 0, 0, 0.75); }
&--danger:hover:not(:disabled) { background: rgba(180, 0, 0, 0.8); }
}
&__approvals {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__approval-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-xs, 11px);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--on {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
}
&__locks {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__lock-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
border: 1.5px dashed var(--color-border);
font-size: var(--text-xs, 11px);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--on {
background: var(--color-warning, #f59e0b);
border-color: var(--color-warning, #f59e0b);
border-style: solid;
color: #fff;
}
}
&__sheet-title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__sheet-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-5);
}
&__sheet-actions {
display: flex;
gap: var(--space-3);
> * { flex: 1; }
}
}
</style>
-9
View File
@@ -1,9 +0,0 @@
<template>
<main class="view">
<h1>Shared</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+308
View File
@@ -0,0 +1,308 @@
<template>
<div class="upload-view">
<!-- Header -->
<header class="upload-view__header">
<button
v-if="step !== 'done'"
class="upload-view__back"
type="button"
:aria-label="step === 'crop' ? 'Cancel' : 'Back'"
@click="goBack"
>
<svg v-if="step === 'crop'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="upload-view__step-label">{{ stepLabel }}</span>
<button
v-if="step === 'stickers'"
class="upload-view__skip"
type="button"
@click="skipStickers"
>Skip</button>
</header>
<!-- Crop step -->
<CropEditor
v-if="step === 'crop' && uploadStore.originalUrl"
:src="uploadStore.originalUrl"
:orientation="contextOrientation"
:device-name="contextDeviceName"
:initial-params="uploadStore.cropParams"
class="upload-view__stage"
@crop="onCrop"
/>
<!-- Stickers step -->
<StickerCanvas
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
:cropped-url="uploadStore.croppedUrl"
:orientation="contextOrientation"
:stickers="uploadStore.stickers"
class="upload-view__stage"
@add-sticker="uploadStore.addSticker"
@update-sticker="uploadStore.updateSticker"
@remove-sticker="uploadStore.removeSticker"
@done="onStickersDone"
/>
<!-- Done -->
<div v-else-if="step === 'done'" class="upload-view__done">
<div class="upload-view__done-icon" aria-hidden="true">🎉</div>
<p class="upload-view__done-title">{{ isEdit ? 'Photo updated!' : 'Photo added!' }}</p>
<p class="upload-view__done-sub">It'll appear on your frame at the next update.</p>
<BaseButton variant="primary" class="upload-view__done-btn" @click="finish">Done</BaseButton>
</div>
<!-- Device picker (only on new uploads, not edits) -->
<DevicePicker
v-if="!isEdit"
v-model="devicePickerOpen"
:devices="devicesStore.devices"
:selected="uploadStore.selectedDeviceIds"
:uploading="uploading"
@update:selected="uploadStore.selectedDeviceIds = $event"
@confirm="doUpload"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUploadStore } from '@/stores/upload'
import { useDevicesStore } from '@/stores/devices'
import { useImagesStore } from '@/stores/images'
import { useToastStore } from '@/stores/toast'
import type { CropParams } from '@/types'
import CropEditor from '@/components/CropEditor.vue'
import StickerCanvas from '@/components/StickerCanvas.vue'
import DevicePicker from '@/components/DevicePicker.vue'
import BaseButton from '@/components/BaseButton.vue'
const router = useRouter()
const uploadStore = useUploadStore()
const devicesStore = useDevicesStore()
const imagesStore = useImagesStore()
const toast = useToastStore()
type Step = 'crop' | 'stickers' | 'done'
const step = ref<Step>('crop')
const uploading = ref(false)
const devicePickerOpen = ref(false)
let finalBlob: Blob | null = null
const isEdit = computed(() => uploadStore.editingImageId !== null)
// ── Bootstrap ─────────────────────────────────────────────────────────────────
onMounted(async () => {
await devicesStore.fetchDevices()
if (!uploadStore.originalFile) {
router.replace('/')
return
}
// When opening for edit, jump straight to crop (state already loaded by caller)
step.value = 'crop'
})
// ── Context device ────────────────────────────────────────────────────────────
const contextDevice = computed(() =>
uploadStore.contextDeviceId
? devicesStore.devices.find(d => d.id === uploadStore.contextDeviceId)
: devicesStore.devices[0]
)
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
contextDevice.value?.orientation ?? 'landscape'
)
const contextDeviceName = computed(() => contextDevice.value?.name)
// ── Steps ─────────────────────────────────────────────────────────────────────
const stepLabel = computed(() => {
if (step.value === 'crop') return isEdit.value ? 'Edit crop' : 'Crop photo'
if (step.value === 'stickers') return 'Add stickers'
return isEdit.value ? 'Updated' : 'Added'
})
function onCrop({ blob, params }: { blob: Blob; params: CropParams }) {
uploadStore.setCrop(blob, params)
step.value = 'stickers'
}
function skipStickers() {
if (!uploadStore.croppedBlob) return
finalBlob = uploadStore.croppedBlob
if (isEdit.value) {
doUpload()
} else {
devicePickerOpen.value = true
}
}
function onStickersDone(blob: Blob) {
finalBlob = blob
if (isEdit.value) {
doUpload()
} else {
devicePickerOpen.value = true
}
}
function goBack() {
if (step.value === 'crop') {
uploadStore.cleanup()
router.replace('/library')
return
}
if (step.value === 'stickers') {
step.value = 'crop'
}
}
// ── Upload / reprocess ────────────────────────────────────────────────────────
async function doUpload() {
if (!finalBlob) return
uploading.value = true
try {
const composited = new File([finalBlob], 'photo.jpg', { type: 'image/jpeg' })
if (isEdit.value) {
await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, {
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
})
devicePickerOpen.value = false
step.value = 'done'
return
}
const image = await imagesStore.uploadImage(composited, {
original: uploadStore.originalFile ?? undefined,
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
})
await Promise.all(
uploadStore.selectedDeviceIds.map(deviceId =>
imagesStore.setApproval(image.id, deviceId, true)
)
)
devicePickerOpen.value = false
step.value = 'done'
} catch (e) {
toast.show(e instanceof Error ? e.message : 'Upload failed', 'error')
} finally {
uploading.value = false
}
}
function finish() {
uploadStore.cleanup()
router.replace('/library')
}
</script>
<style scoped lang="scss">
.upload-view {
position: fixed;
inset: 0;
z-index: 100;
background: var(--color-bg);
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 var(--space-4);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: relative;
}
&__back {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text);
margin-left: -8px;
}
&__step-label {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
}
&__skip {
margin-left: auto;
background: none;
border: none;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
cursor: pointer;
padding: var(--space-2) 0;
}
&__stage {
flex: 1;
min-height: 0;
}
&__done {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-6) var(--space-5);
text-align: center;
}
&__done-icon {
font-size: 64px;
line-height: 1;
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
}
&__done-title {
font-size: var(--text-xl);
font-weight: 700;
}
&__done-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
max-width: 260px;
line-height: 1.5;
}
&__done-btn {
width: 100%;
max-width: 320px;
}
}
</style>
+19 -3
View File
@@ -1,17 +1,33 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
base: '/build/',
build: {
outDir: '../public/build',
emptyOutDir: true,
},
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: [
'src/components/CropEditor.vue',
'src/components/StickerCanvas.vue',
'src/assets/**',
'src/test/**',
]
}
}
})
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505040613 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add image, rendered_asset, image_device_approval tables; add model column to device';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE image (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, original_filename VARCHAR(255) NOT NULL, storage_path VARCHAR(500) NOT NULL, uploaded_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, user_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_C53D045FA76ED395 ON image (user_id)');
$this->addSql('CREATE TABLE image_device_approval (image_id INT NOT NULL, device_id INT NOT NULL, PRIMARY KEY (image_id, device_id))');
$this->addSql('CREATE INDEX IDX_3524D29A3DA5256D ON image_device_approval (image_id)');
$this->addSql('CREATE INDEX IDX_3524D29A94A4C7D4 ON image_device_approval (device_id)');
$this->addSql('CREATE TABLE rendered_asset (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, device_model VARCHAR(255) NOT NULL, orientation VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, file_path VARCHAR(500) DEFAULT NULL, rendered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, image_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_DF34C8E33DA5256D ON rendered_asset (image_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_DF34C8E33DA5256D111092BE3680C556 ON rendered_asset (image_id, device_model, orientation)');
$this->addSql('ALTER TABLE image ADD CONSTRAINT FK_C53D045FA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE image_device_approval ADD CONSTRAINT FK_3524D29A3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE image_device_approval ADD CONSTRAINT FK_3524D29A94A4C7D4 FOREIGN KEY (device_id) REFERENCES device (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE rendered_asset ADD CONSTRAINT FK_DF34C8E33DA5256D FOREIGN KEY (image_id) REFERENCES image (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql("ALTER TABLE device ADD model VARCHAR(255) NOT NULL DEFAULT 'v1'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE image DROP CONSTRAINT FK_C53D045FA76ED395');
$this->addSql('ALTER TABLE image_device_approval DROP CONSTRAINT FK_3524D29A3DA5256D');
$this->addSql('ALTER TABLE image_device_approval DROP CONSTRAINT FK_3524D29A94A4C7D4');
$this->addSql('ALTER TABLE rendered_asset DROP CONSTRAINT FK_DF34C8E33DA5256D');
$this->addSql('DROP TABLE image');
$this->addSql('DROP TABLE image_device_approval');
$this->addSql('DROP TABLE rendered_asset');
$this->addSql('ALTER TABLE device DROP COLUMN model');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add wake_hour to device for time-based wake scheduling';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD wake_hour INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN wake_hour');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add timezone to user';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD timezone VARCHAR(60) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP COLUMN timezone');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add timezone to device (per-device scheduling context)';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE device ADD timezone VARCHAR(60) NOT NULL DEFAULT 'UTC'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN timezone');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add crop_params and sticker_state to image for re-edit support';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE image ADD crop_params TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE image ADD sticker_state TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE image DROP COLUMN crop_params');
$this->addSql('ALTER TABLE image DROP COLUMN sticker_state');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add device_image_history table for rotation uniqueness tracking';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE device_image_history (
id SERIAL NOT NULL,
device_id INT NOT NULL,
image_id INT NOT NULL,
served_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)');
$this->addSql('CREATE INDEX idx_history_device_served ON device_image_history (device_id, served_at)');
$this->addSql('ALTER TABLE device_image_history ADD CONSTRAINT fk_history_device FOREIGN KEY (device_id) REFERENCES device (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE device_image_history ADD CONSTRAINT fk_history_image FOREIGN KEY (image_id) REFERENCES image (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('COMMENT ON COLUMN device_image_history.served_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device_image_history DROP CONSTRAINT fk_history_device');
$this->addSql('ALTER TABLE device_image_history DROP CONSTRAINT fk_history_image');
$this->addSql('DROP TABLE device_image_history');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506010000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add last_seen_at to device';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD last_seen_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN device.last_seen_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN last_seen_at');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506020000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add current_image_id FK to device';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD current_image_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE device ADD CONSTRAINT fk_device_current_image FOREIGN KEY (current_image_id) REFERENCES image (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_device_current_image ON device (current_image_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP CONSTRAINT fk_device_current_image');
$this->addSql('DROP INDEX idx_device_current_image');
$this->addSql('ALTER TABLE device DROP COLUMN current_image_id');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506200000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Rename rotation_interval_hours to rotation_interval_minutes (1 hour = 60 minutes)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device RENAME COLUMN rotation_interval_hours TO rotation_interval_minutes');
$this->addSql('ALTER TABLE device ALTER COLUMN rotation_interval_minutes SET DEFAULT 1440');
$this->addSql('UPDATE device SET rotation_interval_minutes = rotation_interval_minutes * 60');
}
public function down(Schema $schema): void
{
$this->addSql('UPDATE device SET rotation_interval_minutes = rotation_interval_minutes / 60');
$this->addSql('ALTER TABLE device ALTER COLUMN rotation_interval_minutes SET DEFAULT 24');
$this->addSql('ALTER TABLE device RENAME COLUMN rotation_interval_minutes TO rotation_interval_hours');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add locked_image_id to device for pinning an image and bypassing rotation';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD COLUMN locked_image_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE device ADD CONSTRAINT fk_device_locked_image FOREIGN KEY (locked_image_id) REFERENCES image(id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP CONSTRAINT fk_device_locked_image');
$this->addSql('ALTER TABLE device DROP COLUMN locked_image_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260507100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add token table for share and hard-delete flows';
}
public function up(Schema $schema): void
{
$this->addSql("CREATE TABLE token (
uuid VARCHAR(36) NOT NULL,
type VARCHAR(30) NOT NULL,
image_id INT NOT NULL,
recipient_user_id INT DEFAULT NULL,
recipient_email VARCHAR(180) DEFAULT NULL,
expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
PRIMARY KEY(uuid)
)");
$this->addSql("COMMENT ON COLUMN token.expires_at IS '(DC2Type:datetime_immutable)'");
$this->addSql("COMMENT ON COLUMN token.used_at IS '(DC2Type:datetime_immutable)'");
$this->addSql('ALTER TABLE token ADD CONSTRAINT fk_token_image FOREIGN KEY (image_id) REFERENCES image (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE token ADD CONSTRAINT fk_token_recipient FOREIGN KEY (recipient_user_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_token_recipient ON token (recipient_user_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE token DROP CONSTRAINT fk_token_image');
$this->addSql('ALTER TABLE token DROP CONSTRAINT fk_token_recipient');
$this->addSql('DROP TABLE token');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260507200000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add shared_image table for family sharing';
}
public function up(Schema $schema): void
{
$this->addSql("CREATE TABLE shared_image (
id SERIAL NOT NULL,
source_image_id INT NOT NULL,
recipient_user_id INT NOT NULL,
shared_by_id INT NOT NULL,
shared_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
PRIMARY KEY(id)
)");
$this->addSql("COMMENT ON COLUMN shared_image.shared_at IS '(DC2Type:datetime_immutable)'");
$this->addSql('CREATE INDEX idx_shared_recipient_status ON shared_image (recipient_user_id, status)');
$this->addSql('ALTER TABLE shared_image ADD CONSTRAINT fk_shared_source FOREIGN KEY (source_image_id) REFERENCES image (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE shared_image ADD CONSTRAINT fk_shared_recipient FOREIGN KEY (recipient_user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE shared_image ADD CONSTRAINT fk_shared_by FOREIGN KEY (shared_by_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE shared_image DROP CONSTRAINT fk_shared_source');
$this->addSql('ALTER TABLE shared_image DROP CONSTRAINT fk_shared_recipient');
$this->addSql('ALTER TABLE shared_image DROP CONSTRAINT fk_shared_by');
$this->addSql('DROP TABLE shared_image');
}
}
+5
View File
@@ -32,6 +32,10 @@
<directory>src</directory>
</include>
<exclude>
<directory>src/DataFixtures</directory>
</exclude>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
@@ -40,5 +44,6 @@
</source>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
</phpunit>
@@ -0,0 +1 @@
.btn[data-v-7d3f1e61]{justify-content:center;align-items:center;gap:var(--space-2);min-height:var(--touch-min);padding:0 var(--space-5);border-radius:var(--radius-full);font-family:var(--font-family);font-size:var(--text-base);cursor:pointer;transition:opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);white-space:nowrap;border:none;font-weight:600;line-height:1;text-decoration:none;display:inline-flex}.btn[data-v-7d3f1e61]:disabled{opacity:.4;cursor:not-allowed}.btn[data-v-7d3f1e61]:not(:disabled):active{transform:scale(.96)}.btn--primary[data-v-7d3f1e61]{background:var(--color-primary);color:var(--color-primary-fg)}.btn--secondary[data-v-7d3f1e61]{background:var(--color-secondary);color:var(--color-secondary-fg);border:1px solid var(--color-border)}.btn--ghost[data-v-7d3f1e61]{color:var(--color-text);border:1px solid var(--color-border);background:0 0}.btn--destructive[data-v-7d3f1e61]{background:var(--color-destructive);color:var(--color-destructive-fg)}.btn--icon-pill[data-v-7d3f1e61]{width:var(--touch-min);border-radius:var(--radius-full);background:var(--color-surface-2);color:var(--color-text);padding:0}.btn__spinner[data-v-7d3f1e61]{border:2px solid;border-top-color:#0000;border-radius:50%;width:16px;height:16px;animation:.7s linear infinite spin-7d3f1e61}@keyframes spin-7d3f1e61{to{transform:rotate(360deg)}}.sheet-overlay[data-v-81ce2dd1]{z-index:100;background:#0006;align-items:flex-end;display:flex;position:fixed;inset:0}.sheet[data-v-81ce2dd1]{background:var(--color-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;width:100%;padding:var(--space-3) var(--space-4) var(--space-6);outline:none;max-height:90dvh;overflow-y:auto}.sheet__handle[data-v-81ce2dd1]{border-radius:var(--radius-full);background:var(--color-border);width:36px;height:4px;margin:0 auto var(--space-4)}.sheet-enter-active .sheet-overlay[data-v-81ce2dd1]{transition:background var(--duration-base) var(--ease-out)}.sheet-enter-active .sheet[data-v-81ce2dd1]{transition:transform .25s var(--ease-out)}.sheet-leave-active .sheet[data-v-81ce2dd1]{transition:transform .2s ease-in}.sheet-leave-active[data-v-81ce2dd1]{transition:background .2s ease-in}.sheet-enter-from[data-v-81ce2dd1]{background:0 0}.sheet-enter-from .sheet[data-v-81ce2dd1]{transform:translateY(100%)}.sheet-leave-to[data-v-81ce2dd1]{background:0 0}.sheet-leave-to .sheet[data-v-81ce2dd1]{transform:translateY(100%)}
@@ -0,0 +1 @@
import{C as e,H as t,M as n,P as r,R as i,_ as a,d as o,f as s,k as c,p as l,r as u,s as d,t as f,u as p,v as m,w as h,z as g}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{c as _,d as v,f as y}from"./index-6y_HJqaF.js";var b=u(`devices`,()=>{let e=t([]),n=t(!1),r=t(null);async function i(){n.value=!0,r.value=null;try{let t=await fetch(`/api/devices`);if(!t.ok)throw Error(`Failed to load devices`);e.value=await t.json()}catch(e){r.value=e instanceof Error?e.message:`Unknown error`}finally{n.value=!1}}async function a(t,n){let r=await fetch(`/api/devices/${t}`,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:JSON.stringify(n)});if(!r.ok)throw Error(`Failed to update device`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function o(t,n){let r=await fetch(`/api/devices/${t}/lock`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({imageId:n})});if(!r.ok)throw Error(`Failed to lock image`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function s(t){let n=await fetch(`/api/devices/${t}/lock`,{method:`DELETE`});if(!n.ok)throw Error(`Failed to unlock`);let r=await n.json(),i=e.value.findIndex(e=>e.id===t);return i!==-1&&(e.value[i]=r),r}return{devices:e,loading:n,error:r,fetchDevices:i,updateDevice:a,lockImage:o,unlockImage:s}}),x=u(`upload`,()=>{let e=t(null),n=t(null),r=t(null),i=t(null),a=t(null),o=t([]),s=t(null),c=t([]),l=t(null);function u(t,r){g(),e.value=t,n.value=URL.createObjectURL(t),s.value=r??null,c.value=r?[r]:[]}async function d(t,r){g();let i=await(await fetch(t.originalUrl)).blob();e.value=new File([i],t.originalFilename,{type:i.type}),n.value=URL.createObjectURL(i),l.value=t.id,a.value=t.cropParams??null,o.value=t.stickerState?[...t.stickerState]:[],c.value=t.approvedDeviceIds,s.value=r??null}function f(e,t){i.value&&URL.revokeObjectURL(i.value),r.value=e,i.value=URL.createObjectURL(e),a.value=t}function p(e){o.value=[...o.value,e]}function m(e,t){o.value=o.value.map(n=>n.id===e?{...n,...t}:n)}function h(e){o.value=o.value.filter(t=>t.id!==e)}function g(){n.value&&URL.revokeObjectURL(n.value),i.value&&URL.revokeObjectURL(i.value),e.value=null,n.value=null,r.value=null,i.value=null,a.value=null,o.value=[],s.value=null,c.value=[],l.value=null}return{originalFile:e,originalUrl:n,croppedBlob:r,croppedUrl:i,cropParams:a,stickers:o,contextDeviceId:s,selectedDeviceIds:c,editingImageId:l,init:u,initEdit:d,setCrop:f,addSticker:p,updateSticker:m,removeSticker:h,cleanup:g}}),S={key:0,class:`btn__spinner`,"aria-hidden":`true`},C=f(m({__name:`BaseButton`,props:{variant:{default:`primary`},tag:{default:`button`},type:{default:`button`},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1}},setup(t){return(i,a)=>(c(),o(r(t.tag),e({type:t.tag===`button`?t.type:void 0,disabled:t.disabled||t.loading,class:[`btn`,`btn--${t.variant}`,{"btn--loading":t.loading}]},i.$attrs),{default:g(()=>[t.loading?(c(),l(`span`,S)):s(``,!0),n(i.$slots,`default`,{},void 0,!0)]),_:3},16,[`type`,`disabled`,`class`]))}}),[[`__scopeId`,`data-v-7d3f1e61`]]),w=[`aria-label`],T=f(m({__name:`BaseBottomSheet`,props:{modelValue:{type:Boolean},label:{}},emits:[`update:modelValue`],setup(e,{emit:r}){let u=e,f=r,m=t(null),b=null;function x(){f(`update:modelValue`,!1)}return i(()=>u.modelValue,async e=>{e?(b=document.activeElement,await h(),m.value?.focus()):(b?.focus(),b=null)}),(t,r)=>(c(),o(d,{to:`body`},[a(_,{name:`sheet`},{default:g(()=>[e.modelValue?(c(),l(`div`,{key:0,class:`sheet-overlay`,role:`dialog`,"aria-label":e.label,"aria-modal":`true`,onClick:y(x,[`self`]),onKeydown:v(x,[`esc`])},[p(`div`,{ref_key:`sheetRef`,ref:m,class:`sheet`,tabindex:`-1`},[r[0]||=p(`div`,{class:`sheet__handle`,"aria-hidden":`true`},null,-1),n(t.$slots,`default`,{},void 0,!0)],512)],40,w)):s(``,!0)]),_:3})]))}}),[[`__scopeId`,`data-v-81ce2dd1`]]);export{b as i,C as n,x as r,T as t};
@@ -0,0 +1 @@
.device-picker__title[data-v-a6466fa5]{font-size:var(--text-md);margin-bottom:var(--space-2);font-weight:700}.device-picker__sub[data-v-a6466fa5]{font-size:var(--text-sm);color:var(--color-text-muted);margin-bottom:var(--space-4)}.device-picker__list[data-v-a6466fa5]{gap:var(--space-1);margin-bottom:var(--space-5);flex-direction:column;display:flex}.device-picker__row[data-v-a6466fa5]{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);display:flex}.device-picker__check[data-v-a6466fa5]{width:20px;height:20px;accent-color:var(--color-primary);cursor:pointer;flex-shrink:0}.device-picker__name[data-v-a6466fa5]{font-size:var(--text-base);flex:1;font-weight:600}.device-picker__orientation[data-v-a6466fa5]{font-size:var(--text-xs);color:var(--color-text-muted);text-transform:capitalize}.device-picker__confirm[data-v-a6466fa5]{width:100%}
@@ -0,0 +1 @@
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-YbQyMMAQ.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{E as e,c as t,d as n,t as r}from"./_plugin-vue_export-helper-CnSQ-FNj.js";var i={},a={class:`view`};function o(r,i){return e(),n(`main`,a,[...i[0]||=[t(`h1`,null,`Library`,-1)]])}var s=r(i,[[`render`,o],[`__scopeId`,`data-v-afbdd666`]]);export{s as default};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.view[data-v-afbdd666]{padding:var(--space-4)}
@@ -1 +0,0 @@
import{E as e,O as t,Y as n,c as r,d as i,dt as a,g as o,i as s,lt as c,s as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CnSQ-FNj.js";import{n as p,r as m,t as h}from"./index-KHHWwfaX.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(o({__name:`SettingsView`,setup(o){let u=m(),{saveTheme:T}=p(),E=l(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(o,l)=>(e(),i(`main`,g,[l[5]||=r(`h1`,{class:`settings__title`},`Settings`,-1),r(`section`,_,[l[1]||=r(`h2`,{class:`settings__section-title`},`Theme`,-1),r(`div`,v,[(e(!0),i(s,null,t(n(h),t=>(e(),i(`button`,{key:t.id,type:`button`,role:`radio`,"aria-checked":E.value===t.id,"aria-label":t.label,class:c([`theme-swatch`,{"theme-swatch--active":E.value===t.id}]),style:f({"--swatch-bg":t.bg,"--swatch-primary":t.primary,"--swatch-text":t.text}),onClick:e=>D(t.id)},[l[0]||=r(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[r(`span`,{class:`theme-swatch__bar`}),r(`span`,{class:`theme-swatch__dot`})],-1),r(`span`,b,a(t.label),1),E.value===t.id?(e(),i(`span`,x,``)):d(``,!0)],14,y))),128))])]),r(`section`,S,[l[3]||=r(`h2`,{class:`settings__section-title`},`Account`,-1),r(`div`,C,[l[2]||=r(`span`,{class:`settings__row-label`},`Signed in as`,-1),r(`span`,w,a(n(u).user?.email),1)]),l[4]||=r(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-1446e085`]]);export{T as default};
@@ -0,0 +1 @@
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-6y_HJqaF.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,``)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-1446e085`]]);export{T as default};
@@ -1 +0,0 @@
import{E as e,c as t,d as n,t as r}from"./_plugin-vue_export-helper-CnSQ-FNj.js";var i={},a={class:`view`};function o(r,i){return e(),n(`main`,a,[...i[0]||=[t(`h1`,null,`Shared`,-1)]])}var s=r(i,[[`render`,o],[`__scopeId`,`data-v-4046603a`]]);export{s as default};
@@ -1 +0,0 @@
.view[data-v-4046603a]{padding:var(--space-4)}
@@ -0,0 +1 @@
.crop-editor[data-v-ec2eb68c]{touch-action:none;background:#000;flex-direction:column;flex:1;min-height:0;display:flex;position:relative}.crop-editor__canvas[data-v-ec2eb68c]{touch-action:none;cursor:grab;flex:1;min-height:0;display:block}.crop-editor__canvas[data-v-ec2eb68c]:active{cursor:grabbing}.crop-editor__label[data-v-ec2eb68c]{color:#fff;font-size:var(--text-xs);letter-spacing:.04em;pointer-events:none;background:#0009;border-radius:999px;padding:4px 12px;font-weight:700;position:absolute;top:16px;left:50%;transform:translate(-50%)}.crop-editor__actions[data-v-ec2eb68c]{padding:var(--space-4);justify-content:center;display:flex;position:absolute;bottom:0;left:0;right:0}.crop-editor__use-btn[data-v-ec2eb68c]{width:100%;max-width:320px}.sticker-tray__cats[data-v-7eada75b]{gap:var(--space-2);padding-bottom:var(--space-3);scrollbar-width:none;display:flex;overflow-x:auto}.sticker-tray__cats[data-v-7eada75b]::-webkit-scrollbar{display:none}.sticker-tray__cat[data-v-7eada75b]{border:1.5px solid var(--color-border);font-size:var(--text-sm);white-space:nowrap;cursor:pointer;color:var(--color-text-muted);transition:all var(--duration-fast);background:0 0;border-radius:999px;padding:6px 14px;font-weight:600}.sticker-tray__cat--active[data-v-7eada75b]{background:var(--color-primary);border-color:var(--color-primary);color:var(--color-primary-fg)}.sticker-tray__grid[data-v-7eada75b]{gap:var(--space-2);grid-template-columns:repeat(5,1fr);display:grid}.sticker-tray__item[data-v-7eada75b]{padding:var(--space-2) var(--space-1);border-radius:var(--radius-sm);cursor:pointer;transition:background var(--duration-fast);background:0 0;border:none;flex-direction:column;align-items:center;gap:4px;display:flex}.sticker-tray__item[data-v-7eada75b]:active{background:var(--color-surface-2)}.sticker-tray__emoji[data-v-7eada75b]{font-family:Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif;font-size:36px;line-height:1}.sticker-tray__label[data-v-7eada75b]{color:var(--color-text-muted);text-align:center;font-size:10px;font-weight:600;line-height:1.2}.sticker-canvas[data-v-fb52db70]{background:#111;flex-direction:column;flex:1;align-items:center;min-height:0;display:flex;position:relative;overflow:hidden}.sticker-canvas[data-v-fb52db70] .konvajs-content{flex-shrink:0}.sticker-canvas__delete[data-v-fb52db70]{top:var(--space-3);right:var(--space-3);cursor:pointer;color:#fff;z-index:10;background:#c81e1ed9;border:none;border-radius:50%;justify-content:center;align-items:center;width:36px;height:36px;display:flex;position:absolute}.sticker-canvas__bar[data-v-fb52db70]{align-items:center;gap:var(--space-3);height:72px;padding:0 var(--space-4);background:var(--color-surface);border-top:1px solid var(--color-border);display:flex;position:absolute;bottom:0;left:0;right:0}.sticker-canvas__add-btn[data-v-fb52db70]{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);color:var(--color-text);font-size:var(--text-sm);cursor:pointer;white-space:nowrap;background:0 0;font-weight:600;display:flex}.sticker-canvas__next-btn[data-v-fb52db70]{min-width:96px;margin-left:auto}.upload-view[data-v-fca2e263]{z-index:100;background:var(--color-bg);flex-direction:column;display:flex;position:fixed;inset:0}.upload-view__header[data-v-fca2e263]{height:56px;padding:0 var(--space-4);background:var(--color-surface);border-bottom:1px solid var(--color-border);flex-shrink:0;align-items:center;display:flex;position:relative}.upload-view__back[data-v-fca2e263]{cursor:pointer;width:40px;height:40px;color:var(--color-text);background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;margin-left:-8px;display:flex}.upload-view__step-label[data-v-fca2e263]{font-size:var(--text-base);color:var(--color-text);font-weight:700;position:absolute;left:50%;transform:translate(-50%)}.upload-view__skip[data-v-fca2e263]{font-size:var(--text-sm);color:var(--color-text-muted);cursor:pointer;padding:var(--space-2) 0;background:0 0;border:none;margin-left:auto;font-weight:600}.upload-view__stage[data-v-fca2e263]{flex:1;min-height:0}.upload-view__done[data-v-fca2e263]{justify-content:center;align-items:center;gap:var(--space-4);padding:var(--space-6) var(--space-5);text-align:center;flex-direction:column;flex:1;display:flex}.upload-view__done-icon[data-v-fca2e263]{font-family:Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif;font-size:64px;line-height:1}.upload-view__done-title[data-v-fca2e263]{font-size:var(--text-xl);font-weight:700}.upload-view__done-sub[data-v-fca2e263]{font-size:var(--text-sm);color:var(--color-text-muted);max-width:260px;line-height:1.5}.upload-view__done-btn[data-v-fca2e263]{width:100%;max-width:320px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -5,9 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/build/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<script type="module" crossorigin src="/build/assets/index-KHHWwfaX.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-CnSQ-FNj.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BR4D5Ox2.css">
<script type="module" crossorigin src="/build/assets/index-6y_HJqaF.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-DlN2hqev.css">
</head>
<body>
<div id="app"></div>
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Entity\Device;
use App\Entity\SharedImage;
use App\Entity\User;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\SharedImageStatus;
use App\Message\RenderImageMessage;
use App\Repository\SharedImageRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/shared-images')]
#[IsGranted('ROLE_USER')]
class SharedImageApiController extends AbstractController
{
public function __construct(
private readonly SharedImageRepository $sharedImageRepository,
private readonly EntityManagerInterface $em,
private readonly MessageBusInterface $bus,
) {}
#[Route('', name: 'api_shared_images_list', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$status = $request->query->get('status');
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$statusEnum = $status ? SharedImageStatus::tryFrom($status) : null;
$result = $this->sharedImageRepository->findForUser($user, $statusEnum, $page, $limit);
return $this->json([
'items' => array_map($this->serialize(...), $result['items']),
'total' => $result['total'],
'page' => $page,
'limit' => $limit,
'totalPages' => (int) ceil($result['total'] / $limit),
]);
}
#[Route('/pending-count', name: 'api_shared_images_pending_count', methods: ['GET'])]
public function pendingCount(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
return $this->json(['count' => $this->sharedImageRepository->pendingCountForUser($user)]);
}
#[Route('/{id}/approve', name: 'api_shared_images_approve', methods: ['POST'])]
public function approve(int $id, Request $request): JsonResponse
{
$shared = $this->findOwnedShared($id);
if (!$shared) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->getUser();
$deviceIds = json_decode($request->getContent(), true)['deviceIds'] ?? [];
foreach ($deviceIds as $deviceId) {
$device = $this->em->getRepository(Device::class)->findOneBy(['id' => (int) $deviceId, 'user' => $user]);
if (!$device) {
continue;
}
$shared->getSourceImage()->approveForDevice($device);
}
$shared->setStatus(SharedImageStatus::Approved);
$this->em->flush();
// Dispatch renders for any missing rendered assets
$image = $shared->getSourceImage();
foreach (DeviceModel::cases() as $model) {
foreach (Orientation::cases() as $orientation) {
$this->bus->dispatch(new RenderImageMessage($image->getId(), $model->value, $orientation->value));
}
}
return $this->json($this->serialize($shared));
}
#[Route('/{id}/decline', name: 'api_shared_images_decline', methods: ['POST'])]
public function decline(int $id): JsonResponse
{
$shared = $this->findOwnedShared($id);
if (!$shared) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
// Revoke approvals if previously approved
if ($shared->getStatus() === SharedImageStatus::Approved) {
/** @var User $user */
$user = $this->getUser();
$devices = $this->em->getRepository(Device::class)->findBy(['user' => $user]);
foreach ($devices as $device) {
$shared->getSourceImage()->revokeForDevice($device);
}
}
$shared->setStatus(SharedImageStatus::Declined);
$this->em->flush();
return $this->json($this->serialize($shared));
}
private function findOwnedShared(int $id): ?SharedImage
{
return $this->em->getRepository(SharedImage::class)->findOneBy([
'id' => $id,
'recipientUser' => $this->getUser(),
]);
}
private function serialize(SharedImage $s): array
{
return [
'id' => $s->getId(),
'imageId' => $s->getSourceImage()->getId(),
'thumbnailUrl' => '/api/images/' . $s->getSourceImage()->getId() . '/thumbnail',
'sharedBy' => $s->getSharedBy()->getEmail(),
'sharedAt' => $s->getSharedAt()->format(\DateTimeInterface::ATOM),
'status' => $s->getStatus()->value,
];
}
}
+77 -9
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\User;
use App\Enum\Orientation;
use Doctrine\ORM\EntityManagerInterface;
@@ -58,8 +59,21 @@ class DeviceApiController extends AbstractController
$device->setOrientation($orientation);
}
if (isset($body['rotationIntervalHours'])) {
$device->setRotationIntervalHours(max(1, (int) $body['rotationIntervalHours']));
if (isset($body['rotationIntervalMinutes'])) {
$device->setRotationIntervalMinutes(max(1, (int) $body['rotationIntervalMinutes']));
}
if (array_key_exists('wakeHour', $body)) {
$device->setWakeHour($body['wakeHour'] === null ? null : (int) $body['wakeHour']);
}
if (isset($body['timezone'])) {
try {
new \DateTimeZone((string) $body['timezone']);
$device->setTimezone((string) $body['timezone']);
} catch (\Exception) {
return $this->json(['error' => 'Invalid timezone identifier'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
if (isset($body['uniquenessWindow'])) {
@@ -71,16 +85,70 @@ class DeviceApiController extends AbstractController
return $this->json($this->serialize($device));
}
#[Route('/{id}/lock', name: 'api_device_lock', methods: ['PUT'])]
public function lock(int $id, Request $request, EntityManagerInterface $em): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
if (!$device) {
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
}
$body = json_decode($request->getContent(), true) ?? [];
$imageId = $body['imageId'] ?? null;
if (!$imageId) {
return $this->json(['error' => 'imageId required'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$image = $em->getRepository(Image::class)->find($imageId);
if (!$image || $image->getUser() !== $user) {
return $this->json(['error' => 'Image not found'], Response::HTTP_NOT_FOUND);
}
if (!$image->isApprovedForDevice($device)) {
return $this->json(['error' => 'Image is not approved for this device'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$device->setLockedImage($image);
$em->flush();
return $this->json($this->serialize($device));
}
#[Route('/{id}/lock', name: 'api_device_unlock', methods: ['DELETE'])]
public function unlock(int $id, EntityManagerInterface $em): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
if (!$device) {
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
}
$device->setLockedImage(null);
$em->flush();
return $this->json($this->serialize($device));
}
private function serialize(Device $d): array
{
return [
'id' => $d->getId(),
'mac' => $d->getMac(),
'name' => $d->getName(),
'orientation' => $d->getOrientation()->value,
'rotationIntervalHours' => $d->getRotationIntervalHours(),
'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
'id' => $d->getId(),
'mac' => $d->getMac(),
'name' => $d->getName(),
'orientation' => $d->getOrientation()->value,
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
'wakeHour' => $d->getWakeHour(),
'timezone' => $d->getTimezone(),
'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
'lockedImageId' => $d->getLockedImage()?->getId(),
];
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Device;
use App\Entity\RenderedAsset;
use App\Enum\RenderStatus;
use App\Service\RotationService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class DeviceImageController extends AbstractController
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
private readonly RotationService $rotation,
private readonly LoggerInterface $logger,
) {}
private function computeIntervalMs(Device $device): int
{
if ($device->getWakeHour() !== null) {
$tz = new \DateTimeZone($device->getTimezone());
$now = new \DateTimeImmutable('now', $tz);
$next = $now->setTime($device->getWakeHour(), 0, 0);
if ($next->getTimestamp() <= $now->getTimestamp()) {
$next = $next->modify('+1 day');
}
return (int) (($next->getTimestamp() - $now->getTimestamp()) * 1000);
}
return $device->getRotationIntervalMinutes() * 60 * 1000;
}
#[Route('/api/device/{mac}/image', name: 'api_device_image', methods: ['GET'])]
public function image(string $mac, Request $request, EntityManagerInterface $em): Response
{
$device = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
if (!$device) {
$this->logger->warning('device.poll.unknown_mac', ['mac' => $mac]);
return new Response(null, Response::HTTP_NOT_FOUND);
}
$intervalMs = $this->computeIntervalMs($device);
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
$device->markSeen();
// Locked image bypasses rotation entirely.
$image = $device->getLockedImage() ?? $this->rotation->advance($device);
if ($image === null) {
$this->logger->info('device.poll.no_image', [
'device_id' => $device->getId(),
'mac' => $mac,
'interval_ms' => $intervalMs,
]);
$r = new Response(null, Response::HTTP_NO_CONTENT);
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
return $r;
}
// 304: device already has this image — skip the binary transfer and redraw.
if ($image->getId() === $currentImageId) {
$this->logger->info('device.poll.no_change', [
'device_id' => $device->getId(),
'mac' => $mac,
'image_id' => $image->getId(),
'interval_ms' => $intervalMs,
]);
$r = new Response(null, Response::HTTP_NOT_MODIFIED);
$r->headers->set('X-Image-Id', (string) $image->getId());
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
return $r;
}
$asset = $em->getRepository(RenderedAsset::class)->findOneBy([
'image' => $image,
'deviceModel' => $device->getModel(),
'orientation' => $device->getOrientation(),
'status' => RenderStatus::Ready,
]);
if (!$asset?->getFilePath()) {
$this->logger->warning('device.poll.no_asset', [
'device_id' => $device->getId(),
'mac' => $mac,
'image_id' => $image->getId(),
'model' => $device->getModel()->value,
'orientation' => $device->getOrientation()->value,
]);
$r = new Response(null, Response::HTTP_NO_CONTENT);
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
return $r;
}
$binPath = $this->projectDir . '/' . $asset->getFilePath();
if (!file_exists($binPath)) {
$this->logger->error('device.poll.file_missing', [
'device_id' => $device->getId(),
'mac' => $mac,
'image_id' => $image->getId(),
'path' => $binPath,
]);
$r = new Response(null, Response::HTTP_NO_CONTENT);
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
return $r;
}
$this->logger->info('device.poll.served', [
'device_id' => $device->getId(),
'mac' => $mac,
'image_id' => $image->getId(),
'interval_ms' => $intervalMs,
'bytes' => filesize($binPath),
]);
$response = new BinaryFileResponse($binPath);
$response->headers->set('Content-Type', 'application/octet-stream');
$response->headers->set('X-Image-Id', (string) $image->getId());
$response->headers->set('X-Interval-Ms', (string) $intervalMs);
return $response;
}
}
+340
View File
@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Enum\TokenType;
use App\Message\RenderImageMessage;
use App\Service\ShareService;
use App\Service\TokenService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/images')]
#[IsGranted('ROLE_USER')]
class ImageApiController extends AbstractController
{
private const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
private const MAX_BYTES = 30 * 1024 * 1024; // 30 MB
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {}
#[Route('', name: 'api_images_list', methods: ['GET'])]
public function list(EntityManagerInterface $em): JsonResponse
{
$user = $this->getUser();
$images = $em->getRepository(Image::class)->findBy(
['user' => $user, 'deletedAt' => null],
['uploadedAt' => 'DESC'],
);
return $this->json(array_map($this->serialize(...), $images));
}
#[Route('', name: 'api_images_upload', methods: ['POST'])]
public function upload(
Request $request,
EntityManagerInterface $em,
MessageBusInterface $bus,
): JsonResponse {
$file = $request->files->get('file');
if (!$file) {
return $this->json(['error' => 'No file uploaded'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($file->getSize() > self::MAX_BYTES) {
return $this->json(['error' => 'File too large (max 30 MB)'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (!in_array($file->getMimeType(), self::ALLOWED_MIME, true)) {
return $this->json(['error' => 'Unsupported file type'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var \App\Entity\User $user */
$user = $this->getUser();
// Create Image entity first with a placeholder path so we have the ID
$image = (new Image())
->setUser($user)
->setOriginalFilename($file->getClientOriginalName());
$em->persist($image);
$em->flush(); // get ID
// Build storage directory
$storageDir = $this->projectDir . '/var/storage/images/' . $image->getId();
if (!is_dir($storageDir)) {
mkdir($storageDir, 0755, true);
}
// If a separate pre-crop original was sent, save it; otherwise the uploaded file IS the original
$originalFile = $request->files->get('original') ?? $file;
$ext = strtolower($originalFile->guessExtension() ?? 'jpg');
$origRelPath = 'var/storage/images/' . $image->getId() . '/original.' . $ext;
$originalFile->move($storageDir, 'original.' . $ext);
// If a composited (cropped+stickered) version was also sent, save it separately
if ($request->files->get('original')) {
// $file is the composited; move to composited.jpg for the renderer
$file->move($storageDir, 'composited.jpg');
}
$image->setStoragePath($origRelPath);
if ($request->request->has('cropParams')) {
$image->setCropParams($request->request->get('cropParams'));
}
if ($request->request->has('stickerState')) {
$image->setStickerState($request->request->get('stickerState'));
}
// Generate thumbnail from composited if available, otherwise from original
$thumbSrc = file_exists($storageDir . '/composited.jpg')
? $storageDir . '/composited.jpg'
: $storageDir . '/original.' . $ext;
$this->generateThumbnail($thumbSrc, $storageDir . '/thumbnail.jpg');
$em->flush();
// Dispatch rendering for all model × orientation combos
foreach (DeviceModel::cases() as $model) {
foreach (Orientation::cases() as $orientation) {
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel($model)
->setOrientation($orientation);
$em->persist($asset);
$bus->dispatch(new RenderImageMessage($image->getId(), $model->value, $orientation->value));
}
}
$em->flush();
return $this->json($this->serialize($image), Response::HTTP_CREATED);
}
#[Route('/{id}', name: 'api_images_delete', methods: ['DELETE'])]
public function delete(int $id, EntityManagerInterface $em): JsonResponse
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$image->setDeletedAt(new \DateTimeImmutable());
$em->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/{id}/thumbnail', name: 'api_images_thumbnail', methods: ['GET'])]
public function thumbnail(int $id, EntityManagerInterface $em): Response
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$thumbPath = $this->projectDir . '/var/storage/images/' . $id . '/thumbnail.jpg';
if (!file_exists($thumbPath)) {
return $this->json(['error' => 'Thumbnail not ready'], Response::HTTP_NOT_FOUND);
}
return new BinaryFileResponse($thumbPath);
}
#[Route('/{id}/original', name: 'api_images_original', methods: ['GET'])]
public function original(int $id, EntityManagerInterface $em): Response
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$storageDir = $this->projectDir . '/var/storage/images/' . $id;
foreach (['original.jpg', 'original.png', 'original.webp', 'original.gif'] as $candidate) {
$path = $storageDir . '/' . $candidate;
if (file_exists($path)) {
return new BinaryFileResponse($path);
}
}
return $this->json(['error' => 'Original not found'], Response::HTTP_NOT_FOUND);
}
#[Route('/{id}/reprocess', name: 'api_images_reprocess', methods: ['POST'])]
public function reprocess(
int $id,
Request $request,
EntityManagerInterface $em,
MessageBusInterface $bus,
): JsonResponse {
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$file = $request->files->get('file');
if (!$file) {
return $this->json(['error' => 'No file uploaded'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$storageDir = $this->projectDir . '/var/storage/images/' . $id;
// Overwrite composited with the new version
$file->move($storageDir, 'composited.jpg');
// Regenerate thumbnail from new composited
$this->generateThumbnail($storageDir . '/composited.jpg', $storageDir . '/thumbnail.jpg');
// Persist updated crop/sticker metadata if provided
if ($request->request->has('cropParams')) {
$image->setCropParams($request->request->get('cropParams'));
}
if ($request->request->has('stickerState')) {
$image->setStickerState($request->request->get('stickerState'));
}
// Reset all rendered assets so they re-render from the new composited
foreach ($image->getRenderedAssets() as $asset) {
$asset->setStatus(RenderStatus::Pending)->setFilePath(null);
$bus->dispatch(new RenderImageMessage($id, $asset->getDeviceModel()->value, $asset->getOrientation()->value));
}
$em->flush();
return $this->json($this->serialize($image));
}
#[Route('/{id}/share', name: 'api_images_share', methods: ['POST'])]
public function share(int $id, Request $request, EntityManagerInterface $em, ShareService $shareService): JsonResponse
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$body = json_decode($request->getContent(), true) ?? [];
$email = trim((string) ($body['recipientEmail'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->json(['error' => 'Invalid email address'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
/** @var \App\Entity\User $user */
$user = $this->getUser();
$shareService->share($image, $user, $email);
} catch (\InvalidArgumentException $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/{id}/hard-delete-request', name: 'api_images_hard_delete_request', methods: ['POST'])]
public function hardDeleteRequest(int $id, EntityManagerInterface $em, TokenService $tokenService): JsonResponse
{
$image = $this->findOwnedImage($id, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
/** @var \App\Entity\User $user */
$user = $this->getUser();
$ttl = (int) ($_ENV['HARD_DELETE_TOKEN_TTL_DAYS'] ?? 30);
$tokenService->issue(TokenType::HardDeleteConfirm, $image, $user, $user->getEmail(), $ttl);
$em->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/{id}/approve/{deviceId}', name: 'api_images_approve', methods: ['POST'])]
public function approve(int $id, int $deviceId, EntityManagerInterface $em): JsonResponse
{
return $this->toggleApproval($id, $deviceId, $em, true);
}
#[Route('/{id}/approve/{deviceId}', name: 'api_images_revoke', methods: ['DELETE'])]
public function revoke(int $id, int $deviceId, EntityManagerInterface $em): JsonResponse
{
return $this->toggleApproval($id, $deviceId, $em, false);
}
private function toggleApproval(int $imageId, int $deviceId, EntityManagerInterface $em, bool $approve): JsonResponse
{
/** @var \App\Entity\User $user */
$user = $this->getUser();
$image = $this->findOwnedImage($imageId, $em);
if (!$image) {
return $this->json(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$device = $em->getRepository(Device::class)->findOneBy(['id' => $deviceId, 'user' => $user]);
if (!$device) {
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
}
if ($approve) {
$image->approveForDevice($device);
} else {
$image->revokeForDevice($device);
}
$em->flush();
return $this->json($this->serialize($image));
}
private function findOwnedImage(int $id, EntityManagerInterface $em): ?Image
{
$image = $em->getRepository(Image::class)->findOneBy([
'id' => $id,
'user' => $this->getUser(),
'deletedAt' => null,
]);
return $image;
}
private function serialize(Image $image): array
{
$id = $image->getId();
return [
'id' => $id,
'originalFilename' => $image->getOriginalFilename(),
'thumbnailUrl' => '/api/images/' . $id . '/thumbnail',
'originalUrl' => '/api/images/' . $id . '/original',
'uploadedAt' => $image->getUploadedAt()->format(\DateTimeInterface::ATOM),
'approvedDeviceIds' => array_values($image->getApprovedDevices()->map(fn($d) => $d->getId())->toArray()),
'cropParams' => $image->getCropParams() ? json_decode($image->getCropParams(), true) : null,
'stickerState' => $image->getStickerState() ? json_decode($image->getStickerState(), true) : null,
];
}
private function generateThumbnail(string $srcPath, string $destPath): void
{
$imagick = new \Imagick($srcPath);
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
$imagick->setBackgroundColor('white');
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
$imagick->autoOrient();
$imagick->thumbnailImage(800, 600, true);
$imagick->setImageFormat('jpeg');
$imagick->setImageCompressionQuality(80);
$imagick->writeImage($destPath);
$imagick->destroy();
}
}
+2 -2
View File
@@ -125,7 +125,7 @@ class SetupController extends AbstractController
if ($request->isMethod('POST')) {
$name = trim((string) $request->request->get('name', ''));
$orient = $request->request->get('orientation', Orientation::Landscape->value);
$interval = (int) $request->request->get('rotation_interval_hours', 24);
$interval = (int) $request->request->get('rotation_interval_minutes', 1440);
$window = (int) $request->request->get('uniqueness_window', 10);
if (empty($name)) {
@@ -137,7 +137,7 @@ class SetupController extends AbstractController
$device->setName($name);
$device->setOrientation(Orientation::from($orient));
$device->setRotationIntervalHours(max(1, $interval));
$device->setRotationIntervalMinutes(max(1, $interval));
$device->setUniquenessWindow(max(1, $window));
$em->flush();
+5 -4
View File
@@ -38,10 +38,11 @@ class SpaController extends AbstractController
$theme = $user->getTheme() ?? 'warm-craft';
$userData = json_encode([
'id' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'theme' => $theme,
'id' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'theme' => $theme,
'timezone' => $user->getTimezone(),
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
$html = (string) file_get_contents($indexFile);
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Controller\Token;
use App\Entity\Device;
use App\Entity\SharedImage;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\SharedImageStatus;
use App\Enum\TokenType;
use App\Message\RenderImageMessage;
use App\Service\TokenService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class TokenActionController extends AbstractController
{
public function __construct(
private readonly TokenService $tokenService,
private readonly EntityManagerInterface $em,
private readonly MessageBusInterface $bus,
) {}
#[Route('/token/{uuid}/approve', name: 'token_approve_show', methods: ['GET'])]
public function approveShow(string $uuid): Response
{
$token = $this->tokenService->findValid($uuid, TokenType::ShareApprove);
if (!$token) {
return $this->render('token/invalid.html.twig', ['reason' => 'This approval link has expired or already been used.']);
}
$user = $this->getUser();
$devices = $user ? $this->em->getRepository(Device::class)->findBy(['user' => $user]) : [];
return $this->render('token/approve.html.twig', [
'token' => $token,
'devices' => $devices,
'user' => $user,
]);
}
#[Route('/token/{uuid}/approve', name: 'token_approve_submit', methods: ['POST'])]
public function approveSubmit(string $uuid, Request $request): Response
{
$token = $this->tokenService->findValid($uuid, TokenType::ShareApprove);
if (!$token) {
return $this->render('token/invalid.html.twig', ['reason' => 'This approval link has expired or already been used.']);
}
$user = $this->getUser();
if (!$user) {
return $this->redirectToRoute('app_login');
}
$deviceIds = $request->request->all('device_ids');
$image = $token->getImage();
foreach ($deviceIds as $deviceId) {
$device = $this->em->getRepository(Device::class)->findOneBy(['id' => (int) $deviceId, 'user' => $user]);
if (!$device) {
continue;
}
$image->approveForDevice($device);
}
foreach (DeviceModel::cases() as $model) {
foreach (Orientation::cases() as $orientation) {
$this->bus->dispatch(new RenderImageMessage($image->getId(), $model->value, $orientation->value));
}
}
$shared = $this->em->getRepository(SharedImage::class)->findOneBy([
'sourceImage' => $image,
'recipientUser' => $user,
]);
if ($shared) {
$shared->setStatus(SharedImageStatus::Approved);
}
$this->tokenService->consume($uuid, TokenType::ShareApprove);
$this->em->flush();
return $this->render('token/approved.html.twig', ['image' => $image]);
}
#[Route('/token/{uuid}/decline', name: 'token_decline_show', methods: ['GET'])]
public function declineShow(string $uuid, Request $request): Response
{
$token = $this->tokenService->findValid($uuid, TokenType::ShareDecline);
if (!$token) {
return $this->render('token/invalid.html.twig', ['reason' => 'This decline link has expired or already been used.']);
}
return $this->render('token/decline.html.twig', [
'token' => $token,
'approveUuid' => $request->query->get('back'),
]);
}
#[Route('/token/{uuid}/decline', name: 'token_decline_submit', methods: ['POST'])]
public function declineSubmit(string $uuid): Response
{
$token = $this->tokenService->consume($uuid, TokenType::ShareDecline);
$user = $this->getUser();
if ($user) {
$shared = $this->em->getRepository(SharedImage::class)->findOneBy([
'sourceImage' => $token->getImage(),
'recipientUser' => $user,
]);
if ($shared) {
$shared->setStatus(SharedImageStatus::Declined);
$this->em->flush();
}
}
return $this->render('token/declined.html.twig');
}
}
+52
View File
@@ -26,6 +26,34 @@ class UserApiController extends AbstractController
'honey-slate',
];
#[Route('/search', name: 'api_users_search', methods: ['GET'])]
public function search(Request $request, EntityManagerInterface $em): JsonResponse
{
$q = trim((string) $request->query->get('q', ''));
if (strlen($q) < 2) {
return $this->json([]);
}
/** @var User $me */
$me = $this->getUser();
$results = $em->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->where('u.email LIKE :q')
->andWhere('u.id != :me')
->setParameter('q', '%' . $q . '%')
->setParameter('me', $me->getId())
->setMaxResults(10)
->getQuery()
->getResult();
return $this->json(array_map(
static fn(User $u) => ['id' => $u->getId(), 'email' => $u->getEmail()],
$results,
));
}
#[Route('/theme', name: 'api_user_theme', methods: ['PATCH'])]
public function updateTheme(Request $request, EntityManagerInterface $em): JsonResponse
{
@@ -43,4 +71,28 @@ class UserApiController extends AbstractController
return $this->json(['theme' => $theme]);
}
#[Route('/timezone', name: 'api_user_timezone', methods: ['PATCH'])]
public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse
{
$body = json_decode($request->getContent(), true);
$tz = $body['timezone'] ?? null;
if (!is_string($tz)) {
return $this->json(['error' => 'Missing timezone'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
new \DateTimeZone($tz);
} catch (\Exception) {
return $this->json(['error' => 'Invalid timezone identifier'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var User $user */
$user = $this->getUser();
$user->setTimezone($tz);
$em->flush();
return $this->json(['timezone' => $tz]);
}
}

Some files were not shown because too many files have changed in this diff Show More