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:
@@ -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
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@ storage/images/
|
||||
|
||||
# Frontend
|
||||
/frontend/node_modules/
|
||||
/frontend/coverage/
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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 — 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 →<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">✓</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 33–39 (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 — 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">✓</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 34–39) -->
|
||||
<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
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
envelope:
|
||||
sender: '%env(MAILER_SENDER)%'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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}")
|
||||
@@ -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)}")
|
||||
+12007
File diff suppressed because it is too large
Load Diff
Generated
+2289
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<BottomNav />
|
||||
<BottomNav v-if="!route.meta.hideNav" />
|
||||
<BaseToast />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BottomNav from '@/components/BottomNav.vue'
|
||||
import BaseToast from '@/components/BaseToast.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const { applyTheme } = useTheme()
|
||||
|
||||
onMounted(() => {
|
||||
// Sync Vue's theme state with whatever SpaController stamped on <html>
|
||||
const stamped = document.documentElement.dataset.theme
|
||||
if (stamped && auth.user) {
|
||||
auth.user.theme = stamped
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface StickerDef {
|
||||
id: string
|
||||
category: 'seasonal' | 'holidays' | 'fun' | 'family' | 'nature'
|
||||
label: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export const STICKER_CATEGORIES = [
|
||||
{ id: 'seasonal', label: 'Seasonal' },
|
||||
{ id: 'holidays', label: 'Holidays' },
|
||||
{ id: 'fun', label: 'Fun' },
|
||||
{ id: 'family', label: 'Family' },
|
||||
{ id: 'nature', label: 'Nature' },
|
||||
] as const
|
||||
|
||||
export type StickerCategory = typeof STICKER_CATEGORIES[number]['id']
|
||||
|
||||
export const STICKERS: StickerDef[] = [
|
||||
{ id: 'sea-snow', category: 'seasonal', label: 'Snowflake', emoji: '❄️' },
|
||||
{ id: 'sea-sun', category: 'seasonal', label: 'Sun', emoji: '☀️' },
|
||||
{ id: 'sea-leaves', category: 'seasonal', label: 'Autumn', emoji: '🍂' },
|
||||
{ id: 'sea-blossom', category: 'seasonal', label: 'Blossom', emoji: '🌸' },
|
||||
{ id: 'sea-snowman', category: 'seasonal', label: 'Snowman', emoji: '⛄' },
|
||||
{ id: 'hol-tree', category: 'holidays', label: 'Tree', emoji: '🎄' },
|
||||
{ id: 'hol-gift', category: 'holidays', label: 'Gift', emoji: '🎁' },
|
||||
{ id: 'hol-heart', category: 'holidays', label: 'Heart', emoji: '❤️' },
|
||||
{ id: 'hol-party', category: 'holidays', label: 'Party', emoji: '🎉' },
|
||||
{ id: 'hol-cake', category: 'holidays', label: 'Cake', emoji: '🎂' },
|
||||
{ id: 'fun-star', category: 'fun', label: 'Star', emoji: '⭐' },
|
||||
{ id: 'fun-rainbow', category: 'fun', label: 'Rainbow', emoji: '🌈' },
|
||||
{ id: 'fun-balloon', category: 'fun', label: 'Balloon', emoji: '🎈' },
|
||||
{ id: 'fun-sparkle', category: 'fun', label: 'Sparkles', emoji: '✨' },
|
||||
{ id: 'fun-fire', category: 'fun', label: 'Fire', emoji: '🔥' },
|
||||
{ id: 'fam-house', category: 'family', label: 'Home', emoji: '🏠' },
|
||||
{ id: 'fam-paw', category: 'family', label: 'Paw', emoji: '🐾' },
|
||||
{ id: 'fam-camera', category: 'family', label: 'Camera', emoji: '📷' },
|
||||
{ id: 'fam-plane', category: 'family', label: 'Airplane', emoji: '✈️' },
|
||||
{ id: 'fam-music', category: 'family', label: 'Music', emoji: '🎵' },
|
||||
{ id: 'nat-tree', category: 'nature', label: 'Tree', emoji: '🌲' },
|
||||
{ id: 'nat-flower', category: 'nature', label: 'Flower', emoji: '🌺' },
|
||||
{ id: 'nat-bee', category: 'nature', label: 'Bee', emoji: '🐝' },
|
||||
{ id: 'nat-fly', category: 'nature', label: 'Butterfly', emoji: '🦋' },
|
||||
{ id: 'nat-moon', category: 'nature', label: 'Moon', emoji: '🌙' },
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="approve-card">
|
||||
<img :src="item.thumbnailUrl" :alt="`Photo from ${item.sharedBy}`" class="approve-card__thumb" loading="lazy" />
|
||||
|
||||
<div class="approve-card__body">
|
||||
<p class="approve-card__from">From <strong>{{ item.sharedBy }}</strong></p>
|
||||
<p class="approve-card__date">{{ formattedDate }}</p>
|
||||
|
||||
<div class="approve-card__status" v-if="item.status !== 'pending'">
|
||||
<span :class="['approve-card__badge', `approve-card__badge--${item.status}`]">
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="approve-card__actions">
|
||||
<template v-if="item.status === 'pending' || item.status === 'declined'">
|
||||
<BaseButton variant="primary" size="sm" :disabled="busy" @click="showPicker = true">
|
||||
{{ item.status === 'declined' ? 'Add anyway' : 'Add to frame' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
<template v-if="item.status === 'pending' || item.status === 'approved'">
|
||||
<BaseButton variant="ghost" size="sm" :disabled="busy" @click="decline">
|
||||
{{ item.status === 'approved' ? 'Remove' : 'Decline' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DevicePicker
|
||||
v-model="showPicker"
|
||||
:devices="devicesStore.devices"
|
||||
:selected="selectedDeviceIds"
|
||||
:uploading="busy"
|
||||
confirm-label="Add to frames"
|
||||
@update:selected="selectedDeviceIds = $event"
|
||||
@confirm="approve"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SharedImage } from '@/types'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
|
||||
const props = defineProps<{ item: SharedImage }>()
|
||||
const emit = defineEmits<{ (e: 'updated', v: SharedImage): void }>()
|
||||
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
const showPicker = ref(false)
|
||||
const busy = ref(false)
|
||||
const selectedDeviceIds = ref<number[]>([])
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
new Date(props.item.sharedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
)
|
||||
|
||||
async function approve() {
|
||||
showPicker.value = false
|
||||
busy.value = true
|
||||
try {
|
||||
const updated = await imagesStore.approveShared(props.item.id, selectedDeviceIds.value)
|
||||
emit('updated', updated)
|
||||
} finally {
|
||||
busy.value = false
|
||||
selectedDeviceIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
busy.value = true
|
||||
try {
|
||||
const updated = await imagesStore.declineShared(props.item.id)
|
||||
emit('updated', updated)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.approve-card {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
&__thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__from { font-size: var(--text-sm); }
|
||||
&__date { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&--approved { background: #d4edda; color: #1a7f4b; }
|
||||
&--declined { background: #fde8e8; color: #d93025; }
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,12 @@
|
||||
:aria-label="tab.label"
|
||||
:aria-current="isActive(tab.to) ? 'page' : undefined"
|
||||
>
|
||||
<span class="bottom-nav__icon" aria-hidden="true" v-html="tab.icon" />
|
||||
<span class="bottom-nav__icon-wrap" aria-hidden="true">
|
||||
<span class="bottom-nav__icon" v-html="tab.icon" />
|
||||
<span v-if="tab.name === 'shared' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
|
||||
{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="bottom-nav__label">{{ tab.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
@@ -16,8 +21,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const route = useRoute()
|
||||
const route = useRoute()
|
||||
const imagesStore = useImagesStore()
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -35,7 +42,7 @@ const tabs = [
|
||||
{
|
||||
name: 'shared',
|
||||
label: 'Shared',
|
||||
to: '/shared',
|
||||
to: '/library?tab=shared',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
|
||||
},
|
||||
{
|
||||
@@ -46,7 +53,8 @@ const tabs = [
|
||||
},
|
||||
]
|
||||
|
||||
function isActive(path: string) {
|
||||
function isActive(to: string) {
|
||||
const path = to.split('?')[0]
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
@@ -85,6 +93,15 @@ function isActive(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -93,6 +110,24 @@ function isActive(path: string) {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="crop-editor" ref="containerRef">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="crop-editor__canvas"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
/>
|
||||
<div class="crop-editor__label" v-if="deviceName">{{ deviceName }}</div>
|
||||
<div class="crop-editor__actions">
|
||||
<BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop">
|
||||
Use this crop
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { CropParams } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
orientation: 'landscape' | 'portrait'
|
||||
deviceName?: string
|
||||
initialParams?: CropParams | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'crop', result: { blob: Blob; params: CropParams }): void
|
||||
}>()
|
||||
|
||||
// Dimensions for each orientation
|
||||
const OUTPUT_W = props.orientation === 'landscape' ? 1600 : 960
|
||||
const OUTPUT_H = props.orientation === 'landscape' ? 960 : 1600
|
||||
const ASPECT = OUTPUT_W / OUTPUT_H
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let img: HTMLImageElement | null = null
|
||||
let rafId = 0
|
||||
|
||||
// State: pan (canvas px from centered) + zoom multiplier
|
||||
const panX = ref(0)
|
||||
const panY = ref(0)
|
||||
const zoom = ref(1)
|
||||
|
||||
// Crop rect on canvas (set when canvas is sized)
|
||||
let cropRect = { x: 0, y: 0, w: 0, h: 0 }
|
||||
let minScale = 1 // natural px → canvas px at zoom=1 (cover)
|
||||
|
||||
function sizeCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
const container = containerRef.value
|
||||
if (!canvas || !container) return
|
||||
|
||||
const available = container.getBoundingClientRect()
|
||||
// Leave space for bottom button bar
|
||||
const availH = available.height - 80
|
||||
const availW = available.width
|
||||
|
||||
canvas.width = availW
|
||||
canvas.height = availH
|
||||
|
||||
ctx = canvas.getContext('2d')
|
||||
|
||||
// Compute crop rect (inset 24px each side for comfort)
|
||||
const pad = 24
|
||||
const maxW = availW - pad * 2
|
||||
const maxH = availH - pad * 2
|
||||
|
||||
let cropW: number, cropH: number
|
||||
if (maxW / maxH > ASPECT) {
|
||||
cropH = maxH
|
||||
cropW = cropH * ASPECT
|
||||
} else {
|
||||
cropW = maxW
|
||||
cropH = cropW / ASPECT
|
||||
}
|
||||
|
||||
cropRect = {
|
||||
x: (availW - cropW) / 2,
|
||||
y: (availH - cropH) / 2,
|
||||
w: cropW,
|
||||
h: cropH,
|
||||
}
|
||||
|
||||
if (img) {
|
||||
resetView()
|
||||
}
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
if (!img) return
|
||||
minScale = Math.max(cropRect.w / img.naturalWidth, cropRect.h / img.naturalHeight)
|
||||
if (props.initialParams) {
|
||||
restoreView(props.initialParams)
|
||||
} else {
|
||||
zoom.value = 1
|
||||
panX.value = 0
|
||||
panY.value = 0
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
function restoreView(p: CropParams) {
|
||||
if (!img) return
|
||||
// actualScale such that natW fills the crop frame width
|
||||
const actualScale = cropRect.w / p.natW
|
||||
zoom.value = actualScale / minScale
|
||||
// pan: offset from centered position so crop center = frame center
|
||||
panX.value = actualScale * (img.naturalWidth / 2 - p.natX - p.natW / 2)
|
||||
panY.value = actualScale * (img.naturalHeight / 2 - p.natY - p.natH / 2)
|
||||
const [cx, cy] = clampPan(panX.value, panY.value)
|
||||
panX.value = cx
|
||||
panY.value = cy
|
||||
draw()
|
||||
}
|
||||
|
||||
function clampPan(px: number, py: number): [number, number] {
|
||||
if (!img) return [px, py]
|
||||
const actualScale = minScale * zoom.value
|
||||
const imgW = img.naturalWidth * actualScale
|
||||
const imgH = img.naturalHeight * actualScale
|
||||
const maxPx = (imgW - cropRect.w) / 2
|
||||
const maxPy = (imgH - cropRect.h) / 2
|
||||
return [
|
||||
Math.max(-maxPx, Math.min(maxPx, px)),
|
||||
Math.max(-maxPy, Math.min(maxPy, py)),
|
||||
]
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!ctx || !img || !canvasRef.value) return
|
||||
const { width, height } = canvasRef.value
|
||||
const actualScale = minScale * zoom.value
|
||||
const imgW = img.naturalWidth * actualScale
|
||||
const imgH = img.naturalHeight * actualScale
|
||||
const cx = cropRect.x + cropRect.w / 2 + panX.value
|
||||
const cy = cropRect.y + cropRect.h / 2 + panY.value
|
||||
const imgLeft = cx - imgW / 2
|
||||
const imgTop = cy - imgH / 2
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
|
||||
|
||||
// Dark overlay outside crop
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
|
||||
ctx.restore()
|
||||
|
||||
// Crop border + corner marks
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
|
||||
|
||||
const cLen = 20
|
||||
ctx.lineWidth = 3
|
||||
;[
|
||||
[cropRect.x, cropRect.y, cLen, 0, 0, cLen],
|
||||
[cropRect.x + cropRect.w, cropRect.y, -cLen, 0, 0, cLen],
|
||||
[cropRect.x, cropRect.y + cropRect.h, cLen, 0, 0, -cLen],
|
||||
[cropRect.x + cropRect.w, cropRect.y + cropRect.h, -cLen, 0, 0, -cLen],
|
||||
].forEach(([x, y, dx1, dy1, dx2, dy2]) => {
|
||||
ctx!.beginPath()
|
||||
ctx!.moveTo(x + dx1, y + dy1)
|
||||
ctx!.lineTo(x, y)
|
||||
ctx!.lineTo(x + dx2, y + dy2)
|
||||
ctx!.stroke()
|
||||
})
|
||||
}
|
||||
|
||||
// ── Touch / pointer handling ──────────────────────────────────────────────────
|
||||
|
||||
const activePointers = new Map<number, { x: number; y: number }>()
|
||||
let lastPinchDist = 0
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
canvasRef.value?.setPointerCapture(e.pointerId)
|
||||
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
if (activePointers.size === 2) {
|
||||
const pts = [...activePointers.values()]
|
||||
lastPinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!activePointers.has(e.pointerId)) return
|
||||
const prev = activePointers.get(e.pointerId)!
|
||||
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
|
||||
if (activePointers.size === 1) {
|
||||
const dx = e.clientX - prev.x
|
||||
const dy = e.clientY - prev.y
|
||||
const [cx, cy] = clampPan(panX.value + dx, panY.value + dy)
|
||||
panX.value = cx
|
||||
panY.value = cy
|
||||
scheduleDraw()
|
||||
return
|
||||
}
|
||||
|
||||
if (activePointers.size === 2) {
|
||||
const pts = [...activePointers.values()]
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
|
||||
if (lastPinchDist > 0) {
|
||||
const ratio = dist / lastPinchDist
|
||||
const newZoom = Math.max(1, zoom.value * ratio)
|
||||
zoom.value = newZoom
|
||||
const [cx, cy] = clampPan(panX.value, panY.value)
|
||||
panX.value = cx
|
||||
panY.value = cy
|
||||
scheduleDraw()
|
||||
}
|
||||
lastPinchDist = dist
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
activePointers.delete(e.pointerId)
|
||||
lastPinchDist = 0
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function useCrop() {
|
||||
if (!img) return
|
||||
const actualScale = minScale * zoom.value
|
||||
const cx = cropRect.x + cropRect.w / 2 + panX.value
|
||||
const cy = cropRect.y + cropRect.h / 2 + panY.value
|
||||
const imgLeft = cx - img.naturalWidth * actualScale / 2
|
||||
const imgTop = cy - img.naturalHeight * actualScale / 2
|
||||
|
||||
const natCropX = (cropRect.x - imgLeft) / actualScale
|
||||
const natCropY = (cropRect.y - imgTop) / actualScale
|
||||
const natCropW = cropRect.w / actualScale
|
||||
const natCropH = cropRect.h / actualScale
|
||||
|
||||
const out = new OffscreenCanvas(OUTPUT_W, OUTPUT_H)
|
||||
const outCtx = out.getContext('2d')!
|
||||
outCtx.drawImage(img, natCropX, natCropY, natCropW, natCropH, 0, 0, OUTPUT_W, OUTPUT_H)
|
||||
const blob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
|
||||
emit('crop', {
|
||||
blob,
|
||||
params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ro = new ResizeObserver(sizeCanvas)
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) ro.observe(containerRef.value)
|
||||
sizeCanvas()
|
||||
|
||||
img = new Image()
|
||||
img.onload = () => {
|
||||
sizeCanvas()
|
||||
resetView()
|
||||
}
|
||||
img.src = props.src
|
||||
})
|
||||
|
||||
watch(() => props.src, src => {
|
||||
if (!img) return
|
||||
img.onload = () => resetView()
|
||||
img.src = src
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro.disconnect()
|
||||
cancelAnimationFrame(rafId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.crop-editor {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
touch-action: none;
|
||||
|
||||
&__canvas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
&:active { cursor: grabbing; }
|
||||
}
|
||||
|
||||
&__label {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__use-btn {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<BaseBottomSheet :model-value="modelValue" label="Choose frames" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<h2 class="device-picker__title">Add to frames</h2>
|
||||
<p class="device-picker__sub">Choose which frames will show this photo.</p>
|
||||
|
||||
<div class="device-picker__list">
|
||||
<label
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
class="device-picker__row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="device-picker__check"
|
||||
:checked="selected.includes(device.id)"
|
||||
@change="toggle(device.id)"
|
||||
/>
|
||||
<span class="device-picker__name">{{ device.name }}</span>
|
||||
<span class="device-picker__orientation">{{ device.orientation }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="device-picker__confirm"
|
||||
:disabled="selected.length === 0 || uploading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ uploading ? 'Uploading…' : confirmLabel }}
|
||||
</BaseButton>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
devices: Device[]
|
||||
selected: number[]
|
||||
uploading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'update:selected', v: number[]): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function toggle(id: number) {
|
||||
if (props.selected.includes(id)) {
|
||||
emit('update:selected', props.selected.filter(d => d !== id))
|
||||
} else {
|
||||
emit('update:selected', [...props.selected, id])
|
||||
}
|
||||
}
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
const n = props.selected.length
|
||||
return n === 0 ? 'Add to frame' : `Add to ${n} frame${n > 1 ? 's' : ''}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-picker {
|
||||
&__title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__orientation {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__confirm {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<BaseBottomSheet :model-value="modelValue" label="Share photo" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<h2 class="share-sheet__title">Share with someone</h2>
|
||||
<p class="share-sheet__sub">They'll get an email and can add it to their frame.</p>
|
||||
|
||||
<div class="share-sheet__field">
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="share-sheet__input"
|
||||
placeholder="their@email.com"
|
||||
autocomplete="email"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="share-sheet__error">{{ errorMsg }}</p>
|
||||
<p v-if="successMsg" class="share-sheet__success">{{ successMsg }}</p>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="share-sheet__btn"
|
||||
:disabled="sending || !email.trim()"
|
||||
@click="submit"
|
||||
>
|
||||
{{ sending ? 'Sending…' : 'Send invite' }}
|
||||
</BaseButton>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
imageId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const imagesStore = useImagesStore()
|
||||
const email = ref('')
|
||||
const sending = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
|
||||
async function submit() {
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
if (!email.value.trim()) return
|
||||
sending.value = true
|
||||
try {
|
||||
await imagesStore.shareImage(props.imageId, email.value.trim())
|
||||
successMsg.value = `Invite sent to ${email.value.trim()}`
|
||||
email.value = ''
|
||||
} catch (e) {
|
||||
errorMsg.value = e instanceof Error ? e.message : 'Failed to send'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-sheet {
|
||||
&__title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-1); }
|
||||
&__sub { font-size: var(--text-sm); color: var(--color-text-muted); margin-bottom: var(--space-4); }
|
||||
&__field { margin-bottom: var(--space-3); }
|
||||
&__input {
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
&:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||
}
|
||||
&__error { font-size: var(--text-sm); color: var(--color-danger, #d93025); margin-bottom: var(--space-3); }
|
||||
&__success { font-size: var(--text-sm); color: var(--color-success, #1a7f4b); margin-bottom: var(--space-3); }
|
||||
&__btn { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="sticker-canvas" ref="containerRef">
|
||||
<v-stage
|
||||
ref="stageRef"
|
||||
:config="stageConfig"
|
||||
@click="onStageClick"
|
||||
@tap="onStageClick"
|
||||
>
|
||||
<v-layer>
|
||||
<v-image :config="imageConfig" />
|
||||
</v-layer>
|
||||
<v-layer ref="stickerLayerRef">
|
||||
<v-text
|
||||
v-for="s in stickers"
|
||||
:key="s.id"
|
||||
:config="stickerConfig(s)"
|
||||
@click="selectSticker(s.id, $event)"
|
||||
@tap="selectSticker(s.id, $event)"
|
||||
@dragend="onDragEnd(s.id, $event)"
|
||||
@transformend="onTransformEnd(s.id, $event)"
|
||||
/>
|
||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||
</v-layer>
|
||||
</v-stage>
|
||||
|
||||
<!-- Delete selected sticker button -->
|
||||
<button
|
||||
v-if="selectedId"
|
||||
class="sticker-canvas__delete"
|
||||
type="button"
|
||||
aria-label="Remove sticker"
|
||||
@click="removeSelected"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Add sticker / Next -->
|
||||
<div class="sticker-canvas__bar">
|
||||
<button class="sticker-canvas__add-btn" type="button" @click="trayOpen = true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
Add sticker
|
||||
</button>
|
||||
<BaseButton variant="primary" class="sticker-canvas__next-btn" @click="done">Next</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Sticker tray -->
|
||||
<StickerTray v-model="trayOpen" @pick="addStickerFromTray" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import Konva from 'konva'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import StickerTray from '@/components/StickerTray.vue'
|
||||
import type { StickerLayer } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
croppedUrl: string
|
||||
orientation: 'landscape' | 'portrait'
|
||||
stickers: StickerLayer[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'add-sticker', s: StickerLayer): void
|
||||
(e: 'update-sticker', id: string, patch: Partial<StickerLayer>): void
|
||||
(e: 'remove-sticker', id: string): void
|
||||
(e: 'done', blob: Blob): void
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const stageRef = ref()
|
||||
const transformerRef = ref()
|
||||
const stickerLayerRef = ref()
|
||||
|
||||
const trayOpen = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
// Stage dimensions fitted to container
|
||||
const stageW = ref(375)
|
||||
const stageH = ref(225)
|
||||
|
||||
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600)
|
||||
|
||||
function sizeStage() {
|
||||
if (!containerRef.value) return
|
||||
const { width, height } = containerRef.value.getBoundingClientRect()
|
||||
const availH = height - 72 // bottom bar
|
||||
if (width / availH > ASPECT) {
|
||||
stageH.value = availH
|
||||
stageW.value = availH * ASPECT
|
||||
} else {
|
||||
stageW.value = width
|
||||
stageH.value = width / ASPECT
|
||||
}
|
||||
loadImage()
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(sizeStage)
|
||||
onMounted(() => {
|
||||
if (containerRef.value) ro.observe(containerRef.value)
|
||||
sizeStage()
|
||||
attachPinchListeners()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
ro.disconnect()
|
||||
detachPinchListeners()
|
||||
})
|
||||
|
||||
// ── Image ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
function loadImage() {
|
||||
const i = new Image()
|
||||
i.onload = () => { bgImage.value = i }
|
||||
i.src = props.croppedUrl
|
||||
}
|
||||
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
|
||||
|
||||
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
|
||||
const imageConfig = computed(() => ({
|
||||
image: bgImage.value,
|
||||
x: 0, y: 0,
|
||||
width: stageW.value,
|
||||
height: stageH.value,
|
||||
}))
|
||||
|
||||
const transformerConfig = {
|
||||
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
||||
rotateEnabled: true,
|
||||
borderStroke: 'rgba(255,255,255,0.8)',
|
||||
anchorFill: '#fff',
|
||||
anchorSize: 18, // larger for touch
|
||||
keepRatio: true,
|
||||
boundBoxFunc: (_: any, newBox: any) => newBox,
|
||||
}
|
||||
|
||||
// ── Stickers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EMOJI_FONT_SIZE = 52
|
||||
|
||||
function stickerConfig(s: StickerLayer) {
|
||||
return {
|
||||
id: s.id,
|
||||
text: stickerEmoji(s.type),
|
||||
fontSize: EMOJI_FONT_SIZE,
|
||||
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
scaleX: s.scale,
|
||||
scaleY: s.scale,
|
||||
rotation: s.rotation,
|
||||
draggable: true,
|
||||
offsetX: EMOJI_FONT_SIZE / 2,
|
||||
offsetY: EMOJI_FONT_SIZE / 2,
|
||||
}
|
||||
}
|
||||
|
||||
import { STICKERS } from '@/assets/stickers/index'
|
||||
function stickerEmoji(type: string): string {
|
||||
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
|
||||
}
|
||||
|
||||
function selectSticker(id: string, e: any) {
|
||||
e.cancelBubble = true
|
||||
selectedId.value = id
|
||||
nextTick(() => {
|
||||
const layer = stickerLayerRef.value?.getNode()
|
||||
const node = layer?.findOne(`#${id}`)
|
||||
const tr = transformerRef.value?.getNode()
|
||||
if (node && tr) tr.nodes([node])
|
||||
})
|
||||
}
|
||||
|
||||
function onStageClick(e: any) {
|
||||
if (e.target === e.target.getStage()) {
|
||||
selectedId.value = null
|
||||
transformerRef.value?.getNode()?.nodes([])
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
if (!selectedId.value) return
|
||||
emit('remove-sticker', selectedId.value)
|
||||
selectedId.value = null
|
||||
transformerRef.value?.getNode()?.nodes([])
|
||||
}
|
||||
|
||||
function onDragEnd(id: string, e: any) {
|
||||
emit('update-sticker', id, { x: e.target.x(), y: e.target.y() })
|
||||
}
|
||||
|
||||
function onTransformEnd(id: string, e: any) {
|
||||
emit('update-sticker', id, {
|
||||
x: e.target.x(),
|
||||
y: e.target.y(),
|
||||
scale: e.target.scaleX(),
|
||||
rotation: e.target.rotation(),
|
||||
})
|
||||
}
|
||||
|
||||
function addStickerFromTray(stickerId: string) {
|
||||
const s: StickerLayer = {
|
||||
id: `${stickerId}-${Date.now()}`,
|
||||
type: stickerId,
|
||||
x: stageW.value / 2,
|
||||
y: stageH.value / 2,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
}
|
||||
emit('add-sticker', s)
|
||||
trayOpen.value = false
|
||||
// Auto-select the new sticker
|
||||
nextTick(() => selectSticker(s.id, { cancelBubble: false }))
|
||||
}
|
||||
|
||||
// ── Pinch-to-resize ───────────────────────────────────────────────────────────
|
||||
|
||||
let pinchStartDist = 0
|
||||
let pinchStartScale = 1
|
||||
|
||||
function touchDist(touches: TouchList) {
|
||||
const dx = touches[0].clientX - touches[1].clientX
|
||||
const dy = touches[0].clientY - touches[1].clientY
|
||||
return Math.hypot(dx, dy)
|
||||
}
|
||||
|
||||
function onPinchStart(e: TouchEvent) {
|
||||
if (e.touches.length !== 2 || !selectedId.value) return
|
||||
pinchStartDist = touchDist(e.touches)
|
||||
const s = props.stickers.find(x => x.id === selectedId.value)
|
||||
pinchStartScale = s?.scale ?? 1
|
||||
}
|
||||
|
||||
function onPinchMove(e: TouchEvent) {
|
||||
if (e.touches.length !== 2 || !selectedId.value || pinchStartDist === 0) return
|
||||
e.preventDefault()
|
||||
const newScale = Math.max(0.2, Math.min(6, pinchStartScale * (touchDist(e.touches) / pinchStartDist)))
|
||||
emit('update-sticker', selectedId.value, { scale: newScale })
|
||||
}
|
||||
|
||||
function onPinchEnd() {
|
||||
pinchStartDist = 0
|
||||
pinchStartScale = 1
|
||||
}
|
||||
|
||||
function attachPinchListeners() {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
el.addEventListener('touchstart', onPinchStart, { passive: true })
|
||||
el.addEventListener('touchmove', onPinchMove, { passive: false })
|
||||
el.addEventListener('touchend', onPinchEnd, { passive: true })
|
||||
}
|
||||
|
||||
function detachPinchListeners() {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
el.removeEventListener('touchstart', onPinchStart)
|
||||
el.removeEventListener('touchmove', onPinchMove)
|
||||
el.removeEventListener('touchend', onPinchEnd)
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function done() {
|
||||
// Deselect to hide transformer handles before capture
|
||||
selectedId.value = null
|
||||
transformerRef.value?.getNode()?.nodes([])
|
||||
await nextTick()
|
||||
|
||||
const stage: Konva.Stage = stageRef.value?.getNode()
|
||||
if (!stage) return
|
||||
|
||||
const outputW = props.orientation === 'landscape' ? 1600 : 960
|
||||
const pixelRatio = outputW / stageW.value
|
||||
|
||||
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
|
||||
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
|
||||
if (blob) emit('done', blob)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sticker-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.konvajs-content) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__delete {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(200, 30, 30, 0.85);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__next-btn {
|
||||
margin-left: auto;
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<div class="sticker-tray">
|
||||
<div class="sticker-tray__cats" role="tablist">
|
||||
<button
|
||||
v-for="cat in STICKER_CATEGORIES"
|
||||
:key="cat.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
|
||||
@click="activeCategory = cat.id"
|
||||
>{{ cat.label }}</button>
|
||||
</div>
|
||||
<div class="sticker-tray__grid" role="tabpanel">
|
||||
<button
|
||||
v-for="s in visibleStickers"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="sticker-tray__item"
|
||||
:aria-label="s.label"
|
||||
@click="$emit('pick', s.id)"
|
||||
>
|
||||
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
|
||||
<span class="sticker-tray__label">{{ s.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
|
||||
import type { StickerCategory } from '@/assets/stickers/index'
|
||||
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'pick', stickerId: string): void
|
||||
}>()
|
||||
|
||||
const activeCategory = ref<StickerCategory>('seasonal')
|
||||
|
||||
const visibleStickers = computed(() =>
|
||||
STICKERS.filter(s => s.category === activeCategory.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sticker-tray {
|
||||
&__cats {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
&__cat {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast);
|
||||
|
||||
&:active { background: var(--color-surface-2); }
|
||||
}
|
||||
|
||||
&__emoji {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import VueKonva from 'vue-konva'
|
||||
import '@/styles/global.scss'
|
||||
import App from './App.vue'
|
||||
import router from '@/router'
|
||||
@@ -7,4 +8,5 @@ import router from '@/router'
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(VueKonva)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -17,10 +17,10 @@ const router = createRouter({
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/shared',
|
||||
name: 'shared',
|
||||
component: () => import('@/views/SharedView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
path: '/upload',
|
||||
name: 'upload',
|
||||
component: () => import('@/views/UploadView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
@@ -28,6 +28,11 @@ const router = createRouter({
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
// Redirect old /shared to library shared tab
|
||||
{
|
||||
path: '/shared',
|
||||
redirect: '/library?tab=shared',
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
|
||||
@@ -21,7 +21,7 @@ export const useDevicesStore = defineStore('devices', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalHours' | 'uniquenessWindow'>>) {
|
||||
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeHour' | 'timezone' | 'uniquenessWindow'>>) {
|
||||
const res = await fetch(`/api/devices/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -34,5 +34,27 @@ export const useDevicesStore = defineStore('devices', () => {
|
||||
return updated
|
||||
}
|
||||
|
||||
return { devices, loading, error, fetchDevices, updateDevice }
|
||||
async function lockImage(deviceId: number, imageId: number): Promise<Device> {
|
||||
const res = await fetch(`/api/devices/${deviceId}/lock`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageId }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to lock image')
|
||||
const updated: Device = await res.json()
|
||||
const idx = devices.value.findIndex(d => d.id === deviceId)
|
||||
if (idx !== -1) devices.value[idx] = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
async function unlockImage(deviceId: number): Promise<Device> {
|
||||
const res = await fetch(`/api/devices/${deviceId}/lock`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Failed to unlock')
|
||||
const updated: Device = await res.json()
|
||||
const idx = devices.value.findIndex(d => d.id === deviceId)
|
||||
if (idx !== -1) devices.value[idx] = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
return { devices, loading, error, fetchDevices, updateDevice, lockImage, unlockImage }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Image, CropParams, StickerLayer, SharedImage, SharedImagePage } from '@/types'
|
||||
|
||||
interface UploadExtras {
|
||||
original?: File
|
||||
cropParams?: CropParams
|
||||
stickerState?: StickerLayer[]
|
||||
}
|
||||
|
||||
export const useImagesStore = defineStore('images', () => {
|
||||
const images = ref<Image[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const pendingCount = ref(0)
|
||||
|
||||
async function fetchImages() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch('/api/images')
|
||||
if (!res.ok) throw new Error('Failed to load images')
|
||||
images.value = await res.json()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage(file: File, extras?: UploadExtras): Promise<Image> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (extras?.original) form.append('original', extras.original)
|
||||
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
|
||||
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
|
||||
|
||||
const res = await fetch('/api/images', { method: 'POST', body: form })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error ?? 'Upload failed')
|
||||
}
|
||||
const image: Image = await res.json()
|
||||
images.value.unshift(image)
|
||||
return image
|
||||
}
|
||||
|
||||
async function reprocessImage(imageId: number, composited: File, extras?: { cropParams?: CropParams; stickerState?: StickerLayer[] }): Promise<Image> {
|
||||
const form = new FormData()
|
||||
form.append('file', composited)
|
||||
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
|
||||
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
|
||||
|
||||
const res = await fetch(`/api/images/${imageId}/reprocess`, { method: 'POST', body: form })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error ?? 'Reprocess failed')
|
||||
}
|
||||
const updated: Image = await res.json()
|
||||
const idx = images.value.findIndex(i => i.id === imageId)
|
||||
if (idx !== -1) images.value[idx] = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
async function deleteImage(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/images/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
images.value = images.value.filter(img => img.id !== id)
|
||||
}
|
||||
|
||||
async function setApproval(imageId: number, deviceId: number, approved: boolean): Promise<void> {
|
||||
const method = approved ? 'POST' : 'DELETE'
|
||||
const res = await fetch(`/api/images/${imageId}/approve/${deviceId}`, { method })
|
||||
if (!res.ok) throw new Error('Failed to update approval')
|
||||
const updated: Image = await res.json()
|
||||
const idx = images.value.findIndex(i => i.id === imageId)
|
||||
if (idx !== -1) images.value[idx] = updated
|
||||
}
|
||||
|
||||
async function fetchSharedImages(status?: string, page = 1, limit = 20): Promise<SharedImagePage> {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) })
|
||||
if (status) params.set('status', status)
|
||||
const res = await fetch(`/api/shared-images?${params}`)
|
||||
if (!res.ok) throw new Error('Failed to load shared images')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function fetchPendingCount(): Promise<void> {
|
||||
const res = await fetch('/api/shared-images/pending-count')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
pendingCount.value = data.count
|
||||
}
|
||||
}
|
||||
|
||||
async function approveShared(sharedId: number, deviceIds: number[]): Promise<SharedImage> {
|
||||
const res = await fetch(`/api/shared-images/${sharedId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceIds }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to approve')
|
||||
if (pendingCount.value > 0) pendingCount.value--
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function declineShared(sharedId: number): Promise<SharedImage> {
|
||||
const res = await fetch(`/api/shared-images/${sharedId}/decline`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Failed to decline')
|
||||
if (pendingCount.value > 0) pendingCount.value--
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function shareImage(imageId: number, recipientEmail: string): Promise<void> {
|
||||
const res = await fetch(`/api/images/${imageId}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipientEmail }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error ?? 'Failed to share')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
images, loading, error, pendingCount,
|
||||
fetchImages, uploadImage, reprocessImage, deleteImage, setApproval,
|
||||
fetchSharedImages, fetchPendingCount, approveShared, declineShared, shareImage,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { StickerLayer, CropParams } from '@/types'
|
||||
|
||||
export const useUploadStore = defineStore('upload', () => {
|
||||
const originalFile = ref<File | null>(null)
|
||||
const originalUrl = ref<string | null>(null)
|
||||
const croppedBlob = ref<Blob | null>(null)
|
||||
const croppedUrl = ref<string | null>(null)
|
||||
const cropParams = ref<CropParams | null>(null)
|
||||
const stickers = ref<StickerLayer[]>([])
|
||||
const contextDeviceId = ref<number | null>(null)
|
||||
const selectedDeviceIds = ref<number[]>([])
|
||||
const editingImageId = ref<number | null>(null)
|
||||
|
||||
function init(file: File, deviceId?: number) {
|
||||
cleanup()
|
||||
originalFile.value = file
|
||||
originalUrl.value = URL.createObjectURL(file)
|
||||
contextDeviceId.value = deviceId ?? null
|
||||
selectedDeviceIds.value = deviceId ? [deviceId] : []
|
||||
}
|
||||
|
||||
async function initEdit(image: import('@/types').Image, deviceId?: number) {
|
||||
cleanup()
|
||||
const res = await fetch(image.originalUrl)
|
||||
const blob = await res.blob()
|
||||
originalFile.value = new File([blob], image.originalFilename, { type: blob.type })
|
||||
originalUrl.value = URL.createObjectURL(blob)
|
||||
editingImageId.value = image.id
|
||||
cropParams.value = image.cropParams ?? null
|
||||
stickers.value = image.stickerState ? [...image.stickerState] : []
|
||||
selectedDeviceIds.value = image.approvedDeviceIds
|
||||
contextDeviceId.value = deviceId ?? null
|
||||
}
|
||||
|
||||
function setCrop(blob: Blob, params: CropParams) {
|
||||
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
|
||||
croppedBlob.value = blob
|
||||
croppedUrl.value = URL.createObjectURL(blob)
|
||||
cropParams.value = params
|
||||
}
|
||||
|
||||
function addSticker(s: StickerLayer) {
|
||||
stickers.value = [...stickers.value, s]
|
||||
}
|
||||
|
||||
function updateSticker(id: string, patch: Partial<StickerLayer>) {
|
||||
stickers.value = stickers.value.map(s => s.id === id ? { ...s, ...patch } : s)
|
||||
}
|
||||
|
||||
function removeSticker(id: string) {
|
||||
stickers.value = stickers.value.filter(s => s.id !== id)
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (originalUrl.value) URL.revokeObjectURL(originalUrl.value)
|
||||
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
|
||||
originalFile.value = null
|
||||
originalUrl.value = null
|
||||
croppedBlob.value = null
|
||||
croppedUrl.value = null
|
||||
cropParams.value = null
|
||||
stickers.value = []
|
||||
contextDeviceId.value = null
|
||||
selectedDeviceIds.value = []
|
||||
editingImageId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
originalFile, originalUrl,
|
||||
croppedBlob, croppedUrl, cropParams,
|
||||
stickers, contextDeviceId, selectedDeviceIds, editingImageId,
|
||||
init, initEdit, setCrop, addSticker, updateSticker, removeSticker, cleanup,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
describe('BaseButton', () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
slots: { default: 'Click me' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Click me')
|
||||
})
|
||||
|
||||
it('renders as a <button> by default', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
slots: { default: 'OK' },
|
||||
})
|
||||
expect(wrapper.element.tagName).toBe('BUTTON')
|
||||
})
|
||||
|
||||
it('applies primary variant class by default', () => {
|
||||
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
|
||||
expect(wrapper.classes()).toContain('btn--primary')
|
||||
})
|
||||
|
||||
it('applies the given variant class', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { variant: 'destructive' },
|
||||
slots: { default: 'Delete' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('btn--destructive')
|
||||
})
|
||||
|
||||
it('shows spinner element when loading is true', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { loading: true },
|
||||
slots: { default: 'Saving...' },
|
||||
})
|
||||
expect(wrapper.find('.btn__spinner').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show spinner when loading is false', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { loading: false },
|
||||
slots: { default: 'Save' },
|
||||
})
|
||||
expect(wrapper.find('.btn__spinner').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies btn--loading class when loading', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { loading: true },
|
||||
slots: { default: 'Wait' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('btn--loading')
|
||||
})
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { disabled: true },
|
||||
slots: { default: 'Blocked' },
|
||||
})
|
||||
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when loading prop is true', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { loading: true },
|
||||
slots: { default: 'Loading' },
|
||||
})
|
||||
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is not disabled when neither disabled nor loading', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { disabled: false, loading: false },
|
||||
slots: { default: 'Go' },
|
||||
})
|
||||
expect((wrapper.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('emits click event when clicked and not disabled', async () => {
|
||||
const wrapper = mount(BaseButton, { slots: { default: 'Go' } })
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('type attribute defaults to button', () => {
|
||||
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
|
||||
expect(wrapper.attributes('type')).toBe('button')
|
||||
})
|
||||
|
||||
it('type attribute can be set to submit', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { type: 'submit' },
|
||||
slots: { default: 'Submit' },
|
||||
})
|
||||
expect(wrapper.attributes('type')).toBe('submit')
|
||||
})
|
||||
|
||||
it('renders as an anchor when tag is a', () => {
|
||||
const wrapper = mount(BaseButton, {
|
||||
props: { tag: 'a' },
|
||||
slots: { default: 'Link' },
|
||||
})
|
||||
expect(wrapper.element.tagName).toBe('A')
|
||||
// <a> should not have a type attribute
|
||||
expect(wrapper.attributes('type')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
// Stub child components DevicePicker wraps
|
||||
vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseBottomSheet',
|
||||
template: '<div class="bottom-sheet-stub"><slot /></div>',
|
||||
props: ['modelValue', 'label'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/BaseButton.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseButton',
|
||||
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['variant', 'disabled'],
|
||||
emits: ['click'],
|
||||
},
|
||||
}))
|
||||
|
||||
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
id: 1,
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeHour: null,
|
||||
timezone: 'America/Chicago',
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
lockedImageId: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DevicePicker', () => {
|
||||
const devices = [
|
||||
makeDevice({ id: 1, name: 'Living Room' }),
|
||||
makeDevice({ id: 2, name: 'Bedroom' }),
|
||||
]
|
||||
|
||||
function mountPicker(selected: number[] = []) {
|
||||
return mount(DevicePicker, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
devices,
|
||||
selected,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DP-01: Selecting a device emits update:selected with the device added
|
||||
it('checking a device emits update:selected with device id added', async () => {
|
||||
const wrapper = mountPicker([])
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
// Click the first checkbox (Living Room, id=1)
|
||||
await checkboxes[0].trigger('change')
|
||||
|
||||
const emitted = wrapper.emitted('update:selected')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0][0]).toEqual([1])
|
||||
})
|
||||
|
||||
// DP-02: Deselecting a device emits update:selected with device id removed
|
||||
it('unchecking a device emits update:selected with device id removed', async () => {
|
||||
// Start with both selected
|
||||
const wrapper = mountPicker([1, 2])
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
// Click the first checkbox (Living Room, id=1) — it's currently checked, so this deselects
|
||||
await checkboxes[0].trigger('change')
|
||||
|
||||
const emitted = wrapper.emitted('update:selected')
|
||||
expect(emitted).toBeTruthy()
|
||||
// Should emit [2] — Living Room removed
|
||||
expect(emitted![0][0]).toEqual([2])
|
||||
})
|
||||
|
||||
// DP-03: Checkboxes reflect the selected prop
|
||||
it('checkboxes are checked for ids in selected prop', async () => {
|
||||
const wrapper = mountPicker([2])
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false) // id=1 not selected
|
||||
expect((checkboxes[1].element as HTMLInputElement).checked).toBe(true) // id=2 selected
|
||||
})
|
||||
|
||||
// DP-04: Confirm button disabled when nothing selected
|
||||
it('confirm button is disabled when selected is empty', async () => {
|
||||
const wrapper = mountPicker([])
|
||||
const btn = wrapper.find('button')
|
||||
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
// DP-05: Confirm button enabled when at least one device selected
|
||||
it('confirm button is enabled when a device is selected', async () => {
|
||||
const wrapper = mountPicker([1])
|
||||
const btn = wrapper.find('button')
|
||||
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
// DP-06: Device names are rendered
|
||||
it('renders all device names', () => {
|
||||
const wrapper = mountPicker([])
|
||||
expect(wrapper.text()).toContain('Living Room')
|
||||
expect(wrapper.text()).toContain('Bedroom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FrameCard from '@/components/FrameCard.vue'
|
||||
|
||||
// Mock vue-konva to avoid canvas issues if transitively imported
|
||||
vi.mock('vue-konva', () => ({}))
|
||||
|
||||
const defaultProps = {
|
||||
deviceId: 1,
|
||||
name: 'Living Room',
|
||||
size: 'large' as const,
|
||||
status: 'ok' as const,
|
||||
orientation: 'landscape' as const,
|
||||
}
|
||||
|
||||
describe('FrameCard', () => {
|
||||
it('renders device name', () => {
|
||||
const wrapper = mount(FrameCard, { props: defaultProps })
|
||||
expect(wrapper.text()).toContain('Living Room')
|
||||
})
|
||||
|
||||
it('does not show status badge when status is ok', () => {
|
||||
const wrapper = mount(FrameCard, { props: defaultProps })
|
||||
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows "Offline" badge when status is offline', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, status: 'offline' },
|
||||
})
|
||||
const badge = wrapper.find('.frame-card__status-badge')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toContain('Offline')
|
||||
})
|
||||
|
||||
it('shows "Sync issue" badge when status is sync-fail', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, status: 'sync-fail' },
|
||||
})
|
||||
const badge = wrapper.find('.frame-card__status-badge')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toContain('Sync issue')
|
||||
})
|
||||
|
||||
it('applies offline modifier class when status is offline', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, status: 'offline' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('frame-card--offline')
|
||||
})
|
||||
|
||||
it('applies sync-fail modifier class when status is sync-fail', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, status: 'sync-fail' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('frame-card--sync-fail')
|
||||
})
|
||||
|
||||
it('shows settings button in large size', () => {
|
||||
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
|
||||
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show settings button in compact size', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, size: 'compact' },
|
||||
})
|
||||
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows img element when thumbnailUrl is provided', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, thumbnailUrl: '/thumb/test.jpg' },
|
||||
})
|
||||
const img = wrapper.find('img.frame-card__img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('/thumb/test.jpg')
|
||||
})
|
||||
|
||||
it('shows empty preview placeholder when no thumbnailUrl', () => {
|
||||
const wrapper = mount(FrameCard, { props: defaultProps })
|
||||
expect(wrapper.find('.frame-card__empty-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('img.frame-card__img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows photo count in compact size', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, size: 'compact', photoCount: 3 },
|
||||
})
|
||||
expect(wrapper.text()).toContain('3 photos')
|
||||
})
|
||||
|
||||
it('uses singular "photo" when photoCount is 1', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, size: 'compact', photoCount: 1 },
|
||||
})
|
||||
expect(wrapper.text()).toContain('1 photo')
|
||||
expect(wrapper.text()).not.toContain('1 photos')
|
||||
})
|
||||
|
||||
it('emits add-photo with deviceId when add button clicked', async () => {
|
||||
const wrapper = mount(FrameCard, { props: defaultProps })
|
||||
await wrapper.find('.frame-card__add-btn').trigger('click')
|
||||
expect(wrapper.emitted('add-photo')).toBeTruthy()
|
||||
expect(wrapper.emitted('add-photo')![0]).toEqual([1])
|
||||
})
|
||||
|
||||
it('emits edit with deviceId when settings button clicked (large)', async () => {
|
||||
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
|
||||
await wrapper.find('.frame-card__settings-btn').trigger('click')
|
||||
expect(wrapper.emitted('edit')).toBeTruthy()
|
||||
expect(wrapper.emitted('edit')![0]).toEqual([1])
|
||||
})
|
||||
|
||||
it('sets landscape aspect ratio style in large mode', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
|
||||
})
|
||||
const preview = wrapper.find('.frame-card__preview')
|
||||
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
|
||||
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
|
||||
})
|
||||
|
||||
it('sets portrait aspect ratio style in large mode', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
|
||||
})
|
||||
const preview = wrapper.find('.frame-card__preview')
|
||||
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ShareSheet from '@/components/ShareSheet.vue'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const BaseBottomSheetStub = {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue', 'label'],
|
||||
emits: ['update:modelValue'],
|
||||
}
|
||||
|
||||
const BaseButtonStub = {
|
||||
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['variant', 'disabled'],
|
||||
emits: ['click'],
|
||||
}
|
||||
|
||||
describe('ShareSheet', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
function mountShareSheet(imageId = 1) {
|
||||
return mount(ShareSheet, {
|
||||
props: { modelValue: true, imageId },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseBottomSheet: BaseBottomSheetStub,
|
||||
BaseButton: BaseButtonStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SS-01: successful share shows success message and clears email field
|
||||
it('shows success message and clears email on successful share', async () => {
|
||||
const store = useImagesStore()
|
||||
vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
|
||||
|
||||
const wrapper = mountShareSheet()
|
||||
const input = wrapper.find('input[type="email"]')
|
||||
|
||||
await input.setValue('friend@example.com')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
|
||||
expect(wrapper.text()).toContain('Invite sent to friend@example.com')
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
// SS-02: failed share shows error message
|
||||
it('shows error message on failed share', async () => {
|
||||
const store = useImagesStore()
|
||||
vi.spyOn(store, 'shareImage').mockRejectedValue(new Error('Server error'))
|
||||
|
||||
const wrapper = mountShareSheet()
|
||||
const input = wrapper.find('input[type="email"]')
|
||||
|
||||
await input.setValue('friend@example.com')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Server error')
|
||||
expect(wrapper.find('.share-sheet__error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
// SS-03: button is disabled when email input is empty
|
||||
it('button is disabled when email is empty', () => {
|
||||
const wrapper = mountShareSheet()
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const makeUser = (overrides: Partial<User> = {}): User => ({
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
roles: ['ROLE_USER'],
|
||||
theme: null,
|
||||
timezone: 'America/Chicago',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('auth store', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
|
||||
})
|
||||
|
||||
it('isAuthenticated is false when __PF_USER__ is not set', async () => {
|
||||
// No __PF_USER__ on window — should be null
|
||||
vi.stubGlobal('__PF_USER__', undefined)
|
||||
const { useAuthStore } = await import('@/stores/auth')
|
||||
const store = useAuthStore()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.user).toBeNull()
|
||||
})
|
||||
|
||||
it('isAuthenticated is true when user is set via setUser', async () => {
|
||||
vi.stubGlobal('__PF_USER__', undefined)
|
||||
const { useAuthStore } = await import('@/stores/auth')
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeUser())
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.user?.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('setUser(null) clears user and isAuthenticated becomes false', async () => {
|
||||
vi.stubGlobal('__PF_USER__', undefined)
|
||||
const { useAuthStore } = await import('@/stores/auth')
|
||||
const store = useAuthStore()
|
||||
|
||||
store.setUser(makeUser())
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
|
||||
store.setUser(null)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.user).toBeNull()
|
||||
})
|
||||
|
||||
it('bootstraps user from window.__PF_USER__ when present', async () => {
|
||||
const user = makeUser({ id: 99, email: 'bootstrapped@example.com' })
|
||||
// Stub window.__PF_USER__ before the store module is evaluated
|
||||
vi.stubGlobal('__PF_USER__', user)
|
||||
|
||||
// Dynamically re-import so the store sees the stub
|
||||
vi.resetModules()
|
||||
const { useAuthStore } = await import('@/stores/auth')
|
||||
setActivePinia(createPinia())
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.user?.id).toBe(99)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
id: 1,
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeHour: null,
|
||||
timezone: 'America/Chicago',
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
lockedImageId: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('devices store', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// unstubAllGlobals is handled in beforeEach so stubs don't leak
|
||||
// even if a test throws before afterEach runs
|
||||
})
|
||||
|
||||
// DS-01
|
||||
it('fetchDevices success populates devices and clears loading', async () => {
|
||||
const mockDevices = [makeDevice()]
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockDevices),
|
||||
}))
|
||||
|
||||
const store = useDevicesStore()
|
||||
await store.fetchDevices()
|
||||
|
||||
expect(store.devices).toEqual(mockDevices)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
// DS-02
|
||||
it('fetchDevices network error sets error state', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')))
|
||||
|
||||
const store = useDevicesStore()
|
||||
await store.fetchDevices()
|
||||
|
||||
expect(store.devices).toEqual([])
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBe('Network failure')
|
||||
})
|
||||
|
||||
// DS-02b — non-ok response
|
||||
it('fetchDevices non-ok response sets error state', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
|
||||
|
||||
const store = useDevicesStore()
|
||||
await store.fetchDevices()
|
||||
|
||||
expect(store.error).toBe('Failed to load devices')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
// DS-03
|
||||
it('updateDevice patches local array entry', async () => {
|
||||
const original = makeDevice({ id: 1, name: 'Old Name' })
|
||||
const updated = makeDevice({ id: 1, name: 'New Name' })
|
||||
|
||||
const store = useDevicesStore()
|
||||
store.devices = [original]
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(updated),
|
||||
}))
|
||||
|
||||
const result = await store.updateDevice(1, { name: 'New Name' })
|
||||
|
||||
expect(result.name).toBe('New Name')
|
||||
expect(store.devices[0].name).toBe('New Name')
|
||||
})
|
||||
|
||||
// DS-03b — updateDevice throws on failure
|
||||
it('updateDevice throws on non-ok response', async () => {
|
||||
const store = useDevicesStore()
|
||||
store.devices = [makeDevice()]
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
|
||||
|
||||
await expect(store.updateDevice(1, { name: 'x' })).rejects.toThrow('Failed to update device')
|
||||
})
|
||||
|
||||
// DS-04
|
||||
it('lockImage sets lockedImageId on local device', async () => {
|
||||
const device = makeDevice({ id: 1, lockedImageId: null })
|
||||
const locked = makeDevice({ id: 1, lockedImageId: 42 })
|
||||
|
||||
const store = useDevicesStore()
|
||||
store.devices = [device]
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(locked),
|
||||
}))
|
||||
|
||||
const result = await store.lockImage(1, 42)
|
||||
|
||||
expect(result.lockedImageId).toBe(42)
|
||||
expect(store.devices[0].lockedImageId).toBe(42)
|
||||
})
|
||||
|
||||
// DS-05
|
||||
it('unlockImage clears lockedImageId', async () => {
|
||||
const device = makeDevice({ id: 1, lockedImageId: 42 })
|
||||
const unlocked = makeDevice({ id: 1, lockedImageId: null })
|
||||
|
||||
const store = useDevicesStore()
|
||||
store.devices = [device]
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(unlocked),
|
||||
}))
|
||||
|
||||
const result = await store.unlockImage(1)
|
||||
|
||||
expect(result.lockedImageId).toBeNull()
|
||||
expect(store.devices[0].lockedImageId).toBeNull()
|
||||
})
|
||||
|
||||
// DS-05b — lockImage throws on failure
|
||||
it('lockImage throws on non-ok response', async () => {
|
||||
const store = useDevicesStore()
|
||||
store.devices = [makeDevice()]
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
|
||||
|
||||
await expect(store.lockImage(1, 42)).rejects.toThrow('Failed to lock image')
|
||||
})
|
||||
|
||||
// DS-05c — unlockImage throws on failure
|
||||
it('unlockImage throws on non-ok response', async () => {
|
||||
const store = useDevicesStore()
|
||||
store.devices = [makeDevice()]
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
|
||||
|
||||
await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import type { Image } from '@/types'
|
||||
|
||||
const makeImage = (overrides: Partial<Image> = {}): Image => ({
|
||||
id: 1,
|
||||
originalFilename: 'photo.jpg',
|
||||
thumbnailUrl: '/thumb/1.jpg',
|
||||
originalUrl: '/orig/1.jpg',
|
||||
uploadedAt: '2026-01-01T00:00:00Z',
|
||||
approvedDeviceIds: [],
|
||||
cropParams: null,
|
||||
stickerState: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('images store', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// unstubAllGlobals is handled in beforeEach so stubs don't leak
|
||||
// even if a test throws before afterEach runs
|
||||
})
|
||||
|
||||
it('fetchImages success populates images and clears loading', async () => {
|
||||
const mockImages = [makeImage(), makeImage({ id: 2 })]
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockImages),
|
||||
}))
|
||||
|
||||
const store = useImagesStore()
|
||||
await store.fetchImages()
|
||||
|
||||
expect(store.images).toEqual(mockImages)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchImages network error sets error state', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Net error')))
|
||||
|
||||
const store = useImagesStore()
|
||||
await store.fetchImages()
|
||||
|
||||
expect(store.images).toEqual([])
|
||||
expect(store.error).toBe('Net error')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('uploadImage prepends to images list on success', async () => {
|
||||
const existing = makeImage({ id: 1 })
|
||||
const newImage = makeImage({ id: 2 })
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(newImage),
|
||||
}))
|
||||
|
||||
const store = useImagesStore()
|
||||
store.images = [existing]
|
||||
|
||||
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
const result = await store.uploadImage(file)
|
||||
|
||||
expect(result).toEqual(newImage)
|
||||
expect(store.images[0]).toEqual(newImage)
|
||||
expect(store.images).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uploadImage throws with error message on failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: 'File too large' }),
|
||||
}))
|
||||
|
||||
const store = useImagesStore()
|
||||
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
|
||||
await expect(store.uploadImage(file)).rejects.toThrow('File too large')
|
||||
})
|
||||
|
||||
it('deleteImage removes image from list', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
|
||||
|
||||
const store = useImagesStore()
|
||||
store.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
|
||||
|
||||
await store.deleteImage(1)
|
||||
|
||||
expect(store.images).toHaveLength(1)
|
||||
expect(store.images[0].id).toBe(2)
|
||||
})
|
||||
|
||||
it('deleteImage throws on failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
|
||||
|
||||
const store = useImagesStore()
|
||||
store.images = [makeImage()]
|
||||
|
||||
await expect(store.deleteImage(1)).rejects.toThrow('Delete failed')
|
||||
})
|
||||
|
||||
it('setApproval updates image in list', async () => {
|
||||
const original = makeImage({ id: 1, approvedDeviceIds: [] })
|
||||
const updated = makeImage({ id: 1, approvedDeviceIds: [42] })
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(updated),
|
||||
}))
|
||||
|
||||
const store = useImagesStore()
|
||||
store.images = [original]
|
||||
|
||||
await store.setApproval(1, 42, true)
|
||||
|
||||
expect(store.images[0].approvedDeviceIds).toEqual([42])
|
||||
})
|
||||
|
||||
it('fetchPendingCount stores the count', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ count: 5 }),
|
||||
}))
|
||||
|
||||
const store = useImagesStore()
|
||||
await store.fetchPendingCount()
|
||||
|
||||
expect(store.pendingCount).toBe(5)
|
||||
})
|
||||
|
||||
it('approveShared decrements pendingCount', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 1, status: 'approved' }),
|
||||
}))
|
||||
|
||||
const store = useImagesStore()
|
||||
store.pendingCount = 3
|
||||
|
||||
await store.approveShared(1, [42])
|
||||
|
||||
expect(store.pendingCount).toBe(2)
|
||||
})
|
||||
|
||||
it('declineShared decrements pendingCount', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 1, status: 'declined' }),
|
||||
}))
|
||||
|
||||
const store = useImagesStore()
|
||||
store.pendingCount = 2
|
||||
|
||||
await store.declineShared(1)
|
||||
|
||||
expect(store.pendingCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
|
||||
describe('toast store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('show adds a message to toasts', () => {
|
||||
const store = useToastStore()
|
||||
store.show('Hello!')
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].message).toBe('Hello!')
|
||||
expect(store.toasts[0].type).toBe('info')
|
||||
})
|
||||
|
||||
it('show with explicit type sets correct type', () => {
|
||||
const store = useToastStore()
|
||||
store.show('Saved', 'success')
|
||||
|
||||
expect(store.toasts[0].type).toBe('success')
|
||||
})
|
||||
|
||||
it('show with error type sets error type', () => {
|
||||
const store = useToastStore()
|
||||
store.show('Something broke', 'error')
|
||||
|
||||
expect(store.toasts[0].type).toBe('error')
|
||||
})
|
||||
|
||||
it('multiple show calls add multiple toasts', () => {
|
||||
const store = useToastStore()
|
||||
store.show('First')
|
||||
store.show('Second')
|
||||
|
||||
expect(store.toasts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('auto-dismisses after 2500ms', () => {
|
||||
const store = useToastStore()
|
||||
store.show('Temporary')
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(2500)
|
||||
|
||||
expect(store.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not dismiss before 2500ms', () => {
|
||||
const store = useToastStore()
|
||||
store.show('Temporary')
|
||||
|
||||
vi.advanceTimersByTime(2499)
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dismiss removes a specific toast by id', () => {
|
||||
const store = useToastStore()
|
||||
store.show('First')
|
||||
store.show('Second')
|
||||
|
||||
const id = store.toasts[0].id
|
||||
store.dismiss(id)
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
expect(store.toasts[0].message).toBe('Second')
|
||||
})
|
||||
|
||||
it('dismiss with unknown id does nothing', () => {
|
||||
const store = useToastStore()
|
||||
store.show('Msg')
|
||||
store.dismiss(99999)
|
||||
|
||||
expect(store.toasts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('each toast gets a unique id', () => {
|
||||
const store = useToastStore()
|
||||
store.show('A')
|
||||
store.show('B')
|
||||
store.show('C')
|
||||
|
||||
const ids = store.toasts.map(t => t.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import type { StickerLayer } from '@/types'
|
||||
|
||||
const makeSticker = (overrides: Partial<StickerLayer> = {}): StickerLayer => ({
|
||||
id: 'sticker-1',
|
||||
type: 'emoji',
|
||||
x: 100,
|
||||
y: 100,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('upload store', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
// happy-dom has URL.createObjectURL as a stub; ensure it returns something predictable
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: vi.fn(() => 'blob:mock-url'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
|
||||
})
|
||||
|
||||
it('init sets originalFile and originalUrl', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
|
||||
store.init(file)
|
||||
|
||||
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
|
||||
expect(store.originalFile).toStrictEqual(file)
|
||||
expect(store.originalUrl).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('init with deviceId sets contextDeviceId and selectedDeviceIds', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
|
||||
store.init(file, 7)
|
||||
|
||||
expect(store.contextDeviceId).toBe(7)
|
||||
expect(store.selectedDeviceIds).toEqual([7])
|
||||
})
|
||||
|
||||
it('init without deviceId leaves selectedDeviceIds empty', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
|
||||
store.init(file)
|
||||
|
||||
expect(store.contextDeviceId).toBeNull()
|
||||
expect(store.selectedDeviceIds).toEqual([])
|
||||
})
|
||||
|
||||
it('setCrop stores croppedBlob and cropParams', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
store.init(file)
|
||||
|
||||
const blob = new Blob(['crop'], { type: 'image/jpeg' })
|
||||
const params = { natX: 0, natY: 0, natW: 200, natH: 200 }
|
||||
|
||||
store.setCrop(blob, params)
|
||||
|
||||
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
|
||||
expect(store.croppedBlob).toStrictEqual(blob)
|
||||
expect(store.croppedUrl).toBe('blob:mock-url')
|
||||
expect(store.cropParams).toEqual(params)
|
||||
})
|
||||
|
||||
it('addSticker appends to stickers', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
store.init(file)
|
||||
|
||||
store.addSticker(makeSticker({ id: 'a' }))
|
||||
store.addSticker(makeSticker({ id: 'b' }))
|
||||
|
||||
expect(store.stickers).toHaveLength(2)
|
||||
expect(store.stickers[0].id).toBe('a')
|
||||
expect(store.stickers[1].id).toBe('b')
|
||||
})
|
||||
|
||||
it('updateSticker patches matching sticker', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
store.init(file)
|
||||
|
||||
store.addSticker(makeSticker({ id: 'a', x: 10 }))
|
||||
store.updateSticker('a', { x: 99 })
|
||||
|
||||
expect(store.stickers[0].x).toBe(99)
|
||||
})
|
||||
|
||||
it('updateSticker leaves non-matching stickers unchanged', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
store.init(file)
|
||||
|
||||
store.addSticker(makeSticker({ id: 'a', x: 10 }))
|
||||
store.addSticker(makeSticker({ id: 'b', x: 20 }))
|
||||
store.updateSticker('a', { x: 99 })
|
||||
|
||||
expect(store.stickers[1].x).toBe(20)
|
||||
})
|
||||
|
||||
it('removeSticker removes by id', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
store.init(file)
|
||||
|
||||
store.addSticker(makeSticker({ id: 'a' }))
|
||||
store.addSticker(makeSticker({ id: 'b' }))
|
||||
store.removeSticker('a')
|
||||
|
||||
expect(store.stickers).toHaveLength(1)
|
||||
expect(store.stickers[0].id).toBe('b')
|
||||
})
|
||||
|
||||
it('cleanup resets all state', () => {
|
||||
const store = useUploadStore()
|
||||
const file = new File(['data'], 'photo.jpg')
|
||||
store.init(file, 5)
|
||||
store.addSticker(makeSticker())
|
||||
|
||||
store.cleanup()
|
||||
|
||||
expect(store.originalFile).toBeNull()
|
||||
expect(store.originalUrl).toBeNull()
|
||||
expect(store.croppedBlob).toBeNull()
|
||||
expect(store.croppedUrl).toBeNull()
|
||||
expect(store.cropParams).toBeNull()
|
||||
expect(store.stickers).toHaveLength(0)
|
||||
expect(store.contextDeviceId).toBeNull()
|
||||
expect(store.selectedDeviceIds).toEqual([])
|
||||
expect(store.editingImageId).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
// Stub heavy child components so tests focus on HomeView logic
|
||||
vi.mock('@/components/FrameCard.vue', () => ({
|
||||
default: {
|
||||
name: 'FrameCard',
|
||||
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
|
||||
props: ['deviceId', 'name', 'size', 'status', 'orientation'],
|
||||
emits: ['add-photo', 'edit'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseBottomSheet',
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue', 'label'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/BaseButton.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseButton',
|
||||
template: '<button><slot /></button>',
|
||||
props: ['variant', 'disabled'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/BaseInput.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseInput',
|
||||
template: '<input />',
|
||||
props: ['modelValue', 'label', 'maxlength'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/OrientationPicker.vue', () => ({
|
||||
default: {
|
||||
name: 'OrientationPicker',
|
||||
template: '<div />',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Stub vue-router so HomeView can call useRouter() without a real router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Stub URL.createObjectURL used by upload store
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: vi.fn(() => 'blob:mock-url'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
})
|
||||
|
||||
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
id: 1,
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeHour: null,
|
||||
timezone: 'America/Chicago',
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
lockedImageId: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('HomeView', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
// Re-stub URL after unstubAllGlobals
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: vi.fn(() => 'blob:mock-url'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
})
|
||||
|
||||
// Stub fetch so onMounted fetchDevices doesn't fail
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
}))
|
||||
})
|
||||
|
||||
function mountView() {
|
||||
return mount(HomeView, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HV-01: N devices renders N FrameCard stubs
|
||||
it('renders one FrameCard per device when devices are present', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [
|
||||
makeDevice({ id: 1, name: 'Frame A' }),
|
||||
makeDevice({ id: 2, name: 'Frame B' }),
|
||||
makeDevice({ id: 3, name: 'Frame C' }),
|
||||
]
|
||||
// Mock fetchDevices so onMounted doesn't overwrite devices
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const cards = wrapper.findAll('.frame-card-stub')
|
||||
expect(cards).toHaveLength(3)
|
||||
})
|
||||
|
||||
// HV-01b: single device still renders one FrameCard (large variant branch)
|
||||
it('renders one FrameCard for a single device', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 1, name: 'Solo Frame' })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const cards = wrapper.findAll('.frame-card-stub')
|
||||
expect(cards).toHaveLength(1)
|
||||
})
|
||||
|
||||
// HV-02: empty state shown when no devices
|
||||
it('shows empty state when devices list is empty', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = []
|
||||
devicesStore.loading = false
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.home-view__empty').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Set up your first frame')
|
||||
})
|
||||
|
||||
// HV-03: loading state shown while fetching
|
||||
it('shows loading indicator when store is loading', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.loading = true
|
||||
// Keep fetchDevices pending so loading stays true
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.home-view__loading').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Loading')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import LibraryView from '@/views/LibraryView.vue'
|
||||
import type { Image, Device } from '@/types'
|
||||
|
||||
// Stub complex child components
|
||||
vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseBottomSheet',
|
||||
template: '<div class="bottom-sheet-stub"><slot /></div>',
|
||||
props: ['modelValue', 'label'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/BaseButton.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseButton',
|
||||
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['variant', 'disabled'],
|
||||
emits: ['click'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/ApproveCard.vue', () => ({
|
||||
default: {
|
||||
name: 'ApproveCard',
|
||||
template: '<div class="approve-card-stub" />',
|
||||
props: ['item'],
|
||||
emits: ['updated'],
|
||||
},
|
||||
}))
|
||||
vi.mock('@/components/ShareSheet.vue', () => ({
|
||||
default: {
|
||||
name: 'ShareSheet',
|
||||
template: '<div class="share-sheet-stub" />',
|
||||
props: ['modelValue', 'imageId'],
|
||||
emits: ['update:modelValue'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Stub vue-router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
// Stub toast store
|
||||
vi.mock('@/stores/toast', () => ({
|
||||
useToastStore: () => ({ show: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Stub upload store
|
||||
vi.mock('@/stores/upload', () => ({
|
||||
useUploadStore: () => ({ initEdit: vi.fn() }),
|
||||
}))
|
||||
|
||||
const makeImage = (overrides: Partial<Image> = {}): Image => ({
|
||||
id: 1,
|
||||
originalFilename: 'photo.jpg',
|
||||
thumbnailUrl: '/thumb/1.jpg',
|
||||
originalUrl: '/orig/1.jpg',
|
||||
uploadedAt: '2026-01-01T00:00:00Z',
|
||||
approvedDeviceIds: [],
|
||||
cropParams: null,
|
||||
stickerState: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
id: 1,
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeHour: null,
|
||||
timezone: 'America/Chicago',
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
lockedImageId: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('LibraryView', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
// Default fetch stub — returns empty lists so onMounted doesn't error
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
}))
|
||||
})
|
||||
|
||||
function mountView() {
|
||||
return mount(LibraryView, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// LV-01: Default tab shows "All" tab active
|
||||
it('renders the All tab as active by default', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// The "All" tab button should have aria-selected=true
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
const allTab = tabs.find(t => t.text() === 'All')
|
||||
expect(allTab?.attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
// LV-01b: Images from imagesStore are rendered in the grid
|
||||
it('renders image grid when images exist', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 }), makeImage({ id: 3 })]
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const grid = wrapper.find('.library__grid')
|
||||
expect(grid.exists()).toBe(true)
|
||||
expect(wrapper.findAll('.library__item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
// LV-02: Switching to Shared tab shows the shared sub-tabs UI
|
||||
it('switching to Shared tab shows shared sub-tabs and triggers a fetch', async () => {
|
||||
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
|
||||
const imagesStore = useImagesStore()
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
// Set up fetch so fetchSharedImages network call resolves
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(sharedPage),
|
||||
}))
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
const sharedTab = tabs.find(t => t.text() === 'Shared')
|
||||
await sharedTab?.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// After clicking Shared, the sub-tabs (Pending/Approved/Declined) should appear
|
||||
expect(wrapper.find('.library__subtabs').exists()).toBe(true)
|
||||
})
|
||||
|
||||
// LV-03: Lock chip shown for device when image is approved for it
|
||||
it('renders lock chip for device when image is approved for that device', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom' })]
|
||||
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Lock chips are rendered only for approved devices
|
||||
const lockChips = wrapper.findAll('.library__lock-chip')
|
||||
expect(lockChips.length).toBeGreaterThan(0)
|
||||
expect(lockChips[0].text()).toContain('Bedroom')
|
||||
})
|
||||
|
||||
// LV-06: Share button click renders the ShareSheet
|
||||
it('clicking share button renders the ShareSheet', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
imagesStore.images = [makeImage({ id: 5 })]
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Find the share action button (aria-label contains "Share")
|
||||
const shareBtn = wrapper.findAll('.library__action-btn')
|
||||
.find(b => b.attributes('aria-label')?.includes('Share'))
|
||||
expect(shareBtn).toBeTruthy()
|
||||
await shareBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// After clicking, the ShareSheet stub should be rendered
|
||||
expect(wrapper.find('.share-sheet-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
// LV-07: Empty state shown when no images (All tab)
|
||||
it('shows empty state when no images exist', async () => {
|
||||
const imagesStore = useImagesStore()
|
||||
imagesStore.images = []
|
||||
imagesStore.loading = false
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.library__empty').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('No photos yet')
|
||||
})
|
||||
|
||||
// LV-07b: Empty state on shared sub-tab (pending)
|
||||
it('shows shared empty state when no shared items exist', async () => {
|
||||
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
|
||||
const imagesStore = useImagesStore()
|
||||
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Switch to Shared tab
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
const sharedTab = tabs.find(t => t.text() === 'Shared')
|
||||
await sharedTab?.trigger('click')
|
||||
// Wait for async loadShared to complete
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ export interface User {
|
||||
email: string
|
||||
roles: string[]
|
||||
theme: string | null
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
@@ -10,18 +11,20 @@ export interface Device {
|
||||
mac: string
|
||||
name: string
|
||||
orientation: 'landscape' | 'portrait'
|
||||
rotationIntervalHours: number
|
||||
rotationIntervalMinutes: number
|
||||
wakeHour: number | null
|
||||
timezone: string
|
||||
uniquenessWindow: number
|
||||
linkedAt: string
|
||||
lastSeenAt: string | null
|
||||
lockedImageId: number | null
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
id: number
|
||||
source: 'uploaded' | 'shared'
|
||||
filename: string
|
||||
thumbnailUrl: string
|
||||
deletedAt: string | null
|
||||
approvedDeviceIds: number[]
|
||||
export interface CropParams {
|
||||
natX: number
|
||||
natY: number
|
||||
natW: number
|
||||
natH: number
|
||||
}
|
||||
|
||||
export interface StickerLayer {
|
||||
@@ -33,6 +36,17 @@ export interface StickerLayer {
|
||||
rotation: number
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
id: number
|
||||
originalFilename: string
|
||||
thumbnailUrl: string
|
||||
originalUrl: string
|
||||
uploadedAt: string
|
||||
approvedDeviceIds: number[]
|
||||
cropParams: CropParams | null
|
||||
stickerState: StickerLayer[] | null
|
||||
}
|
||||
|
||||
export interface RenderedAsset {
|
||||
id: number
|
||||
imageId: number
|
||||
@@ -41,6 +55,23 @@ export interface RenderedAsset {
|
||||
status: 'pending' | 'processing' | 'ready' | 'failed'
|
||||
}
|
||||
|
||||
export interface SharedImage {
|
||||
id: number
|
||||
imageId: number
|
||||
thumbnailUrl: string
|
||||
sharedBy: string
|
||||
sharedAt: string
|
||||
status: 'pending' | 'approved' | 'declined'
|
||||
}
|
||||
|
||||
export interface SharedImagePage {
|
||||
items: SharedImage[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
uuid: string
|
||||
type: 'share_approve' | 'share_decline' | 'hard_delete_confirm'
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
:deviceId="devicesStore.devices[0].id"
|
||||
:name="devicesStore.devices[0].name"
|
||||
size="large"
|
||||
status="ok"
|
||||
:status="deviceStatus(devicesStore.devices[0])"
|
||||
:orientation="devicesStore.devices[0].orientation"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
@@ -42,7 +42,7 @@
|
||||
:deviceId="device.id"
|
||||
:name="device.name"
|
||||
size="compact"
|
||||
status="ok"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
@@ -67,6 +67,24 @@
|
||||
<OrientationPicker v-model="editOrientation" />
|
||||
</div>
|
||||
|
||||
<div class="home-view__sheet-field">
|
||||
<p class="home-view__sheet-label">Update time</p>
|
||||
<div class="home-view__interval-grid">
|
||||
<button
|
||||
v-for="opt in WAKE_TIME_OPTIONS"
|
||||
:key="opt.hour"
|
||||
type="button"
|
||||
:class="['home-view__interval-chip', { 'home-view__interval-chip--on': editWakeHour === opt.hour }]"
|
||||
@click="editWakeHour = opt.hour"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<select class="home-view__tz-select" v-model="editTimezone">
|
||||
<optgroup v-for="group in TIMEZONE_GROUPS" :key="group.label" :label="group.label">
|
||||
<option v-for="tz in group.zones" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="home-view__sheet-save"
|
||||
@@ -80,30 +98,120 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
function deviceStatus(device: Device): 'ok' | 'offline' {
|
||||
if (!device.lastSeenAt) return 'offline'
|
||||
const seenMs = Date.now() - new Date(device.lastSeenAt).getTime()
|
||||
const windowMs = Math.max(device.rotationIntervalMinutes * 2 * 60_000, 30 * 60_000)
|
||||
return seenMs <= windowMs ? 'ok' : 'offline'
|
||||
}
|
||||
import FrameCard from '@/components/FrameCard.vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import BaseInput from '@/components/BaseInput.vue'
|
||||
import OrientationPicker from '@/components/OrientationPicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const devicesStore = useDevicesStore()
|
||||
const uploadStore = useUploadStore()
|
||||
|
||||
onMounted(() => devicesStore.fetchDevices())
|
||||
onMounted(() => {
|
||||
devicesStore.fetchDevices()
|
||||
})
|
||||
|
||||
function onAddPhoto(deviceId: number) {
|
||||
// Photo upload flow — Epic 3
|
||||
console.log('add-photo', deviceId)
|
||||
// File picker must be triggered in the user-gesture context (the click handler)
|
||||
// before navigating, otherwise browsers block it as a popup.
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
uploadStore.init(file, deviceId)
|
||||
router.push('/upload')
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// ── Settings sheet ────────────────────────────────────────────────────────────
|
||||
|
||||
const WAKE_TIME_OPTIONS = [
|
||||
{ hour: 0, label: '12 AM' },
|
||||
{ hour: 2, label: '2 AM' },
|
||||
{ hour: 4, label: '4 AM' },
|
||||
{ hour: 6, label: '6 AM' },
|
||||
{ hour: 8, label: '8 AM' },
|
||||
{ hour: 10, label: '10 AM' },
|
||||
{ hour: 12, label: '12 PM' },
|
||||
{ hour: 18, label: '6 PM' },
|
||||
{ hour: 20, label: '8 PM' },
|
||||
{ hour: 22, label: '10 PM' },
|
||||
]
|
||||
|
||||
const TIMEZONE_GROUPS = [
|
||||
{ label: 'Americas', zones: [
|
||||
{ value: 'America/New_York', label: 'Eastern — New York, Toronto' },
|
||||
{ value: 'America/Chicago', label: 'Central — Chicago, Mexico City' },
|
||||
{ value: 'America/Denver', label: 'Mountain — Denver, Calgary' },
|
||||
{ value: 'America/Phoenix', label: 'Mountain (no DST) — Phoenix' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific — Los Angeles, Vancouver' },
|
||||
{ value: 'America/Anchorage', label: 'Alaska — Anchorage' },
|
||||
{ value: 'Pacific/Honolulu', label: 'Hawaii — Honolulu' },
|
||||
{ value: 'America/Sao_Paulo', label: 'Brasília — São Paulo' },
|
||||
{ value: 'America/Argentina/Buenos_Aires', label: 'Argentina — Buenos Aires' },
|
||||
{ value: 'America/Bogota', label: 'Colombia — Bogotá' },
|
||||
]},
|
||||
{ label: 'Europe', zones: [
|
||||
{ value: 'Europe/London', label: 'GMT/BST — London, Dublin' },
|
||||
{ value: 'Europe/Lisbon', label: 'WET/WEST — Lisbon' },
|
||||
{ value: 'Europe/Paris', label: 'CET/CEST — Paris, Brussels, Amsterdam' },
|
||||
{ value: 'Europe/Berlin', label: 'CET/CEST — Berlin, Vienna, Zurich' },
|
||||
{ value: 'Europe/Stockholm', label: 'CET/CEST — Stockholm, Oslo, Copenhagen'},
|
||||
{ value: 'Europe/Helsinki', label: 'EET/EEST — Helsinki, Tallinn, Riga' },
|
||||
{ value: 'Europe/Warsaw', label: 'CET/CEST — Warsaw, Prague, Budapest' },
|
||||
{ value: 'Europe/Rome', label: 'CET/CEST — Rome, Madrid' },
|
||||
{ value: 'Europe/Athens', label: 'EET/EEST — Athens, Bucharest' },
|
||||
{ value: 'Europe/Istanbul', label: 'TRT — Istanbul' },
|
||||
{ value: 'Europe/Moscow', label: 'MSK — Moscow' },
|
||||
]},
|
||||
{ label: 'Asia & Pacific', zones: [
|
||||
{ value: 'Asia/Dubai', label: 'GST — Dubai, Abu Dhabi' },
|
||||
{ value: 'Asia/Karachi', label: 'PKT — Karachi, Islamabad' },
|
||||
{ value: 'Asia/Kolkata', label: 'IST — India' },
|
||||
{ value: 'Asia/Dhaka', label: 'BST — Dhaka, Bangladesh' },
|
||||
{ value: 'Asia/Bangkok', label: 'ICT — Bangkok, Jakarta, Hanoi' },
|
||||
{ value: 'Asia/Singapore', label: 'SGT — Singapore, Kuala Lumpur' },
|
||||
{ value: 'Asia/Shanghai', label: 'CST — Beijing, Shanghai, Taipei' },
|
||||
{ value: 'Asia/Seoul', label: 'KST — Seoul' },
|
||||
{ value: 'Asia/Tokyo', label: 'JST — Tokyo' },
|
||||
{ value: 'Australia/Sydney', label: 'AEDT/AEST — Sydney, Melbourne' },
|
||||
{ value: 'Australia/Brisbane',label: 'AEST (no DST) — Brisbane' },
|
||||
{ value: 'Australia/Perth', label: 'AWST — Perth' },
|
||||
{ value: 'Pacific/Auckland', label: 'NZDT/NZST — Auckland' },
|
||||
]},
|
||||
{ label: 'Africa & Middle East', zones: [
|
||||
{ value: 'Africa/Cairo', label: 'EET — Cairo' },
|
||||
{ value: 'Africa/Nairobi', label: 'EAT — Nairobi, East Africa'},
|
||||
{ value: 'Africa/Johannesburg', label: 'SAST — Johannesburg' },
|
||||
{ value: 'Africa/Lagos', label: 'WAT — Lagos, West Africa' },
|
||||
]},
|
||||
{ label: 'UTC', zones: [
|
||||
{ value: 'UTC', label: 'UTC — Coordinated Universal Time' },
|
||||
]},
|
||||
]
|
||||
|
||||
const sheetOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingDevice = ref<Device | null>(null)
|
||||
const editName = ref('')
|
||||
const editOrientation = ref<Device['orientation']>('landscape')
|
||||
const editWakeHour = ref<number>(4)
|
||||
const editTimezone = ref('UTC')
|
||||
|
||||
function onEdit(deviceId: number) {
|
||||
const device = devicesStore.devices.find(d => d.id === deviceId)
|
||||
@@ -111,6 +219,8 @@ function onEdit(deviceId: number) {
|
||||
editingDevice.value = device
|
||||
editName.value = device.name
|
||||
editOrientation.value = device.orientation
|
||||
editWakeHour.value = device.wakeHour ?? 4
|
||||
editTimezone.value = device.timezone ?? 'UTC'
|
||||
sheetOpen.value = true
|
||||
}
|
||||
|
||||
@@ -121,6 +231,8 @@ async function saveSettings() {
|
||||
await devicesStore.updateDevice(editingDevice.value.id, {
|
||||
name: editName.value.trim() || editingDevice.value.name,
|
||||
orientation: editOrientation.value,
|
||||
wakeHour: editWakeHour.value,
|
||||
timezone: editTimezone.value,
|
||||
})
|
||||
sheetOpen.value = false
|
||||
} finally {
|
||||
@@ -208,6 +320,51 @@ async function saveSettings() {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__interval-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__tz-select {
|
||||
width: 100%;
|
||||
margin-top: var(--space-3);
|
||||
min-height: var(--touch-min);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__interval-chip {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
min-height: var(--touch-min);
|
||||
|
||||
&--on {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
}
|
||||
|
||||
&__sheet-save {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
|
||||
@@ -1,9 +1,587 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Library</h1>
|
||||
<main class="library">
|
||||
<!-- Tabs -->
|
||||
<div class="library__tabs" role="tablist">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab.id"
|
||||
:class="['library__tab', { 'library__tab--active': activeTab === tab.id }]"
|
||||
@click="activeTab = tab.id"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="imagesStore.loading" class="library__loading">Loading…</div>
|
||||
|
||||
<!-- All / Mine tab -->
|
||||
<template v-else-if="activeTab !== 'shared'">
|
||||
<div v-if="visibleImages.length === 0" class="library__empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21,15 16,10 5,21"/>
|
||||
</svg>
|
||||
<p class="library__empty-title">No photos yet</p>
|
||||
<p class="library__empty-sub">Tap "+ Add Photo" on the home screen to get started.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="library__grid">
|
||||
<div v-for="image in visibleImages" :key="image.id" class="library__item">
|
||||
<div class="library__thumb">
|
||||
<img
|
||||
:src="image.thumbnailUrl"
|
||||
:alt="image.originalFilename"
|
||||
class="library__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="library__thumb-actions">
|
||||
<button
|
||||
class="library__action-btn"
|
||||
type="button"
|
||||
:aria-label="`Edit ${image.originalFilename}`"
|
||||
:disabled="editingId === image.id"
|
||||
@click="startEdit(image)"
|
||||
>
|
||||
<svg v-if="editingId !== image.id" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="library__action-btn"
|
||||
type="button"
|
||||
:aria-label="`Share ${image.originalFilename}`"
|
||||
@click="openShare(image.id)"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="library__action-btn library__action-btn--danger"
|
||||
type="button"
|
||||
aria-label="Delete photo"
|
||||
@click="confirmDelete(image.id)"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14H6L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/>
|
||||
<path d="M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="devicesStore.devices.length > 0" class="library__approvals">
|
||||
<button
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
:class="['library__approval-chip', { 'library__approval-chip--on': image.approvedDeviceIds.includes(device.id) }]"
|
||||
type="button"
|
||||
:aria-label="`${image.approvedDeviceIds.includes(device.id) ? 'Remove from' : 'Add to'} ${device.name}`"
|
||||
@click="toggleApproval(image.id, device.id, !image.approvedDeviceIds.includes(device.id))"
|
||||
>
|
||||
{{ device.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="devicesStore.devices.length > 0" class="library__locks">
|
||||
<button
|
||||
v-for="device in devicesStore.devices.filter(d => image.approvedDeviceIds.includes(d.id))"
|
||||
:key="device.id"
|
||||
:class="['library__lock-chip', { 'library__lock-chip--on': device.lockedImageId === image.id }]"
|
||||
type="button"
|
||||
:aria-label="`${device.lockedImageId === image.id ? 'Unlock from' : 'Lock to'} ${device.name}`"
|
||||
@click="toggleLock(image.id, device)"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path v-if="device.lockedImageId === image.id" d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<path v-else d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
||||
</svg>
|
||||
{{ device.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shared tab -->
|
||||
<template v-else>
|
||||
<!-- Sub-tabs -->
|
||||
<div class="library__subtabs" role="tablist">
|
||||
<button
|
||||
v-for="st in SHARED_TABS"
|
||||
:key="st.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="sharedTab === st.id"
|
||||
:class="['library__subtab', { 'library__subtab--active': sharedTab === st.id }]"
|
||||
@click="switchSharedTab(st.id)"
|
||||
>{{ st.label }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sharedLoading" class="library__loading">Loading…</div>
|
||||
|
||||
<div v-else-if="sharedItems.length === 0" class="library__shared-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
<p class="library__empty-title">
|
||||
{{ sharedTab === 'pending' ? 'No pending photos' : sharedTab === 'approved' ? 'No approved photos' : 'No declined photos' }}
|
||||
</p>
|
||||
<p class="library__empty-sub">
|
||||
{{ sharedTab === 'pending' ? 'Photos shared with you will appear here.' : 'Photos you\'ve added to a frame will appear here.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="library__shared-list">
|
||||
<ApproveCard
|
||||
v-for="item in sharedItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@updated="onSharedUpdated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="sharedTotalPages > 1" class="library__pagination">
|
||||
<button
|
||||
class="library__page-btn"
|
||||
:disabled="sharedPage <= 1"
|
||||
@click="goSharedPage(sharedPage - 1)"
|
||||
>← Prev</button>
|
||||
<span class="library__page-info">{{ sharedPage }} / {{ sharedTotalPages }}</span>
|
||||
<button
|
||||
class="library__page-btn"
|
||||
:disabled="sharedPage >= sharedTotalPages"
|
||||
@click="goSharedPage(sharedPage + 1)"
|
||||
>Next →</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Share sheet -->
|
||||
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
|
||||
|
||||
<!-- Confirm delete sheet -->
|
||||
<BaseBottomSheet v-model="deleteSheetOpen" label="Delete photo">
|
||||
<h2 class="library__sheet-title">Delete this photo?</h2>
|
||||
<p class="library__sheet-sub">It will be removed from all frames.</p>
|
||||
<div class="library__sheet-actions">
|
||||
<BaseButton variant="secondary" @click="deleteSheetOpen = false">Cancel</BaseButton>
|
||||
<BaseButton variant="destructive" :disabled="deleting" @click="doDelete">
|
||||
{{ deleting ? 'Deleting…' : 'Delete' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseBottomSheet>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import ApproveCard from '@/components/ApproveCard.vue'
|
||||
import ShareSheet from '@/components/ShareSheet.vue'
|
||||
import type { Device, Image, SharedImage } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
const uploadStore = useUploadStore()
|
||||
const toast = useToastStore()
|
||||
const route = useRoute()
|
||||
|
||||
const TABS = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'mine', label: 'Mine' },
|
||||
{ id: 'shared', label: 'Shared' },
|
||||
] as const
|
||||
type Tab = typeof TABS[number]['id']
|
||||
|
||||
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all')
|
||||
|
||||
const SHARED_TABS = [
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'declined', label: 'Declined' },
|
||||
] as const
|
||||
type SharedTab = typeof SHARED_TABS[number]['id']
|
||||
|
||||
const sharedTab = ref<SharedTab>('pending')
|
||||
const sharedItems = ref<SharedImage[]>([])
|
||||
const sharedLoading = ref(false)
|
||||
const sharedPage = ref(1)
|
||||
const sharedTotalPages = ref(1)
|
||||
|
||||
async function loadShared(tab: SharedTab, page = 1) {
|
||||
sharedLoading.value = true
|
||||
try {
|
||||
const result = await imagesStore.fetchSharedImages(tab, page)
|
||||
sharedItems.value = result.items
|
||||
sharedPage.value = result.page
|
||||
sharedTotalPages.value = result.totalPages
|
||||
} finally {
|
||||
sharedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchSharedTab(tab: SharedTab) {
|
||||
sharedTab.value = tab
|
||||
loadShared(tab, 1)
|
||||
}
|
||||
|
||||
function goSharedPage(page: number) {
|
||||
loadShared(sharedTab.value, page)
|
||||
}
|
||||
|
||||
function onSharedUpdated(updated: SharedImage) {
|
||||
const idx = sharedItems.value.findIndex(i => i.id === updated.id)
|
||||
if (idx !== -1) sharedItems.value[idx] = updated
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
imagesStore.fetchImages()
|
||||
devicesStore.fetchDevices()
|
||||
imagesStore.fetchPendingCount()
|
||||
if (activeTab.value === 'shared') loadShared(sharedTab.value)
|
||||
})
|
||||
|
||||
// For now "mine" and "all" show the same list; shared is a placeholder
|
||||
const visibleImages = computed(() => imagesStore.images)
|
||||
|
||||
// ── Share ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const shareSheetOpen = ref(false)
|
||||
const shareImageId = ref<number | null>(null)
|
||||
|
||||
function openShare(id: number) {
|
||||
shareImageId.value = id
|
||||
shareSheetOpen.value = true
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
async function startEdit(image: Image) {
|
||||
if (editingId.value) return
|
||||
editingId.value = image.id
|
||||
try {
|
||||
await uploadStore.initEdit(image)
|
||||
router.push('/upload')
|
||||
} catch {
|
||||
toast.show('Could not load photo for editing', 'error')
|
||||
} finally {
|
||||
editingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lock ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function toggleLock(imageId: number, device: Device) {
|
||||
try {
|
||||
if (device.lockedImageId === imageId) {
|
||||
await devicesStore.unlockImage(device.id)
|
||||
} else {
|
||||
await devicesStore.lockImage(device.id, imageId)
|
||||
}
|
||||
} catch {
|
||||
toast.show('Failed to update lock', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approval toggles ──────────────────────────────────────────────────────────
|
||||
|
||||
async function toggleApproval(imageId: number, deviceId: number, approved: boolean) {
|
||||
try {
|
||||
await imagesStore.setApproval(imageId, deviceId, approved)
|
||||
} catch {
|
||||
toast.show('Failed to update frame approval', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const deleteSheetOpen = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
deletingId.value = id
|
||||
deleteSheetOpen.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deletingId.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await imagesStore.deleteImage(deletingId.value)
|
||||
deleteSheetOpen.value = false
|
||||
toast.show('Photo deleted', 'success')
|
||||
} catch {
|
||||
toast.show('Delete failed', 'error')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
.library {
|
||||
padding-bottom: calc(64px + var(--space-4));
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-bg);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
flex: 1;
|
||||
padding: var(--space-3) 0;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__subtabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
&__subtab {
|
||||
flex: 1;
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__shared-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
&__page-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
&:disabled { opacity: .4; cursor: default; }
|
||||
}
|
||||
|
||||
&__page-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&__empty, &__shared-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6) var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__empty-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__empty-sub {
|
||||
font-size: var(--text-sm);
|
||||
max-width: 280px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 4/3;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__thumb-actions {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: background var(--duration-fast);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
&:hover:not(:disabled) { background: rgba(0, 0, 0, 0.75); }
|
||||
&--danger:hover:not(:disabled) { background: rgba(180, 0, 0, 0.8); }
|
||||
}
|
||||
|
||||
&__approvals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__approval-chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
font-size: var(--text-xs, 11px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--on {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__locks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__lock-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px dashed var(--color-border);
|
||||
font-size: var(--text-xs, 11px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--on {
|
||||
background: var(--color-warning, #f59e0b);
|
||||
border-color: var(--color-warning, #f59e0b);
|
||||
border-style: solid;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__sheet-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__sheet-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
&__sheet-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
> * { flex: 1; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<main class="view">
|
||||
<h1>Shared</h1>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.view { padding: var(--space-4); }
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="upload-view">
|
||||
<!-- Header -->
|
||||
<header class="upload-view__header">
|
||||
<button
|
||||
v-if="step !== 'done'"
|
||||
class="upload-view__back"
|
||||
type="button"
|
||||
:aria-label="step === 'crop' ? 'Cancel' : 'Back'"
|
||||
@click="goBack"
|
||||
>
|
||||
<svg v-if="step === 'crop'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="upload-view__step-label">{{ stepLabel }}</span>
|
||||
<button
|
||||
v-if="step === 'stickers'"
|
||||
class="upload-view__skip"
|
||||
type="button"
|
||||
@click="skipStickers"
|
||||
>Skip</button>
|
||||
</header>
|
||||
|
||||
<!-- Crop step -->
|
||||
<CropEditor
|
||||
v-if="step === 'crop' && uploadStore.originalUrl"
|
||||
:src="uploadStore.originalUrl"
|
||||
:orientation="contextOrientation"
|
||||
:device-name="contextDeviceName"
|
||||
:initial-params="uploadStore.cropParams"
|
||||
class="upload-view__stage"
|
||||
@crop="onCrop"
|
||||
/>
|
||||
|
||||
<!-- Stickers step -->
|
||||
<StickerCanvas
|
||||
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
|
||||
:cropped-url="uploadStore.croppedUrl"
|
||||
:orientation="contextOrientation"
|
||||
:stickers="uploadStore.stickers"
|
||||
class="upload-view__stage"
|
||||
@add-sticker="uploadStore.addSticker"
|
||||
@update-sticker="uploadStore.updateSticker"
|
||||
@remove-sticker="uploadStore.removeSticker"
|
||||
@done="onStickersDone"
|
||||
/>
|
||||
|
||||
<!-- Done -->
|
||||
<div v-else-if="step === 'done'" class="upload-view__done">
|
||||
<div class="upload-view__done-icon" aria-hidden="true">🎉</div>
|
||||
<p class="upload-view__done-title">{{ isEdit ? 'Photo updated!' : 'Photo added!' }}</p>
|
||||
<p class="upload-view__done-sub">It'll appear on your frame at the next update.</p>
|
||||
<BaseButton variant="primary" class="upload-view__done-btn" @click="finish">Done</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Device picker (only on new uploads, not edits) -->
|
||||
<DevicePicker
|
||||
v-if="!isEdit"
|
||||
v-model="devicePickerOpen"
|
||||
:devices="devicesStore.devices"
|
||||
:selected="uploadStore.selectedDeviceIds"
|
||||
:uploading="uploading"
|
||||
@update:selected="uploadStore.selectedDeviceIds = $event"
|
||||
@confirm="doUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { CropParams } from '@/types'
|
||||
import CropEditor from '@/components/CropEditor.vue'
|
||||
import StickerCanvas from '@/components/StickerCanvas.vue'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const uploadStore = useUploadStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
const imagesStore = useImagesStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
type Step = 'crop' | 'stickers' | 'done'
|
||||
const step = ref<Step>('crop')
|
||||
const uploading = ref(false)
|
||||
const devicePickerOpen = ref(false)
|
||||
let finalBlob: Blob | null = null
|
||||
|
||||
const isEdit = computed(() => uploadStore.editingImageId !== null)
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
await devicesStore.fetchDevices()
|
||||
|
||||
if (!uploadStore.originalFile) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
|
||||
// When opening for edit, jump straight to crop (state already loaded by caller)
|
||||
step.value = 'crop'
|
||||
})
|
||||
|
||||
// ── Context device ────────────────────────────────────────────────────────────
|
||||
|
||||
const contextDevice = computed(() =>
|
||||
uploadStore.contextDeviceId
|
||||
? devicesStore.devices.find(d => d.id === uploadStore.contextDeviceId)
|
||||
: devicesStore.devices[0]
|
||||
)
|
||||
|
||||
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
|
||||
contextDevice.value?.orientation ?? 'landscape'
|
||||
)
|
||||
|
||||
const contextDeviceName = computed(() => contextDevice.value?.name)
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stepLabel = computed(() => {
|
||||
if (step.value === 'crop') return isEdit.value ? 'Edit crop' : 'Crop photo'
|
||||
if (step.value === 'stickers') return 'Add stickers'
|
||||
return isEdit.value ? 'Updated' : 'Added'
|
||||
})
|
||||
|
||||
function onCrop({ blob, params }: { blob: Blob; params: CropParams }) {
|
||||
uploadStore.setCrop(blob, params)
|
||||
step.value = 'stickers'
|
||||
}
|
||||
|
||||
function skipStickers() {
|
||||
if (!uploadStore.croppedBlob) return
|
||||
finalBlob = uploadStore.croppedBlob
|
||||
if (isEdit.value) {
|
||||
doUpload()
|
||||
} else {
|
||||
devicePickerOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onStickersDone(blob: Blob) {
|
||||
finalBlob = blob
|
||||
if (isEdit.value) {
|
||||
doUpload()
|
||||
} else {
|
||||
devicePickerOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (step.value === 'crop') {
|
||||
uploadStore.cleanup()
|
||||
router.replace('/library')
|
||||
return
|
||||
}
|
||||
if (step.value === 'stickers') {
|
||||
step.value = 'crop'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload / reprocess ────────────────────────────────────────────────────────
|
||||
|
||||
async function doUpload() {
|
||||
if (!finalBlob) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const composited = new File([finalBlob], 'photo.jpg', { type: 'image/jpeg' })
|
||||
|
||||
if (isEdit.value) {
|
||||
await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, {
|
||||
cropParams: uploadStore.cropParams ?? undefined,
|
||||
stickerState: uploadStore.stickers,
|
||||
})
|
||||
devicePickerOpen.value = false
|
||||
step.value = 'done'
|
||||
return
|
||||
}
|
||||
|
||||
const image = await imagesStore.uploadImage(composited, {
|
||||
original: uploadStore.originalFile ?? undefined,
|
||||
cropParams: uploadStore.cropParams ?? undefined,
|
||||
stickerState: uploadStore.stickers,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
uploadStore.selectedDeviceIds.map(deviceId =>
|
||||
imagesStore.setApproval(image.id, deviceId, true)
|
||||
)
|
||||
)
|
||||
devicePickerOpen.value = false
|
||||
step.value = 'done'
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : 'Upload failed', 'error')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
uploadStore.cleanup()
|
||||
router.replace('/library')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.upload-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__back {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
&__step-label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__skip {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
&__stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__done {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) var(--space-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__done-icon {
|
||||
font-size: 64px;
|
||||
line-height: 1;
|
||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
||||
}
|
||||
|
||||
&__done-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__done-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
max-width: 260px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__done-btn {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+19
-3
@@ -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/**',
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user