From 9e2e6a67275e3cf033812c7ceb78d14f0f6e5c2d Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 12:54:45 +0700 Subject: [PATCH] feat(library): close the mobile preview on the frame the reader swiped to, selected in the thumb grid --- docker/frontend/scripts/mobile-pick-check.mjs | 236 ++++++++++++++++++ docker/frontend/src/styles/mobile-library.css | 6 + docker/frontend/src/ui/MobileLibrary.tsx | 29 ++- 3 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 docker/frontend/scripts/mobile-pick-check.mjs diff --git a/docker/frontend/scripts/mobile-pick-check.mjs b/docker/frontend/scripts/mobile-pick-check.mjs new file mode 100644 index 0000000..c8915f0 --- /dev/null +++ b/docker/frontend/scripts/mobile-pick-check.mjs @@ -0,0 +1,236 @@ +// TEMP check (phase 3, delete after): on the phone, a frame swiped to in the +// SINGLE preview is the frame the ALL THUMBS grid opens selected on. +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const SAMPLE_FILE = process.env.SAMPLE_FILE ?? path.resolve(FRONTEND, '../sample/_3279791-develop-check.jpg'); +const APP_PORT = Number(process.env.APP_PORT ?? 5902); +const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5903); +const FRAMES = Number(process.env.FRAMES ?? 12); +const FOLDER = 'MobRoll'; +const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(2, '0')}.jpg`); +const DAY_MS = 86400000; +const DAY0 = Date.UTC(2023, 0, 12, 12); +const dayOfName = (name) => DAY0 + names.indexOf(name) * DAY_MS; + +let fails = 0; +const step = (what, ok, detail = '') => { + if (!ok) fails++; + console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ` — ${detail}` : ''}`); +}; + +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); + process.exit(0); +} + +const jpg = await readFile(SAMPLE_FILE); +const sampleServer = createServer((req, res) => { + const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); + if (!names.includes(name)) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { + 'Content-Type': 'image/jpeg', + 'Content-Length': jpg.length, + 'Access-Control-Allow-Origin': '*', + }); + res.end(jpg); +}); +await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); + +const app = process.env.BASE_URL + ? null + : spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', + detached: true, + }); +const stopApp = () => { + if (!app?.pid) return; + try { + process.kill(-app.pid, 'SIGTERM'); + } catch {} +}; +const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`; +let up = !app; +for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); +} +if (!up) { + stopApp(); + sampleServer.close(); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first`); + process.exit(1); +} + +const browser = await playwright.chromium.launch({ + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader'], +}); +const context = await browser.newContext({ + viewport: { width: 1500, height: 950 }, + hasTouch: true, + isMobile: true, + deviceScaleFactor: 2, +}); + +await context.route('**/api/**', (route) => { + const url = route.request().url(); + const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check', verified: true, pro: true, admin: false } }); + if (url.includes('/photos')) return json({ photos: [] }); + if (url.includes('/place')) return json({ place: 'Hanoi' }); + if (url.includes('/ratings')) return json({ ratings: [] }); + if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } }); + if (url.includes('/recipes')) return json({ recipes: [] }); + return json({}); +}); + +await context.addInitScript( + ({ origin, names, folder, days }) => { + const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1); + const fileHandle = (name) => { + const handle = { kind: 'file', name, __fake: true }; + Object.defineProperty(handle, 'getFile', { + value: async () => + new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, { + type: 'image/jpeg', + lastModified: dayOfNameIn(name), + }), + }); + return handle; + }; + const dirHandle = (label, node) => { + const handle = { kind: 'directory', name: label, __fake: true }; + Object.defineProperties(handle, { + values: { value: () => [...node.files.map(fileHandle)][Symbol.iterator]() }, + queryPermission: { value: async () => 'granted' }, + requestPermission: { value: async () => 'granted' }, + getDirectoryHandle: { value: async () => { throw new Error('no such folder'); } }, + getFileHandle: { + value: async (name) => { + if (!node.files.includes(name)) throw new Error(`no frame named ${name}`); + return fileHandle(name); + }, + }, + }); + return handle; + }; + const root = () => dirHandle(folder, { files: names }); + window.showDirectoryPicker = async () => root(); + + const revive = (row) => { + if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { + row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name); + } + return row; + }; + for (const method of ['get', 'getAll']) { + const original = IDBObjectStore.prototype[method]; + IDBObjectStore.prototype[method] = function (...args) { + const request = original.apply(this, args); + const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); + Object.defineProperty(request, 'result', { + configurable: true, + get() { + const value = descriptor.get.call(this); + return Array.isArray(value) ? value.map(revive) : revive(value); + }, + }); + return request; + }; + } + }, + { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) } +); + +const page = await context.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200))); + +await page.goto(`${base}/library`); +await page.click('[data-key="lib-add"]'); +await page.waitForSelector(`[data-key="lib-node-${FOLDER}"]`, { timeout: 60_000 }); +await page + .waitForFunction((n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)), FRAMES, { + timeout: 240_000, + polling: 250, + }) + .catch(() => {}); + +// The phone frame: the same roll, read on a narrow window. +await page.setViewportSize({ width: 390, height: 844 }); +await page.waitForSelector('.mob-lib-content', { timeout: 30_000 }); +await page.waitForSelector('.mob-lib-thumb-cell', { timeout: 60_000 }); +step('the phone shows the thumb grid', (await page.$$('.mob-lib-thumb-cell')).length === FRAMES); + +// SINGLE, then a frame previewed off the one-at-a-time list. +await page.click('.mob-lib-tab-bar .mob-lib-tab-item:nth-child(2)'); +await page.waitForSelector('.mob-lib-view-btn[title="Single"]', { timeout: 20_000 }); +await page.click('.mob-lib-view-btn[title="Single"]'); +await page.waitForSelector('.mob-lib-single-card', { timeout: 20_000 }); +await page.click('.mob-lib-single-card'); +await page.waitForSelector('.mob-lib-preview-modal', { timeout: 20_000 }); +const first = (await page.$eval('.mob-lib-preview-title', (e) => e.textContent?.trim() ?? '')) || ''; + +// A finger across the picture: left goes the way the list is read. +await page.$eval('[data-key="mob-lib-preview-body"]', (el) => { + const touch = (x, y) => new Touch({ identifier: 1, target: el, clientX: x, clientY: y }); + const fire = (type, x, y) => { + const t = touch(x, y); + el.dispatchEvent( + new TouchEvent(type, { + touches: type === 'touchend' ? [] : [t], + changedTouches: [t], + bubbles: true, + cancelable: true, + }) + ); + }; + fire('touchstart', 300, 400); + fire('touchend', 100, 400); +}); +await page.waitForFunction((was) => (document.querySelector('.mob-lib-preview-title')?.textContent ?? '') !== was, first, { + timeout: 10_000, +}); +const swiped = (await page.$eval('.mob-lib-preview-title', (e) => e.textContent?.trim() ?? '')) || ''; + +// Closing it. +await page.click('.mob-lib-preview-modal .mob-lib-status-btn'); +await page.waitForSelector('.mob-lib-preview-modal', { state: 'detached', timeout: 10_000 }); +await page.waitForSelector('.mob-lib-grid', { timeout: 10_000 }); + +const read = await page.evaluate(() => { + const cells = [...document.querySelectorAll('.mob-lib-thumb-cell')]; + const sel = cells.filter((c) => c.classList.contains('selected')); + const box = document.querySelector('.mob-lib-content')?.getBoundingClientRect(); + const r = sel[0]?.getBoundingClientRect(); + return { + cells: cells.length, + selected: sel.map((c) => c.querySelector('img')?.getAttribute('alt') ?? ''), + outline: sel[0] ? getComputedStyle(sel[0]).outlineColor : '', + inView: !!(box && r && r.top >= box.top - 4 && r.bottom <= box.bottom + 4), + gridMode: !!document.querySelector('.mob-lib-grid'), + singles: document.querySelectorAll('.mob-lib-single-card').length, + }; +}); +step('closing hands the reader to ALL THUMBS', read.gridMode && read.singles === 0); +step('exactly one frame is selected', read.selected.length === 1, `${read.selected.length}`); +step('the frame swiped to is the selected one', read.selected[0] === swiped, `swiped=${swiped} selected=${read.selected[0]}`); +step('the selected frame is on screen', read.inView); +step('the selected frame is marked in CSS', read.outline === 'rgb(245, 158, 11)', read.outline); +step('no page error', errors.length === 0, errors.join(' | ')); + +await browser.close(); +stopApp(); +sampleServer.close(); +console.log(fails ? `${fails} FAIL` : 'all ok'); +process.exit(fails ? 1 : 0); diff --git a/docker/frontend/src/styles/mobile-library.css b/docker/frontend/src/styles/mobile-library.css index 384b064..5d051d3 100644 --- a/docker/frontend/src/styles/mobile-library.css +++ b/docker/frontend/src/styles/mobile-library.css @@ -332,6 +332,12 @@ contain-intrinsic-size: 110px 110px; } +/* The frame the reader stopped at in the preview, lit up in the grid. */ +.mob-lib-thumb-cell.selected { + outline: 2px solid #f59e0b; + outline-offset: -2px; +} + .mob-lib-thumb-cell img { width: 100%; height: 100%; diff --git a/docker/frontend/src/ui/MobileLibrary.tsx b/docker/frontend/src/ui/MobileLibrary.tsx index e301365..a56b56a 100644 --- a/docker/frontend/src/ui/MobileLibrary.tsx +++ b/docker/frontend/src/ui/MobileLibrary.tsx @@ -115,6 +115,9 @@ export function MobileLibrary({ const [tab, setTab] = useState('photos'); const [selectedAlbum, setSelectedAlbum] = useState(null); const [selectedPhoto, setSelectedPhoto] = useState(null); + // The frame the reader stopped at: it outlives the preview, so closing the + // picture lights that frame up in the thumb grid. + const [activeId, setActiveId] = useState(null); const [shot, setShot] = useState<{ line: string; lat: number | null; lng: number | null; place: string | null } | null>(null); // Read photo EXIF specifications and GPS (similar to Desktop UI) @@ -573,6 +576,19 @@ export function MobileLibrary({ const next = i < 0 ? undefined : searchResults[i + d]; if (next) setSelectedPhoto(next); }; + + // Every frame the preview shows — opened or swiped to — is the one the reader + // stopped at. + useEffect(() => { + if (selectedPhoto) setActiveId(selectedPhoto.id); + }, [selectedPhoto]); + + // Closing the frame in the one-at-a-time list hands the reader to the thumb + // grid, which opens on the frame they stopped at. + const closePhoto = () => { + setSelectedPhoto(null); + if (viewMode === 'single') setViewMode('grid'); + }; // Where the finger went down: only the distance it travelled matters, and only // once it has lifted. const swipeFrom = useRef<{ x: number; y: number } | null>(null); @@ -631,6 +647,13 @@ export function MobileLibrary({ sortOrder, ]); + // Landing on that frame: the grid brings it under the eye rather than leaving + // the reader to hunt for it. + useEffect(() => { + if (viewMode !== 'grid' || !activeId) return; + scrollContainerRef.current?.querySelector('.mob-lib-thumb-cell.selected')?.scrollIntoView({ block: 'center' }); + }, [viewMode, activeId]); + // Available years from photos & server timeline buckets const availableYears = useMemo(() => { const years = new Set(); @@ -1367,7 +1390,7 @@ export function MobileLibrary({ return (
setSelectedPhoto(photo)} > (
setSelectedPhoto(photo)} > {photo.name} @@ -2217,7 +2240,7 @@ export function MobileLibrary({