Compare commits

...

12 Commits

Author SHA1 Message Date
football2801 4002ff9fbf chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled
Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:11:31 -04:00
football2801 dd0970ed7c fix: harden firmware NVS persistence, WDT, and 304 epd_sleep
Three bugs fixed:
- NVS img_id now written before epd_init/draw; new draw_needed flag in NVS
  survives power-loss mid-refresh so next boot re-draws from LittleFS instead
  of showing stale content
- epd_sleep() now only called when display was initialized this cycle,
  preventing a 60 s wait_busy() timeout on every 304 poll
- esp_task_wdt_reset() added to wait_busy() loop so the ~20 s 6-color
  refresh no longer triggers the task watchdog

Also extracts normal_operation into operation.h template and adds
a native PlatformIO test suite (16 tests) covering the full response matrix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:09:37 -04:00
football2801 3740331b5b feat: visual orientation picker on configure page + fix 404→setup QR in firmware
Replace orientation <select> dropdown on setup/configure with the same
visual two-button picker used in the SPA (SVG frame diagrams, ribbon
indicator, active highlight). Hidden input carries the value on submit.

Firmware: normal_operation() now calls show_setup_qr(mac) on 404 instead
of epd_fill(COLOR_RED) — device shows scannable QR with its own MAC when
not yet registered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 21:59:37 -04:00
football2801 c842a1028f fix: render plainPassword first/second fields separately in setup form
CI / test (push) Has been cancelled
RepeatedType renders as a wrapper div when called as a single widget,
producing unstyled inputs with no mismatch error handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 21:30:02 -04:00
football2801 d852fe60ae fix: set vite base to /build/ so asset paths match the actual serve location
CI / test (push) Has been cancelled
Assets built to public/build/assets/ but index.html referenced /assets/
(no /build/ prefix). Nginx couldn't find them, fell through to Symfony's
catch-all SPA route, which served HTML in place of JS — Vue never loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 21:21:51 -04:00
football2801 4a871a1161 fix: trust Traefik reverse proxy so Symfony generates https:// redirects
CI / test (push) Has been cancelled
Traefik terminates TLS and forwards X-Forwarded-Proto: https to Nginx,
which forwards it to PHP-FPM. Without trusted_proxies, Symfony ignored
this header and generated http:// redirect URLs after login/register,
causing session cookie loss on mobile (Secure cookie not sent over HTTP).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 19:10:44 -04:00
football2801 fe416f223e chore: fix .gitignore — exclude .pio build artifacts and __pycache__, track public/build
CI / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 16:59:28 -04:00
football2801 6bce4822e7 feat: orientation model, password confirm, frontend build
- Collapse orientation to landscape/portrait (ribbon left = portrait standard)
- Add OrientationPicker component and wire settings sheet in HomeView
- Add password confirmation field to registration form (RepeatedType)
- Build frontend SPA to public/build/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 16:59:03 -04:00
football2801 2e5ef7fe78 feat(story-2.4): home screen device list with FrameCard component
CI / test (push) Has been cancelled
- FrameCard: large (single device, 5:3 preview + Add Photo CTA) and
  compact (52px thumb + name + count + icon pill) variants; WCAG-
  compliant offline/sync-fail status (color + text, never color alone)
- devices Pinia store: fetchDevices() → GET /api/devices
- HomeView: 0 devices → dashed empty-state card; 1 device → large
  FrameCard; 2+ → compact stack; add-photo wired (Epic 3 stub)
- Fix Device type: rotationInterval → rotationIntervalHours to match API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:50:46 -04:00
football2801 f2af2de36f feat(story-2.2+2.3): device setup page, account linking, naming & configuration
Story 2.2 — /setup/{mac} Twig page (no Vue, works JS-disabled):
- Register tab: creates account + logs in + links device → /setup/{mac}/configure
- Login tab: manual credential check via UserPasswordHasherInterface + Security::login()
  + links device → /setup/{mac}/configure
- Re-provisioning: DeviceService.linkToUser() atomically transfers ownership + stubs
  purgeDeviceHistory() (completed in Epic 3 when Image/Approval entities exist)

Story 2.3 — /setup/{mac}/configure (requires auth):
- GET: device name, orientation (landscape/portrait), rotation interval (6/12/24/48/168h),
  uniqueness window (5/10/20/50 cycles)
- POST: validates name, saves to Device entity, redirects to Vue SPA
- Device entity: mac, name, orientation (Orientation enum), rotationIntervalHours,
  uniquenessWindow, user (ManyToOne), linkedAt
- PATCH /api/devices/{id}: Vue SPA can edit any device field (Story 2.3 "edit from app")
- GET /api/devices: list authenticated user's devices
- Migration: create device table with Orientation enum column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:47:14 -04:00
football2801 d5a7849fbd feat(story-2.1): firmware provisioning — AP mode, captive portal, QR display, image pull
State machine:
- Boot: check 5s reset-button hold → wipe NVS creds; load saved SSID/pass
- If no creds (or reset): enter_provisioning() — WiFi.softAP + DNS redirect + WebServer
- If creds: attempt_wifi(); on success → normal_operation(); on fail → enter_provisioning()
- normal_operation(): HTTPS GET /api/device/{mac}/image → stream to LittleFS → display;
  204 = keep current stored image; 404 = red fill; server error = yellow fill;
  deep sleep 15 min between polls

Provisioning flow:
- AP SSID: "PictureFrame-{last4hex}" broadcast as open network
- QR on e-ink: WIFI:S:PictureFrame-XXXX;T:nopass;; → phone auto-joins AP
- Captive portal: redirect all DNS to 192.168.4.1; serve minimal HTML form
  (handles iOS /hotspot-detect.html and Android /generate_204 redirects)
- POST /connect: async — respond immediately, attempt WiFi in loop()
  Success: save NVS, show Phase 2 setup QR (green bg) → 2min delay → normal_operation()
  Failure: red fill → restart AP

EPD driver refactor:
- Extracted epd_init/sleep/fill/draw_qr/draw_image_from_file into epd.h + epd.cpp
- epd_draw_qr(): ricmoo/QRCode library; computes modules inline per pixel row
- epd_fill(): solid color in one pass (used for red=no-wifi, yellow=sync-fail)
- epd_draw_image_from_file(): streams LittleFS binary directly to display

