Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a42011d8 | |||
| e57e711fcc | |||
| 081ca83613 | |||
| b286a1f241 | |||
| 2adb07518c |
@@ -57,7 +57,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { CropParams } from '@/types'
|
||||
import { panelDims, type CropParams, type DeviceModel } from '@/types'
|
||||
|
||||
type Orientation = 'landscape' | 'portrait'
|
||||
|
||||
@@ -65,6 +65,10 @@ const props = defineProps<{
|
||||
src: string
|
||||
/** Frame's current orientation — used as the toggle's initial value and for the mismatch chip. */
|
||||
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
|
||||
initialParams?: CropParams | null
|
||||
/** 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.
|
||||
const cropOrientation = ref<Orientation>(props.initialOrientation ?? props.orientation)
|
||||
|
||||
const outputDims = computed(() =>
|
||||
cropOrientation.value === 'landscape'
|
||||
? { w: 1600, h: 960 }
|
||||
: { w: 960, h: 1600 }
|
||||
)
|
||||
const outputDims = computed(() => {
|
||||
const { width, height } = panelDims(props.model ?? 'v1', cropOrientation.value)
|
||||
return { w: width, h: height }
|
||||
})
|
||||
const aspect = computed(() => outputDims.value.w / outputDims.value.h)
|
||||
|
||||
// Visible only when the user's choice doesn't match the frame's setting.
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { panelDims, type DeviceModel } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
deviceId: number
|
||||
@@ -77,6 +78,9 @@ const props = defineProps<{
|
||||
size: 'large' | 'compact'
|
||||
status: 'ok' | 'offline' | 'sync-fail'
|
||||
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
|
||||
photoCount?: number
|
||||
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.
|
||||
const previewStyle = computed(() => ({}))
|
||||
|
||||
const emptyAspectStyle = computed(() =>
|
||||
props.size === 'large'
|
||||
? { aspectRatio: props.orientation === 'portrait' ? '3 / 5' : '5 / 3' }
|
||||
: {}
|
||||
)
|
||||
const emptyAspectStyle = computed(() => {
|
||||
if (props.size !== 'large') return {}
|
||||
const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
|
||||
return { aspectRatio: `${width} / ${height}` }
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
|
||||
@@ -74,11 +74,14 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import Konva from 'konva'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import StickerTray from '@/components/StickerTray.vue'
|
||||
import type { StickerLayer } from '@/types'
|
||||
import { panelDims, type StickerLayer, type DeviceModel } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
croppedUrl: string
|
||||
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[]
|
||||
}>()
|
||||
|
||||
@@ -101,7 +104,8 @@ const selectedId = ref<string | null>(null)
|
||||
const stageW = ref(375)
|
||||
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() {
|
||||
if (!containerRef.value) return
|
||||
@@ -384,8 +388,7 @@ async function done() {
|
||||
const stage: Konva.Stage = stageRef.value?.getNode()
|
||||
if (!stage) return
|
||||
|
||||
const outputW = props.orientation === 'landscape' ? 1600 : 960
|
||||
const pixelRatio = outputW / stageW.value
|
||||
const pixelRatio = OUT_W / stageW.value
|
||||
|
||||
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
|
||||
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
|
||||
|
||||
@@ -37,6 +37,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
mac: 'AA',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
model: 'v1',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'UTC',
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
model: 'v1',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'America/Chicago',
|
||||
|
||||
@@ -146,19 +146,35 @@ describe('FrameCard', () => {
|
||||
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, {
|
||||
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
|
||||
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
|
||||
})
|
||||
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, {
|
||||
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
|
||||
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
|
||||
})
|
||||
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',
|
||||
name: 'Den',
|
||||
orientation: 'landscape',
|
||||
model: 'v1',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'UTC',
|
||||
|
||||
@@ -8,6 +8,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
model: 'v1',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'America/Chicago',
|
||||
|
||||
@@ -69,6 +69,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
model: 'v1',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'America/Chicago',
|
||||
@@ -190,38 +191,31 @@ describe('HomeView', () => {
|
||||
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 () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
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()
|
||||
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' })
|
||||
await card.vm.$emit('add-photo', 7)
|
||||
|
||||
expect(capturedInput).not.toBeNull()
|
||||
expect(capturedInput!.type).toBe('file')
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
|
||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
|
||||
capturedInput!.onchange?.(new Event('change'))
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
|
||||
await fileInput.trigger('change')
|
||||
|
||||
const upload = useUploadStore()
|
||||
expect(upload.originalFile).toStrictEqual(file)
|
||||
@@ -788,33 +782,25 @@ describe('HomeView', () => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith({ silent: true })
|
||||
})
|
||||
|
||||
// Add-photo handler creates a hidden file input and (on file pick) navigates
|
||||
// to /upload with the staged file in the upload store.
|
||||
// Add-photo handler clicks the template's persistent hidden file input
|
||||
// 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 () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 7 })]
|
||||
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()
|
||||
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)
|
||||
|
||||
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' })
|
||||
Object.defineProperty(capturedInput!, 'files', { value: [file], configurable: true })
|
||||
capturedInput!.onchange?.(new Event('change'))
|
||||
await flushPromises()
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
|
||||
await fileInput.trigger('change')
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith('/upload')
|
||||
})
|
||||
|
||||
@@ -79,6 +79,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
mac: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
model: 'v1',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'America/Chicago',
|
||||
@@ -147,26 +148,19 @@ describe('LibraryView', () => {
|
||||
vi.spyOn(imagesStore, 'fetchPendingCount').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()
|
||||
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')
|
||||
|
||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
|
||||
capturedInput!.onchange?.(new Event('change'))
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
|
||||
await fileInput.trigger('change')
|
||||
|
||||
expect(uploadInit).toHaveBeenCalledWith(file)
|
||||
expect(routerPush).toHaveBeenCalledWith('/upload')
|
||||
|
||||
@@ -55,6 +55,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
mac: 'AA',
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
model: 'v1',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeTimes: [],
|
||||
timezone: 'UTC',
|
||||
|
||||
@@ -6,10 +6,37 @@ export interface User {
|
||||
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 {
|
||||
id: number
|
||||
mac: string
|
||||
name: string
|
||||
model: DeviceModel
|
||||
orientation: 'landscape' | 'portrait'
|
||||
rotationIntervalMinutes: number
|
||||
/** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
size="large"
|
||||
:status="deviceStatus(devicesStore.devices[0])"
|
||||
:orientation="devicesStore.devices[0].orientation"
|
||||
:model="devicesStore.devices[0].model"
|
||||
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
|
||||
:lastSync="lastSyncLabel(devicesStore.devices[0])"
|
||||
:nextSync="nextSyncLabel(devicesStore.devices[0])"
|
||||
@@ -59,6 +60,7 @@
|
||||
size="large"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:model="device.model"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
:lastSync="lastSyncLabel(device)"
|
||||
:nextSync="nextSyncLabel(device)"
|
||||
@@ -68,6 +70,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Frame settings sheet -->
|
||||
@@ -454,19 +469,28 @@ async function refreshDevices() {
|
||||
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) {
|
||||
// File picker must be triggered in the user-gesture context (the click handler)
|
||||
// before navigating, otherwise browsers block it as a popup.
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
uploadStore.init(file, deviceId)
|
||||
router.push('/upload')
|
||||
}
|
||||
input.click()
|
||||
// File picker must be triggered in the user-gesture context (the click
|
||||
// handler), otherwise browsers block it as a popup.
|
||||
pendingAddDeviceId = deviceId
|
||||
fileInputEl.value?.click()
|
||||
}
|
||||
|
||||
function onFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// Reset right away so re-selecting the same file later still fires change.
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
uploadStore.init(file, pendingAddDeviceId ?? undefined)
|
||||
pendingAddDeviceId = null
|
||||
router.push('/upload')
|
||||
}
|
||||
|
||||
// ── Settings sheet ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -207,6 +207,16 @@
|
||||
</BaseButton>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -299,19 +309,23 @@ onMounted(() => {
|
||||
|
||||
// ── Add Photo ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Hidden file <input> lives in the template (see HomeView for context).
|
||||
const fileInputEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onAddPhoto() {
|
||||
// File picker must be triggered in the user-gesture context (the click
|
||||
// handler) before navigating, otherwise browsers block it as a popup.
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
uploadStore.init(file)
|
||||
router.push('/upload')
|
||||
}
|
||||
input.click()
|
||||
// handler), otherwise browsers block it as a popup.
|
||||
fileInputEl.value?.click()
|
||||
}
|
||||
|
||||
function onFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// Reset so re-selecting the same file later still fires change.
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
uploadStore.init(file)
|
||||
router.push('/upload')
|
||||
}
|
||||
|
||||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,9 +55,73 @@
|
||||
<span class="settings__row-label">Signed in as</span>
|
||||
<span class="settings__row-value">{{ auth.user?.email }}</span>
|
||||
</div>
|
||||
<button type="button" class="settings__action-link" @click="passwordModalOpen = true">
|
||||
Change password
|
||||
</button>
|
||||
<a href="/logout" class="settings__logout">Sign out</a>
|
||||
</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
|
||||
v-if="showIosInstructions"
|
||||
class="install-modal"
|
||||
@@ -128,6 +192,65 @@ async function onNativeInstall() {
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -178,6 +301,23 @@ async function onNativeInstall() {
|
||||
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 {
|
||||
color: var(--color-text-muted);
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
v-if="step === 'crop' && uploadStore.originalUrl"
|
||||
:src="uploadStore.originalUrl"
|
||||
:orientation="contextOrientation"
|
||||
:model="contextModel"
|
||||
:device-name="contextDeviceName"
|
||||
:initial-params="uploadStore.cropParams"
|
||||
:initial-orientation="uploadStore.cropOrientation"
|
||||
@@ -42,6 +43,7 @@
|
||||
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
|
||||
:cropped-url="uploadStore.croppedUrl"
|
||||
:orientation="effectiveOrientation"
|
||||
:model="contextModel"
|
||||
:stickers="uploadStore.stickers"
|
||||
class="upload-view__stage"
|
||||
@add-sticker="uploadStore.addSticker"
|
||||
@@ -78,7 +80,7 @@ import { useUploadStore } from '@/stores/upload'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { CropParams } from '@/types'
|
||||
import type { CropParams, DeviceModel } from '@/types'
|
||||
import CropEditor from '@/components/CropEditor.vue'
|
||||
import StickerCanvas from '@/components/StickerCanvas.vue'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
@@ -120,6 +122,9 @@ const contextDevice = computed(() =>
|
||||
: devicesStore.devices[0]
|
||||
)
|
||||
|
||||
const contextModel = computed<DeviceModel>(() =>
|
||||
contextDevice.value?.model ?? 'v1'
|
||||
)
|
||||
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
|
||||
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
+1
-1
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
-1
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
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
@@ -14,7 +14,7 @@
|
||||
<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-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="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||
</head>
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Entity\Device;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Entity\User;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Enum\RotationMode;
|
||||
@@ -277,6 +278,7 @@ class DeviceApiController extends AbstractController
|
||||
$device->getModel()->nativeWidth(),
|
||||
$device->getModel()->nativeHeight(),
|
||||
$device->getOrientation(),
|
||||
$device->getModel(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,7 +291,7 @@ class DeviceApiController extends AbstractController
|
||||
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);
|
||||
$len = strlen($bin);
|
||||
@@ -311,10 +313,14 @@ class DeviceApiController extends AbstractController
|
||||
$im = new \Imagick();
|
||||
$im->readImageBlob($ppm);
|
||||
|
||||
// The .bin is always laid out in EPD-native scan order. For portrait,
|
||||
// the renderer pre-rotated the photo 90° CCW; rotate 90° here so the
|
||||
// browser-side preview shows the photo upright.
|
||||
if ($orientation === Orientation::Portrait) {
|
||||
// The .bin is always panel-native scan order. The render pipeline
|
||||
// rotates the source 90° CCW only when the user's orientation differs
|
||||
// from the panel's native — see RenderImageMessageHandler. Mirror that
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Service\DeviceSerializer;
|
||||
use App\Service\MercurePublisher;
|
||||
@@ -89,6 +90,27 @@ class DeviceImageController extends AbstractController
|
||||
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
|
||||
$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
|
||||
// directly so its "next sync" label reflects the schedule the device
|
||||
// is actually on, not the freshly-saved one that won't reach it
|
||||
|
||||
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -72,6 +73,35 @@ class UserApiController extends AbstractController
|
||||
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'])]
|
||||
public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
|
||||
+67
-15
@@ -6,27 +6,79 @@ namespace App\Enum;
|
||||
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
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();
|
||||
$width = $model->width($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()
|
||||
. '/' . $model->value . '_' . $orientation->value . '.bin';
|
||||
@@ -87,7 +87,7 @@ final class RenderImageMessageHandler
|
||||
$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->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
|
||||
@@ -136,13 +136,15 @@ final class RenderImageMessageHandler
|
||||
$imagick = $canvas;
|
||||
}
|
||||
|
||||
// Portrait: rotate the fitted photo 90° CCW so the packed .bin's row
|
||||
// layout matches the EPD's native 800-pixel scan order. The frame is
|
||||
// physically rotated 90° CW for portrait (ribbon on right from EPD's
|
||||
// POV → on left from user's view), so the photo's top edge maps to the
|
||||
// EPD's left column. Firmware streams bytes raw — no orientation
|
||||
// awareness on-device.
|
||||
if ($orientation === Orientation::Portrait) {
|
||||
// Rotate the fitted photo 90° CCW when the user's orientation differs
|
||||
// from the panel's natural scan orientation, so the packed .bin's row
|
||||
// layout always matches the panel's native scan order. Firmware streams
|
||||
// bytes raw — no orientation awareness on-device.
|
||||
//
|
||||
// V1 (7.3", wide-native) rotates for Portrait. V2 (13.3", tall-native)
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ final class DeviceSerializer
|
||||
'id' => $d->getId(),
|
||||
'mac' => $d->getMac(),
|
||||
'name' => $d->getName(),
|
||||
'model' => $d->getModel()->value,
|
||||
'orientation' => $d->getOrientation()->value,
|
||||
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
|
||||
'wakeTimes' => $d->getWakeTimes(),
|
||||
|
||||
@@ -576,6 +576,101 @@ class DeviceApiControllerTest extends AppWebTestCase
|
||||
@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) ────────────────────────
|
||||
|
||||
public function test_delete_removes_device_and_purges_history_and_approvals(): void
|
||||
|
||||
@@ -596,4 +596,61 @@ class DeviceImageControllerTest extends AppWebTestCase
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ use PHPUnit\Framework\TestCase;
|
||||
/**
|
||||
* width()/height() must follow orientation, but nativeWidth()/nativeHeight()
|
||||
* 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
|
||||
{
|
||||
@@ -27,12 +30,54 @@ class DeviceModelTest extends TestCase
|
||||
$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(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(''));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class DeviceSerializerTest extends TestCase
|
||||
$payload = $this->serializer->serialize($device);
|
||||
|
||||
$this->assertEqualsCanonicalizing(
|
||||
['id', 'mac', 'name', 'orientation', 'rotationIntervalMinutes',
|
||||
['id', 'mac', 'name', 'model', 'orientation', 'rotationIntervalMinutes',
|
||||
'wakeTimes', 'timezone', 'uniquenessWindow', 'rotationMode',
|
||||
'prioritizeNeverShown', 'linkedAt', 'lastSeenAt',
|
||||
'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
|
||||
{
|
||||
$device = $this->makeDevice();
|
||||
|
||||
Reference in New Issue
Block a user