Compare commits

5 Commits

Author SHA1 Message Date
football2801 82a42011d8 fix(upload): persistent file <input> to survive iOS PWA cold launch
CI / test (push) Has been cancelled
A dynamically-created <input type="file"> that's never attached to the
DOM drops its first `change` event on a cold-launched iOS PWA — the
native photo picker resolves out of the original user-gesture context
and the closure that captured the input is gone. Symptom Matt hit
2026-05-14: first image-pick after hard-close + reopen of the PWA
silently failed to advance to the crop tool; the second attempt worked.

HomeView and LibraryView now keep a hidden <input ref="fileInputEl"
type="file"> live in their templates. onAddPhoto clicks that input
inside the user-gesture context; @change fires reliably even on cold
launches. The picker resets input.value between selections so picking
the same file twice still fires.

Tests updated to query the template input via wrapper.find() instead
of stubbing document.createElement; 347/347 frontend tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:26 -04:00
football2801 e57e711fcc chore(build): rebuild bundle for v2 crop fix + tighten Device.model
CI / test (push) Has been cancelled
The crop-aspect fix didn't reach production on the prior deploy because
public/build/ was 5 days stale. Rebuilds the SPA bundle so the
panelDims-driven CropEditor / StickerCanvas / FrameCard ship.

Also makes Device.model required in the TS type (was optional in this
session's first cut to placate test fixtures) and adds `model: 'v1'` to
every test Device fixture. A new device row from the API always has a
model, so the type should reflect that — leaving it optional was a trap
for production code that defensively assumed undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:29:12 -04:00
football2801 081ca83613 fix(v2): preview rotation + crop aspect for 13.3" hardware
CI / test (push) Has been cancelled
Two related bugs that surfaced on the first 13.3" device's first photo:

1) Web-UI portrait preview was 90° sideways. DeviceApiController::
   renderBinToPng rotated whenever the device was Portrait — correct
   for V1 (landscape-native, Portrait => renderer rotated, so preview
   un-rotates) but wrong for V2 (portrait-native — the renderer
   doesn't rotate, so the preview shouldn't either). Now mirrors the
   render-pipeline check: rotate only when `orientation !==
   model->nativeOrientation()`. Two new functional tests pin the V2
   portrait and V2 landscape PNG dimensions to guard against
   regressions.