Removed: convert_photo.py (pre-rendering moved to server-side Imagick), image.h (PROGMEM array)
Added: config.h, epd.h, epd.cpp; updated platformio.ini (QRCode lib, littlefs fs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:43:53 -04:00
football2801 1ec5364de4 feat(story-1.5): theme selection and persistence
- SpaController: injects data-theme on <html> and window.__PF_USER__ before JS
  hydrates — theme applied without FOUC; no initial API call needed for user data
- UserApiController: PATCH /api/user/theme validates against 6 allowed theme IDs,
  persists to user.theme column, returns {theme}
- useTheme composable: applyTheme() sets html[data-theme], saveTheme() calls API
  and falls back with toast on error
- SettingsView: 3-col theme grid with swatch previews, aria-checked radio semantics,
  active indicator; Sign out link; signed-in email display
- App.vue: onMounted syncs Pinia theme state with SpaController-stamped html[data-theme]

Verified: data-theme injected on / load; PATCH saves to DB; reload shows persisted theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:37:59 -04:00
193 changed files with 30678 additions and 12342 deletions
+1
View File
@@ -9,6 +9,7 @@ database:
composer_version: "2"
webimage_extra_packages:
- php8.4-imagick
- php8.4-pcov
hooks:
post-start:
- exec: composer install --no-interaction 2>/dev/null || true
+6
View File
@@ -45,4 +45,10 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ###
MAILER_DSN=null://null
MAILER_SENDER=noreply@pictureframe.edholm.me
###< symfony/mailer ###
###> pictureframe ###
SHARE_TOKEN_TTL_DAYS=7
HARD_DELETE_TOKEN_TTL_DAYS=30
###< pictureframe ###
+4
View File
@@ -1,3 +1,7 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
MESSENGER_TRANSPORT_DSN=in-memory://
SHARE_TOKEN_TTL_DAYS=7
HARD_DELETE_TOKEN_TTL_DAYS=30
MAILER_SENDER=noreply@test.example
+7 -2
View File
@@ -14,6 +14,7 @@
###< phpunit/phpunit ###
# PlatformIO build artifacts
.pio/
firmware/.pio/
# User-uploaded image storage
@@ -25,6 +26,10 @@ storage/images/
.vscode/
*.swp
# Frontend build
/public/build/
# Frontend
/frontend/node_modules/
/frontend/coverage/
# Python
**/__pycache__/
*.pyc
+34 -6
View File
@@ -1,23 +1,51 @@
# pictureFrame
*Project description goes here.*
Handcrafted e-ink digital picture frame ecosystem — built as a meaningful gift. ESP32 firmware pulls pre-rendered images from a Symfony web app over WiFi; the companion web app handles image management, device configuration, and family sharing.
## Project goal
*What does this project do and why?*
Build gifted e-ink frames that stay personal and current over time, with no ongoing effort required from the recipient. One image per configured interval, cycling through a curated pool of family uploads and shared photos. Setup: scan two QR codes. Ongoing: nothing unless the recipient wants it.
## Stack
*Languages, frameworks, key libraries.*
- **Firmware:** PlatformIO + Arduino framework (C/C++), ESP32 dev board
- **Web app:** Symfony 8.0 (PHP 8.4+), PostgreSQL 16, Nginx-FPM
- **Local dev:** DDEV — mirrors `~/src/aqua-iq` setup
- **Git:** git.edholm.me (self-hosted Gitea/Forgejo)
- **Domain:** pictureframe.edholm.me
## Hardware
*If applicable — describe the target hardware.*
### Dev / V1 hardware (in hand)
- ESP32 dev board (`esp32dev`), dual-core 240MHz, 4MB flash
- Waveshare 7.3" 6-color e-ink (800×480)
- SPI pinout: SCK=18, MOSI=23, CS=5, DC=17, RST=16, BUSY=4
- 4bpp packed, palette: BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6 (same map as Spectra 6; 0x4 unused)
### Target / V2 hardware (on order)
- Waveshare ESP32-S3-ePaper-13.3E6 — 13.3" **Spectra 6** (6-color), ESP32-S3 onboard
- Spectra 6 palette: same as above — BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6
- Battery or plugin at recipient's choice
## Design decisions
*Key architectural or UX choices.*
- Server pre-renders all images to display-ready 4bpp per device model/orientation — ESP32 never transforms images
- Device pull model: `GET /api/device/{mac}/image` → 200 (binary), 204 (no ready image), 404 (unknown MAC)
- Atomic image write: display only refreshes after complete confirmed transfer; last good image persists through outages
- Deep sleep between pull cycles (see ESP32 deep sleep memory)
- Status via border color: yellow = sync fail, red = no WiFi
- 5-second button hold triggers re-provisioning (config wipe + AP mode)
- Two-phase provisioning: AP mode (WiFi credentials) → STA mode (QR to account setup page)
- Async image processing: Symfony Messenger (Doctrine transport), `max_retries: 1`
- Image storage: `storage/images/{id}/{model}_{orientation}.bin`, relative paths in DB
- PHP 8.1 backed enums for `RenderStatus`, `TokenType`, `Orientation`
- Imagick for Floyd-Steinberg dithering (not GD)
- No OTA firmware updates in V1 — API contract must not break without reflash
## Infrastructure reference
For Docker/DDEV config, server location, and SSH details: `~/src/aqua-iq`
## Full spec
*Link to or describe the full specification.*
See `_bmad-output/planning-artifacts/` — PRD, architecture, epics all complete as of 2026-04-27. Symfony web app scaffold + Vue SPA frontend scaffolded. Firmware fully rewritten with WiFi, provisioning, and deep sleep (`firmware/src/`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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

After

Width:  |  Height:  |  Size: 10 KiB

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

After

Width:  |  Height:  |  Size: 10 KiB

+26
View File
@@ -5,3 +5,29 @@ framework = arduino
monitor_speed = 115200
upload_port = /dev/ttyUSB0
monitor_port = /dev/ttyUSB0
board_build.filesystem = littlefs
lib_deps =
ricmoo/QRCode@^0.0.1
; Flash a single image from firmware/data/img.bin — no WiFi, no server needed.
; 1. pio run -e test-display --target uploadfs (upload the image)
; 2. pio run -e test-display --target upload (upload the sketch)
[env:test-display]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_port = /dev/ttyUSB0
monitor_port = /dev/ttyUSB0
board_build.filesystem = littlefs
build_flags = -DENV_TEST_DISPLAY
build_src_filter = +<epd.cpp> +<test_display.cpp>
lib_deps =
ricmoo/QRCode@^0.0.1
[env:native-test]
platform = native
lib_deps =
throwtheswitch/Unity@^2.6
build_flags = -DUNIT_TEST -std=c++17 -iquote test/mocks -iquote test -Itest/mocks -Itest
test_build_src = no
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""
Generate ap_bg.bin and setup_bg.bin — 800×480 4bpp backgrounds for the
pictureFrame e-ink device. QR overlay areas are left WHITE so the
firmware can render the actual QR code at runtime.
Run from the firmware/ directory:
python3 scripts/gen_screens.py
Constants exported (copy to epd.cpp):
AP_QR_X, AP_QR_Y, AP_QR_CELL, AP_QR_PX
SETUP_QR_X, SETUP_QR_Y, SETUP_QR_CELL, SETUP_QR_PX
"""
from PIL import Image, ImageDraw, ImageFont
import os, sys
# ── Display ──────────────────────────────────────────────────────────────────
W, H = 800, 480
# ── EPD palette ───────────────────────────────────────────────────────────────
BLACK = 0x0; BK = (26, 26, 26 )
WHITE = 0x1; WH = (245, 245, 240)
YELLOW = 0x2; YL = (240, 208, 0 )
RED = 0x3; RD = (192, 48, 32 )
BLUE = 0x5; BL = (24, 64, 192)
GREEN = 0x6; GR = (16, 160, 64 )
PALETTE_RGB = {BLACK: BK, WHITE: WH, YELLOW: YL, RED: RD, BLUE: BL, GREEN: GR}
def nearest(r, g, b):
best, best_d = WHITE, float("inf")
for n, (pr, pg, pb) in PALETTE_RGB.items():
d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2
if d < best_d: best, best_d = n, d
return best
def pack(img):
"""Convert RGB PIL image → 4bpp packed bytearray."""
px = img.load()
out = bytearray()
for y in range(H):
for x in range(0, W, 2):
hi = nearest(*px[x, y])
lo = nearest(*px[x+1, y])
out.append((hi << 4) | lo)
return out
# ── Fonts ─────────────────────────────────────────────────────────────────────
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
def ttf(name, size):
try: return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
except: return ImageFont.load_default()
F_HEAD = ttf("DejaVuSans-Bold.ttf", 26)
F_BAR = ttf("DejaVuSans-Bold.ttf", 13)
F_STEP = ttf("DejaVuSans.ttf", 13)
F_STEP_B= ttf("DejaVuSans-Bold.ttf", 13)
F_STEPN = ttf("DejaVuSans-Bold.ttf", 13)
F_LABEL = ttf("DejaVuSans-Bold.ttf", 11)
F_TINY = ttf("DejaVuSans-Bold.ttf", 10)
F_FOOT = ttf("DejaVuSans.ttf", 12)
F_CHIP = ttf("DejaVuSans-Bold.ttf", 12)
F_SUB = ttf("DejaVuSans.ttf", 14)
F_BIG = ttf("DejaVuSans-Bold.ttf", 14)
# ── Layout constants ──────────────────────────────────────────────────────────
BAR_H = 52
BODY_Y = BAR_H # 52
LEFT_X = 0; LEFT_W = 310
DIV1_X = 310; DIV_W = 2
CTR_X = 312; CTR_W = 196
DIV2_X = 508
RIGHT_X = 510; RIGHT_W = 290 # 800-510
# QR positions (MUST match epd.cpp constants)
AP_QR_CELL = 5
AP_QR_MODS = 37 # version 5, ECC_LOW
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 185
SETUP_QR_CELL = 5
SETUP_QR_MODS = 41 # version 6, ECC_LOW
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL # 205
# Centre of right panel
RIGHT_CX = RIGHT_X + RIGHT_W // 2 # 655
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # 563
AP_QR_Y = BODY_CY - AP_QR_PX // 2 # 174 (will nudge down below label)
AP_QR_Y = 185 # nudge down a bit for "SCAN TO CONNECT" label above
SETUP_QR_X = RIGHT_CX - SETUP_QR_PX // 2 # 553
SETUP_QR_Y = BODY_CY - SETUP_QR_PX // 2 # 164
SETUP_QR_Y = 175 # nudge for label
def leave_qr_white(draw, qr_x, qr_y, qr_px):
"""Blank the QR overlay region so firmware can write the real QR."""
draw.rectangle([qr_x, qr_y, qr_x+qr_px-1, qr_y+qr_px-1], fill=WH)
def text_center(draw, cx, y, text, font, fill):
bb = draw.textbbox((0,0), text, font=font)
tw = bb[2]-bb[0]
draw.text((cx - tw//2, y), text, font=font, fill=fill)
def orientation_diagrams(draw, accent, show_active_ls=True):
"""Draw both orientation diagrams in the centre panel.
accent = RGB colour for the active / ribbon highlights."""
cx = CTR_X + CTR_W // 2 # 410
# ── Section title ─────────────────────────────────────────────
text_center(draw, cx, BODY_Y+15, "FRAME", F_TINY, BK)
text_center(draw, cx, BODY_Y+27, "ORIENTATION", F_TINY, BK)
# ── Landscape ──────────────────────────────────────────────────
ls_x, ls_y, ls_w, ls_h = CTR_X+43, BODY_Y+52, 110, 66
rib_w, rib_h = 110, 10
text_center(draw, cx, ls_y-14, "LANDSCAPE", F_LABEL, accent if show_active_ls else BK)
ls_border = accent if show_active_ls else BK
draw.rectangle([ls_x, ls_y, ls_x+ls_w-1, ls_y+ls_h-1], outline=ls_border, width=3)
rib_rgb = accent if show_active_ls else BK
draw.rectangle([ls_x, ls_y+ls_h, ls_x+rib_w-1, ls_y+ls_h+rib_h-1], fill=rib_rgb)
if show_active_ls:
# check badge
bx, by = cx-9, ls_y+ls_h+rib_h+5
draw.rectangle([bx, by, bx+18, by+18], fill=accent)
text_center(draw, bx+9, by+3, "", F_CHIP, BK)
# Thin separator
sep_y = ls_y + ls_h + rib_h + (30 if show_active_ls else 14)
draw.rectangle([CTR_X+10, sep_y, CTR_X+CTR_W-10, sep_y], fill=(180,180,175))
# ── Portrait ──────────────────────────────────────────────────
pt_x, pt_y = CTR_X+56, sep_y+14
pt_w, pt_h = 64, 106
pr_w, pr_h = 10, 106
text_center(draw, cx, pt_y-14, "PORTRAIT", F_LABEL, BK)
draw.rectangle([pt_x-pr_w, pt_y, pt_x-1, pt_y+pr_h-1], fill=BK)
draw.rectangle([pt_x, pt_y, pt_x+pt_w-1, pt_y+pt_h-1], outline=BK, width=3)
# ═══════════════════════════════════════════════════════════════════════════════
# AP SCREEN — yellow accent, WiFi credentials
# ═══════════════════════════════════════════════════════════════════════════════
def gen_ap():
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
# ── Status bar ────────────────────────────────────────────────
draw.rectangle([0, 0, W-1, BAR_H-1], fill=YL)
draw.text((24, 18), "SETUP MODE — STEP 1 OF 2", font=F_BAR, fill=BK)
# Right chip: black box with device SSID
chip_x, chip_y = 498, 11
chip_text = "PictureFrame-91F8"
bb = draw.textbbox((0,0), chip_text, font=F_CHIP)
chip_w = bb[2]-bb[0] + 22
chip_x2 = chip_x + chip_w
draw.rectangle([chip_x, chip_y, chip_x2, BAR_H-12], fill=BK)
draw.text((chip_x+11, chip_y+7), chip_text, font=F_CHIP, fill=YL)
# ── Panel dividers ────────────────────────────────────────────
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
# ── Left panel ────────────────────────────────────────────────
# Heading
draw.text((28, BODY_Y+20), "Connect to", font=F_HEAD, fill=BK)
draw.text((28, BODY_Y+52), "WiFi", font=F_HEAD, fill=BK)
bb = draw.textbbox((0,0), "WiFi", font=F_HEAD)
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=YL)
# Steps
steps = [
("Scan the QR code →", "Phone joins PictureFrame-91F8"),
("Browser opens — enter", "your home WiFi password"),
("Tap Connect and watch", "for the QR code to change"),
]
sy = BODY_Y + 105
for i, (l1, l2) in enumerate(steps):
bx, by = 28, sy + i*46
draw.rectangle([bx, by, bx+24, by+24], fill=BK)
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, YL)
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
# Divider + footnote
draw.rectangle([28, BODY_Y+254, 283, BODY_Y+255], fill=BK)
draw.text((28, BODY_Y+262), "Page didn't open?", font=F_FOOT, fill=BK)
draw.text((28, BODY_Y+276), "Go to 192.168.4.1", font=F_FOOT, fill=BK)
# ── Centre panel ─────────────────────────────────────────────
orientation_diagrams(draw, YL, show_active_ls=True)
# ── Right panel ──────────────────────────────────────────────
cx = RIGHT_CX
# "SCAN TO CONNECT" label
text_center(draw, cx, AP_QR_Y - 26, "SCAN TO CONNECT", F_BIG, BK)
# QR border: yellow outer, black inner
qx, qy, qp = AP_QR_X, AP_QR_Y, AP_QR_PX
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=YL, width=3)
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
# Leave QR area white for firmware overlay
leave_qr_white(draw, qx, qy, qp)
# "Encodes WIFI:..." label below
text_center(draw, cx, qy+qp+10, "WIFI:S:PictureFrame-91F8;T:nopass;;", F_FOOT, (100,100,95))
return img
# ═══════════════════════════════════════════════════════════════════════════════
# SETUP SCREEN — green accent, account link
# ═══════════════════════════════════════════════════════════════════════════════
def gen_setup():
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
# ── Status bar ────────────────────────────────────────────────
draw.rectangle([0, 0, W-1, BAR_H-1], fill=GR)
# WiFi bars icon
bars = [(0, 8), (0, 13), (0, 18), (0, 22)]
bx = 24
for i, (_, bh) in enumerate(bars):
draw.rectangle([bx + i*8, BAR_H//2 - bh//2, bx+i*8+5, BAR_H//2 + bh//2], fill=WH)
draw.text((bx+38, 18), "WIFI CONNECTED — STEP 2 OF 2", font=F_BAR, fill=WH)
# Right IP chip
ip_text = "192.168.x.x"
bb = draw.textbbox((0,0), ip_text, font=F_CHIP)
chip_w = bb[2]-bb[0] + 22
chip_x = W - chip_w - 20
draw.rectangle([chip_x, 11, chip_x+chip_w, BAR_H-12], fill=WH)
draw.text((chip_x+11, 18), ip_text, font=F_CHIP, fill=GR)
# ── Panel dividers ────────────────────────────────────────────
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
# ── Left panel ────────────────────────────────────────────────
draw.text((28, BODY_Y+20), "Almost", font=F_HEAD, fill=BK)
draw.text((28, BODY_Y+52), "ready.", font=F_HEAD, fill=BK)
bb = draw.textbbox((0,0), "ready.", font=F_HEAD)
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=GR)
draw.text((28, BODY_Y+96), "Scan to name this frame and", font=F_STEP, fill=(80,80,75))
draw.text((28, BODY_Y+110), "link it to your account.", font=F_STEP, fill=(80,80,75))
steps = [
("Scan the QR with your phone", "camera or QR app"),
("Sign in at pictureframe", ".edholm.me"),
("Name the frame, choose", "orientation — done."),
]
sy = BODY_Y + 136
for i, (l1, l2) in enumerate(steps):
bx, by = 28, sy + i*46
draw.rectangle([bx, by, bx+24, by+24], fill=GR)
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, WH)
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
# URL bar
url_y = BODY_Y + 278
draw.rectangle([28, url_y, 284, url_y+32], fill=BK)
draw.text((38, url_y+4), "URL", font=F_TINY, fill=GR)
draw.text((38, url_y+16), "pictureframe.edholm.me/setup/...", font=ttf("DejaVuSans.ttf", 10), fill=WH)
# Progress track
prog_y = BODY_Y + 328
draw.text((28, prog_y), "SETUP PROGRESS", font=F_TINY, fill=(140,140,135))
seg_y = prog_y + 14
segs = [("WiFi", GR), ("Account", BK), ("Frame ready", (200,200,195))]
seg_w = (284 - 28 - 8) // 3 # ~82px each
for i, (label, color) in enumerate(segs):
sx = 28 + i*(seg_w+4)
draw.rectangle([sx, seg_y, sx+seg_w, seg_y+6], fill=color)
text_center(draw, sx+seg_w//2, seg_y+10, label, ttf("DejaVuSans.ttf", 9), BK)
# ── Centre panel ─────────────────────────────────────────────
orientation_diagrams(draw, GR, show_active_ls=True)
# ── Right panel ──────────────────────────────────────────────
cx = RIGHT_CX
text_center(draw, cx, SETUP_QR_Y - 26, "SCAN TO FINISH", F_BIG, BK)
qx, qy, qp = SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=GR, width=3)
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
leave_qr_white(draw, qx, qy, qp)
# MAC chip below QR
mac = "1C:C3:AB:D1:91:F8"
bb = draw.textbbox((0,0), mac, font=F_CHIP)
mw = bb[2]-bb[0]+20
mx = cx - mw//2
draw.rectangle([mx, qy+qp+8, mx+mw, qy+qp+26], fill=BK)
draw.text((mx+10, qy+qp+11), mac, font=ttf("DejaVuSans-Bold.ttf", 10), fill=WH)
return img
# ── Save ──────────────────────────────────────────────────────────────────────
def save_bin(img, path, preview_path):
data = pack(img)
with open(path, "wb") as f: f.write(data)
print(f"Written {len(data):,} bytes → {os.path.abspath(path)}")
# Reconstruct preview from packed data for verification
prev = Image.new("RGB", (W, H))
px = prev.load()
for y in range(H):
for x in range(0, W, 2):
byte = data[y*(W//2) + x//2]
px[x, y] = PALETTE_RGB.get(byte >> 4, (128,128,128))
px[x+1, y] = PALETTE_RGB.get(byte & 0xF, (128,128,128))
prev.save(preview_path)
print(f"Preview → {os.path.abspath(preview_path)}")
if __name__ == "__main__":
out_dir = os.path.join(os.path.dirname(__file__), "../data")
os.makedirs(out_dir, exist_ok=True)
print("Generating AP screen…")
save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png")
print()
print("Generating setup screen…")
save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png")
print()
print("QR overlay constants for epd.cpp:")
print(f" AP_QR_X={AP_QR_X}, AP_QR_Y={AP_QR_Y}, AP_QR_CELL={AP_QR_CELL}, AP_QR_PX={AP_QR_PX}")
print(f" SETUP_QR_X={SETUP_QR_X}, SETUP_QR_Y={SETUP_QR_Y}, SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Generate setup_bg.bin — the 800×480 4bpp background for the device setup screen.
The QR code is overlaid at runtime at position (QR_X, QR_Y) by the firmware.
Run from the firmware/ directory: python3 scripts/gen_setup_bg.py
"""
from PIL import Image, ImageDraw, ImageFont
import struct, os, sys
# ── Display + palette ───────────────────────────────────────────────────────────
W, H = 800, 480
# EPD 4bpp palette nibbles
BLACK = 0x0
WHITE = 0x1
YELLOW = 0x2
RED = 0x3
BLUE = 0x5
GREEN = 0x6
# PIL RGB for each nibble (used for drawing and for quantisation)
PALETTE_RGB = {
BLACK: (0, 0, 0 ),
WHITE: (255, 255, 255),
YELLOW: (255, 230, 0 ),
RED: (200, 0, 0 ),
BLUE: (0, 0, 220),
GREEN: (0, 170, 60 ),
}
# ── QR code placement (must match SETUP_QR_X / SETUP_QR_Y in epd.cpp) ──────────
QR_CELL = 5
QR_MODS = 41 # version 6, ECC_LOW
QR_PX = QR_MODS * QR_CELL # 205 px
QR_X = 555
QR_Y = (H - QR_PX) // 2 # 137
# ── Fonts ────────────────────────────────────────────────────────────────────────
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
def font(name, size):
try:
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
except Exception:
return ImageFont.load_default()
font_title = font("DejaVuSans-Bold.ttf", 36)
font_label = font("DejaVuSans-Bold.ttf", 20)
font_sub = font("DejaVuSans.ttf", 15)
font_scan = font("DejaVuSans.ttf", 14)
# ── Draw ─────────────────────────────────────────────────────────────────────────
img = Image.new("RGB", (W, H), PALETTE_RGB[WHITE])
draw = ImageDraw.Draw(img)
BK = PALETTE_RGB[BLACK]
GR = PALETTE_RGB[GREEN]
# Title
draw.text((40, 32), "pictureFrame", font=font_title, fill=BK)
# Thin rule under title
draw.rectangle([40, 80, 490, 82], fill=BK)
# ── Landscape diagram ────────────────────────────────────────────────────────────
LS_X, LS_Y, LS_W, LS_H = 45, 110, 200, 120
RIB_W, RIB_H = 56, 14
LS_RX = LS_X + (LS_W - RIB_W) // 2
LS_RY = LS_Y + LS_H # ribbon protrudes below
BORDER = 3
draw.rectangle([LS_X, LS_Y, LS_X+LS_W, LS_Y+LS_H], outline=BK, width=BORDER)
draw.rectangle([LS_RX, LS_RY, LS_RX+RIB_W, LS_RY+RIB_H], fill=GR)
draw.text((LS_X, LS_RY + RIB_H + 10), "Landscape", font=font_label, fill=BK)
draw.text((LS_X, LS_RY + RIB_H + 34), "Ribbon at bottom", font=font_sub, fill=BK)
# ── Portrait diagram ──────────────────────────────────────────────────────────────
PT_X, PT_Y, PT_W, PT_H = 310, 95, 120, 200
RIB2_W, RIB2_H = 14, 56
PT_RX = PT_X - RIB2_W # ribbon protrudes left
PT_RY = PT_Y + (PT_H - RIB2_H) // 2
draw.rectangle([PT_X, PT_Y, PT_X+PT_W, PT_Y+PT_H], outline=BK, width=BORDER)
draw.rectangle([PT_RX, PT_RY, PT_RX+RIB2_W, PT_RY+RIB2_H], fill=GR)
draw.text((PT_X, PT_Y + PT_H + 10), "Portrait", font=font_label, fill=BK)
draw.text((PT_X, PT_Y + PT_H + 34), "Ribbon on left", font=font_sub, fill=BK)
# ── Divider ───────────────────────────────────────────────────────────────────────
draw.rectangle([520, 0, 522, H], fill=PALETTE_RGB[BLACK])
# ── QR zone label ─────────────────────────────────────────────────────────────────
scan_txt = "Scan to set up"
bb = draw.textbbox((0, 0), scan_txt, font=font_scan)
tw = bb[2] - bb[0]
draw.text((QR_X + (QR_PX - tw) // 2, QR_Y + QR_PX + 12), scan_txt, font=font_scan, fill=BK)
# Leave QR area pure WHITE so the firmware overlay is clean
draw.rectangle([QR_X, QR_Y, QR_X + QR_PX - 1, QR_Y + QR_PX - 1], fill=PALETTE_RGB[WHITE])
# ── Quantise to EPD palette ───────────────────────────────────────────────────────
def nearest(r, g, b):
best, best_d = WHITE, float("inf")
for nibble, (pr, pg, pb) in PALETTE_RGB.items():
d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2
if d < best_d:
best, best_d = nibble, d
return best
pixels = img.load()
out = bytearray()
for y in range(H):
for x in range(0, W, 2):
hi = nearest(*pixels[x, y])
lo = nearest(*pixels[x+1, y])
out.append((hi << 4) | lo)
out_path = os.path.join(os.path.dirname(__file__), "../data/setup_bg.bin")
with open(out_path, "wb") as f:
f.write(out)
print(f"Written {len(out):,} bytes → {os.path.abspath(out_path)}")
print(f"QR overlay position: x={QR_X}, y={QR_Y}, cell={QR_CELL} (copy to SETUP_QR_* in epd.cpp)")
# ── Preview PNG (for inspection) ─────────────────────────────────────────────────
preview = Image.new("RGB", (W, H))
pix = preview.load()
for y in range(H):
for x in range(0, W, 2):
byte = out[y * (W // 2) + x // 2]
pix[x, y] = PALETTE_RGB.get(byte >> 4, (128,128,128))
pix[x+1, y] = PALETTE_RGB.get(byte & 0xF, (128,128,128))
preview_path = out_path.replace(".bin", "_preview.png")
preview.save(preview_path)
print(f"Preview PNG → {os.path.abspath(preview_path)}")
+40
View File
@@ -0,0 +1,40 @@
#pragma once
// ── EPD pins (Waveshare 7.3" ACeP) ──────────────────────────────────────────
#define EPD_WIDTH 800
#define EPD_HEIGHT 480
#define PIN_SCK 18
#define PIN_MOSI 23
#define PIN_CS 5
#define PIN_DC 17
#define PIN_RST 16
#define PIN_BUSY 4
// ── Reset button (BOOT button = GPIO 0 on most dev boards) ──────────────────
#define PIN_BTN_RESET 0
#define RESET_HOLD_MS 5000
// ── EPD color nibbles ────────────────────────────────────────────────────────
// Verified on Waveshare 7.3" hardware. Same map on 13.3" Spectra 6.
#define COLOR_BLACK 0x0
#define COLOR_WHITE 0x1
#define COLOR_YELLOW 0x2
#define COLOR_RED 0x3
#define COLOR_BLUE 0x5
#define COLOR_GREEN 0x6
// ── NVS ──────────────────────────────────────────────────────────────────────
#define NVS_NAMESPACE "pf"
#define NVS_KEY_SSID "ssid"
#define NVS_KEY_PASS "pass"
#define NVS_KEY_IMG_ID "img_id"
#define NVS_KEY_DRAW_NEEDED "draw"
// ── Network ──────────────────────────────────────────────────────────────────
#define APP_BASE_URL "https://pictureframe.edholm.me"
#define AP_IP "192.168.4.1"
#define WIFI_TIMEOUT_MS 30000
#ifndef FETCH_INTERVAL_MS
#define FETCH_INTERVAL_MS 60000 // 1 min deep sleep between polls
#endif
#define IMAGE_PATH "/img.bin"
+150
View File
@@ -0,0 +1,150 @@
#include "epd.h"
#include "config.h"
#include <LittleFS.h>
#include <qrcode.h>
#include <esp_task_wdt.h>
static uint8_t s_row[EPD_WIDTH / 2];
static void wait_busy() {
uint32_t start = millis();
while (digitalRead(PIN_BUSY) == LOW) {
if (millis() - start > 60000) return; // 6-color refresh takes ~20s
delay(5);
esp_task_wdt_reset(); // feed WDT — display refresh can take ~20 s
}
}
static void cmd(uint8_t c) {
digitalWrite(PIN_DC, LOW);
digitalWrite(PIN_CS, LOW);
SPI.transfer(c);
digitalWrite(PIN_CS, HIGH);
}
static void dat(uint8_t d) {
digitalWrite(PIN_DC, HIGH);
digitalWrite(PIN_CS, LOW);
SPI.transfer(d);
digitalWrite(PIN_CS, HIGH);
}
void epd_init() {
digitalWrite(PIN_RST, HIGH); delay(20);
digitalWrite(PIN_RST, LOW); delay(10);
digitalWrite(PIN_RST, HIGH); delay(20);
wait_busy(); delay(30);
cmd(0xAA);
dat(0x49); dat(0x55); dat(0x20);
dat(0x08); dat(0x09); dat(0x18);
cmd(0x01); dat(0x3F);
cmd(0x00); dat(0x5F); dat(0x69);
cmd(0x03); dat(0x00); dat(0x54); dat(0x00); dat(0x44);
cmd(0x05); dat(0x40); dat(0x1F); dat(0x1F); dat(0x2C);
cmd(0x06); dat(0x6F); dat(0x1F); dat(0x17); dat(0x49);
cmd(0x08); dat(0x6F); dat(0x1F); dat(0x1F); dat(0x22);
cmd(0x30); dat(0x03);
cmd(0x50); dat(0x3F);
cmd(0x60); dat(0x02); dat(0x00);
cmd(0x61); dat(0x03); dat(0x20); dat(0x01); dat(0xE0);
cmd(0x84); dat(0x01);
cmd(0xE3); dat(0x2F);
cmd(0x04); wait_busy();
}
void epd_sleep() {
cmd(0x02); dat(0x00); wait_busy();
cmd(0x07); dat(0xA5);
}
static void epd_refresh() {
cmd(0x04); wait_busy();
cmd(0x12); dat(0x00); wait_busy();
}
void epd_fill(uint8_t color) {
uint8_t byte = (color << 4) | color;
cmd(0x10);
digitalWrite(PIN_DC, HIGH);
digitalWrite(PIN_CS, LOW);
for (int i = 0; i < EPD_WIDTH * EPD_HEIGHT / 2; i++) SPI.transfer(byte);
digitalWrite(PIN_CS, HIGH);
epd_refresh();
}
void epd_draw_image_from_file(fs::File& f) {
cmd(0x10);
uint8_t buf[512];
digitalWrite(PIN_DC, HIGH);
digitalWrite(PIN_CS, LOW);
while (f.available()) {
size_t n = f.read(buf, sizeof(buf));
SPI.writeBytes(buf, n);
}
digitalWrite(PIN_CS, HIGH);
epd_refresh();
}
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg) {
int qrPx = qr->size * cellPx;
int offX = (EPD_WIDTH - qrPx) / 2;
int offY = (EPD_HEIGHT - qrPx) / 2;
cmd(0x10);
for (int y = 0; y < EPD_HEIGHT; y++) {
for (int x = 0; x < EPD_WIDTH; x += 2) {
auto nibble = [&](int px) -> uint8_t {
int qx = (px - offX) / cellPx, qy = (y - offY) / cellPx;
if (qx >= 0 && qx < qr->size && qy >= 0 && qy < qr->size)
return qrcode_getModule(qr, qx, qy) ? fg : bg;
return bg;
};
s_row[x/2] = (nibble(x) << 4) | nibble(x+1);
}
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_CS, LOW);
SPI.writeBytes(s_row, sizeof(s_row));
digitalWrite(PIN_CS, HIGH);
}
epd_refresh();
}
// Stream background from LittleFS, overlaying QR at (qr_x, qr_y) with given cell size.
// Falls back to a solid fill if the file is missing.
static void draw_from_lfs(const char* path, uint8_t fallback_color,
QRCode* qr, int qr_x, int qr_y, int qr_cell) {
File f = LittleFS.open(path, "r");
if (!f) { epd_fill(fallback_color); return; }
int qr_px = qr->size * qr_cell;
cmd(0x10);
for (int y = 0; y < EPD_HEIGHT; y++) {
f.read(s_row, sizeof(s_row));
if (y >= qr_y && y < qr_y + qr_px) {
int qy = (y - qr_y) / qr_cell;
int x0 = max(qr_x, 0), x1 = min(qr_x + qr_px, EPD_WIDTH);
for (int x = x0; x < x1; x++) {
uint8_t c = qrcode_getModule(qr, (x - qr_x) / qr_cell, qy)
? COLOR_BLACK : COLOR_WHITE;
if (x & 1) s_row[x/2] = (s_row[x/2] & 0xF0) | c;
else s_row[x/2] = (s_row[x/2] & 0x0F) | (c << 4);
}
}
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_CS, LOW);
SPI.writeBytes(s_row, sizeof(s_row));
digitalWrite(PIN_CS, HIGH);
}
f.close();
epd_refresh();
}
void epd_draw_ap_screen(QRCode* qr) {
// AP_QR_X=563, AP_QR_Y=185, AP_QR_CELL=5 (must match gen_screens.py)
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 563, 185, 5);
}
void epd_draw_setup_screen(QRCode* qr) {
// SETUP_QR_X=553, SETUP_QR_Y=175, SETUP_QR_CELL=5 (must match gen_screens.py)
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 553, 175, 5);
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <Arduino.h>
#include <SPI.h>
#include <FS.h>
void epd_init();
void epd_sleep();
void epd_fill(uint8_t color);
void epd_draw_image_from_file(fs::File& f);
// Draw a QR code centred on the display.
// bg/fg are EPD color nibbles (COLOR_WHITE / COLOR_BLACK).
struct QRCode;
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg);
// Draw the setup screen: pre-rendered background from LittleFS with QR overlaid.
void epd_draw_ap_screen(QRCode* qr);
void epd_draw_setup_screen(QRCode* qr);
-12009
View File
File diff suppressed because it is too large Load Diff
+246 -93
View File
@@ -1,119 +1,272 @@
#include <Arduino.h>
#include <SPI.h>
#include "image.h"
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <qrcode.h>
#include "config.h"
#include "epd.h"
#include "operation.h"
#define EPD_WIDTH 800
#define EPD_HEIGHT 480
#define IMG_W 480 // portrait image dimensions
#define IMG_H 800
#define PIN_SCK 18
#define PIN_MOSI 23
#define PIN_CS 5
#define PIN_DC 17
#define PIN_RST 16
#define PIN_BUSY 4
// ── Globals ──────────────────────────────────────────────────────────────────
static uint8_t row_buf[EPD_WIDTH / 2];
static WebServer server(80);
static DNSServer dns;
static Preferences prefs;
void wait_busy() { while (digitalRead(PIN_BUSY) == LOW) delay(5); }
static bool g_provisioning = false;
static bool g_connect_req = false;
static String g_req_ssid;
static String g_req_pass;
void send_command(uint8_t cmd) {
digitalWrite(PIN_DC, LOW);
digitalWrite(PIN_CS, LOW);
SPI.transfer(cmd);
digitalWrite(PIN_CS, HIGH);
// ── QR helpers ───────────────────────────────────────────────────────────────
static void show_ap_qr(const String& apSsid) {
String content = "WIFI:S:" + apSsid + ";T:nopass;;";
QRCode qr;
uint8_t buf[qrcode_getBufferSize(5)];
qrcode_initText(&qr, buf, 5, ECC_LOW, content.c_str());
epd_draw_ap_screen(&qr);
}
void send_data(uint8_t data) {
digitalWrite(PIN_DC, HIGH);
digitalWrite(PIN_CS, LOW);
SPI.transfer(data);
digitalWrite(PIN_CS, HIGH);
static void show_setup_qr(const String& mac) {
String url = String(APP_BASE_URL) + "/setup/" + mac;
Serial.println("show_setup_qr: " + url);
QRCode qr;
uint8_t buf[qrcode_getBufferSize(6)];
qrcode_initText(&qr, buf, 6, ECC_LOW, url.c_str());
Serial.println("QR size: " + String(qr.size));
epd_draw_setup_screen(&qr);
Serial.println("epd_draw_setup_screen done");
}
void epd_reset() {
digitalWrite(PIN_RST, HIGH); delay(20);
digitalWrite(PIN_RST, LOW); delay(2);
digitalWrite(PIN_RST, HIGH); delay(20);
// ── Captive portal HTML ───────────────────────────────────────────────────────
static const char PORTAL_HTML[] PROGMEM = R"html(
<!DOCTYPE html><html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>pictureFrame Setup</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22}
.card{width:100%;max-width:340px;margin:1rem;padding:1.5rem;background:#fff9f2;
border:1px solid #e8d9c4;border-radius:16px}
h1{font-size:1.25rem;margin:0 0 1.25rem}
label{display:block;font-size:.8125rem;font-weight:600;color:#8a7060;margin-bottom:.3rem}
input{width:100%;min-height:44px;padding:0 .875rem;border:1px solid #e8d9c4;
border-radius:10px;background:#fff;font-size:1rem;color:#3a2e22;box-sizing:border-box;margin-bottom:1rem}
button{width:100%;min-height:44px;background:#c97c3a;color:#fff;border:none;
border-radius:9999px;font-size:1rem;font-weight:700;cursor:pointer}
p{font-size:.875rem;color:#8a7060;margin-top:1rem;text-align:center}
</style>
</head>
<body>
<div class="card">
<h1>Connect to WiFi</h1>
<form method="POST" action="/connect">
<label for="s">WiFi network name</label>
<input id="s" name="ssid" type="text" autocomplete="off" placeholder="e.g. HomeNetwork">
<label for="p">Password</label>
<input id="p" name="pass" type="password" autocomplete="off">
<button type="submit">Connect</button>
</form>
<p>pictureFrame will join your network and display a setup QR code.</p>
</div>
</body></html>
)html";
static const char CONNECTING_HTML[] PROGMEM = R"html(
<!DOCTYPE html><html><head><meta charset="UTF-8">
<title>Connecting</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22;text-align:center}
.card{padding:2rem;max-width:300px}</style></head>
<body><div class="card"><h2>Connecting…</h2>
<p>The frame is joining your network.<br>Watch the display for a setup QR code.</p></div></body></html>
)html";
// ── Web server handlers ───────────────────────────────────────────────────────
static void handle_root() {
server.send_P(200, "text/html", PORTAL_HTML);
}
void epd_init() {
epd_reset();
wait_busy();
delay(30);
send_command(0xAA);
send_data(0x49); send_data(0x55); send_data(0x20);
send_data(0x08); send_data(0x09); send_data(0x18);
send_command(0x01); send_data(0x3F);
send_command(0x00); send_data(0x5F); send_data(0x69);
send_command(0x03);
send_data(0x00); send_data(0x54); send_data(0x00); send_data(0x44);
send_command(0x05);
send_data(0x40); send_data(0x1F); send_data(0x1F); send_data(0x2C);
send_command(0x06);
send_data(0x6F); send_data(0x1F); send_data(0x17); send_data(0x49);
send_command(0x08);
send_data(0x6F); send_data(0x1F); send_data(0x1F); send_data(0x22);
send_command(0x30); send_data(0x03);
send_command(0x50); send_data(0x3F);
send_command(0x60); send_data(0x02); send_data(0x00);
send_command(0x61);
send_data(0x03); send_data(0x20); send_data(0x01); send_data(0xE0);
send_command(0x84); send_data(0x01);
send_command(0xE3); send_data(0x2F);
send_command(0x04);
wait_busy();
}
void epd_sleep() {
send_command(0x02); send_data(0x00); wait_busy();
send_command(0x07); send_data(0xA5);
}
void show_image() {
send_command(0x10);
digitalWrite(PIN_DC, HIGH);
digitalWrite(PIN_CS, LOW);
// 90° CW rotation: display(x,y) → portrait(IMG_W-1-y, x)
// Portrait image is IMG_W×IMG_H, IMAGE_ROW = IMG_W/2 bytes per portrait row.
for (int y = 0; y < EPD_HEIGHT; y++) {
int pcol = (IMG_W - 1) - y;
for (int x = 0; x < EPD_WIDTH; x++) {
uint32_t bidx = (uint32_t)x * IMAGE_ROW + pcol / 2;
uint8_t b = pgm_read_byte(&IMAGE_DATA[bidx]);
uint8_t code = (pcol & 1) ? (b & 0x0F) : (b >> 4);
if (x & 1)
row_buf[x / 2] = (row_buf[x / 2] & 0xF0) | code;
else
row_buf[x / 2] = (code << 4) | (row_buf[x / 2] & 0x0F);
}
SPI.writeBytes(row_buf, sizeof(row_buf));
static void handle_connect() {
if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
server.send(400, "text/plain", "Missing ssid");
return;
}
digitalWrite(PIN_CS, HIGH);
send_command(0x04); wait_busy();
send_command(0x12); send_data(0x00); wait_busy();
g_req_ssid = server.arg("ssid");
g_req_pass = server.arg("pass");
g_connect_req = true;
server.send_P(200, "text/html", CONNECTING_HTML);
}
// Captive portal detection endpoints — all redirect to portal
static void handle_captive() {
server.sendHeader("Location", "http://" AP_IP "/");
server.send(302, "text/plain", "");
}
// ── WiFi provisioning ─────────────────────────────────────────────────────────
static void enter_provisioning(const String& mac) {
// Derive AP name from last 4 hex chars of MAC (no colons)
String suffix = mac;
suffix.replace(":", "");
suffix = suffix.substring(suffix.length() - 4);
suffix.toUpperCase();
String apSsid = "PictureFrame-" + suffix;
Serial.println("AP: " + apSsid);
WiFi.disconnect(true);
WiFi.mode(WIFI_AP);
WiFi.softAP(apSsid.c_str());
delay(500);
// Redirect all DNS to this device
dns.setErrorReplyCode(DNSReplyCode::NoError);
dns.start(53, "*", WiFi.softAPIP());
server.on("/", HTTP_GET, handle_root);
server.on("/connect", HTTP_POST, handle_connect);
server.on("/generate_204", handle_captive);
server.on("/hotspot-detect.html", handle_captive);
server.on("/ncsi.txt", handle_captive);
server.onNotFound(handle_root);
server.begin();
epd_init();
show_ap_qr(apSsid);
epd_sleep();
g_provisioning = true;
}
static bool attempt_wifi(const String& ssid, const String& pass) {
return ::attempt_wifi(ssid.c_str(), pass.c_str());
}
// ── Normal operation ──────────────────────────────────────────────────────────
static void normal_operation(const String& mac) {
String url = String(APP_BASE_URL) + "/api/device/" + mac + "/image";
WiFiClientSecure client;
client.setInsecure(); // V1: no cert pinning for personal-scale device
HTTPClient http;
http.begin(client, url);
normal_operation_impl(mac, http, url, prefs);
}
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
Serial.println("pictureFrame");
Serial.println("pictureFrame boot");
pinMode(PIN_CS, OUTPUT);
pinMode(PIN_DC, OUTPUT);
pinMode(PIN_RST, OUTPUT);
pinMode(PIN_BUSY, INPUT);
// Init GPIO
pinMode(PIN_CS, OUTPUT);
pinMode(PIN_DC, OUTPUT);
pinMode(PIN_RST, OUTPUT);
pinMode(PIN_BUSY, INPUT);
pinMode(PIN_BTN_RESET, INPUT_PULLUP);
SPI.begin(PIN_SCK, -1, PIN_MOSI, PIN_CS);
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
epd_init();
show_image();
epd_sleep();
Serial.println("Done.");
LittleFS.begin(true); // format on first use
// Check reset button: if held at boot, wipe credentials
bool clear_creds = check_reset_button();
prefs.begin(NVS_NAMESPACE, false);
if (clear_creds) {
prefs.clear();
Serial.println("Credentials cleared — entering provisioning");
}
String mac = WiFi.macAddress();
String ssid = prefs.getString(NVS_KEY_SSID, "");
String pass = prefs.getString(NVS_KEY_PASS, "");
prefs.end();
if (ssid.isEmpty()) {
enter_provisioning(mac);
return;
}
// Attempt to join saved network
Serial.println("[wifi] connecting to ssid=" + ssid);
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - start > WIFI_TIMEOUT_MS) break;
delay(200);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("[wifi] connected ip=" + WiFi.localIP().toString());
normal_operation(mac);
// normal_operation calls deep_sleep — never returns
} else {
Serial.println("[wifi] failed after " + String(WIFI_TIMEOUT_MS) + " ms — entering provisioning");
enter_provisioning(mac);
}
}
// ── Loop (provisioning mode only) ────────────────────────────────────────────
void loop() {
delay(60000);
if (!g_provisioning) return;
dns.processNextRequest();
server.handleClient();
if (!g_connect_req) return;
g_connect_req = false;
dns.stop();
server.stop();
String mac = WiFi.macAddress();
bool ok = attempt_wifi(g_req_ssid, g_req_pass);
if (ok) {
// Save credentials for future boots
prefs.begin(NVS_NAMESPACE, false);
prefs.putString(NVS_KEY_SSID, g_req_ssid);
prefs.putString(NVS_KEY_PASS, g_req_pass);
prefs.end();
// Show Phase 2 QR and transition to polling loop
epd_init();
show_setup_qr(mac);
epd_sleep();
g_provisioning = false;
// Give user time to scan the QR, then start normal operation
delay(120000); // 2 minutes
normal_operation(mac);
} else {
// Connection failed — fill red, restart AP
epd_init();
epd_fill(COLOR_RED);
epd_sleep();
delay(3000);
// Re-enter provisioning to retry
enter_provisioning(mac);
}
}
+156
View File
@@ -0,0 +1,156 @@
#pragma once
#include <Arduino.h>
#include <Preferences.h>
#include <LittleFS.h>
#include "config.h"
#ifdef UNIT_TEST
// In unit tests, use mock stubs for hardware-dependent headers.
// The test build adds test/mocks to the include path via -iquote.
#include "epd_mock.h"
#include "esp_sleep.h"
#else
#include "epd.h"
#include <esp_sleep.h>
#endif
#ifndef UNIT_TEST
// Defined in main.cpp
static void show_setup_qr(const String& mac);
#else
// Stub for native tests — tracks call count
extern int g_show_setup_qr_count;
inline void show_setup_qr(const String& mac) { g_show_setup_qr_count++; }
#endif
// ── Utility: derive AP SSID from MAC ─────────────────────────────────────────
// Strips colons, uppercases, takes the last 4 chars.
// Builds via std::string so single-char append is unambiguous on all targets.
inline String ap_ssid_from_mac(const String& mac) {
std::string cleaned;
const char* p = mac.c_str();
while (*p) {
if (*p != ':') cleaned += (char)toupper((unsigned char)*p);
++p;
}
std::string suffix = cleaned.substr(cleaned.size() - 4);
return String(("PictureFrame-" + suffix).c_str());
}
// ── WiFi connection attempt ───────────────────────────────────────────────────
inline bool attempt_wifi(const char* ssid, const char* pass) {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, pass);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - start > WIFI_TIMEOUT_MS) return false;
delay(200);
}
return true;
}
// ── Reset button hold detection ───────────────────────────────────────────────
inline bool check_reset_button() {
uint32_t hold_start = millis();
while (digitalRead(PIN_BTN_RESET) == LOW) {
if (millis() - hold_start >= RESET_HOLD_MS) {
return true;
}
delay(50);
}
return false;
}
template<typename HTTP>
void normal_operation_impl(const String& mac, HTTP& http, const String& url, Preferences& prefs) {
prefs.begin(NVS_NAMESPACE, true);
int32_t currentImgId = prefs.getInt(NVS_KEY_IMG_ID, -1);
bool drawNeeded = prefs.getInt(NVS_KEY_DRAW_NEEDED, 0) != 0;
prefs.end();
if (currentImgId >= 0) {
http.addHeader("X-Current-Image-Id", String(currentImgId));
}
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id" };
http.collectHeaders(collectHeaders, 2);
int code = http.GET();
uint64_t sleepMs = FETCH_INTERVAL_MS;
String intervalHdr = http.header("X-Interval-Ms");
if (intervalHdr.length() > 0) {
uint64_t v = strtoull(intervalHdr.c_str(), nullptr, 10);
if (v > 0) sleepMs = std::min<uint64_t>(v, (uint64_t)FETCH_INTERVAL_MS);
}
bool displayInitialized = false;
if (code == 200) {
String newId = http.header("X-Image-Id");
File f = LittleFS.open(IMAGE_PATH, "w", true);
if (f) { http.writeToStream(&f); f.close(); }
http.end();
// Persist ID and set draw_needed before touching the display.
// If the device loses power during the ~20 s refresh, the flag survives
// in NVS so the next boot re-draws from LittleFS instead of looping on 200.
if (newId.length() > 0) {
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 1);
prefs.putInt(NVS_KEY_IMG_ID, newId.toInt());
prefs.end();
}
displayInitialized = true;
epd_init();
File r = LittleFS.open(IMAGE_PATH, "r");
if (r) { epd_draw_image_from_file(r); r.close(); }
// Draw complete — clear the pending flag.
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
prefs.end();
} else if (code == 304) {
http.end();
// If a previous draw was interrupted (power loss mid-refresh), the image
// file is in LittleFS and the ID is correct in NVS — just re-draw it.
if (drawNeeded) {
File r = LittleFS.open(IMAGE_PATH, "r");
if (r) {
displayInitialized = true;
epd_init();
epd_draw_image_from_file(r);
r.close();
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
prefs.end();
}
}
} else if (code == 204) {
http.end();
displayInitialized = true;
epd_init();
show_setup_qr(mac);
} else if (code == 404) {
http.end();
displayInitialized = true;
epd_init();
show_setup_qr(mac);
} else {
http.end();
displayInitialized = true;
epd_init();
epd_fill(COLOR_YELLOW);
}
// Only power off the display if it was initialized this cycle. Calling
// epd_sleep() when the display is already in hardware deep sleep (from the
// previous cycle) causes wait_busy() to time out at 60 s, wasting the
// entire poll interval on every 304 response.
if (displayInitialized) epd_sleep();
esp_sleep_enable_timer_wakeup(sleepMs * 1000ULL);
esp_deep_sleep_start();
}
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
#ifdef ENV_TEST_DISPLAY
#include <Arduino.h>
#include <SPI.h>
#include <LittleFS.h>
#include "config.h"
#include "epd.h"
void setup() {
Serial.begin(115200);
Serial.println("display test boot");
pinMode(PIN_CS, OUTPUT);
pinMode(PIN_DC, OUTPUT);
pinMode(PIN_RST, OUTPUT);
pinMode(PIN_BUSY, INPUT);
SPI.begin(PIN_SCK, -1, PIN_MOSI, PIN_CS);
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
LittleFS.begin(true);
File f = LittleFS.open("/img.bin", "r");
if (!f) {
Serial.println("ERROR: /img.bin not found — did you run uploadfs?");
return;
}
Serial.printf("/img.bin: %u bytes\n", f.size());
epd_init();
epd_draw_image_from_file(f);
f.close();
epd_sleep();
Serial.println("done");
}
void loop() {
delay(60000);
}
#endif
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <string>
#include <cstring>
#include <cstdint>
#include <cstdlib>
#include <algorithm>
#include <sstream>
#include <cctype>
// Minimal String class that mimics Arduino's String for native tests
struct String {
std::string _s;
String() {}
String(const char* s) : _s(s ? s : "") {}
String(const std::string& s) : _s(s) {}
String(int v) { _s = std::to_string(v); }
String(unsigned long v) { _s = std::to_string(v); }
String(long long v) { _s = std::to_string(v); }
String(unsigned long long v) { _s = std::to_string(v); }
const char* c_str() const { return _s.c_str(); }
size_t length() const { return _s.size(); }
bool isEmpty() const { return _s.empty(); }
bool empty() const { return _s.empty(); }
int toInt() const { return _s.empty() ? 0 : std::stoi(_s); }
String substring(size_t from) const { return String(_s.substr(from)); }
String substring(size_t from, size_t to) const { return String(_s.substr(from, to - from)); }
void replace(const char* from, const char* to_str) {
std::string result;
std::string f(from), t(to_str);
size_t pos = 0, found;
while ((found = _s.find(f, pos)) != std::string::npos) {
result += _s.substr(pos, found - pos);
result += t;
pos = found + f.size();
}
result += _s.substr(pos);
_s = result;
}
void toUpperCase() {
for (char& c : _s) c = (char)toupper((unsigned char)c);
}
bool operator==(const String& o) const { return _s == o._s; }
bool operator==(const char* o) const { return _s == o; }
bool operator!=(const String& o) const { return _s != o._s; }
bool operator!=(const char* o) const { return _s != o; }
String operator+(const String& o) const { return String(_s + o._s); }
String operator+(const char* o) const { return String(_s + o); }
String& operator+=(const String& o) { _s += o._s; return *this; }
String& operator+=(const char* o) { _s += o; return *this; }
// Allow use as map key
operator std::string() const { return _s; }
// toString() for IP-like objects that have it
String toString() const { return *this; }
};
inline String operator+(const char* a, const String& b) { return String(std::string(a) + b._s); }
inline String operator+(const std::string& a, const String& b) { return String(a + b._s); }
// Controllable millis and digitalRead for timeout / button tests
extern uint32_t g_millis_value;
extern int g_digital_read_value;
#ifndef LOW
#define LOW 0
#define HIGH 1
#endif
inline unsigned long millis() { return g_millis_value += 10; }
inline void delay(unsigned long) {}
inline void pinMode(int, int) {}
inline int digitalRead(int) { return g_digital_read_value; }
// Color constants (from config.h)
#define COLOR_YELLOW 0x2
#define COLOR_RED 0x3
// Serial mock
struct SerialMock {
void begin(int) {}
void println(const String&) {}
void println(const char*) {}
void println(int) {}
void print(const String&) {}
void print(const char*) {}
void flush() {}
} Serial;
// strtoull is available from <cstdlib> on native
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
#include <string>
enum class DNSReplyCode { NoError };
struct DNSServer {
void setErrorReplyCode(DNSReplyCode) {}
void start(uint16_t, const char*, const std::string&) {}
void processNextRequest() {}
void stop() {}
};
+30
View File
@@ -0,0 +1,30 @@
#pragma once
// Minimal FS.h stub for native unit tests
// Provides the fs::File type that Arduino's FS library normally defines.
#include "Arduino.h"
#include <string>
#include <cstdint>
namespace fs {
struct File {
std::string* _buf = nullptr;
bool _valid = false;
bool _write = false;
size_t _pos = 0;
explicit operator bool() const { return _valid; }
void close() { _valid = false; }
size_t write(const uint8_t* data, size_t len) {
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
return 0;
}
int read() {
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
return -1;
}
size_t size() { return _buf ? _buf->size() : 0; }
};
} // namespace fs
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "Arduino.h"
#include "LittleFS.h"
#include "WiFiClientSecure.h"
#include <map>
// Global test state for inspecting behavior
extern int g_http_get_code;
extern std::map<std::string, std::string> g_http_response_headers;
extern std::map<std::string, std::string> g_http_request_headers;
extern bool g_http_end_called;
extern std::string g_http_body;
struct MockHTTPClient {
bool _ended = false;
void begin(WiFiClientSecure&, const String& url) {}
void addHeader(const char* name, const String& value) {
g_http_request_headers[name] = value._s;
}
void collectHeaders(const char** headers, int count) {}
int GET() {
_ended = false;
return g_http_get_code;
}
String header(const char* name) {
if (_ended) return String("");
auto it = g_http_response_headers.find(name);
return it != g_http_response_headers.end() ? String(it->second) : String("");
}
size_t writeToStream(File* f) {
if (f && *f) {
f->write((const uint8_t*)g_http_body.data(), g_http_body.size());
}
return g_http_body.size();
}
void end() {
_ended = true;
g_http_end_called = true;
}
};
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include "Arduino.h"
#include <map>
#include <vector>
struct File {
std::string* _buf = nullptr;
bool _valid = false;
bool _write = false;
size_t _pos = 0;
explicit operator bool() const { return _valid; }
void close() { _valid = false; }
size_t write(const uint8_t* data, size_t len) {
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
return 0;
}
int read() {
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
return -1;
}
size_t size() { return _buf ? _buf->size() : 0; }
};
struct LittleFSClass {
std::map<std::string, std::string> files;
bool begin(bool) { return true; }
File open(const char* path, const char* mode, bool create = false) {
File f;
f._valid = true;
f._write = (mode[0] == 'w');
f._buf = &files[path];
if (f._write) f._buf->clear();
f._pos = 0;
return f;
}
} LittleFS;
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "Arduino.h"
#include <map>
// Shared sequence counter — incremented by each instrumented mock call
extern int g_call_seq;
extern int g_prefs_putint_seq; // sequence position of last putInt call
struct Preferences {
std::map<std::string, int32_t> ints;
std::map<std::string, std::string> strings;
bool _open = false;
void begin(const char*, bool) { _open = true; }
void end() { _open = false; }
int32_t getInt(const char* key, int32_t def = 0) {
auto it = ints.find(key);
return it != ints.end() ? it->second : def;
}
void putInt(const char* key, int32_t val) {
ints[key] = val;
// Record the sequence of the FIRST putInt call (ordering test uses this
// to verify NVS is written before epd_draw_image_from_file).
if (g_prefs_putint_seq < 0) g_prefs_putint_seq = g_call_seq;
g_call_seq++;
}
String getString(const char* key, const char* def = "") {
auto it = strings.find(key);
return it != strings.end() ? String(it->second) : String(def);
}
void putString(const char* key, const String& val) { strings[key] = val._s; }
void clear() { ints.clear(); strings.clear(); }
};
+8
View File
@@ -0,0 +1,8 @@
#pragma once
struct SPISettings { SPISettings(uint32_t, uint8_t, uint8_t) {} };
struct SPIClass {
void begin(int,int,int,int) {}
void beginTransaction(SPISettings) {}
} SPI;
#define MSBFIRST 1
#define SPI_MODE0 0
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <string>
struct WebServer {
WebServer(int) {}
void on(const char*, void(*)()) {}
void on(const char*, int, void(*)()) {}
void onNotFound(void(*)()) {}
void begin() {}
void handleClient() {}
void stop() {}
bool hasArg(const char*) { return false; }
std::string arg(const char*) { return ""; }
void send(int, const char*, const char*) {}
void send_P(int, const char*, const char*) {}
void sendHeader(const char*, const char*) {}
};
#define HTTP_GET 0
#define HTTP_POST 1
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "Arduino.h"
#define WIFI_STA 0
#define WIFI_AP 1
#define WL_CONNECTED 3
extern int g_wifi_status;
struct WiFiClass {
String macAddress() { return String("1C:C3:AB:D1:91:F8"); }
int status() { return g_wifi_status; }
void mode(int) {}
void begin(const char*, const char*) {}
void disconnect(bool) {}
bool softAP(const char*) { return true; }
String softAPIP() { return String("192.168.4.1"); }
String localIP() { return String("192.168.1.100"); }
} WiFi;
+4
View File
@@ -0,0 +1,4 @@
#pragma once
struct WiFiClientSecure {
void setInsecure() {}
};
+28
View File
@@ -0,0 +1,28 @@
#pragma once
// Mirror of src/config.h for use in native unit tests.
// Values must match src/config.h so test assertions stay consistent.
#define APP_BASE_URL "https://pictureframe.edholm.me"
#define NVS_NAMESPACE "pf"
#define NVS_KEY_SSID "ssid"
#define NVS_KEY_PASS "pass"
#define NVS_KEY_IMG_ID "img_id"
#define NVS_KEY_DRAW_NEEDED "draw"
#define IMAGE_PATH "/img.bin"
#define FETCH_INTERVAL_MS 60000ULL
#define WIFI_TIMEOUT_MS 30000
#define RESET_HOLD_MS 5000
#define AP_IP "192.168.4.1"
#define PIN_CS 5
#define PIN_DC 17
#define PIN_RST 16
#define PIN_BUSY 4
#define PIN_SCK 18
#define PIN_MOSI 23
#define PIN_BTN_RESET 0
// Color constants (also defined in Arduino mock, repeated here for clarity)
#define COLOR_BLACK 0x0
#define COLOR_WHITE 0x1
#define COLOR_YELLOW 0x2
#define COLOR_RED 0x3
#define COLOR_BLUE 0x5
#define COLOR_GREEN 0x6
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "Arduino.h"
// Call counters for assertions
extern int g_epd_init_count;
extern int g_epd_sleep_count;
extern int g_epd_draw_image_count;
extern int g_epd_fill_count;
extern int g_epd_fill_last_color;
extern int g_epd_draw_setup_count;
inline void epd_init() { g_epd_init_count++; }
inline void epd_sleep() { g_epd_sleep_count++; }
inline void epd_draw_image_from_file(File& f) { g_epd_draw_image_count++; }
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
inline void epd_draw_ap_screen(void*) {}
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "Arduino.h"
// Shared sequence counter — incremented by each instrumented mock call
extern int g_call_seq;
extern int g_epd_draw_seq; // sequence position of last epd_draw_image_from_file call
// Call counters for assertions
extern int g_epd_init_count;
extern int g_epd_sleep_count;
extern int g_epd_draw_image_count;
extern int g_epd_fill_count;
extern int g_epd_fill_last_color;
extern int g_epd_draw_setup_count;
inline void epd_init() { g_epd_init_count++; }
inline void epd_sleep() { g_epd_sleep_count++; }
inline void epd_draw_image_from_file(File& f) {
g_epd_draw_image_count++;
if (g_epd_draw_seq < 0) g_epd_draw_seq = g_call_seq;
g_call_seq++;
}
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
inline void epd_draw_ap_screen(void*) {}
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <cstdint>
extern uint64_t g_sleep_us;
extern bool g_deep_sleep_started;
inline void esp_sleep_enable_timer_wakeup(uint64_t us) { g_sleep_us = us; }
inline void esp_deep_sleep_start() { g_deep_sleep_started = true; }
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <cstdint>
#include <cstddef>
struct QRCode { int size; };
inline size_t qrcode_getBufferSize(int) { return 128; }
inline void qrcode_initText(QRCode* qr, uint8_t*, int, int, const char*) { qr->size = 21; }
#define ECC_LOW 0
@@ -0,0 +1,248 @@
#include <unity.h>
#include <map>
#include <string>
#include <cstdint>
#include <cctype>
// Include mocks first — they shadow system/Arduino headers.
// -iquote test/mocks ensures quoted includes from test_main find mocks first.
// operation.h uses #ifdef UNIT_TEST to pick epd_mock.h and esp_sleep.h.
#include "Arduino.h"
#include "WiFi.h"
#include "WiFiClientSecure.h"
#include "Preferences.h"
#include "LittleFS.h"
#include "epd_mock.h"
#include "esp_sleep.h"
#include "HTTPClient.h"
#include "SPI.h"
#include "WebServer.h"
#include "DNSServer.h"
#include "qrcode.h"
#include "config.h"
// Define globals referenced as extern in the mock headers
int g_http_get_code;
std::map<std::string, std::string> g_http_response_headers;
std::map<std::string, std::string> g_http_request_headers;
bool g_http_end_called;
std::string g_http_body;
int g_epd_init_count, g_epd_sleep_count, g_epd_draw_image_count;
int g_epd_fill_count, g_epd_fill_last_color, g_epd_draw_setup_count;
uint64_t g_sleep_us;
bool g_deep_sleep_started;
// Globals for new mocks
int g_show_setup_qr_count;
uint32_t g_millis_value;
int g_digital_read_value;
int g_wifi_status;
// Ordering / sequencing globals (shared with Preferences.h and epd_mock.h)
int g_call_seq = 0;
int g_prefs_putint_seq = -1;
int g_epd_draw_seq = -1;
// Include the template under test AFTER all mocks are defined.
// operation.h with UNIT_TEST defined will include "epd_mock.h" and "esp_sleep.h"
// via -iquote test/mocks path (real src/epd.h is never pulled in).
#include "../../src/operation.h"
// Test fixtures
static Preferences prefs;
static MockHTTPClient http;
void reset_state() {
g_http_get_code = 200;
g_http_response_headers.clear();
g_http_request_headers.clear();
g_http_end_called = false;
g_http_body = "TESTDATA";
g_epd_init_count = g_epd_sleep_count = g_epd_draw_image_count = 0;
g_epd_fill_count = g_epd_fill_last_color = g_epd_draw_setup_count = 0;
g_sleep_us = 0;
g_deep_sleep_started = false;
g_show_setup_qr_count = 0;
g_millis_value = 0;
g_digital_read_value = HIGH; // button not pressed by default
g_wifi_status = WL_CONNECTED; // connected by default
prefs.clear();
LittleFS.files.clear();
http._ended = false;
g_call_seq = 0;
g_prefs_putint_seq = -1;
g_epd_draw_seq = -1;
}
void setUp() { reset_state(); }
void tearDown() {}
// FW-01: 200 response — file written, epd_draw called, NVS saved, deep sleep started
void test_fw01_200_response_happy_path() {
// Use an interval < FETCH_INTERVAL_MS so server value is honored
g_http_response_headers["X-Image-Id"] = "42";
g_http_response_headers["X-Interval-Ms"] = "30000";
g_http_body = "BINDATA";
normal_operation_impl(String("1C:C3:AB:D1:91:F8"), http, String("https://test/api/device/mac/image"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
TEST_ASSERT_TRUE(g_deep_sleep_started);
TEST_ASSERT_FALSE(LittleFS.files[IMAGE_PATH].empty());
}
// FW-02: REGRESSION — headers must be read BEFORE http.end(), otherwise newId is empty
void test_fw02_headers_read_before_end_regression() {
g_http_response_headers["X-Image-Id"] = "99";
normal_operation_impl(String("mac"), http, String("url"), prefs);
// If newId was read after end(), NVS img_id would remain -1
TEST_ASSERT_EQUAL(99, prefs.getInt("img_id", -1));
}
// FW-03: 304 — no epd draw, no init, deep sleep started
void test_fw03_304_no_redraw() {
g_http_get_code = 304;
// Use an interval < FETCH_INTERVAL_MS so server value is honored
g_http_response_headers["X-Interval-Ms"] = "30000";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_init_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
TEST_ASSERT_TRUE(g_deep_sleep_started);
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
}
// FW-04: 204 — show_setup_qr called exactly once
void test_fw04_204_shows_setup_qr() {
g_http_get_code = 204;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
}
// FW-05: 404 — show_setup_qr called exactly once
void test_fw05_404_shows_setup_qr() {
g_http_get_code = 404;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_show_setup_qr_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
}
// FW-06: other error — epd_fill yellow
void test_fw06_error_fills_yellow() {
g_http_get_code = 500;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_fill_count);
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_fill_last_color);
}
// FW-07: NVS has saved img_id → X-Current-Image-Id header sent
void test_fw07_current_image_id_sent_when_saved() {
prefs.ints["img_id"] = 99;
g_http_response_headers["X-Image-Id"] = "99";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_STRING("99", g_http_request_headers["X-Current-Image-Id"].c_str());
}
// FW-08: NVS img_id = -1 (default) → X-Current-Image-Id NOT sent
void test_fw08_no_current_image_id_when_default() {
// prefs has no img_id — getInt returns -1
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_TRUE(g_http_request_headers.find("X-Current-Image-Id") == g_http_request_headers.end());
}
// FW-09: server interval < FETCH_INTERVAL_MS → server value used
void test_fw09_server_interval_honored() {
g_http_response_headers["X-Interval-Ms"] = "30000";
g_http_response_headers["X-Image-Id"] = "1";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us);
}
// FW-10: server interval > FETCH_INTERVAL_MS → capped at ceiling
void test_fw10_server_interval_capped() {
g_http_response_headers["X-Interval-Ms"] = "999999999";
g_http_response_headers["X-Image-Id"] = "1";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
}
// FW-11: no X-Interval-Ms → default ceiling used
void test_fw11_default_interval_when_absent() {
g_http_response_headers["X-Image-Id"] = "1";
// no X-Interval-Ms set
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us);
}
// FW-14: 304 — epd_sleep NOT called (display already in hardware deep sleep)
void test_fw14_304_skips_epd_sleep() {
g_http_get_code = 304;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_sleep_count);
TEST_ASSERT_EQUAL(0, g_epd_init_count);
}
// FW-15: 200 — NVS img_id saved BEFORE epd_draw_image_from_file; draw_needed cleared after
void test_fw15_nvs_saved_before_epd_draw_and_flag_cleared() {
g_http_response_headers["X-Image-Id"] = "42";
g_http_body = "BINDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_TRUE_MESSAGE(g_prefs_putint_seq < g_epd_draw_seq,
"NVS putInt must be called before epd_draw_image_from_file");
TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1));
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
}
// FW-16: 304 with draw_needed=1 (interrupted draw) — re-draws from LittleFS and clears flag
void test_fw16_304_with_draw_needed_redraws() {
prefs.ints["img_id"] = 42;
prefs.ints["draw"] = 1;
g_http_get_code = 304;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_init_count);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(1, g_epd_sleep_count);
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
}
// FW-12/13: AP SSID derivation via ap_ssid_from_mac()
void test_fw12_ap_ssid_from_mac_aabbcc() {
String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF"));
TEST_ASSERT_EQUAL_STRING("PictureFrame-EEFF", ssid.c_str());
}
void test_fw13_ap_ssid_from_real_mac() {
String ssid = ap_ssid_from_mac(String("1C:C3:AB:D1:91:F8"));
TEST_ASSERT_EQUAL_STRING("PictureFrame-91F8", ssid.c_str());
}
int main(int argc, char** argv) {
UNITY_BEGIN();
RUN_TEST(test_fw01_200_response_happy_path);
RUN_TEST(test_fw02_headers_read_before_end_regression);
RUN_TEST(test_fw03_304_no_redraw);
RUN_TEST(test_fw04_204_shows_setup_qr);
RUN_TEST(test_fw05_404_shows_setup_qr);
RUN_TEST(test_fw06_error_fills_yellow);
RUN_TEST(test_fw07_current_image_id_sent_when_saved);
RUN_TEST(test_fw08_no_current_image_id_when_default);
RUN_TEST(test_fw09_server_interval_honored);
RUN_TEST(test_fw10_server_interval_capped);
RUN_TEST(test_fw11_default_interval_when_absent);
RUN_TEST(test_fw12_ap_ssid_from_mac_aabbcc);
RUN_TEST(test_fw13_ap_ssid_from_real_mac);
RUN_TEST(test_fw14_304_skips_epd_sleep);
RUN_TEST(test_fw15_nvs_saved_before_epd_draw_and_flag_cleared);
RUN_TEST(test_fw16_304_with_draw_needed_redraws);
return UNITY_END();
}
@@ -0,0 +1,84 @@
#include <unity.h>
#include <cstdint>
#include <cctype>
// Include mocks first
#include "Arduino.h"
#include "WiFi.h"
#include "Preferences.h"
#include "config.h"
// Define all extern globals required by mock headers
uint32_t g_millis_value;
int g_digital_read_value;
int g_wifi_status;
// operation.h uses g_show_setup_qr_count under UNIT_TEST
int g_show_setup_qr_count;
// Include the functions under test
#include "../../src/operation.h"
void reset_state() {
g_millis_value = 0;
g_digital_read_value = HIGH; // button not pressed
g_wifi_status = WL_CONNECTED;
g_show_setup_qr_count = 0;
}
void setUp() { reset_state(); }
void tearDown() {}
// ── FW-14: attempt_wifi returns true when WiFi connects immediately ───────────
void test_fw14_attempt_wifi_returns_true_on_connect() {
g_wifi_status = WL_CONNECTED;
bool result = attempt_wifi("myssid", "mypass");
TEST_ASSERT_TRUE(result);
}
// ── FW-15: attempt_wifi returns false after timeout ───────────────────────────
// millis() auto-increments by 10 on each call; after enough iterations the
// elapsed time exceeds WIFI_TIMEOUT_MS (30000 ms).
void test_fw15_attempt_wifi_returns_false_on_timeout() {
g_wifi_status = 0; // never WL_CONNECTED
g_millis_value = 0;
bool result = attempt_wifi("myssid", "mypass");
TEST_ASSERT_FALSE(result);
}
// ── FW-16: loop() state-machine (WiFi-credential submission path) ─────────────
// This test is deferred: loop() orchestrates DNS, WebServer, and WiFi
// together in a single function, making it impractical to unit-test without
// a larger integration harness. The provisioning behavior is covered at the
// integration / hardware level.
// Placeholder: always passes as a reminder.
void test_fw16_loop_state_machine_deferred() {
TEST_PASS_MESSAGE("FW-16 deferred: loop() state machine requires integration harness");
}
// ── FW-17: check_reset_button returns true when button held past threshold ────
// g_digital_read_value = LOW keeps the while-loop spinning; millis()
// auto-increments by 10 per call and will exceed RESET_HOLD_MS (5000 ms).
void test_fw17_reset_button_held_returns_true() {
g_digital_read_value = LOW;
g_millis_value = 0;
bool result = check_reset_button();
TEST_ASSERT_TRUE(result);
}
// ── FW-18: check_reset_button returns false when button not pressed ───────────
void test_fw18_reset_button_not_pressed_returns_false() {
g_digital_read_value = HIGH; // button not pressed — loop exits immediately
bool result = check_reset_button();
TEST_ASSERT_FALSE(result);
}
int main(int argc, char** argv) {
UNITY_BEGIN();
RUN_TEST(test_fw14_attempt_wifi_returns_true_on_connect);
RUN_TEST(test_fw15_attempt_wifi_returns_false_on_timeout);
RUN_TEST(test_fw16_loop_state_machine_deferred);
RUN_TEST(test_fw17_reset_button_held_returns_true);
RUN_TEST(test_fw18_reset_button_not_pressed_returns_false);
return UNITY_END();
}
+2289 -48
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -6,20 +6,31 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"konva": "^10.3.0",
"pinia": "^3.0.4",
"sass": "^1.99.0",
"vue": "^3.5.32",
"vue-konva": "^3.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.5",
"@vue/test-utils": "^2.4.10",
"@vue/tsconfig": "^0.9.1",
"happy-dom": "^20.9.0",
"jsdom": "^29.1.1",
"msw": "^2.14.3",
"typescript": "~6.0.2",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"vue-tsc": "^3.2.7"
}
}
+18 -1
View File
@@ -1,10 +1,27 @@
<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 route = useRoute()
const auth = useAuthStore()
const { applyTheme } = useTheme()
onMounted(() => {
const stamped = document.documentElement.dataset.theme
if (stamped && auth.user) {
auth.user.theme = stamped
} else if (auth.user?.theme) {
applyTheme(auth.user.theme)
}
})
</script>
+44
View File
@@ -0,0 +1,44 @@
export interface StickerDef {
id: string
category: 'seasonal' | 'holidays' | 'fun' | 'family' | 'nature'
label: string
emoji: string
}
export const STICKER_CATEGORIES = [
{ id: 'seasonal', label: 'Seasonal' },
{ id: 'holidays', label: 'Holidays' },
{ id: 'fun', label: 'Fun' },
{ id: 'family', label: 'Family' },
{ id: 'nature', label: 'Nature' },
] as const
export type StickerCategory = typeof STICKER_CATEGORIES[number]['id']
export const STICKERS: StickerDef[] = [
{ id: 'sea-snow', category: 'seasonal', label: 'Snowflake', emoji: '❄️' },
{ id: 'sea-sun', category: 'seasonal', label: 'Sun', emoji: '☀️' },
{ id: 'sea-leaves', category: 'seasonal', label: 'Autumn', emoji: '🍂' },
{ id: 'sea-blossom', category: 'seasonal', label: 'Blossom', emoji: '🌸' },
{ id: 'sea-snowman', category: 'seasonal', label: 'Snowman', emoji: '⛄' },
{ id: 'hol-tree', category: 'holidays', label: 'Tree', emoji: '🎄' },
{ id: 'hol-gift', category: 'holidays', label: 'Gift', emoji: '🎁' },
{ id: 'hol-heart', category: 'holidays', label: 'Heart', emoji: '❤️' },
{ id: 'hol-party', category: 'holidays', label: 'Party', emoji: '🎉' },
{ id: 'hol-cake', category: 'holidays', label: 'Cake', emoji: '🎂' },
{ id: 'fun-star', category: 'fun', label: 'Star', emoji: '⭐' },
{ id: 'fun-rainbow', category: 'fun', label: 'Rainbow', emoji: '🌈' },
{ id: 'fun-balloon', category: 'fun', label: 'Balloon', emoji: '🎈' },
{ id: 'fun-sparkle', category: 'fun', label: 'Sparkles', emoji: '✨' },
{ id: 'fun-fire', category: 'fun', label: 'Fire', emoji: '🔥' },
{ id: 'fam-house', category: 'family', label: 'Home', emoji: '🏠' },
{ id: 'fam-paw', category: 'family', label: 'Paw', emoji: '🐾' },
{ id: 'fam-camera', category: 'family', label: 'Camera', emoji: '📷' },
{ id: 'fam-plane', category: 'family', label: 'Airplane', emoji: '✈️' },
{ id: 'fam-music', category: 'family', label: 'Music', emoji: '🎵' },
{ id: 'nat-tree', category: 'nature', label: 'Tree', emoji: '🌲' },
{ id: 'nat-flower', category: 'nature', label: 'Flower', emoji: '🌺' },
{ id: 'nat-bee', category: 'nature', label: 'Bee', emoji: '🐝' },
{ id: 'nat-fly', category: 'nature', label: 'Butterfly', emoji: '🦋' },
{ id: 'nat-moon', category: 'nature', label: 'Moon', emoji: '🌙' },
]
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="approve-card">
<img :src="item.thumbnailUrl" :alt="`Photo from ${item.sharedBy}`" class="approve-card__thumb" loading="lazy" />
<div class="approve-card__body">
<p class="approve-card__from">From <strong>{{ item.sharedBy }}</strong></p>
<p class="approve-card__date">{{ formattedDate }}</p>
<div class="approve-card__status" v-if="item.status !== 'pending'">
<span :class="['approve-card__badge', `approve-card__badge--${item.status}`]">
{{ item.status }}
</span>
</div>
<div class="approve-card__actions">
<template v-if="item.status === 'pending' || item.status === 'declined'">
<BaseButton variant="primary" size="sm" :disabled="busy" @click="showPicker = true">
{{ item.status === 'declined' ? 'Add anyway' : 'Add to frame' }}
</BaseButton>
</template>
<template v-if="item.status === 'pending' || item.status === 'approved'">
<BaseButton variant="ghost" size="sm" :disabled="busy" @click="decline">
{{ item.status === 'approved' ? 'Remove' : 'Decline' }}
</BaseButton>
</template>
</div>
</div>
</div>
<DevicePicker
v-model="showPicker"
:devices="devicesStore.devices"
:selected="selectedDeviceIds"
:uploading="busy"
confirm-label="Add to frames"
@update:selected="selectedDeviceIds = $event"
@confirm="approve"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { SharedImage } from '@/types'
import BaseButton from '@/components/BaseButton.vue'
import DevicePicker from '@/components/DevicePicker.vue'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
const props = defineProps<{ item: SharedImage }>()
const emit = defineEmits<{ (e: 'updated', v: SharedImage): void }>()
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
const showPicker = ref(false)
const busy = ref(false)
const selectedDeviceIds = ref<number[]>([])
const formattedDate = computed(() =>
new Date(props.item.sharedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
)
async function approve() {
showPicker.value = false
busy.value = true
try {
const updated = await imagesStore.approveShared(props.item.id, selectedDeviceIds.value)
emit('updated', updated)
} finally {
busy.value = false
selectedDeviceIds.value = []
}
}
async function decline() {
busy.value = true
try {
const updated = await imagesStore.declineShared(props.item.id)
emit('updated', updated)
} finally {
busy.value = false
}
}
</script>
<style scoped lang="scss">
.approve-card {
display: flex;
gap: var(--space-3);
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
background: var(--color-border);
}
&__body {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
&__from { font-size: var(--text-sm); }
&__date { font-size: var(--text-xs); color: var(--color-text-muted); }
&__badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
text-transform: capitalize;
&--approved { background: #d4edda; color: #1a7f4b; }
&--declined { background: #fde8e8; color: #d93025; }
}
&__actions {
display: flex;
gap: var(--space-2);
margin-top: auto;
flex-wrap: wrap;
}
}
</style>
+39 -4
View File
@@ -8,7 +8,12 @@
:aria-label="tab.label"
:aria-current="isActive(tab.to) ? 'page' : undefined"
>
<span class="bottom-nav__icon" aria-hidden="true" v-html="tab.icon" />
<span class="bottom-nav__icon-wrap" aria-hidden="true">
<span class="bottom-nav__icon" v-html="tab.icon" />
<span v-if="tab.name === 'shared' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}
</span>
</span>
<span class="bottom-nav__label">{{ tab.label }}</span>
</RouterLink>
</nav>
@@ -16,8 +21,10 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useImagesStore } from '@/stores/images'
const route = useRoute()
const route = useRoute()
const imagesStore = useImagesStore()
const tabs = [
{
@@ -35,7 +42,7 @@ const tabs = [
{
name: 'shared',
label: 'Shared',
to: '/shared',
to: '/library?tab=shared',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
},
{
@@ -46,7 +53,8 @@ const tabs = [
},
]
function isActive(path: string) {
function isActive(to: string) {
const path = to.split('?')[0]
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
@@ -85,6 +93,15 @@ function isActive(path: string) {
}
}
&__icon-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
&__icon {
display: flex;
align-items: center;
@@ -93,6 +110,24 @@ function isActive(path: string) {
height: 24px;
}
&__badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--color-primary);
color: var(--color-primary-fg);
border-radius: 999px;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
&__label {
font-size: var(--text-xs);
font-weight: 600;
+337
View File
@@ -0,0 +1,337 @@
<template>
<div class="crop-editor" ref="containerRef">
<canvas
ref="canvasRef"
class="crop-editor__canvas"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<div class="crop-editor__label" v-if="deviceName">{{ deviceName }}</div>
<div class="crop-editor__actions">
<BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop">
Use this crop
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import type { CropParams } from '@/types'
const props = defineProps<{
src: string
orientation: 'landscape' | 'portrait'
deviceName?: string
initialParams?: CropParams | null
}>()
const emit = defineEmits<{
(e: 'crop', result: { blob: Blob; params: CropParams }): void
}>()
// Dimensions for each orientation
const OUTPUT_W = props.orientation === 'landscape' ? 1600 : 960
const OUTPUT_H = props.orientation === 'landscape' ? 960 : 1600
const ASPECT = OUTPUT_W / OUTPUT_H
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
let ctx: CanvasRenderingContext2D | null = null
let img: HTMLImageElement | null = null
let rafId = 0
// State: pan (canvas px from centered) + zoom multiplier
const panX = ref(0)
const panY = ref(0)
const zoom = ref(1)
// Crop rect on canvas (set when canvas is sized)
let cropRect = { x: 0, y: 0, w: 0, h: 0 }
let minScale = 1 // natural px canvas px at zoom=1 (cover)
function sizeCanvas() {
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
const available = container.getBoundingClientRect()
// Leave space for bottom button bar
const availH = available.height - 80
const availW = available.width
canvas.width = availW
canvas.height = availH
ctx = canvas.getContext('2d')
// Compute crop rect (inset 24px each side for comfort)
const pad = 24
const maxW = availW - pad * 2
const maxH = availH - pad * 2
let cropW: number, cropH: number
if (maxW / maxH > ASPECT) {
cropH = maxH
cropW = cropH * ASPECT
} else {
cropW = maxW
cropH = cropW / ASPECT
}
cropRect = {
x: (availW - cropW) / 2,
y: (availH - cropH) / 2,
w: cropW,
h: cropH,
}
if (img) {
resetView()
}
}
function resetView() {
if (!img) return
minScale = Math.max(cropRect.w / img.naturalWidth, cropRect.h / img.naturalHeight)
if (props.initialParams) {
restoreView(props.initialParams)
} else {
zoom.value = 1
panX.value = 0
panY.value = 0
draw()
}
}
function restoreView(p: CropParams) {
if (!img) return
// actualScale such that natW fills the crop frame width
const actualScale = cropRect.w / p.natW
zoom.value = actualScale / minScale
// pan: offset from centered position so crop center = frame center
panX.value = actualScale * (img.naturalWidth / 2 - p.natX - p.natW / 2)
panY.value = actualScale * (img.naturalHeight / 2 - p.natY - p.natH / 2)
const [cx, cy] = clampPan(panX.value, panY.value)
panX.value = cx
panY.value = cy
draw()
}
function clampPan(px: number, py: number): [number, number] {
if (!img) return [px, py]
const actualScale = minScale * zoom.value
const imgW = img.naturalWidth * actualScale
const imgH = img.naturalHeight * actualScale
const maxPx = (imgW - cropRect.w) / 2
const maxPy = (imgH - cropRect.h) / 2
return [
Math.max(-maxPx, Math.min(maxPx, px)),
Math.max(-maxPy, Math.min(maxPy, py)),
]
}
function draw() {
if (!ctx || !img || !canvasRef.value) return
const { width, height } = canvasRef.value
const actualScale = minScale * zoom.value
const imgW = img.naturalWidth * actualScale
const imgH = img.naturalHeight * actualScale
const cx = cropRect.x + cropRect.w / 2 + panX.value
const cy = cropRect.y + cropRect.h / 2 + panY.value
const imgLeft = cx - imgW / 2
const imgTop = cy - imgH / 2
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
// Dark overlay outside crop
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.55)'
ctx.fillRect(0, 0, width, height)
ctx.globalCompositeOperation = 'destination-out'
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
ctx.restore()
// Crop border + corner marks
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.strokeRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
const cLen = 20
ctx.lineWidth = 3
;[
[cropRect.x, cropRect.y, cLen, 0, 0, cLen],
[cropRect.x + cropRect.w, cropRect.y, -cLen, 0, 0, cLen],
[cropRect.x, cropRect.y + cropRect.h, cLen, 0, 0, -cLen],
[cropRect.x + cropRect.w, cropRect.y + cropRect.h, -cLen, 0, 0, -cLen],
].forEach(([x, y, dx1, dy1, dx2, dy2]) => {
ctx!.beginPath()
ctx!.moveTo(x + dx1, y + dy1)
ctx!.lineTo(x, y)
ctx!.lineTo(x + dx2, y + dy2)
ctx!.stroke()
})
}
// Touch / pointer handling
const activePointers = new Map<number, { x: number; y: number }>()
let lastPinchDist = 0
function onPointerDown(e: PointerEvent) {
canvasRef.value?.setPointerCapture(e.pointerId)
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (activePointers.size === 2) {
const pts = [...activePointers.values()]
lastPinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
}
}
function onPointerMove(e: PointerEvent) {
if (!activePointers.has(e.pointerId)) return
const prev = activePointers.get(e.pointerId)!
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (activePointers.size === 1) {
const dx = e.clientX - prev.x
const dy = e.clientY - prev.y
const [cx, cy] = clampPan(panX.value + dx, panY.value + dy)
panX.value = cx
panY.value = cy
scheduleDraw()
return
}
if (activePointers.size === 2) {
const pts = [...activePointers.values()]
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
if (lastPinchDist > 0) {
const ratio = dist / lastPinchDist
const newZoom = Math.max(1, zoom.value * ratio)
zoom.value = newZoom
const [cx, cy] = clampPan(panX.value, panY.value)
panX.value = cx
panY.value = cy
scheduleDraw()
}
lastPinchDist = dist
}
}
function onPointerUp(e: PointerEvent) {
activePointers.delete(e.pointerId)
lastPinchDist = 0
}
function scheduleDraw() {
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(draw)
}
// Output
async function useCrop() {
if (!img) return
const actualScale = minScale * zoom.value
const cx = cropRect.x + cropRect.w / 2 + panX.value
const cy = cropRect.y + cropRect.h / 2 + panY.value
const imgLeft = cx - img.naturalWidth * actualScale / 2
const imgTop = cy - img.naturalHeight * actualScale / 2
const natCropX = (cropRect.x - imgLeft) / actualScale
const natCropY = (cropRect.y - imgTop) / actualScale
const natCropW = cropRect.w / actualScale
const natCropH = cropRect.h / actualScale
const out = new OffscreenCanvas(OUTPUT_W, OUTPUT_H)
const outCtx = out.getContext('2d')!
outCtx.drawImage(img, natCropX, natCropY, natCropW, natCropH, 0, 0, OUTPUT_W, OUTPUT_H)
const blob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
emit('crop', {
blob,
params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH },
})
}
// Lifecycle
const ro = new ResizeObserver(sizeCanvas)
onMounted(() => {
if (containerRef.value) ro.observe(containerRef.value)
sizeCanvas()
img = new Image()
img.onload = () => {
sizeCanvas()
resetView()
}
img.src = props.src
})
watch(() => props.src, src => {
if (!img) return
img.onload = () => resetView()
img.src = src
})
onBeforeUnmount(() => {
ro.disconnect()
cancelAnimationFrame(rafId)
})
</script>
<style scoped lang="scss">
.crop-editor {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #000;
touch-action: none;
&__canvas {
flex: 1;
min-height: 0;
display: block;
touch-action: none;
cursor: grab;
&:active { cursor: grabbing; }
}
&__label {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: var(--text-xs);
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
letter-spacing: 0.04em;
pointer-events: none;
}
&__actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--space-4);
display: flex;
justify-content: center;
}
&__use-btn {
width: 100%;
max-width: 320px;
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Choose frames" @update:model-value="$emit('update:modelValue', $event)">
<h2 class="device-picker__title">Add to frames</h2>
<p class="device-picker__sub">Choose which frames will show this photo.</p>
<div class="device-picker__list">
<label
v-for="device in devices"
:key="device.id"
class="device-picker__row"
>
<input
type="checkbox"
class="device-picker__check"
:checked="selected.includes(device.id)"
@change="toggle(device.id)"
/>
<span class="device-picker__name">{{ device.name }}</span>
<span class="device-picker__orientation">{{ device.orientation }}</span>
</label>
</div>
<BaseButton
variant="primary"
class="device-picker__confirm"
:disabled="selected.length === 0 || uploading"
@click="$emit('confirm')"
>
{{ uploading ? 'Uploading…' : confirmLabel }}
</BaseButton>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import type { Device } from '@/types'
const props = defineProps<{
modelValue: boolean
devices: Device[]
selected: number[]
uploading?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'update:selected', v: number[]): void
(e: 'confirm'): void
}>()
function toggle(id: number) {
if (props.selected.includes(id)) {
emit('update:selected', props.selected.filter(d => d !== id))
} else {
emit('update:selected', [...props.selected, id])
}
}
const confirmLabel = computed(() => {
const n = props.selected.length
return n === 0 ? 'Add to frame' : `Add to ${n} frame${n > 1 ? 's' : ''}`
})
</script>
<style scoped lang="scss">
.device-picker {
&__title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
&__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-5);
}
&__row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
min-height: var(--touch-min);
}
&__check {
width: 20px;
height: 20px;
accent-color: var(--color-primary);
cursor: pointer;
flex-shrink: 0;
}
&__name {
flex: 1;
font-size: var(--text-base);
font-weight: 600;
}
&__orientation {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: capitalize;
}
&__confirm {
width: 100%;
}
}
</style>
+205
View File
@@ -0,0 +1,205 @@
<template>
<div
:class="[
'frame-card',
`frame-card--${size}`,
status !== 'ok' && `frame-card--${status}`,
]"
>
<!-- Status badge (color + text never color alone) -->
<div v-if="status !== 'ok'" class="frame-card__status-badge" aria-live="polite">
<span class="frame-card__status-dot" aria-hidden="true" />
{{ status === 'offline' ? 'Offline' : 'Sync issue' }}
</div>
<!-- Settings button (large card only) -->
<button
v-if="size === 'large'"
class="frame-card__settings-btn"
type="button"
aria-label="Frame settings"
@click="$emit('edit', deviceId)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Preview area -->
<div class="frame-card__preview" :style="previewStyle">
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
:alt="`Current photo on ${name}`"
class="frame-card__img"
/>
<div v-else class="frame-card__empty-preview" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
</div>
</div>
<div class="frame-card__body">
<p class="frame-card__name">{{ name }}</p>
<p v-if="size === 'compact'" class="frame-card__count">
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
</p>
<BaseButton
:variant="size === 'large' ? 'primary' : 'icon-pill'"
:aria-label="size === 'large' ? `Add photo to ${name}` : `Add photo to ${name}`"
class="frame-card__add-btn"
@click="$emit('add-photo', deviceId)"
>
<span v-if="size === 'large'">+ Add Photo</span>
<span v-else aria-hidden="true">+</span>
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
const props = defineProps<{
deviceId: number
name: string
size: 'large' | 'compact'
status: 'ok' | 'offline' | 'sync-fail'
orientation: 'landscape' | 'portrait'
thumbnailUrl?: string
photoCount?: number
}>()
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
const previewStyle = computed(() =>
props.size === 'large'
? { aspectRatio: props.orientation === 'portrait' ? '3/5' : '5/3' }
: {}
)
</script>
<style scoped lang="scss">
.frame-card {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--duration-fast);
&--offline { border-color: #c0392b; }
&--sync-fail { border-color: #c49a20; }
&__status-badge {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 700;
background: var(--color-surface-2);
.frame-card--offline & { color: #c0392b; }
.frame-card--sync-fail & { color: #8a6a00; }
}
&__status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
.frame-card--offline & { background: #c0392b; }
.frame-card--sync-fail & { background: #c49a20; }
}
// Large (single device)
&--large &__preview {
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
}
&--large &__img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--large &__empty-preview {
color: var(--color-text-muted);
opacity: 0.5;
}
&--large &__body {
padding: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
&--large &__name {
font-size: var(--text-md);
font-weight: 700;
}
// Compact (multi device)
&--compact {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
}
&--compact &__preview {
width: 52px;
height: 52px;
border-radius: var(--radius-sm);
background: var(--color-surface-2);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
&--compact &__img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--compact &__empty-preview {
color: var(--color-text-muted);
opacity: 0.4;
}
&--compact &__body {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
&--compact &__name {
font-size: var(--text-base);
font-weight: 700;
}
&--compact &__count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
&__add-btn { flex-shrink: 0; }
}
</style>
@@ -0,0 +1,118 @@
<template>
<div class="orientation-picker" role="radiogroup" aria-label="Display orientation">
<button
v-for="opt in OPTIONS"
:key="opt.value"
type="button"
role="radio"
:aria-checked="modelValue === opt.value"
:aria-label="opt.label"
:class="['orientation-opt', { 'orientation-opt--active': modelValue === opt.value }]"
@click="$emit('update:modelValue', opt.value)"
>
<!-- Frame diagram: rectangle + ribbon indicator -->
<svg
class="orientation-opt__diagram"
:viewBox="opt.viewBox"
fill="none"
aria-hidden="true"
>
<!-- Display body -->
<rect v-bind="opt.rect" rx="2"
:stroke="modelValue === opt.value ? 'var(--color-primary)' : 'currentColor'"
stroke-width="1.5"
:fill="modelValue === opt.value ? 'color-mix(in srgb, var(--color-primary) 12%, transparent)' : 'var(--color-surface-2)'"
/>
<!-- Ribbon cable indicator -->
<rect v-bind="opt.ribbon"
:fill="modelValue === opt.value ? 'var(--color-primary)' : 'var(--color-text-muted)'"
rx="1"
/>
</svg>
<span class="orientation-opt__label">{{ opt.label }}</span>
<span class="orientation-opt__sub">{{ opt.sub }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import type { Device } from '@/types'
type Orientation = Device['orientation']
defineProps<{ modelValue: Orientation }>()
defineEmits<{ 'update:modelValue': [value: Orientation] }>()
// All diagrams use a 48×48 viewBox.
// rect = the display body; ribbon = the cable tab protruding from one edge.
const OPTIONS: Array<{
value: Orientation
label: string
sub: string
viewBox: string
rect: Record<string, number>
ribbon: Record<string, number>
}> = [
{
value: 'landscape',
label: 'Landscape',
sub: 'Ribbon at bottom',
viewBox: '0 0 48 48',
rect: { x: 4, y: 12, width: 40, height: 24 },
ribbon: { x: 20, y: 36, width: 8, height: 5 },
},
{
value: 'portrait',
label: 'Portrait',
sub: 'Ribbon on left',
viewBox: '0 0 48 48',
rect: { x: 12, y: 4, width: 24, height: 40 },
ribbon: { x: 7, y: 20, width: 5, height: 8 },
},
]
</script>
<style scoped lang="scss">
.orientation-picker {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.orientation-opt {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-3) var(--space-2);
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--duration-fast);
min-height: var(--touch-min);
&--active {
border-color: var(--color-primary);
}
&__diagram {
width: 48px;
height: 48px;
color: var(--color-text-muted);
}
&__label {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text);
}
&__sub {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
}
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Share photo" @update:model-value="$emit('update:modelValue', $event)">
<h2 class="share-sheet__title">Share with someone</h2>
<p class="share-sheet__sub">They'll get an email and can add it to their frame.</p>
<div class="share-sheet__field">
<input
v-model="email"
type="email"
class="share-sheet__input"
placeholder="their@email.com"
autocomplete="email"
@keydown.enter.prevent="submit"
/>
</div>
<p v-if="errorMsg" class="share-sheet__error">{{ errorMsg }}</p>
<p v-if="successMsg" class="share-sheet__success">{{ successMsg }}</p>
<BaseButton
variant="primary"
class="share-sheet__btn"
:disabled="sending || !email.trim()"
@click="submit"
>
{{ sending ? 'Sending…' : 'Send invite' }}
</BaseButton>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import { useImagesStore } from '@/stores/images'
const props = defineProps<{
modelValue: boolean
imageId: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const imagesStore = useImagesStore()
const email = ref('')
const sending = ref(false)
const errorMsg = ref('')
const successMsg = ref('')
async function submit() {
errorMsg.value = ''
successMsg.value = ''
if (!email.value.trim()) return
sending.value = true
try {
await imagesStore.shareImage(props.imageId, email.value.trim())
successMsg.value = `Invite sent to ${email.value.trim()}`
email.value = ''
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'Failed to send'
} finally {
sending.value = false
}
}
</script>
<style scoped lang="scss">
.share-sheet {
&__title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-1); }
&__sub { font-size: var(--text-sm); color: var(--color-text-muted); margin-bottom: var(--space-4); }
&__field { margin-bottom: var(--space-3); }
&__input {
width: 100%;
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
&:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; }
}
&__error { font-size: var(--text-sm); color: var(--color-danger, #d93025); margin-bottom: var(--space-3); }
&__success { font-size: var(--text-sm); color: var(--color-success, #1a7f4b); margin-bottom: var(--space-3); }
&__btn { width: 100%; }
}
</style>
+353
View File
@@ -0,0 +1,353 @@
<template>
<div class="sticker-canvas" ref="containerRef">
<v-stage
ref="stageRef"
:config="stageConfig"
@click="onStageClick"
@tap="onStageClick"
>
<v-layer>
<v-image :config="imageConfig" />
</v-layer>
<v-layer ref="stickerLayerRef">
<v-text
v-for="s in stickers"
:key="s.id"
:config="stickerConfig(s)"
@click="selectSticker(s.id, $event)"
@tap="selectSticker(s.id, $event)"
@dragend="onDragEnd(s.id, $event)"
@transformend="onTransformEnd(s.id, $event)"
/>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
</v-stage>
<!-- Delete selected sticker button -->
<button
v-if="selectedId"
class="sticker-canvas__delete"
type="button"
aria-label="Remove sticker"
@click="removeSelected"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<!-- Add sticker / Next -->
<div class="sticker-canvas__bar">
<button class="sticker-canvas__add-btn" type="button" @click="trayOpen = true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
</svg>
Add sticker
</button>
<BaseButton variant="primary" class="sticker-canvas__next-btn" @click="done">Next</BaseButton>
</div>
<!-- Sticker tray -->
<StickerTray v-model="trayOpen" @pick="addStickerFromTray" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import Konva from 'konva'
import BaseButton from '@/components/BaseButton.vue'
import StickerTray from '@/components/StickerTray.vue'
import type { StickerLayer } from '@/types'
const props = defineProps<{
croppedUrl: string
orientation: 'landscape' | 'portrait'
stickers: StickerLayer[]
}>()
const emit = defineEmits<{
(e: 'add-sticker', s: StickerLayer): void
(e: 'update-sticker', id: string, patch: Partial<StickerLayer>): void
(e: 'remove-sticker', id: string): void
(e: 'done', blob: Blob): void
}>()
const containerRef = ref<HTMLDivElement>()
const stageRef = ref()
const transformerRef = ref()
const stickerLayerRef = ref()
const trayOpen = ref(false)
const selectedId = ref<string | null>(null)
// Stage dimensions fitted to container
const stageW = ref(375)
const stageH = ref(225)
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600)
function sizeStage() {
if (!containerRef.value) return
const { width, height } = containerRef.value.getBoundingClientRect()
const availH = height - 72 // bottom bar
if (width / availH > ASPECT) {
stageH.value = availH
stageW.value = availH * ASPECT
} else {
stageW.value = width
stageH.value = width / ASPECT
}
loadImage()
}
const ro = new ResizeObserver(sizeStage)
onMounted(() => {
if (containerRef.value) ro.observe(containerRef.value)
sizeStage()
attachPinchListeners()
})
onBeforeUnmount(() => {
ro.disconnect()
detachPinchListeners()
})
// Image
const bgImage = ref<HTMLImageElement | null>(null)
function loadImage() {
const i = new Image()
i.onload = () => { bgImage.value = i }
i.src = props.croppedUrl
}
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
const imageConfig = computed(() => ({
image: bgImage.value,
x: 0, y: 0,
width: stageW.value,
height: stageH.value,
}))
const transformerConfig = {
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
rotateEnabled: true,
borderStroke: 'rgba(255,255,255,0.8)',
anchorFill: '#fff',
anchorSize: 18, // larger for touch
keepRatio: true,
boundBoxFunc: (_: any, newBox: any) => newBox,
}
// Stickers
const EMOJI_FONT_SIZE = 52
function stickerConfig(s: StickerLayer) {
return {
id: s.id,
text: stickerEmoji(s.type),
fontSize: EMOJI_FONT_SIZE,
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
x: s.x,
y: s.y,
scaleX: s.scale,
scaleY: s.scale,
rotation: s.rotation,
draggable: true,
offsetX: EMOJI_FONT_SIZE / 2,
offsetY: EMOJI_FONT_SIZE / 2,
}
}
import { STICKERS } from '@/assets/stickers/index'
function stickerEmoji(type: string): string {
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
}
function selectSticker(id: string, e: any) {
e.cancelBubble = true
selectedId.value = id
nextTick(() => {
const layer = stickerLayerRef.value?.getNode()
const node = layer?.findOne(`#${id}`)
const tr = transformerRef.value?.getNode()
if (node && tr) tr.nodes([node])
})
}
function onStageClick(e: any) {
if (e.target === e.target.getStage()) {
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
}
}
function removeSelected() {
if (!selectedId.value) return
emit('remove-sticker', selectedId.value)
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
}
function onDragEnd(id: string, e: any) {
emit('update-sticker', id, { x: e.target.x(), y: e.target.y() })
}
function onTransformEnd(id: string, e: any) {
emit('update-sticker', id, {
x: e.target.x(),
y: e.target.y(),
scale: e.target.scaleX(),
rotation: e.target.rotation(),
})
}
function addStickerFromTray(stickerId: string) {
const s: StickerLayer = {
id: `${stickerId}-${Date.now()}`,
type: stickerId,
x: stageW.value / 2,
y: stageH.value / 2,
scale: 1,
rotation: 0,
}
emit('add-sticker', s)
trayOpen.value = false
// Auto-select the new sticker
nextTick(() => selectSticker(s.id, { cancelBubble: false }))
}
// Pinch-to-resize
let pinchStartDist = 0
let pinchStartScale = 1
function touchDist(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.hypot(dx, dy)
}
function onPinchStart(e: TouchEvent) {
if (e.touches.length !== 2 || !selectedId.value) return
pinchStartDist = touchDist(e.touches)
const s = props.stickers.find(x => x.id === selectedId.value)
pinchStartScale = s?.scale ?? 1
}
function onPinchMove(e: TouchEvent) {
if (e.touches.length !== 2 || !selectedId.value || pinchStartDist === 0) return
e.preventDefault()
const newScale = Math.max(0.2, Math.min(6, pinchStartScale * (touchDist(e.touches) / pinchStartDist)))
emit('update-sticker', selectedId.value, { scale: newScale })
}
function onPinchEnd() {
pinchStartDist = 0
pinchStartScale = 1
}
function attachPinchListeners() {
const el = containerRef.value
if (!el) return
el.addEventListener('touchstart', onPinchStart, { passive: true })
el.addEventListener('touchmove', onPinchMove, { passive: false })
el.addEventListener('touchend', onPinchEnd, { passive: true })
}
function detachPinchListeners() {
const el = containerRef.value
if (!el) return
el.removeEventListener('touchstart', onPinchStart)
el.removeEventListener('touchmove', onPinchMove)
el.removeEventListener('touchend', onPinchEnd)
}
// Output
async function done() {
// Deselect to hide transformer handles before capture
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
await nextTick()
const stage: Konva.Stage = stageRef.value?.getNode()
if (!stage) return
const outputW = props.orientation === 'landscape' ? 1600 : 960
const pixelRatio = outputW / stageW.value
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
if (blob) emit('done', blob)
}
</script>
<style scoped lang="scss">
.sticker-canvas {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
background: #111;
overflow: hidden;
:deep(.konvajs-content) {
flex-shrink: 0;
}
&__delete {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(200, 30, 30, 0.85);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
z-index: 10;
}
&__bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 72px;
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-4);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
&__add-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: 1.5px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
&__next-btn {
margin-left: auto;
min-width: 96px;
}
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<template>
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
<div class="sticker-tray">
<div class="sticker-tray__cats" role="tablist">
<button
v-for="cat in STICKER_CATEGORIES"
:key="cat.id"
type="button"
role="tab"
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
@click="activeCategory = cat.id"
>{{ cat.label }}</button>
</div>
<div class="sticker-tray__grid" role="tabpanel">
<button
v-for="s in visibleStickers"
:key="s.id"
type="button"
class="sticker-tray__item"
:aria-label="s.label"
@click="$emit('pick', s.id)"
>
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
<span class="sticker-tray__label">{{ s.label }}</span>
</button>
</div>
</div>
</BaseBottomSheet>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
import type { StickerCategory } from '@/assets/stickers/index'
defineProps<{ modelValue: boolean }>()
defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'pick', stickerId: string): void
}>()
const activeCategory = ref<StickerCategory>('seasonal')
const visibleStickers = computed(() =>
STICKERS.filter(s => s.category === activeCategory.value)
)
</script>
<style scoped lang="scss">
.sticker-tray {
&__cats {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-3);
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
&__cat {
padding: 6px 14px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-fg);
}
}
&__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-2);
}
&__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-2) var(--space-1);
border-radius: var(--radius-sm);
border: none;
background: transparent;
cursor: pointer;
transition: background var(--duration-fast);
&:active { background: var(--color-surface-2); }
}
&__emoji {
font-size: 36px;
line-height: 1;
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
}
&__label {
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
text-align: center;
line-height: 1.2;
}
}
</style>
+45
View File
@@ -0,0 +1,45 @@
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
export interface ThemeOption {
id: string
label: string
primary: string
bg: string
text: string
}
export const THEMES: ThemeOption[] = [
{ id: 'warm-craft', label: 'Warm Craft', primary: '#c97c3a', bg: '#fdf6ee', text: '#3a2e22' },
{ id: 'playful-pop', label: 'Playful Pop', primary: '#d63aab', bg: '#fff0fb', text: '#2d0a28' },
{ id: 'sage-cream', label: 'Sage & Cream', primary: '#4e7c3a', bg: '#f6f8f3', text: '#1e2b1a' },
{ id: 'dusty-mauve', label: 'Dusty Mauve', primary: '#8e4a84', bg: '#f6f0f4', text: '#2a1828' },
{ id: 'ocean-dusk', label: 'Ocean Dusk', primary: '#1a6ea8', bg: '#eef3f8', text: '#0e2030' },
{ id: 'honey-slate', label: 'Honey & Slate', primary: '#c49a20', bg: '#f2f2ee', text: '#1c1c18' },
]
export function useTheme() {
const auth = useAuthStore()
const toast = useToastStore()
function applyTheme(themeId: string) {
document.documentElement.dataset.theme = themeId
if (auth.user) auth.user.theme = themeId
}
async function saveTheme(themeId: string) {
applyTheme(themeId)
try {
const res = await fetch('/api/user/theme', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: themeId }),
})
if (!res.ok) throw new Error('Failed to save theme')
} catch {
toast.show('Could not save theme — try again', 'error')
}
}
return { THEMES, applyTheme, saveTheme }
}
+2
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import VueKonva from 'vue-konva'
import '@/styles/global.scss'
import App from './App.vue'
import router from '@/router'
@@ -7,4 +8,5 @@ import router from '@/router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(VueKonva)
app.mount('#app')
+9 -4
View File
@@ -17,10 +17,10 @@ const router = createRouter({
meta: { requiresAuth: true },
},
{
path: '/shared',
name: 'shared',
component: () => import('@/views/SharedView.vue'),
meta: { requiresAuth: true },
path: '/upload',
name: 'upload',
component: () => import('@/views/UploadView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/settings',
@@ -28,6 +28,11 @@ const router = createRouter({
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true },
},
// Redirect old /shared to library shared tab
{
path: '/shared',
redirect: '/library?tab=shared',
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
+60
View File
@@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Device } from '@/types'
export const useDevicesStore = defineStore('devices', () => {
const devices = ref<Device[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchDevices() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/devices')
if (!res.ok) throw new Error('Failed to load devices')
devices.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
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' },
body: JSON.stringify(patch),
})
if (!res.ok) throw new Error('Failed to update device')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === id)
if (idx !== -1) devices.value[idx] = updated
return updated
}
async function lockImage(deviceId: number, imageId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageId }),
})
if (!res.ok) throw new Error('Failed to lock image')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
async function unlockImage(deviceId: number): Promise<Device> {
const res = await fetch(`/api/devices/${deviceId}/lock`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to unlock')
const updated: Device = await res.json()
const idx = devices.value.findIndex(d => d.id === deviceId)
if (idx !== -1) devices.value[idx] = updated
return updated
}
return { devices, loading, error, fetchDevices, updateDevice, lockImage, unlockImage }
})
+131
View File
@@ -0,0 +1,131 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Image, CropParams, StickerLayer, SharedImage, SharedImagePage } from '@/types'
interface UploadExtras {
original?: File
cropParams?: CropParams
stickerState?: StickerLayer[]
}
export const useImagesStore = defineStore('images', () => {
const images = ref<Image[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const pendingCount = ref(0)
async function fetchImages() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/images')
if (!res.ok) throw new Error('Failed to load images')
images.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
async function uploadImage(file: File, extras?: UploadExtras): Promise<Image> {
const form = new FormData()
form.append('file', file)
if (extras?.original) form.append('original', extras.original)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
const res = await fetch('/api/images', { method: 'POST', body: form })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Upload failed')
}
const image: Image = await res.json()
images.value.unshift(image)
return image
}
async function reprocessImage(imageId: number, composited: File, extras?: { cropParams?: CropParams; stickerState?: StickerLayer[] }): Promise<Image> {
const form = new FormData()
form.append('file', composited)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
const res = await fetch(`/api/images/${imageId}/reprocess`, { method: 'POST', body: form })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Reprocess failed')
}
const updated: Image = await res.json()
const idx = images.value.findIndex(i => i.id === imageId)
if (idx !== -1) images.value[idx] = updated
return updated
}
async function deleteImage(id: number): Promise<void> {
const res = await fetch(`/api/images/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
images.value = images.value.filter(img => img.id !== id)
}
async function setApproval(imageId: number, deviceId: number, approved: boolean): Promise<void> {
const method = approved ? 'POST' : 'DELETE'
const res = await fetch(`/api/images/${imageId}/approve/${deviceId}`, { method })
if (!res.ok) throw new Error('Failed to update approval')
const updated: Image = await res.json()
const idx = images.value.findIndex(i => i.id === imageId)
if (idx !== -1) images.value[idx] = updated
}
async function fetchSharedImages(status?: string, page = 1, limit = 20): Promise<SharedImagePage> {
const params = new URLSearchParams({ page: String(page), limit: String(limit) })
if (status) params.set('status', status)
const res = await fetch(`/api/shared-images?${params}`)
if (!res.ok) throw new Error('Failed to load shared images')
return res.json()
}
async function fetchPendingCount(): Promise<void> {
const res = await fetch('/api/shared-images/pending-count')
if (res.ok) {
const data = await res.json()
pendingCount.value = data.count
}
}
async function approveShared(sharedId: number, deviceIds: number[]): Promise<SharedImage> {
const res = await fetch(`/api/shared-images/${sharedId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceIds }),
})
if (!res.ok) throw new Error('Failed to approve')
if (pendingCount.value > 0) pendingCount.value--
return res.json()
}
async function declineShared(sharedId: number): Promise<SharedImage> {
const res = await fetch(`/api/shared-images/${sharedId}/decline`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to decline')
if (pendingCount.value > 0) pendingCount.value--
return res.json()
}
async function shareImage(imageId: number, recipientEmail: string): Promise<void> {
const res = await fetch(`/api/images/${imageId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipientEmail }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? 'Failed to share')
}
}
return {
images, loading, error, pendingCount,
fetchImages, uploadImage, reprocessImage, deleteImage, setApproval,
fetchSharedImages, fetchPendingCount, approveShared, declineShared, shareImage,
}
})
+76
View File
@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { StickerLayer, CropParams } from '@/types'
export const useUploadStore = defineStore('upload', () => {
const originalFile = ref<File | null>(null)
const originalUrl = ref<string | null>(null)
const croppedBlob = ref<Blob | null>(null)
const croppedUrl = ref<string | null>(null)
const cropParams = ref<CropParams | null>(null)
const stickers = ref<StickerLayer[]>([])
const contextDeviceId = ref<number | null>(null)
const selectedDeviceIds = ref<number[]>([])
const editingImageId = ref<number | null>(null)
function init(file: File, deviceId?: number) {
cleanup()
originalFile.value = file
originalUrl.value = URL.createObjectURL(file)
contextDeviceId.value = deviceId ?? null
selectedDeviceIds.value = deviceId ? [deviceId] : []
}
async function initEdit(image: import('@/types').Image, deviceId?: number) {
cleanup()
const res = await fetch(image.originalUrl)
const blob = await res.blob()
originalFile.value = new File([blob], image.originalFilename, { type: blob.type })
originalUrl.value = URL.createObjectURL(blob)
editingImageId.value = image.id
cropParams.value = image.cropParams ?? null
stickers.value = image.stickerState ? [...image.stickerState] : []
selectedDeviceIds.value = image.approvedDeviceIds
contextDeviceId.value = deviceId ?? null
}
function setCrop(blob: Blob, params: CropParams) {
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
croppedBlob.value = blob
croppedUrl.value = URL.createObjectURL(blob)
cropParams.value = params
}
function addSticker(s: StickerLayer) {
stickers.value = [...stickers.value, s]
}
function updateSticker(id: string, patch: Partial<StickerLayer>) {
stickers.value = stickers.value.map(s => s.id === id ? { ...s, ...patch } : s)
}
function removeSticker(id: string) {
stickers.value = stickers.value.filter(s => s.id !== id)
}
function cleanup() {
if (originalUrl.value) URL.revokeObjectURL(originalUrl.value)
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
originalFile.value = null
originalUrl.value = null
croppedBlob.value = null
croppedUrl.value = null
cropParams.value = null
stickers.value = []
contextDeviceId.value = null
selectedDeviceIds.value = []
editingImageId.value = null
}
return {
originalFile, originalUrl,
croppedBlob, croppedUrl, cropParams,
stickers, contextDeviceId, selectedDeviceIds, editingImageId,
init, initEdit, setCrop, addSticker, updateSticker, removeSticker, cleanup,
}
})
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'
describe('BaseButton', () => {
it('renders slot content', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toContain('Click me')
})
it('renders as a <button> by default', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'OK' },
})
expect(wrapper.element.tagName).toBe('BUTTON')
})
it('applies primary variant class by default', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.classes()).toContain('btn--primary')
})
it('applies the given variant class', () => {
const wrapper = mount(BaseButton, {
props: { variant: 'destructive' },
slots: { default: 'Delete' },
})
expect(wrapper.classes()).toContain('btn--destructive')
})
it('shows spinner element when loading is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Saving...' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(true)
})
it('does not show spinner when loading is false', () => {
const wrapper = mount(BaseButton, {
props: { loading: false },
slots: { default: 'Save' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(false)
})
it('applies btn--loading class when loading', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Wait' },
})
expect(wrapper.classes()).toContain('btn--loading')
})
it('is disabled when disabled prop is true', () => {
const wrapper = mount(BaseButton, {
props: { disabled: true },
slots: { default: 'Blocked' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is disabled when loading prop is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Loading' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is not disabled when neither disabled nor loading', () => {
const wrapper = mount(BaseButton, {
props: { disabled: false, loading: false },
slots: { default: 'Go' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(false)
})
it('emits click event when clicked and not disabled', async () => {
const wrapper = mount(BaseButton, { slots: { default: 'Go' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('type attribute defaults to button', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.attributes('type')).toBe('button')
})
it('type attribute can be set to submit', () => {
const wrapper = mount(BaseButton, {
props: { type: 'submit' },
slots: { default: 'Submit' },
})
expect(wrapper.attributes('type')).toBe('submit')
})
it('renders as an anchor when tag is a', () => {
const wrapper = mount(BaseButton, {
props: { tag: 'a' },
slots: { default: 'Link' },
})
expect(wrapper.element.tagName).toBe('A')
// <a> should not have a type attribute
expect(wrapper.attributes('type')).toBeUndefined()
})
})
@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DevicePicker from '@/components/DevicePicker.vue'
import type { Device } from '@/types'
// Stub child components DevicePicker wraps
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('DevicePicker', () => {
const devices = [
makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'Bedroom' }),
]
function mountPicker(selected: number[] = []) {
return mount(DevicePicker, {
props: {
modelValue: true,
devices,
selected,
},
})
}
// DP-01: Selecting a device emits update:selected with the device added
it('checking a device emits update:selected with device id added', async () => {
const wrapper = mountPicker([])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1)
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toEqual([1])
})
// DP-02: Deselecting a device emits update:selected with device id removed
it('unchecking a device emits update:selected with device id removed', async () => {
// Start with both selected
const wrapper = mountPicker([1, 2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1) — it's currently checked, so this deselects
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
// Should emit [2] — Living Room removed
expect(emitted![0][0]).toEqual([2])
})
// DP-03: Checkboxes reflect the selected prop
it('checkboxes are checked for ids in selected prop', async () => {
const wrapper = mountPicker([2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false) // id=1 not selected
expect((checkboxes[1].element as HTMLInputElement).checked).toBe(true) // id=2 selected
})
// DP-04: Confirm button disabled when nothing selected
it('confirm button is disabled when selected is empty', async () => {
const wrapper = mountPicker([])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
// DP-05: Confirm button enabled when at least one device selected
it('confirm button is enabled when a device is selected', async () => {
const wrapper = mountPicker([1])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
})
// DP-06: Device names are rendered
it('renders all device names', () => {
const wrapper = mountPicker([])
expect(wrapper.text()).toContain('Living Room')
expect(wrapper.text()).toContain('Bedroom')
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FrameCard from '@/components/FrameCard.vue'
// Mock vue-konva to avoid canvas issues if transitively imported
vi.mock('vue-konva', () => ({}))
const defaultProps = {
deviceId: 1,
name: 'Living Room',
size: 'large' as const,
status: 'ok' as const,
orientation: 'landscape' as const,
}
describe('FrameCard', () => {
it('renders device name', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.text()).toContain('Living Room')
})
it('does not show status badge when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
})
it('shows "Offline" badge when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Offline')
})
it('shows "Sync issue" badge when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Sync issue')
})
it('applies offline modifier class when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
expect(wrapper.classes()).toContain('frame-card--offline')
})
it('applies sync-fail modifier class when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
expect(wrapper.classes()).toContain('frame-card--sync-fail')
})
it('shows settings button in large size', () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(true)
})
it('does not show settings button in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact' },
})
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(false)
})
it('shows img element when thumbnailUrl is provided', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, thumbnailUrl: '/thumb/test.jpg' },
})
const img = wrapper.find('img.frame-card__img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/thumb/test.jpg')
})
it('shows empty preview placeholder when no thumbnailUrl', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__empty-preview').exists()).toBe(true)
expect(wrapper.find('img.frame-card__img').exists()).toBe(false)
})
it('shows photo count in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 3 },
})
expect(wrapper.text()).toContain('3 photos')
})
it('uses singular "photo" when photoCount is 1', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 1 },
})
expect(wrapper.text()).toContain('1 photo')
expect(wrapper.text()).not.toContain('1 photos')
})
it('emits add-photo with deviceId when add button clicked', async () => {
const wrapper = mount(FrameCard, { props: defaultProps })
await wrapper.find('.frame-card__add-btn').trigger('click')
expect(wrapper.emitted('add-photo')).toBeTruthy()
expect(wrapper.emitted('add-photo')![0]).toEqual([1])
})
it('emits edit with deviceId when settings button clicked (large)', async () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
await wrapper.find('.frame-card__settings-btn').trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('sets landscape aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
})
const preview = wrapper.find('.frame-card__preview')
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
})
it('sets portrait aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
})
const preview = wrapper.find('.frame-card__preview')
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareSheet from '@/components/ShareSheet.vue'
import { useImagesStore } from '@/stores/images'
const BaseBottomSheetStub = {
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
}
const BaseButtonStub = {
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
}
describe('ShareSheet', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
function mountShareSheet(imageId = 1) {
return mount(ShareSheet, {
props: { modelValue: true, imageId },
global: {
stubs: {
BaseBottomSheet: BaseBottomSheetStub,
BaseButton: BaseButtonStub,
},
},
})
}
// SS-01: successful share shows success message and clears email field
it('shows success message and clears email on successful share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
expect(wrapper.text()).toContain('Invite sent to friend@example.com')
expect((input.element as HTMLInputElement).value).toBe('')
})
// SS-02: failed share shows error message
it('shows error message on failed share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockRejectedValue(new Error('Server error'))
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Server error')
expect(wrapper.find('.share-sheet__error').exists()).toBe(true)
})
// SS-03: button is disabled when email input is empty
it('button is disabled when email is empty', () => {
const wrapper = mountShareSheet()
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
})
})
+10
View File
@@ -0,0 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.unstubAllGlobals()
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import type { User } from '@/types'
const makeUser = (overrides: Partial<User> = {}): User => ({
id: 1,
email: 'test@example.com',
roles: ['ROLE_USER'],
theme: null,
timezone: 'America/Chicago',
...overrides,
})
describe('auth store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('isAuthenticated is false when __PF_USER__ is not set', async () => {
// No __PF_USER__ on window — should be null
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('isAuthenticated is true when user is set via setUser', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
expect(store.user?.email).toBe('test@example.com')
})
it('setUser(null) clears user and isAuthenticated becomes false', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
store.setUser(null)
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('bootstraps user from window.__PF_USER__ when present', async () => {
const user = makeUser({ id: 99, email: 'bootstrapped@example.com' })
// Stub window.__PF_USER__ before the store module is evaluated
vi.stubGlobal('__PF_USER__', user)
// Dynamically re-import so the store sees the stub
vi.resetModules()
const { useAuthStore } = await import('@/stores/auth')
setActivePinia(createPinia())
const store = useAuthStore()
expect(store.isAuthenticated).toBe(true)
expect(store.user?.id).toBe(99)
})
})
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import type { Device } from '@/types'
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('devices store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
// DS-01
it('fetchDevices success populates devices and clears loading', async () => {
const mockDevices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockDevices),
}))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual(mockDevices)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
// DS-02
it('fetchDevices network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBe('Network failure')
})
// DS-02b — non-ok response
it('fetchDevices non-ok response sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.error).toBe('Failed to load devices')
expect(store.loading).toBe(false)
})
// DS-03
it('updateDevice patches local array entry', async () => {
const original = makeDevice({ id: 1, name: 'Old Name' })
const updated = makeDevice({ id: 1, name: 'New Name' })
const store = useDevicesStore()
store.devices = [original]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const result = await store.updateDevice(1, { name: 'New Name' })
expect(result.name).toBe('New Name')
expect(store.devices[0].name).toBe('New Name')
})
// DS-03b — updateDevice throws on failure
it('updateDevice throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.updateDevice(1, { name: 'x' })).rejects.toThrow('Failed to update device')
})
// DS-04
it('lockImage sets lockedImageId on local device', async () => {
const device = makeDevice({ id: 1, lockedImageId: null })
const locked = makeDevice({ id: 1, lockedImageId: 42 })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(locked),
}))
const result = await store.lockImage(1, 42)
expect(result.lockedImageId).toBe(42)
expect(store.devices[0].lockedImageId).toBe(42)
})
// DS-05
it('unlockImage clears lockedImageId', async () => {
const device = makeDevice({ id: 1, lockedImageId: 42 })
const unlocked = makeDevice({ id: 1, lockedImageId: null })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(unlocked),
}))
const result = await store.unlockImage(1)
expect(result.lockedImageId).toBeNull()
expect(store.devices[0].lockedImageId).toBeNull()
})
// DS-05b — lockImage throws on failure
it('lockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.lockImage(1, 42)).rejects.toThrow('Failed to lock image')
})
// DS-05c — unlockImage throws on failure
it('unlockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock')
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import type { Image } from '@/types'
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
describe('images store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
it('fetchImages success populates images and clears loading', async () => {
const mockImages = [makeImage(), makeImage({ id: 2 })]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockImages),
}))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual(mockImages)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('fetchImages network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Net error')))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual([])
expect(store.error).toBe('Net error')
expect(store.loading).toBe(false)
})
it('uploadImage prepends to images list on success', async () => {
const existing = makeImage({ id: 1 })
const newImage = makeImage({ id: 2 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(newImage),
}))
const store = useImagesStore()
store.images = [existing]
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
const result = await store.uploadImage(file)
expect(result).toEqual(newImage)
expect(store.images[0]).toEqual(newImage)
expect(store.images).toHaveLength(2)
})
it('uploadImage throws with error message on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'File too large' }),
}))
const store = useImagesStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
await expect(store.uploadImage(file)).rejects.toThrow('File too large')
})
it('deleteImage removes image from list', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const store = useImagesStore()
store.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
await store.deleteImage(1)
expect(store.images).toHaveLength(1)
expect(store.images[0].id).toBe(2)
})
it('deleteImage throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
store.images = [makeImage()]
await expect(store.deleteImage(1)).rejects.toThrow('Delete failed')
})
it('setApproval updates image in list', async () => {
const original = makeImage({ id: 1, approvedDeviceIds: [] })
const updated = makeImage({ id: 1, approvedDeviceIds: [42] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [original]
await store.setApproval(1, 42, true)
expect(store.images[0].approvedDeviceIds).toEqual([42])
})
it('fetchPendingCount stores the count', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ count: 5 }),
}))
const store = useImagesStore()
await store.fetchPendingCount()
expect(store.pendingCount).toBe(5)
})
it('approveShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'approved' }),
}))
const store = useImagesStore()
store.pendingCount = 3
await store.approveShared(1, [42])
expect(store.pendingCount).toBe(2)
})
it('declineShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'declined' }),
}))
const store = useImagesStore()
store.pendingCount = 2
await store.declineShared(1)
expect(store.pendingCount).toBe(1)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useToastStore } from '@/stores/toast'
describe('toast store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('show adds a message to toasts', () => {
const store = useToastStore()
store.show('Hello!')
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Hello!')
expect(store.toasts[0].type).toBe('info')
})
it('show with explicit type sets correct type', () => {
const store = useToastStore()
store.show('Saved', 'success')
expect(store.toasts[0].type).toBe('success')
})
it('show with error type sets error type', () => {
const store = useToastStore()
store.show('Something broke', 'error')
expect(store.toasts[0].type).toBe('error')
})
it('multiple show calls add multiple toasts', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
expect(store.toasts).toHaveLength(2)
})
it('auto-dismisses after 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
expect(store.toasts).toHaveLength(1)
vi.advanceTimersByTime(2500)
expect(store.toasts).toHaveLength(0)
})
it('does not dismiss before 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
vi.advanceTimersByTime(2499)
expect(store.toasts).toHaveLength(1)
})
it('dismiss removes a specific toast by id', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
const id = store.toasts[0].id
store.dismiss(id)
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Second')
})
it('dismiss with unknown id does nothing', () => {
const store = useToastStore()
store.show('Msg')
store.dismiss(99999)
expect(store.toasts).toHaveLength(1)
})
it('each toast gets a unique id', () => {
const store = useToastStore()
store.show('A')
store.show('B')
store.show('C')
const ids = store.toasts.map(t => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
})
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUploadStore } from '@/stores/upload'
import type { StickerLayer } from '@/types'
const makeSticker = (overrides: Partial<StickerLayer> = {}): StickerLayer => ({
id: 'sticker-1',
type: 'emoji',
x: 100,
y: 100,
scale: 1,
rotation: 0,
...overrides,
})
describe('upload store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
// happy-dom has URL.createObjectURL as a stub; ensure it returns something predictable
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('init sets originalFile and originalUrl', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.originalFile).toStrictEqual(file)
expect(store.originalUrl).toBe('blob:mock-url')
})
it('init with deviceId sets contextDeviceId and selectedDeviceIds', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file, 7)
expect(store.contextDeviceId).toBe(7)
expect(store.selectedDeviceIds).toEqual([7])
})
it('init without deviceId leaves selectedDeviceIds empty', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
})
it('setCrop stores croppedBlob and cropParams', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
const blob = new Blob(['crop'], { type: 'image/jpeg' })
const params = { natX: 0, natY: 0, natW: 200, natH: 200 }
store.setCrop(blob, params)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.croppedBlob).toStrictEqual(blob)
expect(store.croppedUrl).toBe('blob:mock-url')
expect(store.cropParams).toEqual(params)
})
it('addSticker appends to stickers', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
expect(store.stickers).toHaveLength(2)
expect(store.stickers[0].id).toBe('a')
expect(store.stickers[1].id).toBe('b')
})
it('updateSticker patches matching sticker', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[0].x).toBe(99)
})
it('updateSticker leaves non-matching stickers unchanged', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.addSticker(makeSticker({ id: 'b', x: 20 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[1].x).toBe(20)
})
it('removeSticker removes by id', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
store.removeSticker('a')
expect(store.stickers).toHaveLength(1)
expect(store.stickers[0].id).toBe('b')
})
it('cleanup resets all state', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file, 5)
store.addSticker(makeSticker())
store.cleanup()
expect(store.originalFile).toBeNull()
expect(store.originalUrl).toBeNull()
expect(store.croppedBlob).toBeNull()
expect(store.croppedUrl).toBeNull()
expect(store.cropParams).toBeNull()
expect(store.stickers).toHaveLength(0)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
expect(store.editingImageId).toBeNull()
})
})
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import HomeView from '@/views/HomeView.vue'
import type { Device } from '@/types'
// Stub heavy child components so tests focus on HomeView logic
vi.mock('@/components/FrameCard.vue', () => ({
default: {
name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation'],
emits: ['add-photo', 'edit'],
},
}))
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button><slot /></button>',
props: ['variant', 'disabled'],
},
}))
vi.mock('@/components/BaseInput.vue', () => ({
default: {
name: 'BaseInput',
template: '<input />',
props: ['modelValue', 'label', 'maxlength'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/OrientationPicker.vue', () => ({
default: {
name: 'OrientationPicker',
template: '<div />',
props: ['modelValue'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router so HomeView can call useRouter() without a real router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
// Stub URL.createObjectURL used by upload store
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('HomeView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Re-stub URL after unstubAllGlobals
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
// Stub fetch so onMounted fetchDevices doesn't fail
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(HomeView, {
global: {
plugins: [pinia],
},
})
}
// HV-01: N devices renders N FrameCard stubs
it('renders one FrameCard per device when devices are present', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'Frame A' }),
makeDevice({ id: 2, name: 'Frame B' }),
makeDevice({ id: 3, name: 'Frame C' }),
]
// Mock fetchDevices so onMounted doesn't overwrite devices
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(3)
})
// HV-01b: single device still renders one FrameCard (large variant branch)
it('renders one FrameCard for a single device', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, name: 'Solo Frame' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(1)
})
// HV-02: empty state shown when no devices
it('shows empty state when devices list is empty', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = []
devicesStore.loading = false
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('Set up your first frame')
})
// HV-03: loading state shown while fetching
it('shows loading indicator when store is loading', async () => {
const devicesStore = useDevicesStore()
devicesStore.loading = true
// Keep fetchDevices pending so loading stays true
vi.spyOn(devicesStore, 'fetchDevices').mockReturnValue(new Promise(() => {}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading')
})
})
+260
View File
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import LibraryView from '@/views/LibraryView.vue'
import type { Image, Device } from '@/types'
// Stub complex child components
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
vi.mock('@/components/ApproveCard.vue', () => ({
default: {
name: 'ApproveCard',
template: '<div class="approve-card-stub" />',
props: ['item'],
emits: ['updated'],
},
}))
vi.mock('@/components/ShareSheet.vue', () => ({
default: {
name: 'ShareSheet',
template: '<div class="share-sheet-stub" />',
props: ['modelValue', 'imageId'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ query: {} }),
}))
// Stub toast store
vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: vi.fn() }),
}))
// Stub upload store
vi.mock('@/stores/upload', () => ({
useUploadStore: () => ({ initEdit: vi.fn() }),
}))
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('LibraryView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Default fetch stub — returns empty lists so onMounted doesn't error
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(LibraryView, {
global: {
plugins: [pinia],
},
})
}
// LV-01: Default tab shows "All" tab active
it('renders the All tab as active by default', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// The "All" tab button should have aria-selected=true
const tabs = wrapper.findAll('[role="tab"]')
const allTab = tabs.find(t => t.text() === 'All')
expect(allTab?.attributes('aria-selected')).toBe('true')
})
// LV-01b: Images from imagesStore are rendered in the grid
it('renders image grid when images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 }), makeImage({ id: 3 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const grid = wrapper.find('.library__grid')
expect(grid.exists()).toBe(true)
expect(wrapper.findAll('.library__item')).toHaveLength(3)
})
// LV-02: Switching to Shared tab shows the shared sub-tabs UI
it('switching to Shared tab shows shared sub-tabs and triggers a fetch', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
// Set up fetch so fetchSharedImages network call resolves
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(sharedPage),
}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
// After clicking Shared, the sub-tabs (Pending/Approved/Declined) should appear
expect(wrapper.find('.library__subtabs').exists()).toBe(true)
})
// LV-03: Lock chip shown for device when image is approved for it
it('renders lock chip for device when image is approved for that device', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Lock chips are rendered only for approved devices
const lockChips = wrapper.findAll('.library__lock-chip')
expect(lockChips.length).toBeGreaterThan(0)
expect(lockChips[0].text()).toContain('Bedroom')
})
// LV-06: Share button click renders the ShareSheet
it('clicking share button renders the ShareSheet', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 5 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Find the share action button (aria-label contains "Share")
const shareBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.includes('Share'))
expect(shareBtn).toBeTruthy()
await shareBtn!.trigger('click')
await wrapper.vm.$nextTick()
// After clicking, the ShareSheet stub should be rendered
expect(wrapper.find('.share-sheet-stub').exists()).toBe(true)
})
// LV-07: Empty state shown when no images (All tab)
it('shows empty state when no images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('No photos yet')
})
// LV-07b: Empty state on shared sub-tab (pending)
it('shows shared empty state when no shared items exist', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Switch to Shared tab
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
// Wait for async loadShared to complete
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
})
})
+39 -8
View File
@@ -3,6 +3,7 @@ export interface User {
email: string
roles: string[]
theme: string | null
timezone: string
}
export interface Device {
@@ -10,18 +11,20 @@ export interface Device {
mac: string
name: string
orientation: 'landscape' | 'portrait'
rotationInterval: 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'
+367 -3
View File
@@ -1,9 +1,373 @@
<template>
<main class="view">
<h1>Home</h1>
<main class="home-view">
<!-- Loading -->
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
Loading
</div>
<!-- Empty state -->
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
<div class="home-view__empty-card">
<svg class="home-view__empty-icon" 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="home-view__empty-title">Set up your first frame</p>
<p class="home-view__empty-sub">
Power on your pictureFrame device and scan the QR code it displays to get started.
</p>
</div>
</div>
<!-- Single device large card -->
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
<FrameCard
:deviceId="devicesStore.devices[0].id"
:name="devicesStore.devices[0].name"
size="large"
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
<!-- Multiple devices compact stack -->
<div v-else class="home-view__list">
<FrameCard
v-for="device in devicesStore.devices"
:key="device.id"
:deviceId="device.id"
:name="device.name"
size="compact"
:status="deviceStatus(device)"
:orientation="device.orientation"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
</main>
<!-- Frame settings sheet -->
<BaseBottomSheet v-model="sheetOpen" label="Frame settings">
<h2 class="home-view__sheet-title">Frame settings</h2>
<div class="home-view__sheet-field">
<BaseInput
v-model="editName"
label="Frame name"
maxlength="100"
/>
</div>
<div class="home-view__sheet-field">
<p class="home-view__sheet-label">Orientation</p>
<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"
:disabled="saving"
@click="saveSettings"
>
{{ saving ? 'Saving…' : 'Save' }}
</BaseButton>
</BaseBottomSheet>
</template>
<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()
})
function onAddPhoto(deviceId: number) {
// 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)
if (!device) return
editingDevice.value = device
editName.value = device.name
editOrientation.value = device.orientation
editWakeHour.value = device.wakeHour ?? 4
editTimezone.value = device.timezone ?? 'UTC'
sheetOpen.value = true
}
async function saveSettings() {
if (!editingDevice.value) return
saving.value = true
try {
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 {
saving.value = false
}
}
</script>
<style scoped lang="scss">
.view { padding: var(--space-4); }
.home-view {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
&__loading {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4) 0;
text-align: center;
}
&__empty {
display: flex;
justify-content: center;
padding-top: var(--space-6);
}
&__empty-card {
background: var(--color-surface);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6) var(--space-5);
text-align: center;
max-width: 320px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
&__empty-icon {
color: var(--color-text-muted);
opacity: 0.5;
}
&__empty-title {
font-size: var(--text-md);
font-weight: 700;
}
&__empty-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
line-height: 1.5;
}
&__single {
display: flex;
flex-direction: column;
}
&__list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__sheet-title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-4);
}
&__sheet-field {
margin-bottom: var(--space-4);
}
&__sheet-label {
display: block;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
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);
}
}
</style>
+581 -3
View File
@@ -1,9 +1,587 @@
<template>
<main class="view">
<h1>Library</h1>
<main class="library">
<!-- Tabs -->
<div class="library__tabs" role="tablist">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
role="tab"
:aria-selected="activeTab === tab.id"
:class="['library__tab', { 'library__tab--active': activeTab === tab.id }]"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<!-- Loading -->
<div v-if="imagesStore.loading" class="library__loading">Loading</div>
<!-- All / Mine tab -->
<template v-else-if="activeTab !== 'shared'">
<div v-if="visibleImages.length === 0" class="library__empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="library__empty-title">No photos yet</p>
<p class="library__empty-sub">Tap "+ Add Photo" on the home screen to get started.</p>
</div>
<div v-else class="library__grid">
<div v-for="image in visibleImages" :key="image.id" class="library__item">
<div class="library__thumb">
<img
:src="image.thumbnailUrl"
:alt="image.originalFilename"
class="library__img"
loading="lazy"
/>
<div class="library__thumb-actions">
<button
class="library__action-btn"
type="button"
:aria-label="`Edit ${image.originalFilename}`"
:disabled="editingId === image.id"
@click="startEdit(image)"
>
<svg v-if="editingId !== image.id" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</button>
<button
class="library__action-btn"
type="button"
:aria-label="`Share ${image.originalFilename}`"
@click="openShare(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
<button
class="library__action-btn library__action-btn--danger"
type="button"
aria-label="Delete photo"
@click="confirmDelete(image.id)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
</div>
<div v-if="devicesStore.devices.length > 0" class="library__approvals">
<button
v-for="device in devicesStore.devices"
:key="device.id"
:class="['library__approval-chip', { 'library__approval-chip--on': image.approvedDeviceIds.includes(device.id) }]"
type="button"
:aria-label="`${image.approvedDeviceIds.includes(device.id) ? 'Remove from' : 'Add to'} ${device.name}`"
@click="toggleApproval(image.id, device.id, !image.approvedDeviceIds.includes(device.id))"
>
{{ device.name }}
</button>
</div>
<div v-if="devicesStore.devices.length > 0" class="library__locks">
<button
v-for="device in devicesStore.devices.filter(d => image.approvedDeviceIds.includes(d.id))"
:key="device.id"
:class="['library__lock-chip', { 'library__lock-chip--on': device.lockedImageId === image.id }]"
type="button"
:aria-label="`${device.lockedImageId === image.id ? 'Unlock from' : 'Lock to'} ${device.name}`"
@click="toggleLock(image.id, device)"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path v-if="device.lockedImageId === image.id" d="M7 11V7a5 5 0 0 1 10 0v4"/>
<path v-else d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>
{{ device.name }}
</button>
</div>
</div>
</div>
</template>
<!-- Shared tab -->
<template v-else>
<!-- Sub-tabs -->
<div class="library__subtabs" role="tablist">
<button
v-for="st in SHARED_TABS"
:key="st.id"
type="button"
role="tab"
:aria-selected="sharedTab === st.id"
:class="['library__subtab', { 'library__subtab--active': sharedTab === st.id }]"
@click="switchSharedTab(st.id)"
>{{ st.label }}</button>
</div>
<div v-if="sharedLoading" class="library__loading">Loading</div>
<div v-else-if="sharedItems.length === 0" class="library__shared-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<p class="library__empty-title">
{{ sharedTab === 'pending' ? 'No pending photos' : sharedTab === 'approved' ? 'No approved photos' : 'No declined photos' }}
</p>
<p class="library__empty-sub">
{{ sharedTab === 'pending' ? 'Photos shared with you will appear here.' : 'Photos you\'ve added to a frame will appear here.' }}
</p>
</div>
<div v-else class="library__shared-list">
<ApproveCard
v-for="item in sharedItems"
:key="item.id"
:item="item"
@updated="onSharedUpdated"
/>
</div>
<!-- Pagination -->
<div v-if="sharedTotalPages > 1" class="library__pagination">
<button
class="library__page-btn"
:disabled="sharedPage <= 1"
@click="goSharedPage(sharedPage - 1)"
> Prev</button>
<span class="library__page-info">{{ sharedPage }} / {{ sharedTotalPages }}</span>
<button
class="library__page-btn"
:disabled="sharedPage >= sharedTotalPages"
@click="goSharedPage(sharedPage + 1)"
>Next </button>
</div>
</template>
<!-- Share sheet -->
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
<!-- Confirm delete sheet -->
<BaseBottomSheet v-model="deleteSheetOpen" label="Delete photo">
<h2 class="library__sheet-title">Delete this photo?</h2>
<p class="library__sheet-sub">It will be removed from all frames.</p>
<div class="library__sheet-actions">
<BaseButton variant="secondary" @click="deleteSheetOpen = false">Cancel</BaseButton>
<BaseButton variant="destructive" :disabled="deleting" @click="doDelete">
{{ deleting ? 'Deleting…' : 'Delete' }}
</BaseButton>
</div>
</BaseBottomSheet>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import { useToastStore } from '@/stores/toast'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import ApproveCard from '@/components/ApproveCard.vue'
import ShareSheet from '@/components/ShareSheet.vue'
import type { Device, Image, SharedImage } from '@/types'
const router = useRouter()
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
const toast = useToastStore()
const route = useRoute()
const TABS = [
{ id: 'all', label: 'All' },
{ id: 'mine', label: 'Mine' },
{ id: 'shared', label: 'Shared' },
] as const
type Tab = typeof TABS[number]['id']
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all')
const SHARED_TABS = [
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'declined', label: 'Declined' },
] as const
type SharedTab = typeof SHARED_TABS[number]['id']
const sharedTab = ref<SharedTab>('pending')
const sharedItems = ref<SharedImage[]>([])
const sharedLoading = ref(false)
const sharedPage = ref(1)
const sharedTotalPages = ref(1)
async function loadShared(tab: SharedTab, page = 1) {
sharedLoading.value = true
try {
const result = await imagesStore.fetchSharedImages(tab, page)
sharedItems.value = result.items
sharedPage.value = result.page
sharedTotalPages.value = result.totalPages
} finally {
sharedLoading.value = false
}
}
function switchSharedTab(tab: SharedTab) {
sharedTab.value = tab
loadShared(tab, 1)
}
function goSharedPage(page: number) {
loadShared(sharedTab.value, page)
}
function onSharedUpdated(updated: SharedImage) {
const idx = sharedItems.value.findIndex(i => i.id === updated.id)
if (idx !== -1) sharedItems.value[idx] = updated
}
onMounted(() => {
imagesStore.fetchImages()
devicesStore.fetchDevices()
imagesStore.fetchPendingCount()
if (activeTab.value === 'shared') loadShared(sharedTab.value)
})
// For now "mine" and "all" show the same list; shared is a placeholder
const visibleImages = computed(() => imagesStore.images)
// Share
const shareSheetOpen = ref(false)
const shareImageId = ref<number | null>(null)
function openShare(id: number) {
shareImageId.value = id
shareSheetOpen.value = true
}
// Edit
const editingId = ref<number | null>(null)
async function startEdit(image: Image) {
if (editingId.value) return
editingId.value = image.id
try {
await uploadStore.initEdit(image)
router.push('/upload')
} catch {
toast.show('Could not load photo for editing', 'error')
} finally {
editingId.value = null
}
}
// Lock
async function toggleLock(imageId: number, device: Device) {
try {
if (device.lockedImageId === imageId) {
await devicesStore.unlockImage(device.id)
} else {
await devicesStore.lockImage(device.id, imageId)
}
} catch {
toast.show('Failed to update lock', 'error')
}
}
// Approval toggles
async function toggleApproval(imageId: number, deviceId: number, approved: boolean) {
try {
await imagesStore.setApproval(imageId, deviceId, approved)
} catch {
toast.show('Failed to update frame approval', 'error')
}
}
// Delete
const deleteSheetOpen = ref(false)
const deletingId = ref<number | null>(null)
const deleting = ref(false)
function confirmDelete(id: number) {
deletingId.value = id
deleteSheetOpen.value = true
}
async function doDelete() {
if (!deletingId.value) return
deleting.value = true
try {
await imagesStore.deleteImage(deletingId.value)
deleteSheetOpen.value = false
toast.show('Photo deleted', 'success')
} catch {
toast.show('Delete failed', 'error')
} finally {
deleting.value = false
}
}
</script>
<style scoped lang="scss">
.view { padding: var(--space-4); }
.library {
padding-bottom: calc(64px + var(--space-4));
&__tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--color-bg);
z-index: 5;
}
&__tab {
flex: 1;
padding: var(--space-3) 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__loading {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4);
text-align: center;
}
&__subtabs {
display: flex;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
&__subtab {
flex: 1;
padding: var(--space-2) 0;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast);
&--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
&__shared-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
&__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-4);
}
&__page-btn {
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: none;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
cursor: pointer;
&:disabled { opacity: .4; cursor: default; }
}
&__page-info {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
&__empty, &__shared-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
&__empty-title {
font-size: var(--text-md);
font-weight: 700;
color: var(--color-text);
}
&__empty-sub {
font-size: var(--text-sm);
max-width: 280px;
line-height: 1.5;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
padding: var(--space-4);
}
&__item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__thumb {
position: relative;
aspect-ratio: 4/3;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-2);
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&__thumb-actions {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: 4px;
}
&__action-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background var(--duration-fast);
flex-shrink: 0;
&:disabled { opacity: 0.5; cursor: default; }
&:hover:not(:disabled) { background: rgba(0, 0, 0, 0.75); }
&--danger:hover:not(:disabled) { background: rgba(180, 0, 0, 0.8); }
}
&__approvals {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__approval-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1.5px solid var(--color-border);
font-size: var(--text-xs, 11px);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--on {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
}
&__locks {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__lock-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
border: 1.5px dashed var(--color-border);
font-size: var(--text-xs, 11px);
font-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
&--on {
background: var(--color-warning, #f59e0b);
border-color: var(--color-warning, #f59e0b);
border-style: solid;
color: #fff;
}
}
&__sheet-title {
font-size: var(--text-md);
font-weight: 700;
margin-bottom: var(--space-2);
}
&__sheet-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-5);
}
&__sheet-actions {
display: flex;
gap: var(--space-3);
> * { flex: 1; }
}
}
</style>
+169 -3
View File
@@ -1,9 +1,175 @@
<template>
<main class="view">
<h1>Settings</h1>
<main class="settings">
<h1 class="settings__title">Settings</h1>
<section class="settings__section">
<h2 class="settings__section-title">Theme</h2>
<div class="theme-grid" role="radiogroup" aria-label="Choose theme">
<button
v-for="t in THEMES"
:key="t.id"
type="button"
role="radio"
:aria-checked="currentTheme === t.id"
:aria-label="t.label"
:class="['theme-swatch', { 'theme-swatch--active': currentTheme === t.id }]"
:style="{ '--swatch-bg': t.bg, '--swatch-primary': t.primary, '--swatch-text': t.text }"
@click="select(t.id)"
>
<span class="theme-swatch__preview" aria-hidden="true">
<span class="theme-swatch__bar" />
<span class="theme-swatch__dot" />
</span>
<span class="theme-swatch__label">{{ t.label }}</span>
<span v-if="currentTheme === t.id" class="theme-swatch__check" aria-hidden="true"></span>
</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Account</h2>
<div class="settings__row">
<span class="settings__row-label">Signed in as</span>
<span class="settings__row-value">{{ auth.user?.email }}</span>
</div>
<a href="/logout" class="settings__logout">Sign out</a>
</section>
</main>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useTheme, THEMES } from '@/composables/useTheme'
const auth = useAuthStore()
const { saveTheme } = useTheme()
const currentTheme = computed(() => auth.user?.theme ?? 'warm-craft')
function select(themeId: string) {
saveTheme(themeId)
}
</script>
<style scoped lang="scss">
.view { padding: var(--space-4); }
.settings {
padding: var(--space-4) var(--space-4) calc(64px + var(--space-6));
max-width: 480px;
margin: 0 auto;
&__title {
font-size: var(--text-xl);
font-weight: 700;
margin-bottom: var(--space-6);
}
&__section {
margin-bottom: var(--space-6);
}
&__section-title {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--space-3);
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
font-size: var(--text-base);
}
&__row-label { color: var(--color-text-muted); }
&__row-value { font-weight: 600; }
&__logout {
display: flex;
align-items: center;
min-height: var(--touch-min);
padding: var(--space-3) 0;
color: var(--color-destructive);
font-weight: 600;
text-decoration: none;
font-size: var(--text-base);
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
.theme-swatch {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
background: var(--swatch-bg);
border: 2px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--duration-fast);
min-height: var(--touch-min);
&--active {
border-color: var(--swatch-primary);
}
&__preview {
width: 100%;
height: 36px;
border-radius: var(--radius-sm);
background: var(--swatch-bg);
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 0 6px;
border: 1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);
}
&__bar {
display: block;
height: 6px;
border-radius: 3px;
background: var(--swatch-primary);
width: 60%;
}
&__dot {
display: block;
height: 4px;
border-radius: 2px;
background: var(--swatch-text);
width: 80%;
opacity: 0.4;
}
&__label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
text-align: center;
line-height: 1.2;
}
&__check {
position: absolute;
top: var(--space-1);
right: var(--space-2);
font-size: var(--text-sm);
color: var(--swatch-primary);
font-weight: 700;
}
}
</style>
-9
View File
@@ -1,9 +0,0 @@
<template>
<main class="view">
<h1>Shared</h1>
</main>
</template>
<style scoped lang="scss">
.view { padding: var(--space-4); }
</style>
+308
View File
@@ -0,0 +1,308 @@
<template>
<div class="upload-view">
<!-- Header -->
<header class="upload-view__header">
<button
v-if="step !== 'done'"
class="upload-view__back"
type="button"
:aria-label="step === 'crop' ? 'Cancel' : 'Back'"
@click="goBack"
>
<svg v-if="step === 'crop'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="upload-view__step-label">{{ stepLabel }}</span>
<button
v-if="step === 'stickers'"
class="upload-view__skip"
type="button"
@click="skipStickers"
>Skip</button>
</header>
<!-- Crop step -->
<CropEditor
v-if="step === 'crop' && uploadStore.originalUrl"
:src="uploadStore.originalUrl"
:orientation="contextOrientation"
:device-name="contextDeviceName"
:initial-params="uploadStore.cropParams"
class="upload-view__stage"
@crop="onCrop"
/>
<!-- Stickers step -->
<StickerCanvas
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
:cropped-url="uploadStore.croppedUrl"
:orientation="contextOrientation"
:stickers="uploadStore.stickers"
class="upload-view__stage"
@add-sticker="uploadStore.addSticker"
@update-sticker="uploadStore.updateSticker"
@remove-sticker="uploadStore.removeSticker"
@done="onStickersDone"
/>
<!-- Done -->
<div v-else-if="step === 'done'" class="upload-view__done">
<div class="upload-view__done-icon" aria-hidden="true">🎉</div>
<p class="upload-view__done-title">{{ isEdit ? 'Photo updated!' : 'Photo added!' }}</p>
<p class="upload-view__done-sub">It'll appear on your frame at the next update.</p>
<BaseButton variant="primary" class="upload-view__done-btn" @click="finish">Done</BaseButton>
</div>
<!-- Device picker (only on new uploads, not edits) -->
<DevicePicker
v-if="!isEdit"
v-model="devicePickerOpen"
:devices="devicesStore.devices"
:selected="uploadStore.selectedDeviceIds"
:uploading="uploading"
@update:selected="uploadStore.selectedDeviceIds = $event"
@confirm="doUpload"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUploadStore } from '@/stores/upload'
import { useDevicesStore } from '@/stores/devices'
import { useImagesStore } from '@/stores/images'
import { useToastStore } from '@/stores/toast'
import type { CropParams } from '@/types'
import CropEditor from '@/components/CropEditor.vue'
import StickerCanvas from '@/components/StickerCanvas.vue'
import DevicePicker from '@/components/DevicePicker.vue'
import BaseButton from '@/components/BaseButton.vue'
const router = useRouter()
const uploadStore = useUploadStore()
const devicesStore = useDevicesStore()
const imagesStore = useImagesStore()
const toast = useToastStore()
type Step = 'crop' | 'stickers' | 'done'
const step = ref<Step>('crop')
const uploading = ref(false)
const devicePickerOpen = ref(false)
let finalBlob: Blob | null = null
const isEdit = computed(() => uploadStore.editingImageId !== null)
// Bootstrap
onMounted(async () => {
await devicesStore.fetchDevices()
if (!uploadStore.originalFile) {
router.replace('/')
return
}
// When opening for edit, jump straight to crop (state already loaded by caller)
step.value = 'crop'
})
// Context device
const contextDevice = computed(() =>
uploadStore.contextDeviceId
? devicesStore.devices.find(d => d.id === uploadStore.contextDeviceId)
: devicesStore.devices[0]
)
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
contextDevice.value?.orientation ?? 'landscape'
)
const contextDeviceName = computed(() => contextDevice.value?.name)
// Steps
const stepLabel = computed(() => {
if (step.value === 'crop') return isEdit.value ? 'Edit crop' : 'Crop photo'
if (step.value === 'stickers') return 'Add stickers'
return isEdit.value ? 'Updated' : 'Added'
})
function onCrop({ blob, params }: { blob: Blob; params: CropParams }) {
uploadStore.setCrop(blob, params)
step.value = 'stickers'
}
function skipStickers() {
if (!uploadStore.croppedBlob) return
finalBlob = uploadStore.croppedBlob
if (isEdit.value) {
doUpload()
} else {
devicePickerOpen.value = true
}
}
function onStickersDone(blob: Blob) {
finalBlob = blob
if (isEdit.value) {
doUpload()
} else {
devicePickerOpen.value = true
}
}
function goBack() {
if (step.value === 'crop') {
uploadStore.cleanup()
router.replace('/library')
return
}
if (step.value === 'stickers') {
step.value = 'crop'
}
}
// Upload / reprocess
async function doUpload() {
if (!finalBlob) return
uploading.value = true
try {
const composited = new File([finalBlob], 'photo.jpg', { type: 'image/jpeg' })
if (isEdit.value) {
await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, {
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
})
devicePickerOpen.value = false
step.value = 'done'
return
}
const image = await imagesStore.uploadImage(composited, {
original: uploadStore.originalFile ?? undefined,
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
})
await Promise.all(
uploadStore.selectedDeviceIds.map(deviceId =>
imagesStore.setApproval(image.id, deviceId, true)
)
)
devicePickerOpen.value = false
step.value = 'done'
} catch (e) {
toast.show(e instanceof Error ? e.message : 'Upload failed', 'error')
} finally {
uploading.value = false
}
}
function finish() {
uploadStore.cleanup()
router.replace('/library')
}
</script>
<style scoped lang="scss">
.upload-view {
position: fixed;
inset: 0;
z-index: 100;
background: var(--color-bg);
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 var(--space-4);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: relative;
}
&__back {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text);
margin-left: -8px;
}
&__step-label {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
}
&__skip {
margin-left: auto;
background: none;
border: none;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
cursor: pointer;
padding: var(--space-2) 0;
}
&__stage {
flex: 1;
min-height: 0;
}
&__done {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-6) var(--space-5);
text-align: center;
}
&__done-icon {
font-size: 64px;
line-height: 1;
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
}
&__done-title {
font-size: var(--text-xl);
font-weight: 700;
}
&__done-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
max-width: 260px;
line-height: 1.5;
}
&__done-btn {
width: 100%;
max-width: 320px;
}
}
</style>
+20 -3
View File
@@ -1,16 +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/**',
]
}
}
})
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260428044656 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE device (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, mac VARCHAR(17) NOT NULL, name VARCHAR(100) NOT NULL, orientation VARCHAR(255) NOT NULL, rotation_interval_hours INT NOT NULL, uniqueness_window INT NOT NULL, linked_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_92FB68E1713EB65 ON device (mac)');
$this->addSql('CREATE INDEX IDX_92FB68EA76ED395 ON device (user_id)');
$this->addSql('ALTER TABLE device ADD CONSTRAINT FK_92FB68EA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE device DROP CONSTRAINT FK_92FB68EA76ED395');
$this->addSql('DROP TABLE device');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260504000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Split portrait orientation into portrait_left / portrait_right; migrate existing rows to portrait_left';
}
public function up(Schema $schema): void
{
// Migrate any pre-existing 'portrait' rows to 'portrait_left'.
// (Ribbon at bottom in landscape → ribbon on left when right edge faces up.)
$this->addSql("UPDATE device SET orientation = 'portrait_left' WHERE orientation = 'portrait'");
}
public function down(Schema $schema): void
{
$this->addSql("UPDATE device SET orientation = 'portrait' WHERE orientation IN ('portrait_left', 'portrait_right')");
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260504000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Collapse portrait_left / portrait_right into a single portrait orientation (ribbon always on left)';
}
public function up(Schema $schema): void
{
$this->addSql("UPDATE device SET orientation = 'portrait' WHERE orientation IN ('portrait_left', 'portrait_right')");
}
public function down(Schema $schema): void
{
$this->addSql("UPDATE device SET orientation = 'portrait_left' WHERE orientation = 'portrait'");
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505040613 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add image, rendered_asset, image_device_approval tables; add model column to device';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE image (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, original_filename VARCHAR(255) NOT NULL, storage_path VARCHAR(500) NOT NULL, uploaded_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, user_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_C53D045FA76ED395 ON image (user_id)');
$this->addSql('CREATE TABLE image_device_approval (image_id INT NOT NULL, device_id INT NOT NULL, PRIMARY KEY (image_id, device_id))');
$this->addSql('CREATE INDEX IDX_3524D29A3DA5256D ON image_device_approval (image_id)');
$this->addSql('CREATE INDEX IDX_3524D29A94A4C7D4 ON image_device_approval (device_id)');
$this->addSql('CREATE TABLE rendered_asset (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, device_model VARCHAR(255) NOT NULL, orientation VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, file_path VARCHAR(500) DEFAULT NULL, rendered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, image_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_DF34C8E33DA5256D ON rendered_asset (image_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_DF34C8E33DA5256D111092BE3680C556 ON rendered_asset (image_id, device_model, orientation)');
$this->addSql('ALTER TABLE image ADD CONSTRAINT FK_C53D045FA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE image_device_approval ADD CONSTRAINT FK_3524D29A3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE image_device_approval ADD CONSTRAINT FK_3524D29A94A4C7D4 FOREIGN KEY (device_id) REFERENCES device (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE rendered_asset ADD CONSTRAINT FK_DF34C8E33DA5256D FOREIGN KEY (image_id) REFERENCES image (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql("ALTER TABLE device ADD model VARCHAR(255) NOT NULL DEFAULT 'v1'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE image DROP CONSTRAINT FK_C53D045FA76ED395');
$this->addSql('ALTER TABLE image_device_approval DROP CONSTRAINT FK_3524D29A3DA5256D');
$this->addSql('ALTER TABLE image_device_approval DROP CONSTRAINT FK_3524D29A94A4C7D4');
$this->addSql('ALTER TABLE rendered_asset DROP CONSTRAINT FK_DF34C8E33DA5256D');
$this->addSql('DROP TABLE image');
$this->addSql('DROP TABLE image_device_approval');
$this->addSql('DROP TABLE rendered_asset');
$this->addSql('ALTER TABLE device DROP COLUMN model');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add wake_hour to device for time-based wake scheduling';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD wake_hour INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN wake_hour');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add timezone to user';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD timezone VARCHAR(60) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP COLUMN timezone');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add timezone to device (per-device scheduling context)';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE device ADD timezone VARCHAR(60) NOT NULL DEFAULT 'UTC'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN timezone');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260505150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add crop_params and sticker_state to image for re-edit support';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE image ADD crop_params TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE image ADD sticker_state TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE image DROP COLUMN crop_params');
$this->addSql('ALTER TABLE image DROP COLUMN sticker_state');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add device_image_history table for rotation uniqueness tracking';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE device_image_history (
id SERIAL NOT NULL,
device_id INT NOT NULL,
image_id INT NOT NULL,
served_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)');
$this->addSql('CREATE INDEX idx_history_device_served ON device_image_history (device_id, served_at)');
$this->addSql('ALTER TABLE device_image_history ADD CONSTRAINT fk_history_device FOREIGN KEY (device_id) REFERENCES device (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE device_image_history ADD CONSTRAINT fk_history_image FOREIGN KEY (image_id) REFERENCES image (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('COMMENT ON COLUMN device_image_history.served_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device_image_history DROP CONSTRAINT fk_history_device');
$this->addSql('ALTER TABLE device_image_history DROP CONSTRAINT fk_history_image');
$this->addSql('DROP TABLE device_image_history');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506010000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add last_seen_at to device';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD last_seen_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN device.last_seen_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN last_seen_at');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506020000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add current_image_id FK to device';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD current_image_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE device ADD CONSTRAINT fk_device_current_image FOREIGN KEY (current_image_id) REFERENCES image (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_device_current_image ON device (current_image_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP CONSTRAINT fk_device_current_image');
$this->addSql('DROP INDEX idx_device_current_image');
$this->addSql('ALTER TABLE device DROP COLUMN current_image_id');
}
}

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