Compare commits
2 Commits
2e5ef7fe78
...
fe416f223e
| Author | SHA1 | Date | |
|---|---|---|---|
| fe416f223e | |||
| 6bce4822e7 |
+6
-2
@@ -14,6 +14,7 @@
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
# PlatformIO build artifacts
|
||||
.pio/
|
||||
firmware/.pio/
|
||||
|
||||
# User-uploaded image storage
|
||||
@@ -25,6 +26,9 @@ storage/images/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Frontend build
|
||||
/public/build/
|
||||
# Frontend
|
||||
/frontend/node_modules/
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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/`).
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -8,3 +8,19 @@ 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
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
#define RESET_HOLD_MS 5000
|
||||
|
||||
// ── EPD color nibbles ────────────────────────────────────────────────────────
|
||||
#define COLOR_BLACK 0x0
|
||||
#define COLOR_WHITE 0x1
|
||||
#define COLOR_GREEN 0x2
|
||||
#define COLOR_BLUE 0x3
|
||||
#define COLOR_RED 0x4
|
||||
#define COLOR_YELLOW 0x5
|
||||
#define COLOR_ORANGE 0x6
|
||||
// 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"
|
||||
|
||||
@@ -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
|
||||
Generated
-48
@@ -260,9 +260,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -283,9 +280,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -306,9 +300,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -329,9 +320,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -352,9 +340,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -375,9 +360,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -544,9 +526,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -564,9 +543,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -584,9 +560,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -604,9 +577,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -624,9 +594,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -644,9 +611,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1271,9 +1235,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1295,9 +1256,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1319,9 +1277,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1343,9 +1298,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -12,8 +12,22 @@
|
||||
{{ 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">
|
||||
<div class="frame-card__preview" :style="previewStyle">
|
||||
<img
|
||||
v-if="thumbnailUrl"
|
||||
:src="thumbnailUrl"
|
||||
@@ -49,18 +63,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
defineProps<{
|
||||
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] }>()
|
||||
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">
|
||||
@@ -99,7 +121,6 @@ defineEmits<{ 'add-photo': [deviceId: number] }>()
|
||||
|
||||
// ── Large (single device) ────────────────────────────────────────────────
|
||||
&--large &__preview {
|
||||
aspect-ratio: 5/3;
|
||||
background: var(--color-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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>
|
||||
@@ -21,5 +21,18 @@ export const useDevicesStore = defineStore('devices', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { devices, loading, error, fetchDevices }
|
||||
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalHours' | '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
|
||||
}
|
||||
|
||||
return { devices, loading, error, fetchDevices, updateDevice }
|
||||
})
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
:name="devicesStore.devices[0].name"
|
||||
size="large"
|
||||
status="ok"
|
||||
:orientation="devicesStore.devices[0].orientation"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -41,16 +43,50 @@
|
||||
:name="device.name"
|
||||
size="compact"
|
||||
status="ok"
|
||||
: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>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="home-view__sheet-save"
|
||||
:disabled="saving"
|
||||
@click="saveSettings"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</BaseButton>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import type { Device } from '@/types'
|
||||
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 devicesStore = useDevicesStore()
|
||||
|
||||
@@ -60,6 +96,37 @@ function onAddPhoto(deviceId: number) {
|
||||
// Photo upload flow — Epic 3
|
||||
console.log('add-photo', deviceId)
|
||||
}
|
||||
|
||||
// ── Settings sheet ────────────────────────────────────────────────────────────
|
||||
|
||||
const sheetOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingDevice = ref<Device | null>(null)
|
||||
const editName = ref('')
|
||||
const editOrientation = ref<Device['orientation']>('landscape')
|
||||
|
||||
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
|
||||
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,
|
||||
})
|
||||
sheetOpen.value = false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -122,5 +189,28 @@ function onAddPhoto(deviceId: number) {
|
||||
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);
|
||||
}
|
||||
|
||||
&__sheet-save {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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')");
|
||||
}
|
||||
}
|
||||
@@ -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'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the default pictureFrame registration screen.
|
||||
|
||||
Full-field baby blue background, "pictureFrame" title, centered QR code, and
|
||||
registration instructions. Output is a 4bpp binary packed for the Waveshare
|
||||
7.3" display (always 800×480 on the wire; portrait content is rotated 90° CW
|
||||
into that format so the frame can be mounted either way).
|
||||
|
||||
Nibble map (verified on hardware):
|
||||
BLACK=0x0 WHITE=0x1 YELLOW=0x2 RED=0x3 BLUE=0x5 GREEN=0x6
|
||||
|
||||
Usage:
|
||||
python3 scripts/generate_default_image.py --orientation landscape
|
||||
python3 scripts/generate_default_image.py --orientation portrait
|
||||
python3 scripts/generate_default_image.py --mac AA:BB:CC:DD:EE:FF --orientation portrait
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import qrcode
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
EPD_W, EPD_H = 800, 480 # physical display dimensions, always
|
||||
BABY_BLUE = (135, 206, 235)
|
||||
BLACK = (0, 0, 0)
|
||||
BASE_URL = "https://pictureframe.edholm.me"
|
||||
|
||||
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
FONT_PLAIN = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
|
||||
# Verified on Waveshare 7.3" hardware. Same map applies to 13.3" Spectra 6.
|
||||
_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)
|
||||
|
||||
|
||||
def _floyd_steinberg(img_rgb: np.ndarray) -> np.ndarray:
|
||||
h, w = img_rgb.shape[:2]
|
||||
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)
|
||||
idx = int(np.argmin(((_PAL_RGB - px) ** 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 _make_canvas_landscape(url: str) -> Image.Image:
|
||||
"""800×480 — frame mounted horizontally."""
|
||||
W, H = 800, 480
|
||||
canvas = Image.new("RGB", (W, H), BABY_BLUE)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
font_title = ImageFont.truetype(FONT_BOLD, 52)
|
||||
font_body = ImageFont.truetype(FONT_PLAIN, 26)
|
||||
|
||||
title = "pictureFrame"
|
||||
tw = draw.textlength(title, font=font_title)
|
||||
draw.text(((W - tw) / 2, 26), title, fill=BLACK, font=font_title)
|
||||
|
||||
qr_size = 268
|
||||
qr_x, qr_y = (W - qr_size) // 2, 108
|
||||
_paste_qr(canvas, url, qr_x, qr_y, qr_size)
|
||||
|
||||
for i, line in enumerate(["Scan to register your frame", url]):
|
||||
lw = draw.textlength(line, font=font_body)
|
||||
draw.text(((W - lw) / 2, qr_y + qr_size + 16 + i * 34), line, fill=BLACK, font=font_body)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def _make_canvas_portrait(url: str) -> Image.Image:
|
||||
"""Design at 480×800 (portrait), rotate 90° CCW → 800×480 for the display.
|
||||
Hold the frame with the RIGHT edge up to read portrait content correctly."""
|
||||
W, H = 480, 800
|
||||
canvas = Image.new("RGB", (W, H), BABY_BLUE)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
font_title = ImageFont.truetype(FONT_BOLD, 52)
|
||||
font_body = ImageFont.truetype(FONT_PLAIN, 26)
|
||||
|
||||
title = "pictureFrame"
|
||||
tw = draw.textlength(title, font=font_title)
|
||||
draw.text(((W - tw) / 2, 36), title, fill=BLACK, font=font_title)
|
||||
|
||||
qr_size = 320
|
||||
qr_x, qr_y = (W - qr_size) // 2, 130
|
||||
_paste_qr(canvas, url, qr_x, qr_y, qr_size)
|
||||
|
||||
for i, line in enumerate(["Scan to register your frame", url]):
|
||||
lw = draw.textlength(line, font=font_body)
|
||||
draw.text(((W - lw) / 2, qr_y + qr_size + 20 + i * 36), line, fill=BLACK, font=font_body)
|
||||
|
||||
# ROTATE_90 (90° CCW): hold frame with RIGHT edge up to see content upright.
|
||||
# Combined framing guide uses the same direction — keep them in sync.
|
||||
return canvas.transpose(Image.ROTATE_90)
|
||||
|
||||
|
||||
def _half_canvas(url: str, w: int, h: int, title_size: int, body_size: int, qr_size: int, qr_y: int) -> Image.Image:
|
||||
"""Build a registration layout at arbitrary (w, h) — used for each half of the combined image."""
|
||||
canvas = Image.new("RGB", (w, h), BABY_BLUE)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
font_title = ImageFont.truetype(FONT_BOLD, title_size)
|
||||
font_body = ImageFont.truetype(FONT_PLAIN, body_size)
|
||||
|
||||
title = "pictureFrame"
|
||||
tw = draw.textlength(title, font=font_title)
|
||||
draw.text(((w - tw) / 2, 20), title, fill=BLACK, font=font_title)
|
||||
|
||||
_paste_qr(canvas, url, (w - qr_size) // 2, qr_y, qr_size)
|
||||
|
||||
for i, line in enumerate(["Scan to register your frame", url]):
|
||||
lw = draw.textlength(line, font=font_body)
|
||||
draw.text(((w - lw) / 2, qr_y + qr_size + 10 + i * (body_size + 6)), line, fill=BLACK, font=font_body)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
def _make_canvas_combined(url: str) -> Image.Image:
|
||||
"""800×480 — left half shows landscape content upright; right half shows portrait
|
||||
content physically rotated 90° CCW, so it appears sideways when the frame is flat.
|
||||
The user rotates the frame to see each half correctly — making orientation obvious."""
|
||||
DIVIDER = 2
|
||||
LEFT_W = (EPD_W - DIVIDER) // 2 # 399
|
||||
RIGHT_W = EPD_W - LEFT_W - DIVIDER # 399
|
||||
|
||||
combined = Image.new("RGB", (EPD_W, EPD_H), BABY_BLUE)
|
||||
draw = ImageDraw.Draw(combined)
|
||||
font_lbl = ImageFont.truetype(FONT_BOLD, 18)
|
||||
|
||||
# ── Left half: upright landscape layout ──────────────────────────────────
|
||||
left = _half_canvas(url, w=LEFT_W, h=EPD_H, title_size=36, body_size=18, qr_size=210, qr_y=76)
|
||||
combined.paste(left, (0, 0))
|
||||
draw.text((6, EPD_H - 26), "LANDSCAPE", fill=BLACK, font=font_lbl)
|
||||
|
||||
# ── Divider ───────────────────────────────────────────────────────────────
|
||||
draw.rectangle([LEFT_W, 0, LEFT_W + DIVIDER - 1, EPD_H - 1], fill=BLACK)
|
||||
|
||||
# ── Right half: portrait content designed at 480×399, rotated 90° CCW ────
|
||||
# Designing at 480(h)×399(w) then rotating 90° CCW gives 399(w)×480(h) to
|
||||
# fill the right half. The content appears sideways on the flat display —
|
||||
# rotate the frame 90° CCW (left edge up) to read this half correctly.
|
||||
portrait_src = _half_canvas(url, w=480, h=RIGHT_W,
|
||||
title_size=36, body_size=18, qr_size=210, qr_y=76)
|
||||
# ROTATE_90 = 90° CCW: 480×399 → 399×480
|
||||
portrait_half = portrait_src.transpose(Image.ROTATE_90)
|
||||
combined.paste(portrait_half, (LEFT_W + DIVIDER, 0))
|
||||
|
||||
# Label drawn rotated — PIL doesn't rotate text natively, so draw on a
|
||||
# temp canvas then rotate and composite into bottom-right corner.
|
||||
lbl_text = "PORTRAIT"
|
||||
lbl_font = ImageFont.truetype(FONT_BOLD, 18)
|
||||
lbl_w = int(draw.textlength(lbl_text, font=lbl_font))
|
||||
lbl_h = 24
|
||||
lbl_img = Image.new("RGB", (lbl_w + 4, lbl_h), BABY_BLUE)
|
||||
ImageDraw.Draw(lbl_img).text((2, 2), lbl_text, fill=BLACK, font=lbl_font)
|
||||
lbl_rot = lbl_img.transpose(Image.ROTATE_90) # rotate label to match content
|
||||
lx = LEFT_W + DIVIDER + 4
|
||||
ly = EPD_H - lbl_rot.height - 4
|
||||
combined.paste(lbl_rot, (lx, ly))
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def _paste_qr(canvas: Image.Image, url: str, x: int, y: int, size: int) -> None:
|
||||
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color=BLACK, back_color=BABY_BLUE).convert("RGB")
|
||||
canvas.paste(qr_img.resize((size, size), Image.NEAREST), (x, y))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--orientation", choices=["landscape", "portrait", "combined"], default="landscape")
|
||||
ap.add_argument("--mac", default=None, metavar="AA:BB:CC:DD:EE:FF")
|
||||
ap.add_argument("--out", default=None, help="Output .bin path (default: firmware/data/img_<orientation>.bin)")
|
||||
ap.add_argument("--preview", default=None, help="Preview PNG path (default: /tmp/pictureframe_<orientation>_preview.png)")
|
||||
args = ap.parse_args()
|
||||
|
||||
url = f"{BASE_URL}/setup/{args.mac}" if args.mac else BASE_URL
|
||||
out = Path(args.out) if args.out else Path(f"firmware/data/img_{args.orientation}.bin")
|
||||
preview = Path(args.preview) if args.preview else Path(f"/tmp/pictureframe_{args.orientation}_preview.png")
|
||||
|
||||
print(f"Orientation : {args.orientation}")
|
||||
print(f"QR URL : {url}")
|
||||
|
||||
makers = {"landscape": _make_canvas_landscape, "portrait": _make_canvas_portrait, "combined": _make_canvas_combined}
|
||||
canvas = makers[args.orientation](url)
|
||||
assert canvas.size == (EPD_W, EPD_H), f"canvas is {canvas.size}, expected ({EPD_W}, {EPD_H})"
|
||||
|
||||
canvas.save(str(preview))
|
||||
print(f"Preview : {preview}")
|
||||
|
||||
print(f"Dithering {EPD_W}×{EPD_H} to 6-color palette…")
|
||||
codes = _floyd_steinberg(np.array(canvas, dtype=np.float32))
|
||||
packed = ((codes[:, 0::2] << 4) | codes[:, 1::2]).astype(np.uint8).tobytes()
|
||||
assert len(packed) == EPD_H * (EPD_W // 2)
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(packed)
|
||||
print(f"Written : {len(packed):,} bytes → {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,6 +6,13 @@ namespace App\Enum;
|
||||
|
||||
enum Orientation: string
|
||||
{
|
||||
/** Ribbon at bottom; nail/hanging point at top. */
|
||||
case Landscape = 'landscape';
|
||||
/** Ribbon on left; nail/hanging point on right. */
|
||||
case Portrait = 'portrait';
|
||||
|
||||
public function isPortrait(): bool
|
||||
{
|
||||
return $this === self::Portrait;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Entity\User;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Email;
|
||||
@@ -26,15 +27,22 @@ class RegistrationFormType extends AbstractType
|
||||
new Email(message: 'Please enter a valid email address'),
|
||||
],
|
||||
])
|
||||
->add('plainPassword', PasswordType::class, [
|
||||
'label' => 'Password',
|
||||
->add('plainPassword', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'mapped' => false,
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter a password'),
|
||||
new Length(
|
||||
min: 8,
|
||||
minMessage: 'Your password must be at least {{ limit }} characters',
|
||||
),
|
||||
'invalid_message' => 'Passwords do not match.',
|
||||
'first_options' => [
|
||||
'label' => 'Password',
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'Please enter a password'),
|
||||
new Length(
|
||||
min: 8,
|
||||
minMessage: 'Your password must be at least {{ limit }} characters',
|
||||
),
|
||||
],
|
||||
],
|
||||
'second_options' => [
|
||||
'label' => 'Confirm password',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -90,14 +90,27 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
{{ form_label(form.plainPassword) }}
|
||||
{{ form_widget(form.plainPassword, {attr: {
|
||||
{{ form_label(form.plainPassword.first) }}
|
||||
{{ form_widget(form.plainPassword.first, {attr: {
|
||||
id: 'reg-password',
|
||||
autocomplete: 'new-password',
|
||||
'aria-describedby': 'reg-password-error',
|
||||
'aria-invalid': form.plainPassword.vars.errors|length > 0 ? 'true' : 'false'
|
||||
'aria-invalid': form.plainPassword.first.vars.errors|length > 0 ? 'true' : 'false'
|
||||
}}) }}
|
||||
<p id="reg-password-error" class="field-error" role="alert">
|
||||
{% for error in form.plainPassword.first.vars.errors %}{{ error.message }}{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
{{ form_label(form.plainPassword.second) }}
|
||||
{{ form_widget(form.plainPassword.second, {attr: {
|
||||
id: 'reg-password-confirm',
|
||||
autocomplete: 'new-password',
|
||||
'aria-describedby': 'reg-password-confirm-error',
|
||||
'aria-invalid': form.plainPassword.vars.errors|length > 0 ? 'true' : 'false'
|
||||
}}) }}
|
||||
<p id="reg-password-confirm-error" class="field-error" role="alert">
|
||||
{% for error in form.plainPassword.vars.errors %}{{ error.message }}{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
@@ -116,9 +129,10 @@
|
||||
var emailError = document.getElementById('reg-email-error');
|
||||
var pwInput = document.getElementById('reg-password');
|
||||
var pwError = document.getElementById('reg-password-error');
|
||||
var confirmInput = document.getElementById('reg-password-confirm');
|
||||
var confirmError = document.getElementById('reg-password-confirm-error');
|
||||
|
||||
function validateEmail() {
|
||||
// Only run client-side validation when there is no server-side error already shown
|
||||
if (emailError.dataset.serverError) return;
|
||||
var val = emailInput.value.trim();
|
||||
if (!val) {
|
||||
@@ -142,6 +156,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfirm() {
|
||||
if (confirmError.dataset.serverError) return;
|
||||
if (confirmInput.value && confirmInput.value !== pwInput.value) {
|
||||
setError(confirmInput, confirmError, 'Passwords do not match.');
|
||||
} else {
|
||||
clearError(confirmInput, confirmError);
|
||||
}
|
||||
}
|
||||
|
||||
function setError(input, errorEl, message) {
|
||||
input.setAttribute('aria-invalid', 'true');
|
||||
errorEl.textContent = message;
|
||||
@@ -155,17 +178,23 @@
|
||||
// Mark existing server errors so client-side blur doesn't clobber them
|
||||
if (emailError.textContent.trim()) emailError.dataset.serverError = '1';
|
||||
if (pwError.textContent.trim()) pwError.dataset.serverError = '1';
|
||||
if (confirmError.textContent.trim()) confirmError.dataset.serverError = '1';
|
||||
|
||||
// Clear server-error flag once user starts typing
|
||||
emailInput.addEventListener('input', function () { delete emailError.dataset.serverError; });
|
||||
pwInput.addEventListener('input', function () { delete pwError.dataset.serverError; });
|
||||
confirmInput.addEventListener('input', function () { delete confirmError.dataset.serverError; });
|
||||
|
||||
emailInput.addEventListener('blur', validateEmail);
|
||||
pwInput.addEventListener('blur', validatePassword);
|
||||
confirmInput.addEventListener('blur', validateConfirm);
|
||||
// Re-validate confirm whenever the password field changes
|
||||
pwInput.addEventListener('input', function () { if (confirmInput.value) validateConfirm(); });
|
||||
|
||||
form.addEventListener('submit', function () {
|
||||
form.addEventListener('submit', function (e) {
|
||||
validateEmail();
|
||||
validatePassword();
|
||||
validateConfirm();
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
<div class="field">
|
||||
<label for="orientation">Display orientation</label>
|
||||
<select id="orientation" name="orientation">
|
||||
<option value="landscape" {% if device.orientation.value == 'landscape' %}selected{% endif %}>Landscape</option>
|
||||
<option value="portrait" {% if device.orientation.value == 'portrait' %}selected{% endif %}>Portrait</option>
|
||||
<option value="landscape" {% if device.orientation.value == 'landscape' %}selected{% endif %}>Landscape — ribbon at bottom</option>
|
||||
<option value="portrait" {% if device.orientation.value == 'portrait' %}selected{% endif %}>Portrait — ribbon on left</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user