2) Cropped photo letterboxed on the 13.3" panel. CropEditor /
   StickerCanvas / FrameCard had V1 dimensions hardcoded (1600×960
   = 5:3 aspect). V2 is 4:3 (1200×1600 portrait / 1600×1200
   landscape), so a "full crop" came out the wrong shape and the
   server's white-canvas composite added bars. New `panelDims(model,
   orientation)` helper in @/types is the single source of truth on
   the frontend; matches DeviceModel::width/height on the server.
   Threaded `model` through Device serializer → Device type →
   UploadView → CropEditor / StickerCanvas, and HomeView → FrameCard.
   FrameCard tests updated to cover all four model × orientation
   placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:02:39 -04:00
football2801 b286a1f241 feat(devices): DeviceModel::V2 for Waveshare 13.3" Spectra-6
CI / test (push) Has been cancelled
Adds the second panel model alongside V1 (800x480, 7.3"). V2 is
1200x1600 panel-native (tall) — the inverse aspect ratio means
its "natural" orientation is portrait, not landscape:
- DeviceModel::nativeOrientation() — V1 returns Landscape, V2 returns
  Portrait. Render rotates the source image 90 CCW only when the user's
  orientation differs from the panel's native, so the .bin stays
  panel-native scan order without per-model branches.
- DeviceModel::panelId() / fromPanelId() — string mapping for the
  firmware's X-Panel-Id header (matches -DPANEL_ID build flag).
- DeviceImageController: on every poll, if X-Panel-Id maps to a known
  model and differs from the device's current model, auto-correct.
  New Devices are created with the V1 default, so a freshly-claimed
  13.3" unit needs this correction before the first image render
  produces a wrong-dimension .bin the firmware would reject.

8 new DeviceModel unit tests, 3 new controller tests cover the
header-correction behaviour (different, same, unknown panel-id).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:53:59 -04:00
football2801 2adb07518c feat(account): change-password endpoint + Settings modal
CI / test (push) Has been cancelled
PATCH /api/user/password — verifies the current password, enforces
8-char minimum on the new one, and rehashes via the configured
password hasher. Returns 204 on success, 422 with an `error` body
on every validation failure (wrong current, too-short new, missing
fields).

Settings adds a "Change password" link under the Account section
that opens a modal with current/new/confirm fields and posts to the
new endpoint. Confirm-mismatch and submit-disabled wiring is
client-side; backend errors surface inline.

Tests: 4 new controller tests cover success, wrong-current,
short-new, and missing-fields; success path also re-fetches the
user and checks the hash actually changed.
2026-05-09 15:25:54 -04:00
45 changed files with 798 additions and 141 deletions
+9 -6
View File
@@ -57,7 +57,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import type { CropParams } from '@/types' import { panelDims, type CropParams, type DeviceModel } from '@/types'
type Orientation = 'landscape' | 'portrait' type Orientation = 'landscape' | 'portrait'
@@ -65,6 +65,10 @@ const props = defineProps<{
src: string src: string
/** Frame's current orientation used as the toggle's initial value and for the mismatch chip. */ /** Frame's current orientation used as the toggle's initial value and for the mismatch chip. */
orientation: Orientation orientation: Orientation
/** Frame's hardware model drives crop output dimensions. V1 (7.3", 5:3) and
* V2 (13.3", 4:3) have different aspect ratios. Defaults to 'v1' so existing
* callers that don't pass a model keep the original behaviour. */
model?: DeviceModel
deviceName?: string deviceName?: string
initialParams?: CropParams | null initialParams?: CropParams | null
/** When editing an existing image, defaults the toggle to the saved choice instead of `orientation`. */ /** When editing an existing image, defaults the toggle to the saved choice instead of `orientation`. */
@@ -85,11 +89,10 @@ const ORIENT_OPTS: Array<{ value: Orientation; label: string }> = [
// the crop frame and the eventual output blob. // the crop frame and the eventual output blob.
const cropOrientation = ref<Orientation>(props.initialOrientation ?? props.orientation) const cropOrientation = ref<Orientation>(props.initialOrientation ?? props.orientation)
const outputDims = computed(() => const outputDims = computed(() => {
cropOrientation.value === 'landscape' const { width, height } = panelDims(props.model ?? 'v1', cropOrientation.value)
? { w: 1600, h: 960 } return { w: width, h: height }
: { w: 960, h: 1600 } })
)
const aspect = computed(() => outputDims.value.w / outputDims.value.h) const aspect = computed(() => outputDims.value.w / outputDims.value.h)
// Visible only when the user's choice doesn't match the frame's setting. // Visible only when the user's choice doesn't match the frame's setting.
+9 -5
View File
@@ -70,6 +70,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import { panelDims, type DeviceModel } from '@/types'
const props = defineProps<{ const props = defineProps<{
deviceId: number deviceId: number
@@ -77,6 +78,9 @@ const props = defineProps<{
size: 'large' | 'compact' size: 'large' | 'compact'
status: 'ok' | 'offline' | 'sync-fail' status: 'ok' | 'offline' | 'sync-fail'
orientation: 'landscape' | 'portrait' orientation: 'landscape' | 'portrait'
/** Frame's hardware model — drives the empty-preview placeholder aspect
* ratio. V1 is 5:3, V2 is 4:3 (or 3:4 portrait). Defaults to 'v1'. */
model?: DeviceModel
thumbnailUrl?: string thumbnailUrl?: string
photoCount?: number photoCount?: number
lastSync?: string | null lastSync?: string | null
@@ -91,11 +95,11 @@ defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
// roughly the same shape so the layout doesn't jump. // roughly the same shape so the layout doesn't jump.
const previewStyle = computed(() => ({})) const previewStyle = computed(() => ({}))
const emptyAspectStyle = computed(() => const emptyAspectStyle = computed(() => {
props.size === 'large' if (props.size !== 'large') return {}
? { aspectRatio: props.orientation === 'portrait' ? '3 / 5' : '5 / 3' } const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
: {} return { aspectRatio: `${width} / ${height}` }
) })
const statusText = computed(() => { const statusText = computed(() => {
switch (props.status) { switch (props.status) {
+7 -4
View File
@@ -74,11 +74,14 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import Konva from 'konva' import Konva from 'konva'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import StickerTray from '@/components/StickerTray.vue' import StickerTray from '@/components/StickerTray.vue'
import type { StickerLayer } from '@/types' import { panelDims, type StickerLayer, type DeviceModel } from '@/types'
const props = defineProps<{ const props = defineProps<{
croppedUrl: string croppedUrl: string
orientation: 'landscape' | 'portrait' orientation: 'landscape' | 'portrait'
/** Frame's hardware model drives stage aspect ratio + final output dims.
* Defaults to 'v1' so existing callers keep their behaviour. */
model?: DeviceModel
stickers: StickerLayer[] stickers: StickerLayer[]
}>() }>()
@@ -101,7 +104,8 @@ const selectedId = ref<string | null>(null)
const stageW = ref(375) const stageW = ref(375)
const stageH = ref(225) const stageH = ref(225)
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600) const { width: OUT_W, height: OUT_H } = panelDims(props.model ?? 'v1', props.orientation)
const ASPECT = OUT_W / OUT_H
function sizeStage() { function sizeStage() {
if (!containerRef.value) return if (!containerRef.value) return
@@ -384,8 +388,7 @@ async function done() {
const stage: Konva.Stage = stageRef.value?.getNode() const stage: Konva.Stage = stageRef.value?.getNode()
if (!stage) return if (!stage) return
const outputW = props.orientation === 'landscape' ? 1600 : 960 const pixelRatio = OUT_W / stageW.value
const pixelRatio = outputW / stageW.value
// Use explicit mimeType so the blob is a real JPEG, not the default PNG // 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 const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
@@ -37,6 +37,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
mac: 'AA', mac: 'AA',
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
model: 'v1',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeTimes: [], wakeTimes: [],
timezone: 'UTC', timezone: 'UTC',
@@ -26,6 +26,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
mac: 'AA:BB:CC:DD:EE:FF', mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
model: 'v1',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeTimes: [], wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
+22 -6
View File
@@ -146,19 +146,35 @@ describe('FrameCard', () => {
expect(wrapper.emitted('edit')![0]).toEqual([1]) expect(wrapper.emitted('edit')![0]).toEqual([1])
}) })
it('reserves a 5:3 aspect placeholder when no thumbnail is present (landscape)', () => { it('reserves a V1 landscape (1600 × 960) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, { const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' }, props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
}) })
const empty = wrapper.find('.frame-card__empty-preview') const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/) expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
}) })
it('reserves a 3:5 aspect placeholder when no thumbnail is present (portrait)', () => { it('reserves a V1 portrait (960 × 1600) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, { const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' }, props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
}) })
const empty = wrapper.find('.frame-card__empty-preview') const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/) expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
})
it('reserves a V2 portrait (1200 × 1600) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
})
it('reserves a V2 landscape (1600 × 1200) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
}) })
}) })
@@ -41,6 +41,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
mac: 'AA', mac: 'AA',
name: 'Den', name: 'Den',
orientation: 'landscape', orientation: 'landscape',
model: 'v1',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeTimes: [], wakeTimes: [],
timezone: 'UTC', timezone: 'UTC',
+1
View File
@@ -8,6 +8,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
mac: 'AA:BB:CC:DD:EE:FF', mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
model: 'v1',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeTimes: [], wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
+22 -36
View File
@@ -69,6 +69,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
mac: 'AA:BB:CC:DD:EE:FF', mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
model: 'v1',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeTimes: [], wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
@@ -190,38 +191,31 @@ describe('HomeView', () => {
expect(wrapper.text()).toContain('Loading') expect(wrapper.text()).toContain('Loading')
}) })
// HV-04: add-photo opens a file picker, primes the upload store, and navigates // HV-04: add-photo opens a file picker, primes the upload store, and navigates.
// The file input lives in the template (persistent across renders) so iOS
// PWA cold launches don't drop the first change event on a detached node.
it('add-photo from a FrameCard primes upload state and routes to /upload', async () => { it('add-photo from a FrameCard primes upload state and routes to /upload', async () => {
const devicesStore = useDevicesStore() const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })] devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
routerPush.mockClear() routerPush.mockClear()
// Spy on createElement so we can intercept the synthetic file input
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
// Don't actually open a file dialog
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView() const wrapper = mountView()
await flushPromises() await flushPromises()
const fileInput = wrapper.find('input[type="file"]')
expect(fileInput.exists()).toBe(true)
// Stub click so jsdom doesn't try to open a real picker.
const clickSpy = vi.fn()
;(fileInput.element as HTMLInputElement).click = clickSpy
const card = wrapper.findComponent({ name: 'FrameCard' }) const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('add-photo', 7) await card.vm.$emit('add-photo', 7)
expect(clickSpy).toHaveBeenCalled()
expect(capturedInput).not.toBeNull()
expect(capturedInput!.type).toBe('file')
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }) const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true }) Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change')) await fileInput.trigger('change')
const upload = useUploadStore() const upload = useUploadStore()
expect(upload.originalFile).toStrictEqual(file) expect(upload.originalFile).toStrictEqual(file)
@@ -788,33 +782,25 @@ describe('HomeView', () => {
expect(fetchSpy).toHaveBeenCalledWith({ silent: true }) expect(fetchSpy).toHaveBeenCalledWith({ silent: true })
}) })
// Add-photo handler creates a hidden file input and (on file pick) navigates // Add-photo handler clicks the template's persistent hidden file input
// to /upload with the staged file in the upload store. // and (on file pick) navigates to /upload with the staged file.
it('add-photo opens a file picker and navigates after a file is chosen', async () => { it('add-photo opens a file picker and navigates after a file is chosen', async () => {
const devicesStore = useDevicesStore() const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7 })] devicesStore.devices = [makeDevice({ id: 7 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
let capturedInput: HTMLInputElement | null = null
const origCreate = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = origCreate(tag) as HTMLInputElement
if (tag === 'input') capturedInput = el
return el
})
const wrapper = mountView() const wrapper = mountView()
await flushPromises() await flushPromises()
const fileInput = wrapper.find('input[type="file"]')
expect(fileInput.exists()).toBe(true)
;(fileInput.element as HTMLInputElement).click = vi.fn()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7) await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7)
expect(capturedInput).not.toBeNull()
expect(capturedInput!.type).toBe('file')
// Simulate the user picking a file.
const file = new File(['x'], 'pic.jpg', { type: 'image/jpeg' }) const file = new File(['x'], 'pic.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput!, 'files', { value: [file], configurable: true }) Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change')) await fileInput.trigger('change')
await flushPromises()
expect(routerPush).toHaveBeenCalledWith('/upload') expect(routerPush).toHaveBeenCalledWith('/upload')
}) })
+8 -14
View File
@@ -79,6 +79,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
mac: 'AA:BB:CC:DD:EE:FF', mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
model: 'v1',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeTimes: [], wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
@@ -147,26 +148,19 @@ describe('LibraryView', () => {
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue() vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
// Spy on createElement so we can intercept the synthetic file input
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView() const wrapper = mountView()
await flushPromises() await flushPromises()
// The file <input> is persistent in the template (see LibraryView).
const fileInput = wrapper.find('input[type="file"]')
expect(fileInput.exists()).toBe(true)
;(fileInput.element as HTMLInputElement).click = vi.fn()
await wrapper.find('.library__add-btn').trigger('click') await wrapper.find('.library__add-btn').trigger('click')
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }) const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true }) Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change')) await fileInput.trigger('change')
expect(uploadInit).toHaveBeenCalledWith(file) expect(uploadInit).toHaveBeenCalledWith(file)
expect(routerPush).toHaveBeenCalledWith('/upload') expect(routerPush).toHaveBeenCalledWith('/upload')
@@ -55,6 +55,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
mac: 'AA', mac: 'AA',
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
model: 'v1',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeTimes: [], wakeTimes: [],
timezone: 'UTC', timezone: 'UTC',
+27
View File
@@ -6,10 +6,37 @@ export interface User {
timezone: string timezone: string
} }
export type DeviceModel = 'v1' | 'v2'
/** Panel dimensions a device renders to for a given user orientation. */
export interface PanelDims {
/** Pre-rotation source width — matches the renderer's thumbnail target. */
width: number
/** Pre-rotation source height. */
height: number
}
/**
* Crop / render dimensions for a device's photo at the user's chosen
* orientation. Mirrors `DeviceModel::width(Orientation)` / `height(...)` on
* the server. Keep these tables in sync.
*
* - V1: native landscape 800×480 → upscaled to 1600×960 for better dither
* - V2: native portrait 1200×1600
*/
export function panelDims(model: DeviceModel, orientation: 'landscape' | 'portrait'): PanelDims {
if (model === 'v1') {
return orientation === 'landscape' ? { width: 1600, height: 960 } : { width: 960, height: 1600 }
}
// v2 — 13.3" Spectra-6, portrait-native
return orientation === 'landscape' ? { width: 1600, height: 1200 } : { width: 1200, height: 1600 }
}
export interface Device { export interface Device {
id: number id: number
mac: string mac: string
name: string name: string
model: DeviceModel
orientation: 'landscape' | 'portrait' orientation: 'landscape' | 'portrait'
rotationIntervalMinutes: number rotationIntervalMinutes: number
/** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */ /** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */
+36 -12
View File
@@ -30,6 +30,7 @@
size="large" size="large"
:status="deviceStatus(devicesStore.devices[0])" :status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation" :orientation="devicesStore.devices[0].orientation"
:model="devicesStore.devices[0].model"
:thumbnailUrl="previewUrl(devicesStore.devices[0])" :thumbnailUrl="previewUrl(devicesStore.devices[0])"
:lastSync="lastSyncLabel(devicesStore.devices[0])" :lastSync="lastSyncLabel(devicesStore.devices[0])"
:nextSync="nextSyncLabel(devicesStore.devices[0])" :nextSync="nextSyncLabel(devicesStore.devices[0])"
@@ -59,6 +60,7 @@
size="large" size="large"
:status="deviceStatus(device)" :status="deviceStatus(device)"
:orientation="device.orientation" :orientation="device.orientation"
:model="device.model"
:thumbnailUrl="previewUrl(device)" :thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)" :lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)" :nextSync="nextSyncLabel(device)"
@@ -68,6 +70,19 @@
</div> </div>
</div> </div>
</PullToRefresh> </PullToRefresh>
<!-- Hidden persistent file picker. iOS Safari's PWA shell drops the
first `change` event of a dynamically-created (DOM-detached)
input element on cold launches — the photo picker resolves
out-of-gesture-context and the closure never fires. Keeping the
input live in the template sidesteps that. -->
<input
ref="fileInputEl"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
@change="onFileSelected"
/>
</main> </main>
<!-- Frame settings sheet --> <!-- Frame settings sheet -->
@@ -454,19 +469,28 @@ async function refreshDevices() {
await devicesStore.fetchDevices({ silent: true }) await devicesStore.fetchDevices({ silent: true })
} }
// Hidden file <input> lives in the template so iOS PWA cold launches
// can't drop the first `change` event on a detached node. Click handlers
// route through this single input.
const fileInputEl = ref<HTMLInputElement | null>(null)
let pendingAddDeviceId: number | null = null
function onAddPhoto(deviceId: number) { function onAddPhoto(deviceId: number) {
// File picker must be triggered in the user-gesture context (the click handler) // File picker must be triggered in the user-gesture context (the click
// before navigating, otherwise browsers block it as a popup. // handler), otherwise browsers block it as a popup.
const input = document.createElement('input') pendingAddDeviceId = deviceId
input.type = 'file' fileInputEl.value?.click()
input.accept = 'image/jpeg,image/png,image/webp,image/gif' }
input.onchange = () => {
const file = input.files?.[0] function onFileSelected(e: Event) {
if (!file) return const input = e.target as HTMLInputElement
uploadStore.init(file, deviceId) const file = input.files?.[0]
router.push('/upload') // Reset right away so re-selecting the same file later still fires change.
} input.value = ''
input.click() if (!file) return
uploadStore.init(file, pendingAddDeviceId ?? undefined)
pendingAddDeviceId = null
router.push('/upload')
} }
// ── Settings sheet ──────────────────────────────────────────────────────────── // ── Settings sheet ────────────────────────────────────────────────────────────
+25 -11
View File
@@ -207,6 +207,16 @@
</BaseButton> </BaseButton>
</div> </div>
</BaseBottomSheet> </BaseBottomSheet>
<!-- Hidden persistent file picker see the matching block in HomeView
for why this isn't a one-shot document.createElement('input'). -->
<input
ref="fileInputEl"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
@change="onFileSelected"
/>
</main> </main>
</template> </template>
@@ -299,19 +309,23 @@ onMounted(() => {
// ── Add Photo ───────────────────────────────────────────────────────────────── // ── Add Photo ─────────────────────────────────────────────────────────────────
// Hidden file <input> lives in the template (see HomeView for context).
const fileInputEl = ref<HTMLInputElement | null>(null)
function onAddPhoto() { function onAddPhoto() {
// File picker must be triggered in the user-gesture context (the click // File picker must be triggered in the user-gesture context (the click
// handler) before navigating, otherwise browsers block it as a popup. // handler), otherwise browsers block it as a popup.
const input = document.createElement('input') fileInputEl.value?.click()
input.type = 'file' }
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
input.onchange = () => { function onFileSelected(e: Event) {
const file = input.files?.[0] const input = e.target as HTMLInputElement
if (!file) return const file = input.files?.[0]
uploadStore.init(file) // Reset so re-selecting the same file later still fires change.
router.push('/upload') input.value = ''
} if (!file) return
input.click() uploadStore.init(file)
router.push('/upload')
} }
// ── Pull-to-refresh ─────────────────────────────────────────────────────────── // ── Pull-to-refresh ───────────────────────────────────────────────────────────
+195
View File
@@ -55,9 +55,73 @@
<span class="settings__row-label">Signed in as</span> <span class="settings__row-label">Signed in as</span>
<span class="settings__row-value">{{ auth.user?.email }}</span> <span class="settings__row-value">{{ auth.user?.email }}</span>
</div> </div>
<button type="button" class="settings__action-link" @click="passwordModalOpen = true">
Change password
</button>
<a href="/logout" class="settings__logout">Sign out</a> <a href="/logout" class="settings__logout">Sign out</a>
</section> </section>
<div
v-if="passwordModalOpen"
class="install-modal"
role="dialog"
aria-modal="true"
aria-labelledby="pw-modal-title"
@click.self="closePasswordModal"
>
<div class="install-modal__card">
<button type="button" class="install-modal__close" aria-label="Close" @click="closePasswordModal">×</button>
<h2 id="pw-modal-title" class="install-modal__title">Change password</h2>
<form class="pw-form" @submit.prevent="submitPasswordChange">
<label class="pw-form__field">
<span class="pw-form__label">Current password</span>
<input
v-model="pwCurrent"
type="password"
autocomplete="current-password"
required
class="pw-form__input"
/>
</label>
<label class="pw-form__field">
<span class="pw-form__label">New password</span>
<input
v-model="pwNew"
type="password"
autocomplete="new-password"
minlength="8"
required
class="pw-form__input"
/>
<span class="pw-form__hint">At least 8 characters.</span>
</label>
<label class="pw-form__field">
<span class="pw-form__label">Confirm new password</span>
<input
v-model="pwConfirm"
type="password"
autocomplete="new-password"
required
class="pw-form__input"
:aria-invalid="pwConfirmMismatch ? 'true' : 'false'"
/>
<span v-if="pwConfirmMismatch" class="pw-form__error">Passwords don't match.</span>
</label>
<p v-if="pwError" class="pw-form__error" role="alert">{{ pwError }}</p>
<p v-if="pwSuccess" class="pw-form__success" role="status">Password updated.</p>
<button
type="submit"
class="settings__install"
:disabled="pwSubmitting || pwConfirmMismatch || !pwCurrent || !pwNew"
>
{{ pwSubmitting ? 'Saving…' : 'Update password' }}
</button>
</form>
</div>
</div>
<div <div
v-if="showIosInstructions" v-if="showIosInstructions"
class="install-modal" class="install-modal"
@@ -128,6 +192,65 @@ async function onNativeInstall() {
showIosInstructions.value = true showIosInstructions.value = true
} }
} }
// ── Change password ──────────────────────────────────────────────────────────
const passwordModalOpen = ref(false)
const pwCurrent = ref('')
const pwNew = ref('')
const pwConfirm = ref('')
const pwSubmitting = ref(false)
const pwError = ref<string | null>(null)
const pwSuccess = ref(false)
const pwConfirmMismatch = computed(() =>
pwConfirm.value.length > 0 && pwConfirm.value !== pwNew.value,
)
function resetPasswordForm() {
pwCurrent.value = ''
pwNew.value = ''
pwConfirm.value = ''
pwError.value = null
pwSuccess.value = false
pwSubmitting.value = false
}
function closePasswordModal() {
passwordModalOpen.value = false
resetPasswordForm()
}
async function submitPasswordChange() {
if (pwConfirmMismatch.value) return
pwError.value = null
pwSuccess.value = false
pwSubmitting.value = true
try {
const res = await fetch('/api/user/password', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword: pwCurrent.value,
newPassword: pwNew.value,
}),
})
if (res.status === 204) {
pwSuccess.value = true
pwCurrent.value = ''
pwNew.value = ''
pwConfirm.value = ''
// Auto-close after a moment so the user sees the confirmation.
setTimeout(closePasswordModal, 1500)
return
}
const body = await res.json().catch(() => ({}))
pwError.value = body?.error ?? 'Could not update password.'
} catch {
pwError.value = 'Network error. Try again.'
} finally {
pwSubmitting.value = false
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -178,6 +301,23 @@ async function onNativeInstall() {
font-size: var(--text-base); font-size: var(--text-base);
} }
&__action-link {
display: flex;
align-items: center;
width: 100%;
min-height: var(--touch-min);
padding: var(--space-3) 0;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-primary);
font-weight: 600;
font-size: var(--text-base);
cursor: pointer;
text-align: left;
font-family: inherit;
}
&__hint { &__hint {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--text-sm); font-size: var(--text-sm);
@@ -262,6 +402,61 @@ async function onNativeInstall() {
} }
} }
.pw-form {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-2);
&__field {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
}
&__input {
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: 16px;
font-family: inherit;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&[aria-invalid="true"] {
border-color: var(--color-destructive, #c0392b);
}
}
&__hint {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
&__error {
font-size: var(--text-sm);
color: var(--color-destructive, #c0392b);
}
&__success {
font-size: var(--text-sm);
color: var(--color-success, #2e7d32);
font-weight: 600;
}
}
.theme-grid { .theme-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
+6 -1
View File
@@ -30,6 +30,7 @@
v-if="step === 'crop' && uploadStore.originalUrl" v-if="step === 'crop' && uploadStore.originalUrl"
:src="uploadStore.originalUrl" :src="uploadStore.originalUrl"
:orientation="contextOrientation" :orientation="contextOrientation"
:model="contextModel"
:device-name="contextDeviceName" :device-name="contextDeviceName"
:initial-params="uploadStore.cropParams" :initial-params="uploadStore.cropParams"
:initial-orientation="uploadStore.cropOrientation" :initial-orientation="uploadStore.cropOrientation"
@@ -42,6 +43,7 @@
v-else-if="step === 'stickers' && uploadStore.croppedUrl" v-else-if="step === 'stickers' && uploadStore.croppedUrl"
:cropped-url="uploadStore.croppedUrl" :cropped-url="uploadStore.croppedUrl"
:orientation="effectiveOrientation" :orientation="effectiveOrientation"
:model="contextModel"
:stickers="uploadStore.stickers" :stickers="uploadStore.stickers"
class="upload-view__stage" class="upload-view__stage"
@add-sticker="uploadStore.addSticker" @add-sticker="uploadStore.addSticker"
@@ -78,7 +80,7 @@ import { useUploadStore } from '@/stores/upload'
import { useDevicesStore } from '@/stores/devices' import { useDevicesStore } from '@/stores/devices'
import { useImagesStore } from '@/stores/images' import { useImagesStore } from '@/stores/images'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import type { CropParams } from '@/types' import type { CropParams, DeviceModel } from '@/types'
import CropEditor from '@/components/CropEditor.vue' import CropEditor from '@/components/CropEditor.vue'
import StickerCanvas from '@/components/StickerCanvas.vue' import StickerCanvas from '@/components/StickerCanvas.vue'
import DevicePicker from '@/components/DevicePicker.vue' import DevicePicker from '@/components/DevicePicker.vue'
@@ -120,6 +122,9 @@ const contextDevice = computed(() =>
: devicesStore.devices[0] : devicesStore.devices[0]
) )
const contextModel = computed<DeviceModel>(() =>
contextDevice.value?.model ?? 'v1'
)
const contextOrientation = computed<'landscape' | 'portrait'>(() => const contextOrientation = computed<'landscape' | 'portrait'>(() =>
contextDevice.value?.orientation ?? 'landscape' contextDevice.value?.orientation ?? 'landscape'
) )
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{C as e,F as t,J as n,K as r,N as i,U as a,Y as o,f as s,g as c,h as l,p as u,q as d,t as f,v as p,x as m,y as h}from"./_plugin-vue_export-helper-BNDVmFr7.js";import{n as g,r as _,t as v}from"./index-DdJ5jHP4.js";var y=a(null),b=a(!1);function x(){return typeof window>`u`?!1:window.matchMedia?.(`(display-mode: standalone)`).matches?!0:window.navigator.standalone===!0}function S(){if(typeof navigator>`u`)return!1;let e=navigator.userAgent,t=e.includes(`Mac`)&&navigator.maxTouchPoints>1;return/iPhone|iPod/.test(e)||t}var C=!1;function w(){C||typeof window>`u`||(C=!0,b.value=x(),window.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),y.value=e}),window.addEventListener(`appinstalled`,()=>{y.value=null,b.value=!0}),window.matchMedia?.(`(display-mode: standalone)`).addEventListener(`change`,e=>{b.value=e.matches}))}w();function T(){let e=S(),t=l(()=>y.value!==null);async function n(){let e=y.value;if(!e)return!1;await e.prompt();let t=await e.userChoice;return y.value=null,t.outcome===`accepted`}return{isStandalone:b,isIOS:e,canPromptInstall:t,install:n}}var E={class:`settings`},D={key:0,class:`settings__section`},O={class:`settings__section`},k={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},A=[`aria-checked`,`aria-label`,`onClick`],j={class:`theme-swatch__label`},M={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},N={class:`settings__section`},P={class:`settings__row`},F={class:`settings__row-value`},I={class:`install-modal__card`},L={id:`install-modal-title`,class:`install-modal__title`},R={class:`install-modal__steps`},z={key:0},B={key:1},V={key:0},H=f(e({__name:`SettingsView`,setup(e){let f=_(),{saveTheme:y}=g(),{isStandalone:b,isIOS:x,canPromptInstall:S,install:C}=T(),w=l(()=>f.user?.theme??`warm-craft`),H=a(!1);function U(e){y(e)}async function W(){!await C()&&!S.value&&(H.value=!0)}return(e,a)=>(i(),h(`main`,E,[a[18]||=c(`h1`,{class:`settings__title`},`Settings`,-1),r(b)?p(``,!0):(i(),h(`section`,D,[a[3]||=c(`h2`,{class:`settings__section-title`},`Install app`,-1),a[4]||=c(`p`,{class:`settings__hint`},` Pin pictureFrame to your home screen so it opens like a native app. `,-1),r(S)?(i(),h(`button`,{key:0,type:`button`,class:`settings__install`,onClick:W},` Install pictureFrame `)):(i(),h(`button`,{key:1,type:`button`,class:`settings__install`,onClick:a[0]||=e=>H.value=!0},` Add to Home Screen `))])),c(`section`,O,[a[6]||=c(`h2`,{class:`settings__section-title`},`Theme`,-1),c(`div`,k,[(i(!0),h(u,null,t(r(v),e=>(i(),h(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":w.value===e.id,"aria-label":e.label,class:d([`theme-swatch`,{"theme-swatch--active":w.value===e.id}]),style:n({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>U(e.id)},[a[5]||=c(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[c(`span`,{class:`theme-swatch__bar`}),c(`span`,{class:`theme-swatch__dot`})],-1),c(`span`,j,o(e.label),1),w.value===e.id?(i(),h(`span`,M,``)):p(``,!0)],14,A))),128))])]),c(`section`,N,[a[8]||=c(`h2`,{class:`settings__section-title`},`Account`,-1),c(`div`,P,[a[7]||=c(`span`,{class:`settings__row-label`},`Signed in as`,-1),c(`span`,F,o(r(f).user?.email),1)]),a[9]||=c(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)]),H.value?(i(),h(`div`,{key:1,class:`install-modal`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`install-modal-title`,onClick:a[2]||=s(e=>H.value=!1,[`self`])},[c(`div`,I,[c(`button`,{type:`button`,class:`install-modal__close`,"aria-label":`Close`,onClick:a[1]||=e=>H.value=!1},`×`),c(`h2`,L,o(r(x)?`Add to your iPhone home screen`:`Add to your home screen`),1),c(`ol`,R,[r(x)?(i(),h(`li`,z,[...a[10]||=[m(` Tap the `,-1),c(`strong`,null,`Share`,-1),m(` icon at the bottom of Safari (the square with an up-arrow). `,-1)]])):(i(),h(`li`,B,[...a[11]||=[m(` Open your browser's menu (usually the three dots `,-1),c(`strong`,null,``,-1),m(` in the top right). `,-1)]])),c(`li`,null,[a[13]||=m(` Scroll down and tap `,-1),a[14]||=c(`strong`,null,`Add to Home Screen`,-1),r(x)?p(``,!0):(i(),h(`span`,V,[...a[12]||=[m(`or `,-1),c(`strong`,null,`Install app`,-1)]])),a[15]||=m(`. `,-1)]),a[16]||=c(`li`,null,[m(` Tap `),c(`strong`,null,`Add`),m(` in the top right to confirm. `)],-1)]),a[17]||=c(`p`,{class:`install-modal__footer`},` The app will appear on your home screen. Open it from there and it runs like a regular app — no address bar, no tabs. `,-1)])])):p(``,!0)]))}}),[[`__scopeId`,`data-v-fb5d8496`]]);export{H as default};
@@ -1 +0,0 @@
.settings[data-v-fb5d8496]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-fb5d8496]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-fb5d8496]{margin-bottom:var(--space-6)}.settings__section-title[data-v-fb5d8496]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-fb5d8496]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-fb5d8496]{color:var(--color-text-muted)}.settings__row-value[data-v-fb5d8496]{font-weight:600}.settings__logout[data-v-fb5d8496]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.settings__hint[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-bottom:var(--space-3);line-height:1.4}.settings__install[data-v-fb5d8496]{width:100%;min-height:var(--touch-min);background:var(--color-primary);color:var(--color-on-primary);border-radius:var(--radius-pill,9999px);font-size:var(--text-base);cursor:pointer;border:none;justify-content:center;align-items:center;font-weight:700;display:flex}.install-modal[data-v-fb5d8496]{padding:var(--space-4);z-index:100;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.install-modal__card[data-v-fb5d8496]{background:var(--color-surface);color:var(--color-text);border-radius:var(--radius-lg,16px);padding:var(--space-5);width:100%;max-width:380px;position:relative;box-shadow:0 20px 60px #00000040}.install-modal__close[data-v-fb5d8496]{top:var(--space-2);right:var(--space-3);color:var(--color-text-muted);cursor:pointer;padding:var(--space-1) var(--space-2);background:0 0;border:none;font-size:1.75rem;line-height:1;position:absolute}.install-modal__title[data-v-fb5d8496]{font-size:var(--text-lg);margin-bottom:var(--space-3);padding-right:var(--space-5);font-weight:700}.install-modal__steps[data-v-fb5d8496]{margin:0 0 var(--space-3) var(--space-4);padding:0;line-height:1.5}.install-modal__steps li[data-v-fb5d8496]{margin-bottom:var(--space-2)}.install-modal__footer[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-top:var(--space-3);border-top:1px solid var(--color-border);padding-top:var(--space-3);line-height:1.4}.theme-grid[data-v-fb5d8496]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-fb5d8496]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-fb5d8496]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-fb5d8496]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-fb5d8496]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-fb5d8496]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-fb5d8496]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-fb5d8496]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function e(e,t){return e===`v1`?t===`landscape`?{width:1600,height:960}:{width:960,height:1600}:t===`landscape`?{width:1600,height:1200}:{width:1200,height:1600}}export{e as t};
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" /> <meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-DdJ5jHP4.js"></script> <script type="module" crossorigin src="/build/assets/index-DHJU4XGZ.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js"> <link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css"> <link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head> </head>
+11 -5
View File
@@ -8,6 +8,7 @@ use App\Entity\Device;
use App\Entity\Image; use App\Entity\Image;
use App\Entity\RenderedAsset; use App\Entity\RenderedAsset;
use App\Entity\User; use App\Entity\User;
use App\Enum\DeviceModel;
use App\Enum\Orientation; use App\Enum\Orientation;
use App\Enum\RenderStatus; use App\Enum\RenderStatus;
use App\Enum\RotationMode; use App\Enum\RotationMode;
@@ -277,6 +278,7 @@ class DeviceApiController extends AbstractController
$device->getModel()->nativeWidth(), $device->getModel()->nativeWidth(),
$device->getModel()->nativeHeight(), $device->getModel()->nativeHeight(),
$device->getOrientation(), $device->getOrientation(),
$device->getModel(),
); );
} }
@@ -289,7 +291,7 @@ class DeviceApiController extends AbstractController
return $response; return $response;
} }
private function renderBinToPng(string $binPath, string $pngPath, int $width, int $height, Orientation $orientation): void private function renderBinToPng(string $binPath, string $pngPath, int $width, int $height, Orientation $orientation, DeviceModel $model): void
{ {
$bin = (string) file_get_contents($binPath); $bin = (string) file_get_contents($binPath);
$len = strlen($bin); $len = strlen($bin);
@@ -311,10 +313,14 @@ class DeviceApiController extends AbstractController
$im = new \Imagick(); $im = new \Imagick();
$im->readImageBlob($ppm); $im->readImageBlob($ppm);
// The .bin is always laid out in EPD-native scan order. For portrait, // The .bin is always panel-native scan order. The render pipeline
// the renderer pre-rotated the photo 90° CCW; rotate 90° here so the // rotates the source 90° CCW only when the user's orientation differs
// browser-side preview shows the photo upright. // from the panel's native — see RenderImageMessageHandler. Mirror that
if ($orientation === Orientation::Portrait) { // here so the preview un-rotates exactly when the renderer rotated:
// V1 (landscape-native) + Portrait → renderer rotated, preview rotates back.
// V2 (portrait-native) + Portrait → renderer did NOT rotate, no rotate here.
// V2 (portrait-native) + Landscape → renderer rotated, preview rotates back.
if ($orientation !== $model->nativeOrientation()) {
$im->rotateImage(new \ImagickPixel('white'), 90); $im->rotateImage(new \ImagickPixel('white'), 90);
} }
+22
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Entity\Device; use App\Entity\Device;
use App\Entity\RenderedAsset; use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\RenderStatus; use App\Enum\RenderStatus;
use App\Service\DeviceSerializer; use App\Service\DeviceSerializer;
use App\Service\MercurePublisher; use App\Service\MercurePublisher;
@@ -89,6 +90,27 @@ class DeviceImageController extends AbstractController
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1'); $currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
$device->markSeen(); $device->markSeen();
// Auto-correct Device.model from the firmware's X-Panel-Id header. New
// Devices are created with the default V1 model (see Device entity), so
// a freshly-claimed 13.3" unit ends up wrongly flagged until its first
// poll reaches here. Mis-routed renders produce wrong-dimension .bin
// files that the firmware rejects on size mismatch, blocking the
// first-image-after-claim flow. Existing rendered assets at the old
// model stay in storage but become irrelevant — RotationService will
// dispatch a fresh render at the new model on the next image change.
$panelIdHeader = (string) $request->headers->get('X-Panel-Id', '');
if ($panelIdHeader !== '') {
$detected = DeviceModel::fromPanelId($panelIdHeader);
if ($detected !== null && $detected !== $device->getModel()) {
$this->logger->info('[device] panel-id correction', [
'mac' => $device->getMac(),
'from' => $device->getModel()->value,
'to' => $detected->value,
]);
$device->setModel($detected);
}
}
// Stamp when we expect the device to call back — the PWA reads this // Stamp when we expect the device to call back — the PWA reads this
// directly so its "next sync" label reflects the schedule the device // directly so its "next sync" label reflects the schedule the device
// is actually on, not the freshly-saved one that won't reach it // is actually on, not the freshly-saved one that won't reach it
+30
View File
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -72,6 +73,35 @@ class UserApiController extends AbstractController
return $this->json(['theme' => $theme]); return $this->json(['theme' => $theme]);
} }
#[Route('/password', name: 'api_user_password', methods: ['PATCH'])]
public function updatePassword(
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher,
): JsonResponse {
$body = json_decode($request->getContent(), true);
$currentPassword = $body['currentPassword'] ?? null;
$newPassword = $body['newPassword'] ?? null;
if (!is_string($currentPassword) || !is_string($newPassword)) {
return $this->json(['error' => 'Both currentPassword and newPassword are required'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (strlen($newPassword) < 8) {
return $this->json(['error' => 'New password must be at least 8 characters'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var User $user */
$user = $this->getUser();
if (!$hasher->isPasswordValid($user, $currentPassword)) {
return $this->json(['error' => 'Current password is incorrect'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$user->setPassword($hasher->hashPassword($user, $newPassword));
$em->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/timezone', name: 'api_user_timezone', methods: ['PATCH'])] #[Route('/timezone', name: 'api_user_timezone', methods: ['PATCH'])]
public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse
{ {
+67 -15
View File
@@ -6,27 +6,79 @@ namespace App\Enum;
enum DeviceModel: string enum DeviceModel: string
{ {
case V1 = 'v1'; // Waveshare 7.3" 800×480 case V1 = 'v1'; // Waveshare 7.3" Spectra-6, 800 × 480 native (wide → landscape natural)
case V2 = 'v2'; // Waveshare 13.3" Spectra-6, 1200 × 1600 native (tall → portrait natural)
public function width(Orientation $orientation): int
{
return $orientation === Orientation::Portrait ? 480 : 800;
}
public function height(Orientation $orientation): int
{
return $orientation === Orientation::Portrait ? 800 : 480;
}
/** EPD's hardware scan-row width — independent of user orientation. */
public function nativeWidth(): int public function nativeWidth(): int
{ {
return 800; return match ($this) {
self::V1 => 800,
self::V2 => 1200,
};
} }
/** EPD's hardware scan-row count — independent of user orientation. */
public function nativeHeight(): int public function nativeHeight(): int
{ {
return 480; return match ($this) {
self::V1 => 480,
self::V2 => 1600,
};
}
/**
* The orientation the panel scans natively. V1 is wide-native so its
* natural mounting is landscape; V2 is tall-native so its natural
* mounting is portrait. Rendering only rotates the source image when
* the user's chosen orientation differs from this.
*/
public function nativeOrientation(): Orientation
{
return match ($this) {
self::V1 => Orientation::Landscape,
self::V2 => Orientation::Portrait,
};
}
/** User-facing width (pre-rotation) for the given orientation. */
public function width(Orientation $orientation): int
{
return $orientation === $this->nativeOrientation()
? $this->nativeWidth()
: $this->nativeHeight();
}
/** User-facing height (pre-rotation) for the given orientation. */
public function height(Orientation $orientation): int
{
return $orientation === $this->nativeOrientation()
? $this->nativeHeight()
: $this->nativeWidth();
}
/**
* Panel-id string the firmware reports via X-Panel-Id on /image polls.
* Must match the -DPANEL_ID build flag in firmware/platformio.ini.
*/
public function panelId(): string
{
return match ($this) {
self::V1 => 'waveshare-7.3-spectra6',
self::V2 => 'waveshare-13.3-spectra6',
};
}
/**
* Reverse lookup for the X-Panel-Id header. Returns null if the
* firmware reports a panel-id the server doesn't recognise the
* controller treats that as "no panel-id reported" and leaves the
* device's existing model alone.
*/
public static function fromPanelId(string $panelId): ?self
{
return match ($panelId) {
'waveshare-7.3-spectra6' => self::V1,
'waveshare-13.3-spectra6' => self::V2,
default => null,
};
} }
} }
@@ -70,7 +70,7 @@ final class RenderImageMessageHandler
: $this->projectDir . '/' . $image->getStoragePath(); : $this->projectDir . '/' . $image->getStoragePath();
$width = $model->width($orientation); $width = $model->width($orientation);
$height = $model->height($orientation); $height = $model->height($orientation);
$bin = $this->renderToBin($originalPath, $width, $height, $orientation); $bin = $this->renderToBin($originalPath, $width, $height, $orientation, $model);
$relPath = 'var/storage/images/' . $image->getId() $relPath = 'var/storage/images/' . $image->getId()
. '/' . $model->value . '_' . $orientation->value . '.bin'; . '/' . $model->value . '_' . $orientation->value . '.bin';
@@ -87,7 +87,7 @@ final class RenderImageMessageHandler
$this->em->flush(); $this->em->flush();
} }
private function renderToBin(string $path, int $width, int $height, Orientation $orientation): string private function renderToBin(string $path, int $width, int $height, Orientation $orientation, DeviceModel $model): string
{ {
$imagick = new \Imagick($path); $imagick = new \Imagick($path);
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
@@ -136,13 +136,15 @@ final class RenderImageMessageHandler
$imagick = $canvas; $imagick = $canvas;
} }
// Portrait: rotate the fitted photo 90° CCW so the packed .bin's row // Rotate the fitted photo 90° CCW when the user's orientation differs
// layout matches the EPD's native 800-pixel scan order. The frame is // from the panel's natural scan orientation, so the packed .bin's row
// physically rotated 90° CW for portrait (ribbon on right from EPD's // layout always matches the panel's native scan order. Firmware streams
// POV → on left from user's view), so the photo's top edge maps to the // bytes raw — no orientation awareness on-device.
// EPD's left column. Firmware streams bytes raw — no orientation //
// awareness on-device. // V1 (7.3", wide-native) rotates for Portrait. V2 (13.3", tall-native)
if ($orientation === Orientation::Portrait) { // rotates for Landscape. The model's nativeOrientation() makes the
// pipeline panel-agnostic — no per-model branches in this file.
if ($orientation !== $model->nativeOrientation()) {
$imagick->rotateImage(new \ImagickPixel('white'), -90); $imagick->rotateImage(new \ImagickPixel('white'), -90);
} }
+1
View File
@@ -21,6 +21,7 @@ final class DeviceSerializer
'id' => $d->getId(), 'id' => $d->getId(),
'mac' => $d->getMac(), 'mac' => $d->getMac(),
'name' => $d->getName(), 'name' => $d->getName(),
'model' => $d->getModel()->value,
'orientation' => $d->getOrientation()->value, 'orientation' => $d->getOrientation()->value,
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(), 'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
'wakeTimes' => $d->getWakeTimes(), 'wakeTimes' => $d->getWakeTimes(),
@@ -576,6 +576,101 @@ class DeviceApiControllerTest extends AppWebTestCase
@unlink(preg_replace('/\.bin$/', '.png', $absPath)); @unlink(preg_replace('/\.bin$/', '.png', $absPath));
} }
// V2 panel (13.3", 1200×1600 portrait-native) + Portrait orientation MUST
// NOT rotate the preview — Portrait is the natural scan orientation, the
// render pipeline didn't rotate the source, so the preview must mirror
// that. The bug before this guard: every V2 portrait preview came out
// 90° sideways in the web UI.
public function test_preview_v2_portrait_not_rotated(): void
{
$user = $this->createUser('ppng-v2p@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E7', $user);
$device->setModel(DeviceModel::V2);
$device->setOrientation(Orientation::Portrait);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
// 1200×1600 = 1,920,000 nibbles = 960,000 bytes.
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview-v2-portrait/v2_portrait.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
file_put_contents($absPath, str_repeat(chr(0x11), 960000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V2)
->setOrientation(Orientation::Portrait)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
// Decode the PNG and confirm orientation — taller than wide means
// no spurious 90° rotation happened (would be 1600×1200 if it had).
$pngPath = preg_replace('/\.bin$/', '.png', $absPath);
$this->assertFileExists($pngPath);
$im = new \Imagick($pngPath);
$this->assertSame(1200, $im->getImageWidth(), 'V2 portrait PNG must be 1200 wide (not rotated)');
$this->assertSame(1600, $im->getImageHeight(), 'V2 portrait PNG must be 1600 tall (not rotated)');
$im->destroy();
@unlink($absPath);
@unlink($pngPath);
}
// V2 panel + Landscape orientation MUST rotate — Landscape is non-native
// for V2 (portrait-native), so the renderer pre-rotated and the preview
// needs to rotate back to upright landscape.
public function test_preview_v2_landscape_rotated(): void
{
$user = $this->createUser('ppng-v2l@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E8', $user);
$device->setModel(DeviceModel::V2);
$device->setOrientation(Orientation::Landscape);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview-v2-landscape/v2_landscape.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
file_put_contents($absPath, str_repeat(chr(0x11), 960000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V2)
->setOrientation(Orientation::Landscape)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
// The .bin is panel-native 1200×1600 (tall); after the 90° rotation
// for non-native orientation the PNG must be 1600×1200 (wide).
$pngPath = preg_replace('/\.bin$/', '.png', $absPath);
$im = new \Imagick($pngPath);
$this->assertSame(1600, $im->getImageWidth(), 'V2 landscape PNG must be 1600 wide (rotated)');
$this->assertSame(1200, $im->getImageHeight(), 'V2 landscape PNG must be 1200 tall (rotated)');
$im->destroy();
@unlink($absPath);
@unlink($pngPath);
}
// ── DELETE /api/devices/{id} (sell/give-away) ──────────────────────── // ── DELETE /api/devices/{id} (sell/give-away) ────────────────────────
public function test_delete_removes_device_and_purges_history_and_approvals(): void public function test_delete_removes_device_and_purges_history_and_approvals(): void
@@ -596,4 +596,61 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(204); $this->assertResponseStatusCodeSame(204);
} }
// Poll with X-Panel-Id matching a different DeviceModel must auto-update
// the device's model. New Devices are created with the V1 default, so a
// 13.3" unit ends up wrongly flagged until the controller corrects it.
public function test_x_panel_id_header_updates_device_model(): void
{
$setup = $this->createTestSetup(true, false);
$this->assertSame(DeviceModel::V1, $setup['device']->getModel());
$this->client->request(
'GET',
'/api/device/' . self::MAC . '/image',
[],
[],
['HTTP_X_PANEL_ID' => 'waveshare-13.3-spectra6'],
);
$this->em()->refresh($setup['device']);
$this->assertSame(DeviceModel::V2, $setup['device']->getModel());
}
// Same-model X-Panel-Id is a no-op — no churn on every poll.
public function test_x_panel_id_header_matching_current_model_does_not_change(): void
{
$setup = $this->createTestSetup(true, false);
$this->client->request(
'GET',
'/api/device/' . self::MAC . '/image',
[],
[],
['HTTP_X_PANEL_ID' => 'waveshare-7.3-spectra6'],
);
$this->em()->refresh($setup['device']);
$this->assertSame(DeviceModel::V1, $setup['device']->getModel());
}
// Unknown panel-id strings must be ignored — never silently drop a known
// device into an unknown state because firmware reported an unrecognised
// panel.
public function test_x_panel_id_header_unknown_leaves_model_alone(): void
{
$setup = $this->createTestSetup(true, false);
$this->client->request(
'GET',
'/api/device/' . self::MAC . '/image',
[],
[],
['HTTP_X_PANEL_ID' => 'totally-fake-panel'],
);
$this->em()->refresh($setup['device']);
$this->assertSame(DeviceModel::V1, $setup['device']->getModel());
}
} }
@@ -115,4 +115,66 @@ class UserApiControllerTest extends AppWebTestCase
$this->assertResponseRedirects('/login'); $this->assertResponseRedirects('/login');
} }
// US-09: updatePassword valid → 204 and the new password works for re-login
public function test_update_password_valid_returns_204_and_rehashes(): void
{
$user = $this->createUser('us09@example.com', 'currentPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['currentPassword' => 'currentPass1', 'newPassword' => 'brandNew2025']));
$this->assertResponseStatusCodeSame(204);
// Re-fetch the user; the stored hash must accept the new password and
// reject the old one.
$hasher = static::getContainer()->get('security.user_password_hasher');
$em = static::getContainer()->get('doctrine.orm.entity_manager');
$em->clear();
$reloaded = $em->getRepository(\App\Entity\User::class)->findOneBy(['email' => 'us09@example.com']);
$this->assertNotNull($reloaded);
$this->assertTrue($hasher->isPasswordValid($reloaded, 'brandNew2025'));
$this->assertFalse($hasher->isPasswordValid($reloaded, 'currentPass1'));
}
// US-10: updatePassword wrong current → 422
public function test_update_password_wrong_current_returns_422(): void
{
$user = $this->createUser('us10@example.com', 'rightPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['currentPassword' => 'wrongPass1', 'newPassword' => 'brandNew2025']));
$this->assertResponseStatusCodeSame(422);
}
// US-11: updatePassword too-short new → 422
public function test_update_password_short_new_returns_422(): void
{
$user = $this->createUser('us11@example.com', 'currentPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['currentPassword' => 'currentPass1', 'newPassword' => 'short']));
$this->assertResponseStatusCodeSame(422);
}
// US-12: updatePassword missing fields → 422
public function test_update_password_missing_fields_returns_422(): void
{
$user = $this->createUser('us12@example.com', 'currentPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['newPassword' => 'brandNew2025']));
$this->assertResponseStatusCodeSame(422);
}
} }
+50 -5
View File
@@ -11,7 +11,10 @@ use PHPUnit\Framework\TestCase;
/** /**
* width()/height() must follow orientation, but nativeWidth()/nativeHeight() * width()/height() must follow orientation, but nativeWidth()/nativeHeight()
* are the EPD's hardware scan dimensions and must NOT depend on orientation * are the EPD's hardware scan dimensions and must NOT depend on orientation
* (the renderer pre-rotates portrait images and streams raw bytes). * (the renderer pre-rotates non-native images and streams raw bytes).
*
* V1 panel is wide-native (800×480 landscape); V2 is tall-native (1200×1600
* portrait). nativeOrientation() decides whether rendering rotates.
*/ */
class DeviceModelTest extends TestCase class DeviceModelTest extends TestCase
{ {
@@ -27,12 +30,54 @@ class DeviceModelTest extends TestCase
$this->assertSame(800, DeviceModel::V1->height(Orientation::Portrait)); $this->assertSame(800, DeviceModel::V1->height(Orientation::Portrait));
} }
public function test_native_dimensions_ignore_orientation(): void public function test_v1_native_dimensions_ignore_orientation(): void
{ {
// The firmware streams 800x480 EPD-native rows regardless of how the
// photo was framed; renderer rotates the input photo, then writes in
// EPD scan order.
$this->assertSame(800, DeviceModel::V1->nativeWidth()); $this->assertSame(800, DeviceModel::V1->nativeWidth());
$this->assertSame(480, DeviceModel::V1->nativeHeight()); $this->assertSame(480, DeviceModel::V1->nativeHeight());
} }
public function test_v1_native_orientation_is_landscape(): void
{
$this->assertSame(Orientation::Landscape, DeviceModel::V1->nativeOrientation());
}
public function test_v2_portrait_dimensions_are_1200x1600(): void
{
$this->assertSame(1200, DeviceModel::V2->width(Orientation::Portrait));
$this->assertSame(1600, DeviceModel::V2->height(Orientation::Portrait));
}
public function test_v2_landscape_dimensions_are_swapped(): void
{
$this->assertSame(1600, DeviceModel::V2->width(Orientation::Landscape));
$this->assertSame(1200, DeviceModel::V2->height(Orientation::Landscape));
}
public function test_v2_native_dimensions_ignore_orientation(): void
{
$this->assertSame(1200, DeviceModel::V2->nativeWidth());
$this->assertSame(1600, DeviceModel::V2->nativeHeight());
}
public function test_v2_native_orientation_is_portrait(): void
{
$this->assertSame(Orientation::Portrait, DeviceModel::V2->nativeOrientation());
}
public function test_panel_id_round_trips(): void
{
$this->assertSame(DeviceModel::V1, DeviceModel::fromPanelId(DeviceModel::V1->panelId()));
$this->assertSame(DeviceModel::V2, DeviceModel::fromPanelId(DeviceModel::V2->panelId()));
}
public function test_panel_ids_are_distinct(): void
{
$this->assertNotSame(DeviceModel::V1->panelId(), DeviceModel::V2->panelId());
}
public function test_from_panel_id_returns_null_for_unknown(): void
{
$this->assertNull(DeviceModel::fromPanelId('not-a-real-panel'));
$this->assertNull(DeviceModel::fromPanelId(''));
}
} }
+9 -1
View File
@@ -34,7 +34,7 @@ class DeviceSerializerTest extends TestCase
$payload = $this->serializer->serialize($device); $payload = $this->serializer->serialize($device);
$this->assertEqualsCanonicalizing( $this->assertEqualsCanonicalizing(
['id', 'mac', 'name', 'orientation', 'rotationIntervalMinutes', ['id', 'mac', 'name', 'model', 'orientation', 'rotationIntervalMinutes',
'wakeTimes', 'timezone', 'uniquenessWindow', 'rotationMode', 'wakeTimes', 'timezone', 'uniquenessWindow', 'rotationMode',
'prioritizeNeverShown', 'linkedAt', 'lastSeenAt', 'prioritizeNeverShown', 'linkedAt', 'lastSeenAt',
'nextPollExpectedAt', 'lockedImageId', 'currentImageId'], 'nextPollExpectedAt', 'lockedImageId', 'currentImageId'],
@@ -42,6 +42,14 @@ class DeviceSerializerTest extends TestCase
); );
} }
public function test_serializes_model_field(): void
{
$device = $this->makeDevice();
$device->setModel(\App\Enum\DeviceModel::V2);
$payload = $this->serializer->serialize($device);
$this->assertSame('v2', $payload['model']);
}
public function test_serializes_scalars_in_expected_shapes(): void public function test_serializes_scalars_in_expected_shapes(): void
{ {
$device = $this->makeDevice(); $device = $this->makeDevice();