Compare commits
2 Commits
3312facd82
...
81637b8502
| Author | SHA1 | Date | |
|---|---|---|---|
| 81637b8502 | |||
| 0f2e109aa2 |
@@ -6,6 +6,16 @@
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/png" href="/assets/RecipesCamIcon.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/RecipesCamIcon.png" />
|
||||
<!-- The installed app. The manifest is a static file in public/ (see
|
||||
public/manifest.json and scripts/make-icons.mjs for its icons), so a build
|
||||
needs no plugin to ship it. iOS ignores most of the manifest and reads these
|
||||
three metas instead: 'capable' is what makes a home-screen bookmark open
|
||||
without Safari's chrome, 'black-translucent' gives it the app's black bars,
|
||||
and 'title' is the name under the icon. -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="RecipesCam" />
|
||||
<title>RecipesCam — web</title>
|
||||
<meta name="description" content="RecipesCam web — grade photos with the same film recipes as the app." />
|
||||
<link rel="canonical" href="/" />
|
||||
|
||||
@@ -51,6 +51,24 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The two files whose names never change but whose contents must: a cached worker
|
||||
# keeps answering with the previous build's shell long after the deploy that
|
||||
# replaced it, and a cached manifest keeps pointing at the icon set it shipped with.
|
||||
# Nothing else would ever flush them, which is exactly why they opt out of the
|
||||
# long-lived caching above. The worker is a script under the embedder's COEP like
|
||||
# the .mjs files, so it restates the two isolation headers for the same reason.
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.json {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The one route that carries a whole data dir back in (see /api/admin/restore
|
||||
# — who may call it is the API's own admin check, done before it reads a byte).
|
||||
# The upload cap that holds everywhere else would reject it, and unpacking the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "RecipesCam — Color Recipes & Film Camera Studio",
|
||||
"short_name": "RecipesCam",
|
||||
"description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.",
|
||||
"lang": "vi",
|
||||
"start_url": "/app",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Thư viện",
|
||||
"short_name": "Thư viện",
|
||||
"url": "/library"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// The app shell as a service worker, so an installed RecipesCam opens with no
|
||||
// network at all. Not bundled on purpose: through vite/rollup it would stop being
|
||||
// readable in DevTools, the one place a worker gets debugged. VERSION is the cache
|
||||
// name and the whole update story — bump it when the shell changes, and `activate`
|
||||
// drops every older cache.
|
||||
const VERSION = 'recipescam-v1';
|
||||
|
||||
// nginx answers all three with the same index.html (SPA fallback), so they are one
|
||||
// document under three keys: an offline navigation finds it whichever key it asks.
|
||||
const SHELL = ['/', '/app', '/library'];
|
||||
|
||||
// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
|
||||
// the models and the icons never change under a given build.
|
||||
const STATIC = /^\/(assets|wasm|models|icons)\//;
|
||||
|
||||
// Only a complete same-origin 200 is worth keeping — an opaque or error body
|
||||
// stored here comes back as a failure on the next visit, and nothing in the worker
|
||||
// can tell the two apart by then.
|
||||
const cachedFetch = (request) =>
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(VERSION).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
// Offline, the studio the app is for; the landing page for a build without /app.
|
||||
const offlineShell = async () =>
|
||||
(await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error();
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Best effort, one route at a time: a deploy caught mid-flight must not leave the
|
||||
// worker uninstalled, so each route fails on its own.
|
||||
event.waitUntil(
|
||||
caches.open(VERSION)
|
||||
.then((cache) => Promise.all(SHELL.map((url) => cache.add(url).catch(() => {}))))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
// A worker is a cache, not a proxy: a POST, the API, another origin's font — a
|
||||
// request that is not a plain same-origin read — goes straight out.
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// A navigation is network-first even when it is cached: a shell answered from the
|
||||
// cache while the network holds a newer build pins the visitor to the old one. It
|
||||
// is also the path an installed app opens through with no network.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(fetch(request).catch(offlineShell));
|
||||
return;
|
||||
}
|
||||
|
||||
// ponytail: a navigation is never written back, the precached entry is what
|
||||
// answers offline. Add a put() here once a route must survive on its own.
|
||||
if (STATIC.test(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else — a font, the manifest, an uploaded photo — is network-first,
|
||||
// and answered from the cache only when that fails.
|
||||
event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
// The catalogue, checked where it actually lives: in a browser. A directory
|
||||
// handle, IndexedDB, a grid of tiles and a handover into the studio have no
|
||||
// meaning outside one, so this is a Playwright run rather than a pure-node
|
||||
// assert.
|
||||
//
|
||||
// Only Chromium can hand a page a folder, and only a person can answer the
|
||||
// picker. The check stands in for both: it serves two real frames (one JPEG, one
|
||||
// RAW) off a local server and answers `showDirectoryPicker` with a folder made of
|
||||
// them. The stand-in handles carry their methods as *non-enumerable* properties,
|
||||
// which is what lets them go through IndexedDB's structured clone at all (a
|
||||
// clone copies enumerable own keys and drops the rest, and a function anywhere
|
||||
// else is a DataCloneError); the methods are put back where the catalogue reads
|
||||
// them, because a cloned handle is a plain object with no `getFile`.
|
||||
//
|
||||
// npm run build && node scripts/library-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-check.mjs
|
||||
//
|
||||
// Playwright is not a dependency of this package (a browser run needs it, a
|
||||
// build does not), so a run without it says SKIP and exits 0.
|
||||
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 SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
|
||||
const JPG = 'P1010256.JPG';
|
||||
const RAW = 'P1010256.RW2';
|
||||
|
||||
let failures = 0;
|
||||
function step(name, ok, detail = '') {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
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=<path to its index.mjs>');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- the two frames, off a local server (the page fetches them by CORS) ------
|
||||
const sampleServer = createServer(async (req, res) => {
|
||||
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
|
||||
if (name !== JPG && name !== RAW) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(path.join(SAMPLES, name));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': bytes.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(bytes);
|
||||
});
|
||||
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
||||
|
||||
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
|
||||
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
|
||||
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const base = `http://127.0.0.1:${APP_PORT}`;
|
||||
let up = false;
|
||||
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) {
|
||||
app.kill('SIGTERM');
|
||||
sampleServer.close();
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- the browser ------------------------------------------------------------
|
||||
const browser = await playwright.chromium.launch({
|
||||
// Playwright's own Chromium when it has one; CHROME points at any other.
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
// The folder the picker would have returned, plus the revival of its handles on
|
||||
// the way back out of IndexedDB.
|
||||
await context.addInitScript(
|
||||
({ origin, names, folder }) => {
|
||||
const bytes = new Map();
|
||||
const load = async (name) => {
|
||||
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
|
||||
return bytes.get(name);
|
||||
};
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }),
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = () => {
|
||||
const handle = { kind: 'directory', name: folder };
|
||||
Object.defineProperties(handle, {
|
||||
values: { value: () => names.map(fileHandle)[Symbol.iterator]() },
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
window.showDirectoryPicker = async () => dirHandle();
|
||||
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? dirHandle() : 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: [JPG, RAW], folder: 'CheckRoll' }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
const catalogue = () =>
|
||||
page.evaluate(async () => {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const all = (store) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = db.transaction(store).objectStore(store).getAll();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const [photos, edits] = await Promise.all([all('photos'), all('edits')]);
|
||||
const thumbs = [];
|
||||
for (const photo of photos) {
|
||||
const head = photo.thumb ? new Uint8Array(await photo.thumb.slice(0, 2).arrayBuffer()) : null;
|
||||
thumbs.push({
|
||||
id: photo.id,
|
||||
bytes: photo.thumb?.size ?? 0,
|
||||
jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false,
|
||||
});
|
||||
}
|
||||
return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) };
|
||||
});
|
||||
|
||||
// --- the run ----------------------------------------------------------------
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
const tiles = '[data-key^="lib-photo-"]';
|
||||
await page.waitForSelector(tiles, { timeout: 120_000 });
|
||||
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, tiles, { timeout: 120_000 });
|
||||
step('both frames indexed as tiles', true, `2 tiles from ${JPG} + ${RAW}`);
|
||||
|
||||
const indexed = await catalogue();
|
||||
for (const name of [JPG, RAW]) {
|
||||
const row = indexed.thumbs.find((t) => t.id.endsWith(`/${name}`));
|
||||
step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`);
|
||||
}
|
||||
|
||||
// One tap on a tile hands the frame to the studio. The RAW is the harder half —
|
||||
// it has to be read back off the (stand-in) handle and developed — so it is the
|
||||
// one that gets opened.
|
||||
const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id;
|
||||
await page.click(`[data-key="lib-open-${rawId}"]`);
|
||||
await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 });
|
||||
await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 });
|
||||
step('studio developed the frame from its handle', true, 'stage no longer on the drop zone');
|
||||
|
||||
// The handover is an address, not a state: the studio puts the frame into its
|
||||
// own store and drops the query, so a reload reopens it like any other photo.
|
||||
step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`);
|
||||
|
||||
const editsIn = () =>
|
||||
page.evaluate(async () => {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return await new Promise((resolve) => {
|
||||
const req = db.transaction('edits').objectStore('edits').getAll();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
});
|
||||
});
|
||||
// The recipe is written 600ms after the last change (a slider drag fires it per
|
||||
// frame), so the row arrives a beat after the frame does.
|
||||
let filed = [];
|
||||
for (let i = 0; i < 40 && !filed.length; i++) {
|
||||
filed = await editsIn();
|
||||
if (!filed.length) await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed));
|
||||
|
||||
await page.goto(`${base}/library`);
|
||||
await page.waitForSelector(`[data-key="lib-photo-${rawId}"]`);
|
||||
const meta = await page.locator(`[data-key="lib-photo-${rawId}"] .adm-meta`).innerText();
|
||||
step('the tile says the frame is edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · '));
|
||||
|
||||
await browser.close();
|
||||
sampleServer.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,71 @@
|
||||
// The manifest needs its icons at fixed sizes, and the only artwork there is ships
|
||||
// at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the
|
||||
// engine grades through it — so it does the resize here rather than a new image
|
||||
// tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is
|
||||
// committed, so a build never has to decode and rescale anything.
|
||||
//
|
||||
// node scripts/make-icons.mjs
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
||||
|
||||
const SOURCE = 'public/assets/RecipesCamIcon.png';
|
||||
const OUT = 'public/icons';
|
||||
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () =>
|
||||
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
|
||||
// Buffers are typed arrays already, but Skia's binding wants one it can read as a
|
||||
// plain byte view, not node's pooled slab.
|
||||
const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE)));
|
||||
if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`);
|
||||
|
||||
// `scale` is the art's share of the square. The plain icons bleed to the edge,
|
||||
// exactly as the source does. The maskable one cannot: the launcher supplies the
|
||||
// mask, and most of them are circles, so the art has to stay inside the 80%
|
||||
// safe zone the spec draws — the background around it is the app's own #0a0a0a
|
||||
// rather than nothing at all, because a masked icon with transparent corners
|
||||
// shows whatever the launcher paints behind it.
|
||||
const icons = [
|
||||
{ file: 'icon-192.png', size: 192, scale: 1, background: false },
|
||||
{ file: 'icon-512.png', size: 512, scale: 1, background: false },
|
||||
{ file: 'maskable-512.png', size: 512, scale: 0.8, background: true },
|
||||
];
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
for (const { file, size, scale, background } of icons) {
|
||||
const surface = CanvasKit.MakeSurface(size, size);
|
||||
if (!surface) throw new Error(`could not make a ${size}x${size} surface`);
|
||||
const canvas = surface.getCanvas();
|
||||
|
||||
if (background) {
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setColor(CanvasKit.Color(10, 10, 10));
|
||||
canvas.drawPaint(paint);
|
||||
paint.delete();
|
||||
}
|
||||
|
||||
const side = size * scale;
|
||||
const offset = (size - side) / 2;
|
||||
// Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where
|
||||
// nearest or bilinear would drop detail off the camera's thin outlines.
|
||||
canvas.drawImageRectCubic(
|
||||
art,
|
||||
CanvasKit.XYWHRect(0, 0, art.width(), art.height()),
|
||||
CanvasKit.XYWHRect(offset, offset, side, side),
|
||||
1 / 3,
|
||||
1 / 3,
|
||||
null,
|
||||
);
|
||||
surface.flush();
|
||||
|
||||
const png = surface.makeImageSnapshot().encodeToBytes();
|
||||
surface.dispose();
|
||||
if (!png) throw new Error(`could not encode ${file}`);
|
||||
writeFileSync(`${OUT}/${file}`, png);
|
||||
console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`);
|
||||
}
|
||||
|
||||
art.delete();
|
||||
@@ -0,0 +1,86 @@
|
||||
// Install support is not visible in the source. It is the manifest the browser
|
||||
// reads, the worker it registers, and what that worker still holds once the network
|
||||
// is gone — so unlike the other checks here, which read a module and assert on it,
|
||||
// this one has to drive a real browser at a real build. Four assertions, one line
|
||||
// each, against a preview server:
|
||||
//
|
||||
// npx vite build && npx vite preview --port 4183 &
|
||||
// node scripts/pwa-check.mjs
|
||||
//
|
||||
// The browser is Playwright's own, installed by whoever runs this — there is no
|
||||
// test runner in this repo, and software GL is what the studio needs to paint at
|
||||
// all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser.
|
||||
const BASE = process.argv[2] ?? 'http://localhost:4183';
|
||||
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=<path to its index.mjs>');
|
||||
process.exit(0);
|
||||
}
|
||||
const { chromium } = playwright;
|
||||
|
||||
const browser = await chromium.launch({
|
||||
// Playwright's own Chromium when it has one; CHROME points at any other.
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
// Every assertion prints, rather than throwing on the first one: which of the four
|
||||
// works and which does not is the whole answer.
|
||||
let failed = 0;
|
||||
const check = (ok, label) => {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
|
||||
if (!ok) failed++;
|
||||
};
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${BASE}/app`);
|
||||
|
||||
// (a) The manifest, as the browser fetches it before it offers anything.
|
||||
const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json());
|
||||
check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`);
|
||||
|
||||
// (b) The worker registration main.tsx makes on load. `ready` only ever resolves
|
||||
// with a registration that has an `active` worker; that worker's own state string
|
||||
// is 'activated' (the ServiceWorkerState enum has no 'active' in it).
|
||||
const registration = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
return { scope: reg.scope, state: reg.active?.state ?? null };
|
||||
});
|
||||
check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`);
|
||||
|
||||
// (c) What it put away. Read after a reload, because the first load is the one that
|
||||
// installs it and the reload is the first one it is in control of.
|
||||
await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined);
|
||||
await page.reload();
|
||||
const cached = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
for (const name of await caches.keys()) {
|
||||
out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const names = Object.keys(cached);
|
||||
const urls = Object.values(cached).flat().sort();
|
||||
check(
|
||||
urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)),
|
||||
`worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`,
|
||||
);
|
||||
|
||||
// (d) The point of all of it: the network gone, the shell still paints.
|
||||
await context.setOffline(true);
|
||||
let brand = 'nothing';
|
||||
try {
|
||||
await page.reload();
|
||||
brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null);
|
||||
} catch (err) {
|
||||
brand = `reload failed: ${err.message.split('\n')[0]}`;
|
||||
}
|
||||
check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
|
||||
|
||||
await browser.close();
|
||||
if (failed) {
|
||||
console.error(`pwa-check: ${failed} of 4 failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('pwa-check ok');
|
||||
+69
-13
@@ -18,6 +18,8 @@ import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
|
||||
import { developRaw, isRawName } from './engine/rawDevelop';
|
||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||
import { getPhoto, loadEdit, readPhotoFile, saveEdit } from './engine/library';
|
||||
import { offerInstall } from './pwa/install';
|
||||
import { cameraSupported } from './engine/camera';
|
||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
||||
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||
@@ -572,6 +574,10 @@ export function Workspace() {
|
||||
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
|
||||
// The saved photo standing on the stage, if any — its own consent chip shows.
|
||||
const [openPhotoId, setOpenPhotoId] = useState<number | null>(null);
|
||||
// The catalogue frame on the stage, if any (`/library` hands one over as
|
||||
// `/app?lib=<id>`). It is what the recipe is filed back against, so the frame
|
||||
// reopens in the studio at the look it was left at.
|
||||
const [libPhotoId, setLibPhotoId] = useState<string | null>(null);
|
||||
// Recipes made in this session, guest ones included. A guest's copy lives
|
||||
// here and nowhere else, so reloading the page is what deletes it.
|
||||
const [created, setCreated] = useState<Recipe[]>([]);
|
||||
@@ -638,18 +644,23 @@ export function Workspace() {
|
||||
// ...and the photo half of the session, back out of IndexedDB. When that is
|
||||
// empty but a RAW is still parked in OPFS, the RAW is what "the last photo"
|
||||
// means: develop it again rather than open on nothing.
|
||||
loadPhoto()
|
||||
.then(async (photo) => {
|
||||
if (!alive) return;
|
||||
if (photo) {
|
||||
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
|
||||
}
|
||||
const parked = await readRaw();
|
||||
if (!alive || !parked) return;
|
||||
const jpeg = await developRaw(parked.bytes);
|
||||
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
// A frame handed over by the catalogue (`/app?lib=<id>`) wins over both: the
|
||||
// visitor just clicked it, and it is newer than anything the session holds.
|
||||
const wanted = new URLSearchParams(window.location.search).get('lib');
|
||||
if (wanted) openLibraryPhoto(wanted);
|
||||
else
|
||||
loadPhoto()
|
||||
.then(async (photo) => {
|
||||
if (!alive) return;
|
||||
if (photo) {
|
||||
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
|
||||
}
|
||||
const parked = await readRaw();
|
||||
if (!alive || !parked) return;
|
||||
const jpeg = await developRaw(parked.bytes);
|
||||
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
@@ -936,6 +947,7 @@ export function Workspace() {
|
||||
setError(null);
|
||||
setBusy(t('auth.busy'));
|
||||
setOpenPhotoId(null);
|
||||
setLibPhotoId(null);
|
||||
try {
|
||||
await adoptPhoto(bytes, name, blob);
|
||||
// A shot survives a reload like any other photo of the session's own.
|
||||
@@ -955,8 +967,10 @@ export function Workspace() {
|
||||
setBusy(t('auth.busy'));
|
||||
// A file from the disk is nobody's saved frame: SAVE PHOTO must add one,
|
||||
// not overwrite whichever of the folder's own was open before. Opening one
|
||||
// of those sets the id back, after this call.
|
||||
// of those sets the id back, after this call. Same for a catalogue frame —
|
||||
// the caller that hands one over names it again on the way back.
|
||||
setOpenPhotoId(null);
|
||||
setLibPhotoId(null);
|
||||
try {
|
||||
const bytes = await fileToBytes(file);
|
||||
if (isRawName(file.name)) {
|
||||
@@ -1635,6 +1649,10 @@ export function Workspace() {
|
||||
);
|
||||
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
|
||||
// The moment the app has done the visitor's work is the moment it has earned
|
||||
// a place on the machine: the install offer waits for this instead of
|
||||
// opening on the way in (which nobody reads).
|
||||
offerInstall();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('err.generic', { msg: String(err) }));
|
||||
@@ -1866,6 +1884,44 @@ export function Workspace() {
|
||||
[loadFile, applyRecipe, restoreLook, defaultLook, clearPreview, t]
|
||||
);
|
||||
|
||||
// A frame from the local catalogue, handed over as `/app?lib=<id>`: the bytes
|
||||
// come off the disk through the handle the catalogue kept, then the recipe that
|
||||
// frame was last left at goes on top. A frame first opened has no recipe yet
|
||||
// and arrives on the neutral stock, like any other file.
|
||||
const openLibraryPhoto = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy(t('auth.busy'));
|
||||
setError(null);
|
||||
// The address is a handover, not a state: the frame lives in the session
|
||||
// from here on, so a reload reopens it like any other photo on the stage.
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
clearPreview();
|
||||
setHistPick(null);
|
||||
try {
|
||||
const row = await getPhoto(id);
|
||||
if (!row) throw new Error('not in the catalogue');
|
||||
await loadFile(await readPhotoFile(row));
|
||||
const saved = await loadEdit(id);
|
||||
if (saved) applyRecipe(saved.recipe);
|
||||
setLibPhotoId(id);
|
||||
} catch {
|
||||
setError(t('err.decode'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[loadFile, applyRecipe, clearPreview, t]
|
||||
);
|
||||
|
||||
// What the catalogue remembers about a frame is the look it was left at, filed
|
||||
// back on every change — debounced, because a slider drag fires this per frame
|
||||
// of the drag and IndexedDB is on the wrong side of that.
|
||||
useEffect(() => {
|
||||
if (!libPhotoId || !source) return;
|
||||
const timer = setTimeout(() => void saveEdit(libPhotoId, recipe), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [libPhotoId, source, recipe]);
|
||||
|
||||
// The saved frame's own switch. The reel reads `consent`, so the tick answers
|
||||
// the click at once; only the store's refusal (a failed call) takes it back.
|
||||
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import {
|
||||
canBrowseFolders,
|
||||
ensurePermission,
|
||||
getPhoto,
|
||||
listEditedIds,
|
||||
listFolders,
|
||||
listPhotos,
|
||||
pickFolder,
|
||||
readPhotoFile,
|
||||
removeFolder,
|
||||
scanFolder,
|
||||
type LibraryFolder,
|
||||
type LibraryPhoto,
|
||||
type ScanProgress,
|
||||
} from './engine/library';
|
||||
|
||||
// The catalogue screen: folders from the visitor's own disk, a tile per frame,
|
||||
// and a recipe remembered per frame. Nothing is uploaded — the page never holds
|
||||
// more than the thumbnails, and the RAW itself is read only when a frame is
|
||||
// opened in the studio (`/app?lib=<id>`).
|
||||
//
|
||||
// ponytail: no rating, no flags, no collections, no search. IndexedDB already
|
||||
// holds the fields a few of those would need (folder, taken); add them once the
|
||||
// catalogue is big enough that scrolling it stops working.
|
||||
export function Library() {
|
||||
const { t } = useI18n();
|
||||
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
||||
const [ready, setReady] = useState<Set<string>>(new Set());
|
||||
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
|
||||
const [edited, setEdited] = useState<Set<string>>(new Set());
|
||||
const [pick, setPick] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<ScanProgress | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const stopRef = useRef(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]);
|
||||
setPhotos(rows);
|
||||
setEdited(edits);
|
||||
}, []);
|
||||
|
||||
// The catalogue outlives the tab; the permission to read those folders does
|
||||
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
|
||||
// a button that asks again on a click.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
const rows = await listFolders();
|
||||
if (!alive) return;
|
||||
setFolders(rows);
|
||||
const ok = new Set<string>();
|
||||
for (const folder of rows) {
|
||||
if (await ensurePermission(folder.handle)) ok.add(folder.name);
|
||||
}
|
||||
if (!alive) return;
|
||||
setReady(ok);
|
||||
await reload();
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const scan = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
stopRef.current = false;
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const result = await scanFolder(folder, setProgress, () => stopRef.current);
|
||||
setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total }));
|
||||
await reload();
|
||||
} catch {
|
||||
setNote(t('lib.failed'));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[reload, t]
|
||||
);
|
||||
|
||||
const add = useCallback(async () => {
|
||||
setNote(null);
|
||||
try {
|
||||
const folder = await pickFolder();
|
||||
if (!folder) return;
|
||||
setFolders((prev) => [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
setPick(folder.name);
|
||||
await scan(folder);
|
||||
} catch (err) {
|
||||
// Closing the picker is a "not now", not a failure worth a line of text.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setNote(t('lib.failed'));
|
||||
}
|
||||
}, [scan, t]);
|
||||
|
||||
const reconnect = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
if (await ensurePermission(folder.handle)) {
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
await scan(folder);
|
||||
} else {
|
||||
setNote(t('lib.failed'));
|
||||
}
|
||||
},
|
||||
[scan, t]
|
||||
);
|
||||
|
||||
const drop = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
await removeFolder(folder.name);
|
||||
setFolders((prev) => prev.filter((f) => f.name !== folder.name));
|
||||
if (pick === folder.name) setPick(null);
|
||||
await reload();
|
||||
},
|
||||
[pick, reload]
|
||||
);
|
||||
|
||||
// The frame goes to the studio through the address bar: the studio owns the
|
||||
// load (develop, EXIF, session) and this page owns no part of it.
|
||||
const open = useCallback(async (photo: LibraryPhoto) => {
|
||||
const row = await getPhoto(photo.id);
|
||||
if (!row) return;
|
||||
// Read once here so a moved or deleted file answers with a note instead of
|
||||
// a studio that opens on nothing.
|
||||
try {
|
||||
await readPhotoFile(row);
|
||||
} catch {
|
||||
setNote(t('lib.missing', { name: row.name }));
|
||||
return;
|
||||
}
|
||||
window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`;
|
||||
}, [t]);
|
||||
|
||||
const shown = useMemo(() => (pick ? photos.filter((p) => p.folder === pick) : photos), [photos, pick]);
|
||||
const urls = useThumbUrls(shown);
|
||||
|
||||
return (
|
||||
<div className="adm">
|
||||
<header className="adm-bar">
|
||||
<a className="adm-brand" href="/">
|
||||
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
|
||||
<span>Recipes<em>Cam</em></span>
|
||||
</a>
|
||||
<b className="adm-title">{t('lib.title')}</b>
|
||||
<span className="adm-spacer" />
|
||||
<span className="hint" data-key="lib-count">
|
||||
{t('lib.count', { n: photos.length })}
|
||||
</span>
|
||||
<a className="btn" data-key="lib-studio" href="/app">
|
||||
{t('nav.studio')}
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{note ? <p className="hint adm-note">{note}</p> : null}
|
||||
|
||||
{canBrowseFolders() ? (
|
||||
<div className="adm-frame" data-key="lib-frame">
|
||||
<div className="adm-pane">
|
||||
<p className="hint adm-sub">{t('lib.hint')}</p>
|
||||
<div className="chip-row" style={{ marginBottom: 12 }}>
|
||||
<button type="button" className="btn primary" data-key="lib-add" disabled={busy} onClick={() => void add()}>
|
||||
{t('lib.add')}
|
||||
</button>
|
||||
{progress ? (
|
||||
<button type="button" className="btn" data-key="lib-stop" onClick={() => (stopRef.current = true)}>
|
||||
{t('lib.stop')}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="hint" data-key="lib-progress">
|
||||
{progress ? t('lib.scanning', { done: progress.done, total: progress.total, added: progress.added }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{folders.length === 0 ? <p className="hint adm-note">{t('lib.noFolders')}</p> : null}
|
||||
|
||||
<div className="chip-row" style={{ marginBottom: 12 }}>
|
||||
{folders.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={pick === null ? 'chip on' : 'chip'}
|
||||
data-key="lib-folder-all"
|
||||
onClick={() => setPick(null)}
|
||||
>
|
||||
{t('lib.all')}
|
||||
</button>
|
||||
) : null}
|
||||
{folders.map((folder) => (
|
||||
<span key={folder.name} className="chip-row">
|
||||
<button
|
||||
type="button"
|
||||
className={pick === folder.name ? 'chip on' : 'chip'}
|
||||
data-key={`lib-folder-${folder.name}`}
|
||||
onClick={() => setPick(folder.name)}
|
||||
>
|
||||
{folder.name}
|
||||
</button>
|
||||
{ready.has(folder.name) ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
data-key={`lib-rescan-${folder.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => void scan(folder)}
|
||||
>
|
||||
{t('lib.rescan')}
|
||||
</button>
|
||||
<button type="button" className="btn ghost" data-key={`lib-drop-${folder.name}`} onClick={() => void drop(folder)}>
|
||||
{t('lib.remove')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key={`lib-reconnect-${folder.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => void reconnect(folder)}
|
||||
>
|
||||
{t('lib.reconnect')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shown.length === 0 && !busy ? <p className="hint adm-note">{t('lib.empty')}</p> : null}
|
||||
|
||||
<div className="adm-grid">
|
||||
{shown.map((photo) => (
|
||||
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
|
||||
{urls[photo.id] ? (
|
||||
<img src={urls[photo.id]} alt="" loading="lazy" onClick={() => void open(photo)} title={t('lib.open')} />
|
||||
) : (
|
||||
<div className="lib-noimg" onClick={() => void open(photo)} title={t('lib.open')}>
|
||||
RAW
|
||||
</div>
|
||||
)}
|
||||
<div className="adm-meta">
|
||||
<b>{photo.name}</b>
|
||||
<span>
|
||||
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
|
||||
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
|
||||
{t('lib.open')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="adm-note hint">{t('lib.unsupported')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One object URL per tile, made when the row does and dropped with it: a folder
|
||||
// of thousands cannot hold thousands of live URLs, and `shown` is the only list
|
||||
// the grid paints.
|
||||
function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
|
||||
const [urls, setUrls] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const photo of photos) if (photo.thumb) next[photo.id] = URL.createObjectURL(photo.thumb);
|
||||
setUrls(next);
|
||||
return () => {
|
||||
for (const url of Object.values(next)) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [photos]);
|
||||
return urls;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// The local catalogue: the folders a visitor has handed over, a thumbnail of
|
||||
// every frame found in them, and the recipe each frame was last graded with.
|
||||
// Nothing here leaves the machine — no upload, no server, no account.
|
||||
//
|
||||
// The three stores are what the folder is made of: `folders` keeps the directory
|
||||
// handle so the catalogue survives a reload without a second trip through the
|
||||
// picker, `photos` keeps a file handle (the RAW itself is read only when a frame
|
||||
// is opened) plus the small JPEG the grid paints, and `edits` keeps the recipe a
|
||||
// frame was left at, keyed by the same id the grid uses.
|
||||
//
|
||||
// ponytail: Dexie would wrap this in three lines, but IndexedDB stores a
|
||||
// FileSystemHandle by itself and the whole surface is seven calls — a dependency
|
||||
// does not pay for itself here. Revisit if the catalogue grows queries (tags,
|
||||
// smart collections) that hand-rolled cursors would make ugly.
|
||||
import type { Recipe } from '../../shared/types';
|
||||
import { readCapturedAt } from './imageOps';
|
||||
import { isRawName, rawThumbnail } from './rawDevelop';
|
||||
|
||||
const DB_NAME = 'recipescam-library';
|
||||
const FOLDERS = 'folders';
|
||||
const PHOTOS = 'photos';
|
||||
const EDITS = 'edits';
|
||||
|
||||
// The grid cell is ~220px wide and a retina display doubles it: 512 on the long
|
||||
// edge is the largest a tile ever shows, and it costs ~30KB per frame.
|
||||
const THUMB_MAX = 512;
|
||||
const THUMB_QUALITY = 0.75;
|
||||
// Frames go down in batches, so a 3000-file folder is 60 transactions rather
|
||||
// than 3000 of them.
|
||||
const BATCH = 50;
|
||||
|
||||
export interface LibraryFolder {
|
||||
name: string;
|
||||
handle: FileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
export interface LibraryPhoto {
|
||||
// `${folder}/${name}` — one frame appears once in the catalogue, under the
|
||||
// folder it was found in.
|
||||
id: string;
|
||||
folder: string;
|
||||
name: string;
|
||||
handle: FileSystemFileHandle;
|
||||
thumb: Blob | null;
|
||||
// When the shutter fired, per EXIF, else the file's own lastModified.
|
||||
taken: number;
|
||||
size: number;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface LibraryEdit {
|
||||
photoId: string;
|
||||
recipe: Recipe;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const SUPPORTED = /\.(jpe?g|png|webp|heic|heif|tiff?|avif)$/i;
|
||||
|
||||
export function isSupportedPhoto(name: string): boolean {
|
||||
return isRawName(name) || SUPPORTED.test(name);
|
||||
}
|
||||
|
||||
export function photoId(folder: string, name: string): string {
|
||||
return `${folder}/${name}`;
|
||||
}
|
||||
|
||||
// The picker is Chromium-only (Chrome, Edge, Opera, Brave): Firefox and Safari
|
||||
// have no way to hand a page a folder that outlives the visit, and the caller
|
||||
// says so instead of showing a button that cannot work.
|
||||
export function canBrowseFolders(): boolean {
|
||||
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||||
const photos = db.createObjectStore(PHOTOS, { keyPath: 'id' });
|
||||
photos.createIndex('folder', 'folder');
|
||||
photos.createIndex('taken', 'taken');
|
||||
db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// One request per call: the transaction is the store's own, which is all a
|
||||
// keyed get/put/delete and a getAll ever need. A cursor-based scan (the folder
|
||||
// walk) goes through `walk` below instead, because it holds one transaction
|
||||
// across many requests.
|
||||
function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const req = run(db.transaction(store, mode).objectStore(store));
|
||||
req.onsuccess = () => resolve(req.result as T);
|
||||
req.onerror = () => reject(req.error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --- folders ---------------------------------------------------------------
|
||||
|
||||
export async function listFolders(): Promise<LibraryFolder[]> {
|
||||
try {
|
||||
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
|
||||
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// The picker's own dialog; `id` makes the browser reopen at the folder this page
|
||||
// was last pointed at, which is what makes a second visit one click.
|
||||
export async function pickFolder(): Promise<LibraryFolder | null> {
|
||||
if (!canBrowseFolders()) throw new Error('no-folder-picker');
|
||||
const handle = await (
|
||||
window as unknown as { showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle> }
|
||||
).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-library' });
|
||||
const folder: LibraryFolder = { name: handle.name, handle };
|
||||
await ask(FOLDERS, 'readwrite', (s) => s.put(folder));
|
||||
return folder;
|
||||
}
|
||||
|
||||
export async function removeFolder(name: string): Promise<void> {
|
||||
const ids = (await listPhotos(name)).map((p) => p.id);
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite');
|
||||
tx.objectStore(FOLDERS).delete(name);
|
||||
const photos = tx.objectStore(PHOTOS);
|
||||
for (const id of ids) {
|
||||
photos.delete(id);
|
||||
tx.objectStore(EDITS).delete(id);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// A handle kept in IndexedDB comes back without its permission: the browser
|
||||
// dropped it when the tab closed, and only the visitor can hand it back. Asking
|
||||
// costs one quiet prompt, which is why this runs at start-up rather than the
|
||||
// moment a frame is clicked. `requestPermission` needs the user's gesture in
|
||||
// some builds, so a refusal is not fatal — the folder is listed as needing a
|
||||
// click.
|
||||
export async function ensurePermission(handle: FileSystemHandle, write = false): Promise<boolean> {
|
||||
// `FileSystemPermissionMode` is not in the DOM lib this project compiles
|
||||
// against, and the two values are the whole type.
|
||||
type PermissionMode = 'read' | 'readwrite';
|
||||
const mode: PermissionMode = write ? 'readwrite' : 'read';
|
||||
const askable = handle as FileSystemHandle & {
|
||||
queryPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
||||
requestPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
||||
};
|
||||
try {
|
||||
if (!askable.queryPermission) return false;
|
||||
if ((await askable.queryPermission({ mode })) === 'granted') return true;
|
||||
return (await askable.requestPermission?.({ mode })) === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- thumbnails ------------------------------------------------------------
|
||||
|
||||
interface Bitmap {
|
||||
width: number;
|
||||
height: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function downscale(blob: Blob, draw: (bitmap: Bitmap, w: number, h: number) => Promise<Blob | null>): Promise<Blob | null> {
|
||||
return createImageBitmap(blob)
|
||||
.then(async (bitmap) => {
|
||||
try {
|
||||
const scale = Math.min(1, THUMB_MAX / Math.max(bitmap.width, bitmap.height));
|
||||
return await draw(bitmap, Math.max(1, Math.round(bitmap.width * scale)), Math.max(1, Math.round(bitmap.height * scale)));
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
// A canvas rather than OffscreenCanvas: this runs on the main thread anyway (the
|
||||
// scan is a background chore, not a render), and the element works in every
|
||||
// browser the studio supports.
|
||||
async function toThumbnail(bitmap: Bitmap, w: number, h: number): Promise<Blob | null> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bitmap as unknown as CanvasImageSource, 0, 0, w, h);
|
||||
return new Promise<Blob | null>((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', THUMB_QUALITY));
|
||||
}
|
||||
|
||||
async function makeThumbnail(file: File, bytes: Uint8Array): Promise<Blob | null> {
|
||||
if (!isRawName(file.name)) return downscale(file, toThumbnail);
|
||||
// A RAW is unpacked to the preview the camera wrote inside it — a seek and a
|
||||
// copy, where a develop is a full decode of every pixel. A file that carries
|
||||
// no preview gets no tile; the studio develops it the moment it is opened.
|
||||
const preview = await rawThumbnail(bytes);
|
||||
if (!preview) return null;
|
||||
return downscale(new Blob([preview as BlobPart], { type: 'image/jpeg' }), toThumbnail);
|
||||
}
|
||||
|
||||
// --- scanning --------------------------------------------------------------
|
||||
|
||||
export interface ScanProgress {
|
||||
folder: string;
|
||||
total: number;
|
||||
done: number;
|
||||
added: number;
|
||||
}
|
||||
|
||||
// Walk the folder, keep what is new or changed, and leave the rest alone: a
|
||||
// second scan of a 2000-file folder only reads the frames that moved.
|
||||
//
|
||||
// ponytail: read one frame at a time, whole, on the main thread — a RAW is read
|
||||
// as its bytes for LibRaw and released again. A folder of 5000 RAW files takes
|
||||
// minutes and makes the page lumpy while it runs. Move the walk into a worker
|
||||
// (LibRaw is happy in one) if a catalogue that large is ever real.
|
||||
export async function scanFolder(
|
||||
folder: LibraryFolder,
|
||||
onProgress?: (p: ScanProgress) => void,
|
||||
shouldStop?: () => boolean
|
||||
): Promise<ScanProgress> {
|
||||
const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p]));
|
||||
const entries: FileSystemFileHandle[] = [];
|
||||
for await (const entry of folder.handle.values()) {
|
||||
// `values()` is typed as the base handle, and only a file handle has the
|
||||
// getFile this walk needs.
|
||||
const file = entry as FileSystemFileHandle;
|
||||
if (entry.kind === 'file' && isSupportedPhoto(file.name)) entries.push(file);
|
||||
}
|
||||
const progress: ScanProgress = { folder: folder.name, total: entries.length, done: 0, added: 0 };
|
||||
let batch: LibraryPhoto[] = [];
|
||||
// One transaction per batch, a put per row: a store with `keyPath: 'id'` takes
|
||||
// a record, not an array of them.
|
||||
const flush = async () => {
|
||||
if (!batch.length) return;
|
||||
const rows = batch;
|
||||
batch = [];
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(PHOTOS, 'readwrite');
|
||||
const store = tx.objectStore(PHOTOS);
|
||||
for (const row of rows) store.put(row);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
};
|
||||
for (const handle of entries) {
|
||||
if (shouldStop?.()) break;
|
||||
progress.done++;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const id = photoId(folder.name, handle.name);
|
||||
const seen = known.get(id);
|
||||
if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) {
|
||||
onProgress?.(progress);
|
||||
continue;
|
||||
}
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const [thumb, taken] = [await makeThumbnail(file, bytes), (await readCapturedAt(bytes)) ?? file.lastModified];
|
||||
batch.push({
|
||||
id,
|
||||
folder: folder.name,
|
||||
name: handle.name,
|
||||
handle,
|
||||
thumb,
|
||||
taken,
|
||||
size: file.size,
|
||||
addedAt: seen?.addedAt ?? Date.now(),
|
||||
});
|
||||
progress.added++;
|
||||
if (batch.length >= BATCH) await flush();
|
||||
} catch {
|
||||
// A frame that will not read is a frame the catalogue skips: one bad file
|
||||
// in a folder is not a failed folder.
|
||||
}
|
||||
onProgress?.(progress);
|
||||
}
|
||||
await flush();
|
||||
return progress;
|
||||
}
|
||||
|
||||
// --- reading ---------------------------------------------------------------
|
||||
|
||||
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
|
||||
try {
|
||||
const rows = await ask<LibraryPhoto[]>(PHOTOS, 'readonly', (s) =>
|
||||
folder ? s.index('folder').getAll(folder) : s.getAll()
|
||||
);
|
||||
// Newest shutter first — a folder of stills reads in the order it was shot.
|
||||
return rows.sort((a, b) => b.taken - a.taken);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPhoto(id: string): Promise<LibraryPhoto | null> {
|
||||
try {
|
||||
return (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The frame itself, at last: nothing is read from the disk until this runs, so
|
||||
// the catalogue costs thumbnails and no more.
|
||||
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
||||
return photo.handle.getFile();
|
||||
}
|
||||
|
||||
// --- edits -----------------------------------------------------------------
|
||||
|
||||
export async function saveEdit(id: string, recipe: Recipe): Promise<void> {
|
||||
try {
|
||||
const edit: LibraryEdit = { photoId: id, recipe, updatedAt: Date.now() };
|
||||
await ask(EDITS, 'readwrite', (s) => s.put(edit));
|
||||
} catch {
|
||||
// Private mode: the frame is still editable, it just forgets the recipe.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEdit(id: string): Promise<LibraryEdit | null> {
|
||||
try {
|
||||
return (await ask<LibraryEdit | undefined>(EDITS, 'readonly', (s) => s.get(id))) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEditedIds(): Promise<Set<string>> {
|
||||
try {
|
||||
const rows = await ask<LibraryEdit[]>(EDITS, 'readonly', (s) => s.getAll());
|
||||
return new Set(rows.map((e) => e.photoId));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,27 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
|
||||
return new Uint8Array(thumb.data);
|
||||
}
|
||||
|
||||
// The preview on its own, for the catalogue: a folder of RAW files has to show
|
||||
// a tile per frame, and unpack_thumb is a seek and a copy where the develop
|
||||
// above is a full decode of every pixel at full resolution. No preview inside
|
||||
// the file means no tile — the row still lists the frame by name, and the
|
||||
// studio develops it the moment it is opened.
|
||||
//
|
||||
// ponytail: still a whole LibRaw open per file, reading the file's bytes into
|
||||
// memory to do it. Fine for the scan's one-at-a-time walk; give the catalogue a
|
||||
// worker and a sync-access handle if a 10k-frame folder ever turns up.
|
||||
export async function rawThumbnail(bytes: Uint8Array): Promise<Uint8Array | null> {
|
||||
const raw = new LibRaw();
|
||||
try {
|
||||
await raw.open(bytes as unknown as BufferSource, SETTINGS);
|
||||
return await cameraPreview(raw);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
raw.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// MATCH_GRID x MATCH_GRID block colours of a frame, one byte per channel.
|
||||
function gridOf(image: any, n = MATCH_GRID): Uint8Array | null {
|
||||
const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n);
|
||||
|
||||
@@ -13,6 +13,13 @@ export const en: Dict = {
|
||||
'nav.back': 'Back to home',
|
||||
'nav.guest': 'Guest',
|
||||
|
||||
// The install offer (see vi.ts).
|
||||
'install.title': 'Install RecipesCam',
|
||||
'install.body': 'Add it to your home screen to open it faster — smoother, offline, full screen like an app.',
|
||||
'install.yes': 'INSTALL',
|
||||
'install.no': 'NO THANKS',
|
||||
'install.ios': 'Tap the Share button in the toolbar, then choose “Add to Home Screen”.',
|
||||
|
||||
'act.undo': 'UNDO',
|
||||
'act.redo': 'REDO',
|
||||
'act.reset': 'RESET',
|
||||
@@ -218,9 +225,32 @@ export const en: Dict = {
|
||||
'photos.failed': 'That did not work: {msg}',
|
||||
'photos.noLabels': 'No strip labels yet',
|
||||
|
||||
'lib.title': 'Library',
|
||||
'lib.hint':
|
||||
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
|
||||
'lib.add': 'ADD FOLDER',
|
||||
'lib.stop': 'STOP SCAN',
|
||||
'lib.rescan': 'RESCAN',
|
||||
'lib.remove': 'REMOVE',
|
||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||
'lib.all': 'ALL',
|
||||
'lib.open': 'Open in the studio',
|
||||
'lib.edited': 'edited',
|
||||
'lib.size': '{mb} MB',
|
||||
'lib.count': '{n} photos',
|
||||
'lib.scanning': 'Scanning {done}/{total} — {added} new…',
|
||||
'lib.scanned': 'Scanned {folder}: {added}/{total} new frames.',
|
||||
'lib.empty': 'Nothing in this folder yet.',
|
||||
'lib.noFolders': 'No folder yet. Hit ADD FOLDER and pick a photo folder on this machine.',
|
||||
'lib.missing': 'Could not open {name} — the file has been moved or deleted.',
|
||||
'lib.failed': 'That folder could not be read.',
|
||||
'lib.unsupported':
|
||||
'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.',
|
||||
|
||||
'nav.admin': 'Admin',
|
||||
'nav.photos': 'My photos',
|
||||
'nav.profile': 'Profile',
|
||||
'nav.library': 'Library',
|
||||
'nav.studio': 'Studio',
|
||||
'adm.title': 'Strip moderation',
|
||||
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
|
||||
|
||||
@@ -17,9 +17,19 @@ export const vi = {
|
||||
'nav.admin': 'Quản trị',
|
||||
'nav.photos': 'Ảnh của tôi',
|
||||
'nav.profile': 'Hồ sơ',
|
||||
'nav.library': 'Thư viện',
|
||||
'nav.studio': 'Studio',
|
||||
'nav.guest': 'Khách',
|
||||
|
||||
// The install offer (src/ui/InstallPrompt.tsx). The only dialog that asks for
|
||||
// something the visitor did not come for, so the copy sells what they get out of
|
||||
// it and the refusal is one word away.
|
||||
'install.title': 'Cài RecipesCam lên màn hình chính',
|
||||
'install.body': 'Cài lên màn hình chính để mở nhanh hơn — mượt mà hơn, offline, toàn màn hình như một app.',
|
||||
'install.yes': 'ĐỒNG Ý',
|
||||
'install.no': 'KHÔNG CẢM ƠN',
|
||||
'install.ios': 'Bấm nút Chia sẻ trên thanh địa chỉ, rồi chọn "Thêm vào màn hình chính".',
|
||||
|
||||
'act.undo': 'HOÀN TÁC',
|
||||
'act.redo': 'LÀM LẠI',
|
||||
'act.reset': 'ĐẶT LẠI',
|
||||
@@ -228,6 +238,28 @@ export const vi = {
|
||||
'photos.failed': 'Không thực hiện được: {msg}',
|
||||
'photos.noLabels': 'Chưa có nhãn dải phim',
|
||||
|
||||
'lib.title': 'Thư viện',
|
||||
'lib.hint':
|
||||
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
|
||||
'lib.add': 'THÊM THƯ MỤC',
|
||||
'lib.stop': 'DỪNG QUÉT',
|
||||
'lib.rescan': 'QUÉT LẠI',
|
||||
'lib.remove': 'BỎ',
|
||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||
'lib.all': 'TẤT CẢ',
|
||||
'lib.open': 'Mở trong studio',
|
||||
'lib.edited': 'đã chỉnh',
|
||||
'lib.size': '{mb} MB',
|
||||
'lib.count': '{n} ảnh',
|
||||
'lib.scanning': 'Đang quét {done}/{total} — thêm {added}…',
|
||||
'lib.scanned': 'Đã quét {folder}: {added}/{total} ảnh mới.',
|
||||
'lib.empty': 'Chưa có ảnh nào trong thư mục này.',
|
||||
'lib.noFolders': 'Chưa có thư mục nào. Bấm THÊM THƯ MỤC để chọn một thư mục ảnh trên máy.',
|
||||
'lib.missing': 'Không mở được {name} — tệp đã bị di chuyển hoặc xoá.',
|
||||
'lib.failed': 'Không đọc được thư mục này.',
|
||||
'lib.unsupported':
|
||||
'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',
|
||||
|
||||
'adm.title': 'Quản trị dải phim',
|
||||
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
||||
'adm.upload': 'THÊM ẢNH',
|
||||
|
||||
@@ -10,11 +10,14 @@ import { Workspace } from './App';
|
||||
import { Admin } from './Admin';
|
||||
import { ProfilePage } from './ProfilePage';
|
||||
import { PhotosPage } from './PhotosPage';
|
||||
import { Library } from './Library';
|
||||
import { InstallPrompt } from './ui/InstallPrompt';
|
||||
import { installTracking } from './track';
|
||||
|
||||
// Five routes, no router: the landing page, the workspace, the strip moderation
|
||||
// screen, the member's own profile and the member's photo folder. nginx serves
|
||||
// index.html for all of them (SPA fallback), so this is a pathname check.
|
||||
// Six routes, no router: the landing page, the workspace, the strip moderation
|
||||
// screen, the member's own profile, the member's photo folder and the local
|
||||
// catalogue of folders on the visitor's own disk. nginx serves index.html for
|
||||
// all of them (SPA fallback), so this is a pathname check.
|
||||
const path = window.location.pathname;
|
||||
const page = path.startsWith('/app') ? (
|
||||
<Workspace />
|
||||
@@ -22,6 +25,8 @@ const page = path.startsWith('/app') ? (
|
||||
<Admin />
|
||||
) : path.startsWith('/photos') ? (
|
||||
<PhotosPage />
|
||||
) : path.startsWith('/library') ? (
|
||||
<Library />
|
||||
) : path.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
@@ -35,9 +40,24 @@ if (!root) throw new Error('#root missing');
|
||||
installTracking();
|
||||
|
||||
// No StrictMode: it double-invokes effects, which would load CanvasKit twice and
|
||||
// run the render pipeline twice on every mount.
|
||||
// run the render pipeline twice on every mount. The install offer hangs off the
|
||||
// i18n provider because its copy comes from the dictionaries.
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>{page}</I18nProvider>
|
||||
<I18nProvider>
|
||||
{page}
|
||||
<InstallPrompt />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Service worker, production only: in dev it would answer the browser with the
|
||||
// last build's shell instead of vite's modules. Registered after `load` so its
|
||||
// precache does not race the studio's own 20MB of wasm for the connection, and a
|
||||
// browser that refuses it (private mode, a plain-http origin) just loses the
|
||||
// offline shell — nothing else depends on it.
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Installability lives outside React. The browser fires `beforeinstallprompt` once,
|
||||
// whenever it likes, and the event has to be held until a click spends it — so this
|
||||
// is a module-level slot plus a subscriber list, and no store, hook or context.
|
||||
//
|
||||
// The one piece of state the visitor owns is the refusal: remember it, and the offer
|
||||
// never comes back. A dismissal is an answer, not a snooze.
|
||||
|
||||
type InstallEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
};
|
||||
|
||||
const DISMISSED = 'rc.install.dismissed.v1';
|
||||
|
||||
// The window event a control elsewhere in the app (the Library's own button, say)
|
||||
// fires to pull the offer forward instead of waiting out the delay.
|
||||
export const OFFER_EVENT = 'rc:install-offer';
|
||||
|
||||
let deferred: InstallEvent | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
// Registers `listener` for every change in installability and returns the
|
||||
// unsubscribe — the shape `useEffect` already has.
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function canInstall(): boolean {
|
||||
return deferred !== null && !dismissed();
|
||||
}
|
||||
|
||||
// Spends the browser's prompt and reports the answer. Null means there was nothing
|
||||
// to spend: the event never arrived, or this is a second call.
|
||||
export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> {
|
||||
const event = deferred;
|
||||
if (!event) return null;
|
||||
// Spent either way, so it is cleared before the await: a second prompt() on the
|
||||
// same event rejects.
|
||||
deferred = null;
|
||||
notify();
|
||||
await event.prompt();
|
||||
const { outcome } = await event.userChoice;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// The installed app is its own window; a Safari tab that was added to the home
|
||||
// screen keeps `standalone` on the navigator instead.
|
||||
export function isStandalone(): boolean {
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
// Every browser on iOS is WebKit, but only Safari's own UI has "Add to Home Screen",
|
||||
// and only there does the manual instruction make sense.
|
||||
export function isIosSafari(): boolean {
|
||||
const ua = navigator.userAgent;
|
||||
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
return ios && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
|
||||
}
|
||||
|
||||
export function dismissed(): boolean {
|
||||
return localStorage.getItem(DISMISSED) === '1';
|
||||
}
|
||||
|
||||
export function dismissInstall(): void {
|
||||
localStorage.setItem(DISMISSED, '1');
|
||||
notify();
|
||||
}
|
||||
|
||||
// Asks for the offer now.
|
||||
export function offerInstall(): void {
|
||||
window.dispatchEvent(new Event(OFFER_EVENT));
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
// Without this Chromium shows its own mini-infobar and the event is spent; the
|
||||
// app's dialog is the one the visitor gets.
|
||||
event.preventDefault();
|
||||
deferred = event as InstallEvent;
|
||||
notify();
|
||||
});
|
||||
|
||||
// Fires once the app is installed, from our prompt or from the browser's own menu,
|
||||
// and is the only signal that the offer is done.
|
||||
window.addEventListener('appinstalled', () => {
|
||||
deferred = null;
|
||||
notify();
|
||||
});
|
||||
@@ -973,6 +973,21 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
padding: 10px;
|
||||
}
|
||||
.adm-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||||
/* A frame whose RAW carries no preview of its own: the tile stands in for the
|
||||
picture and opens the file just the same. */
|
||||
.lib-noimg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm-meta { display: flex; flex-direction: column; gap: 2px; font-size: 11.5px; color: var(--text-dim); min-width: 0; }
|
||||
.adm-meta b { color: var(--text); font-family: var(--mono); }
|
||||
.adm-meta span { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import {
|
||||
OFFER_EVENT,
|
||||
canInstall,
|
||||
dismissInstall,
|
||||
dismissed,
|
||||
isIosSafari,
|
||||
isStandalone,
|
||||
promptInstall,
|
||||
subscribe,
|
||||
} from '../pwa/install';
|
||||
|
||||
// The install offer, and the only dialog the app opens without being asked. It waits
|
||||
// for the browser to say the app is installable, and then for the visitor to have
|
||||
// stayed long enough to want it — a prompt on the way in is a prompt nobody reads.
|
||||
// Markup and classes are the auth dialog's (see AuthModal.tsx); there is nothing new
|
||||
// to draw here.
|
||||
//
|
||||
// Long enough to have opened a photo and worked on it, short enough that the offer
|
||||
// still lands in the visit that earned it.
|
||||
const DELAY_MS = 120_000;
|
||||
|
||||
export function InstallPrompt() {
|
||||
const { t } = useI18n();
|
||||
// 'manual' is Safari: no install event exists there, so the only thing to show is
|
||||
// where the instruction lives.
|
||||
const [face, setFace] = useState<'native' | 'manual' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// An installed app has nothing to offer, and a refusal is final — the visitor
|
||||
// said no to this dialog, not to this session.
|
||||
if (isStandalone() || dismissed()) return;
|
||||
|
||||
const manual = isIosSafari();
|
||||
let waited = false;
|
||||
let asked = false;
|
||||
let shown = false;
|
||||
|
||||
// Both ways in have to agree: installability (or Safari, which never fires the
|
||||
// event) and either the delay running out or something in the app asking for the
|
||||
// offer — a Library button, say.
|
||||
const maybeShow = () => {
|
||||
if (shown || (!waited && !asked)) return;
|
||||
if (manual) {
|
||||
shown = true;
|
||||
setFace('manual');
|
||||
} else if (canInstall()) {
|
||||
shown = true;
|
||||
setFace('native');
|
||||
}
|
||||
};
|
||||
const onOffer = () => {
|
||||
asked = true;
|
||||
maybeShow();
|
||||
};
|
||||
|
||||
window.addEventListener(OFFER_EVENT, onOffer);
|
||||
const unsubscribe = subscribe(maybeShow);
|
||||
const timer = window.setTimeout(() => {
|
||||
waited = true;
|
||||
maybeShow();
|
||||
}, DELAY_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener(OFFER_EVENT, onOffer);
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!face) return null;
|
||||
const close = () => {
|
||||
dismissInstall();
|
||||
setFace(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && close()}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="install-title">
|
||||
<h2 id="install-title">{t('install.title')}</h2>
|
||||
<p className="hint">{t('install.body')}</p>
|
||||
{face === 'manual' ? <p className="hint">{t('install.ios')}</p> : null}
|
||||
{face === 'native' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-key="install-yes"
|
||||
// Not a dismissal: taking the offer and waving the browser's own sheet
|
||||
// away are different answers, and the app's own button can still ask.
|
||||
onClick={() => {
|
||||
void promptInstall();
|
||||
setFace(null);
|
||||
}}
|
||||
>
|
||||
{t('install.yes')}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn ghost" data-key="install-no" onClick={close}>
|
||||
{t('install.no')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,9 @@ export function TopBar({
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
|
||||
{/* The catalogue is a page of its own, open to guests as much as to
|
||||
members — it reads the visitor's own folders and never the server. */}
|
||||
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
|
||||
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user