web: the QR card hands out the look that made the photo
A photo's landing section can now be the QR card, and that section is the only one that hands something out: the server writes the photo's own stored look back as the app's .recipe file, at GET /api/photos/:id/preset.recipe, for any row the curator ticked into the qr slot. Nothing new is stored — the file is built from the recipe the upload already carried, so it works for a photo uploaded by the phone too. The admin pane grows a fourth checkbox and a fourth row (QR card); the row draws the download link as a scannable code, and the box is dead for a photo with no stored look. The landing's QR card now encodes the curated photo's own link instead of a mock address. The listing exposes hasPreset, never the recipe itself.
This commit is contained in:
@@ -338,6 +338,10 @@ export type Photo = {
|
||||
// The uploader's permission for this photo to appear on the landing strip.
|
||||
// The owner's own folder reads it back to draw the toggle.
|
||||
consent: boolean;
|
||||
// Whether the row still carries the look that made it — the only thing the
|
||||
// QR card can hand out (see the preset route in server.ts). A bool, not the
|
||||
// recipe itself: the public listing has no business shipping looks.
|
||||
hasPreset: boolean;
|
||||
};
|
||||
// The owner's own row adds the look that made it, so it can be opened again,
|
||||
// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX.
|
||||
@@ -354,15 +358,20 @@ export type PhotoMeta = {
|
||||
// One SELECT list, so the call sites cannot drift apart.
|
||||
const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slots AS slots,
|
||||
photos.tag AS tag, photos.title AS title, photos.meta AS meta,
|
||||
photos.consent AS consent`;
|
||||
photos.consent AS consent, (photos.recipe IS NOT NULL) AS hasPreset`;
|
||||
|
||||
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
|
||||
// `slots` comes back as the stored comma list, turned into a set on the way out.
|
||||
type PhotoRow = Omit<Photo, 'consent' | 'slots'> & { consent: number; slots: string | null };
|
||||
type PhotoRow = Omit<Photo, 'consent' | 'slots' | 'hasPreset'> & {
|
||||
consent: number;
|
||||
slots: string | null;
|
||||
hasPreset: number;
|
||||
};
|
||||
type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
|
||||
const toPhoto = (row: PhotoRow): Photo => ({
|
||||
...row,
|
||||
consent: row.consent === 1,
|
||||
hasPreset: row.hasPreset === 1,
|
||||
slots: parseSlots(row.slots),
|
||||
});
|
||||
// A row whose JSON will not parse is still a photo: its settings are simply
|
||||
@@ -539,6 +548,7 @@ export function createPhoto(
|
||||
title: meta?.title ?? null,
|
||||
meta: meta?.meta ?? null,
|
||||
consent: meta?.consent !== false,
|
||||
hasPreset: meta?.recipe !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -609,6 +619,16 @@ export function photoFile(id: number): { file: string; mime: string } | undefine
|
||||
| undefined;
|
||||
}
|
||||
|
||||
// The QR card's payload: the stored look, as raw JSON, and where the photo is
|
||||
// allowed to show. The route decides who may read it (a curated `qr` slot), so
|
||||
// this returns the row as stored, recipe included.
|
||||
export function photoPreset(id: number): { recipe: string | null; slots: PhotoSlot[] } | undefined {
|
||||
const row = db.prepare('SELECT recipe, slots FROM photos WHERE id = ?').get(id) as
|
||||
| { recipe: string | null; slots: string | null }
|
||||
| undefined;
|
||||
return row && { recipe: row.recipe, slots: parseSlots(row.slots) };
|
||||
}
|
||||
|
||||
export function deletePhoto(id: number): string | undefined {
|
||||
const row = db.prepare('SELECT file FROM photos WHERE id = ?').get(id) as { file: string } | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// The `.recipe` file the app writes on EXPORT and reads back on IMPORT. It is a
|
||||
// small XML envelope around a scrambled hex payload, and the landing page's QR
|
||||
// card hands a photo's look back as exactly this file, so the server has to
|
||||
// write the app's own format. The source of truth is the web app's
|
||||
// `shared/utils/recipeShare.ts`; this module and the phone app's
|
||||
// `src/utils/recipeShare.ts` are its copies, and `test/security.mjs` pins the
|
||||
// envelope's shape. Change all three together, or the QR stops importing.
|
||||
//
|
||||
// ponytail: obfuscation, not cryptography — the key ships inside the app. Same
|
||||
// caveat as the app's copy: swap in a real cipher at these two functions if the
|
||||
// envelope ever has to resist a determined reader.
|
||||
const KEY = 'RecipesCam::recipe-share::v1';
|
||||
const ALGORITHM = 'xor16-v1';
|
||||
|
||||
const hash = (s: string): number => {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
// xorshift32: one 16-bit word of keystream per call.
|
||||
const stream = (seed: number) => {
|
||||
let s = seed >>> 0 || 0x9e3779b9;
|
||||
return () => {
|
||||
s ^= s << 13;
|
||||
s >>>= 0;
|
||||
s ^= s >>> 17;
|
||||
s ^= s << 5;
|
||||
s >>>= 0;
|
||||
return s & 0xffff;
|
||||
};
|
||||
};
|
||||
|
||||
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
|
||||
// with Vietnamese diacritics included) survives the round trip.
|
||||
const scramble = (text: string, salt: number): string => {
|
||||
const k = stream(hash(`${KEY}|${salt}`));
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
|
||||
return out;
|
||||
};
|
||||
|
||||
// The recipe as the app stored it, wrapped in the envelope. The payload is the
|
||||
// stored JSON whole: the parser on the other side keeps the fields it knows and
|
||||
// drops the rest, so this side needs no opinion about what a recipe contains.
|
||||
export function recipeFile(recipeJson: string): string {
|
||||
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
|
||||
` <payload>${scramble(recipeJson, salt)}</payload>`,
|
||||
'</recipescam-recipe>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recipeFile } from './recipeFile';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
avatarPath,
|
||||
photoFile,
|
||||
photoPath,
|
||||
photoPreset,
|
||||
sessionUser,
|
||||
setPhotoSlots,
|
||||
setPhotoConsent,
|
||||
@@ -644,6 +646,24 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) =
|
||||
.send(data);
|
||||
});
|
||||
|
||||
// The QR card's payload: the `.recipe` file the app reads back on IMPORT, built
|
||||
// from the look the photo was uploaded with. Public like the strip, but only
|
||||
// for a row the curator ticked into the `qr` section — that checkbox is the
|
||||
// whole permission. Everything else is a 404 rather than a 403, so the route
|
||||
// cannot be used to probe which photos carry a look.
|
||||
app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req, reply) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
|
||||
const row = photoPreset(id);
|
||||
if (!row || row.recipe === null || !row.slots.includes('qr')) return reply.status(404).send({ error: 'not_found' });
|
||||
return reply
|
||||
.header('content-type', 'application/xml; charset=utf-8')
|
||||
.header('x-content-type-options', 'nosniff')
|
||||
.header('content-disposition', `attachment; filename="recipescam-${id}.recipe"`)
|
||||
.header('cache-control', 'public, max-age=60')
|
||||
.send(recipeFile(row.recipe));
|
||||
});
|
||||
|
||||
// The uploader's own permission switch: may this photo show on the landing
|
||||
// strip? Only the owner may flip it (an admin curates the slot, not the
|
||||
// consent), and only their own row is reachable — the user_id in the WHERE is
|
||||
|
||||
@@ -331,6 +331,57 @@ try {
|
||||
check('a bare slot string is refused', (await patch(`/admin/photos/${id}`, { slots: 'strip' })).status === 400);
|
||||
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slots: ['qr'] })).status === 404);
|
||||
|
||||
// ---- preset link --------------------------------------------------------
|
||||
// The look a photo was uploaded with is the landing QR card's payload: the
|
||||
// `.recipe` file the app reads back on IMPORT. The curator's `qr` tick is the
|
||||
// whole permission — nothing else is a link, and the listing exposes only
|
||||
// whether a look exists, never the look.
|
||||
const look = { name: 'QR LOOK', baseFilter: 'velvia', adjustments: { contrast: 7 }, frameId: 'none' };
|
||||
const withLook = await user.req(`/photos?recipe=${encodeURIComponent(JSON.stringify(look))}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'image/png' },
|
||||
body: PNG,
|
||||
});
|
||||
const presetId = withLook.body?.photo?.id;
|
||||
check('an upload that carried a look says so', withLook.body?.photo?.hasPreset === true);
|
||||
check('a photo with no look says no', rows.find((r) => r.id === id)?.hasPreset === false);
|
||||
const listedRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === presetId);
|
||||
check('the public listing counts looks, never ships them', listedRow?.hasPreset === true && !('recipe' in listedRow));
|
||||
check('an un-curated photo is not a link', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
|
||||
check('a lookless photo is not a link either', (await actor().req(`/photos/${id}/preset.recipe`)).status === 404);
|
||||
check('an unknown row is a 404', (await actor().req('/photos/999999/preset.recipe')).status === 404);
|
||||
check('a nonsense id is a 404, not a crash', (await actor().req('/photos/abc/preset.recipe')).status === 404);
|
||||
|
||||
await patch(`/admin/photos/${presetId}`, { slots: ['qr'] });
|
||||
const presetFile = await fetch(`${BASE}/photos/${presetId}/preset.recipe`);
|
||||
const presetXml = await presetFile.text();
|
||||
check(
|
||||
'a curated photo serves its look as a download',
|
||||
presetFile.status === 200 && /^application\/xml/.test(presetFile.headers.get('content-type') ?? ''),
|
||||
`got ${presetFile.status}`,
|
||||
);
|
||||
check(
|
||||
'the download carries the row it came from',
|
||||
presetFile.headers.get('content-disposition') === `attachment; filename="recipescam-${presetId}.recipe"`,
|
||||
presetFile.headers.get('content-disposition') ?? '',
|
||||
);
|
||||
check(
|
||||
'the file is the app’s own envelope',
|
||||
/^<\?xml version="1\.0" encoding="UTF-8"\?>\n<recipescam-recipe version="1" algorithm="xor16-v1" salt="[0-9a-f]+">\n {2}<payload>[0-9a-f]+<\/payload>\n<\/recipescam-recipe>\n$/.test(
|
||||
presetXml,
|
||||
),
|
||||
);
|
||||
const again = await (await fetch(`${BASE}/photos/${presetId}/preset.recipe`)).text();
|
||||
check(
|
||||
'every download gets its own salt',
|
||||
/salt="([0-9a-f]+)"/.exec(again)?.[1] !== /salt="([0-9a-f]+)"/.exec(presetXml)?.[1],
|
||||
);
|
||||
|
||||
// Taking the photo out of the `qr` section takes the link away with it.
|
||||
await patch(`/admin/photos/${presetId}`, { slots: [] });
|
||||
check('an un-curated photo loses its link again', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
|
||||
await user.req(`/photos/${presetId}`, { method: 'DELETE' });
|
||||
|
||||
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
|
||||
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
|
||||
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { Profile } from './ui/Profile';
|
||||
import { QrCode } from './ui/QrCode';
|
||||
import { Stats } from './ui/Stats';
|
||||
import type { MsgKey } from './i18n/vi';
|
||||
|
||||
@@ -27,6 +28,7 @@ const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [
|
||||
{ id: 'strip', key: 'adm.pickStrip' },
|
||||
{ id: 'tester', key: 'adm.pickTester' },
|
||||
{ id: 'creator', key: 'adm.pickCreator' },
|
||||
{ id: 'qr', key: 'adm.pickQr' },
|
||||
];
|
||||
|
||||
// The left column, in the order it reads.
|
||||
@@ -283,7 +285,11 @@ export function Admin() {
|
||||
type="checkbox"
|
||||
data-key={`adm-pick-${d.id}-${p.id}`}
|
||||
checked={p.slots.includes(d.id)}
|
||||
disabled={busy}
|
||||
// A photo with no stored look has no file to
|
||||
// hand out, so the QR box would only ever draw
|
||||
// an empty card.
|
||||
disabled={busy || (d.id === 'qr' && !p.hasPreset)}
|
||||
title={d.id === 'qr' && !p.hasPreset ? t('adm.noPreset') : undefined}
|
||||
onChange={() =>
|
||||
void setSlots(
|
||||
p.id,
|
||||
@@ -326,6 +332,22 @@ export function Admin() {
|
||||
{inRow.map((p) => (
|
||||
<figure className="adm-thumb" key={p.id} data-key={`adm-thumb-${p.id}`}>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
{/* The QR section is the one whose artefact is
|
||||
not the photo: it is the link that hands
|
||||
the look back to the app. A photo uploaded
|
||||
without a look has none to hand out. */}
|
||||
{d.id === 'qr' ? (
|
||||
<div className="adm-qr">
|
||||
{p.hasPreset ? (
|
||||
<QrCode
|
||||
url={new URL(api.presetUrl(p.id), window.location.origin).href}
|
||||
size={120}
|
||||
/>
|
||||
) : (
|
||||
<span>{t('adm.noPreset')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<figcaption>#{p.id}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from './i18n/I18nProvider';
|
||||
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
||||
import { AuthModal } from './ui/AuthModal';
|
||||
import { Avatar } from './ui/Avatar';
|
||||
import { QrCode } from './ui/QrCode';
|
||||
|
||||
// Landing page, built to the landing blueprint: the 10-section funnel. The
|
||||
// marketing prose is bilingual and lives here next to the markup as `Txt`
|
||||
@@ -160,7 +161,6 @@ export function Landing() {
|
||||
const [sat, setSat] = useState(100);
|
||||
const [con, setCon] = useState(100);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [qrOk, setQrOk] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
// Sign up / log in happens in a dialog on this page, not by a trip to the
|
||||
@@ -236,11 +236,24 @@ export function Landing() {
|
||||
// render; the built-in sample stands in while a section has no curation. A
|
||||
// photo the curator put in several sections can turn up in each of them.
|
||||
const slotPhoto = useMemo(() => {
|
||||
const pick = (slot: PhotoSlot, fallback: string) => {
|
||||
const pick = (slot: PhotoSlot) => {
|
||||
const list = photos.filter((p) => p.slots.includes(slot));
|
||||
return list.length ? api.photoUrl(list[Math.floor(Math.random() * list.length)].id) : fallback;
|
||||
return list.length ? list[Math.floor(Math.random() * list.length)] : null;
|
||||
};
|
||||
const src = (slot: PhotoSlot, fallback: string) => {
|
||||
const p = pick(slot);
|
||||
return p ? api.photoUrl(p.id) : fallback;
|
||||
};
|
||||
// The QR card's payload is the curated photo's own look, as the link the
|
||||
// phone app downloads it from. The card's mock address stands in while the
|
||||
// section holds nothing that has a look to hand out.
|
||||
const qr = pick('qr');
|
||||
return {
|
||||
tester: src('tester', SAMPLE(1043)),
|
||||
creator: src('creator', SAMPLE(1015)),
|
||||
qr: qr ? api.photoUrl(qr.id) : SAMPLE(1069),
|
||||
qrLink: qr?.hasPreset ? new URL(api.presetUrl(qr.id), window.location.origin).href : `${window.location.origin}/app`,
|
||||
};
|
||||
return { tester: pick('tester', SAMPLE(1043)), creator: pick('creator', SAMPLE(1015)), qr: pick('qr', SAMPLE(1069)) };
|
||||
}, [photos]);
|
||||
|
||||
// The theme popover closes on a click anywhere else, like the workspace's.
|
||||
@@ -708,15 +721,7 @@ export function Landing() {
|
||||
</div>
|
||||
<div className="lp-rc-row">
|
||||
<div className="lp-rc-qr">
|
||||
{qrOk ? (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`${window.location.origin}/app`)}`}
|
||||
alt="QR"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setQrOk(false)}
|
||||
/>
|
||||
) : null}
|
||||
<QrCode url={slotPhoto.qrLink} />
|
||||
</div>
|
||||
<div className="lp-rc-meta">
|
||||
<b>SUNSET GLOW</b>
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface Photo {
|
||||
// The uploader's consent to show this frame on the landing film strip. Own
|
||||
// rows carry it back; the public reel only returns consented ones anyway.
|
||||
consent?: boolean;
|
||||
// Whether the row still carries the look that made it. That look is the QR
|
||||
// card's payload, so the admin needs to know a photo can hand one out without
|
||||
// the listing shipping the recipe itself.
|
||||
hasPreset: boolean;
|
||||
// The look the photo was saved with, on the owner's own listing only — the
|
||||
// studio reads it back so a saved photo can be reopened for editing.
|
||||
recipe?: Recipe | null;
|
||||
@@ -215,6 +219,9 @@ export const api = {
|
||||
// The owner's own delete; an admin may pass any id here too.
|
||||
deletePhoto: (id: number) => call<void>(`/photos/${id}`, { method: 'DELETE' }),
|
||||
photoUrl: (id: number) => `/api/photos/${id}/file`,
|
||||
// The look a photo was uploaded with, as the `.recipe` file the app imports on
|
||||
// a scan. Only answers for a photo the curator ticked into the `qr` section.
|
||||
presetUrl: (id: number) => `/api/photos/${id}/preset.recipe`,
|
||||
|
||||
// Admin only: every account, with how many photos it owns.
|
||||
adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'),
|
||||
|
||||
@@ -168,8 +168,10 @@ export const en: Dict = {
|
||||
'adm.pickStrip': 'Film strip',
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
'adm.pickQr': 'QR card',
|
||||
'adm.noPreset': 'no look to hand out',
|
||||
'adm.rowEmpty': 'Nothing here yet.',
|
||||
'adm.slotHint': 'Tick the sections a photo should appear in — a photo can sit in several at once, and each section draws one of its own at random per visit. Unticking every box takes the photo off the landing page without deleting it — its owner keeps it in MY PHOTOS.',
|
||||
'adm.slotHint': 'Tick the sections a photo should appear in — a photo can sit in several at once, and each section draws one of its own at random per visit. Unticking every box takes the photo off the landing page without deleting it — its owner keeps it in MY PHOTOS. The QR section is the one that hands the look out: its card shows a code that downloads the photo’s own .recipe file for the phone app to scan in.',
|
||||
'adm.signIn': 'Sign in with an admin account',
|
||||
'adm.notAdmin': 'This account has no admin rights.',
|
||||
'adm.empty': 'No contributions yet.',
|
||||
|
||||
@@ -172,8 +172,10 @@ export const vi = {
|
||||
'adm.pickStrip': 'Dải phim',
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
'adm.pickQr': 'Thẻ QR',
|
||||
'adm.noPreset': 'không có look để chia sẻ',
|
||||
'adm.rowEmpty': 'Chưa có gì ở đây.',
|
||||
'adm.slotHint': 'Tích vào những mục ảnh sẽ xuất hiện — một ảnh có thể nằm cùng lúc ở nhiều mục, và mỗi mục bốc ngẫu nhiên một ảnh trong nhóm của nó mỗi lần ghé. Bỏ tích hết sẽ gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS.',
|
||||
'adm.slotHint': 'Tích vào những mục ảnh sẽ xuất hiện — một ảnh có thể nằm cùng lúc ở nhiều mục, và mỗi mục bốc ngẫu nhiên một ảnh trong nhóm của nó mỗi lần ghé. Bỏ tích hết sẽ gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS. Mục QR là mục duy nhất phát công thức: thẻ của nó vẽ một mã tải chính tệp .recipe của ảnh để app trên điện thoại quét vào.',
|
||||
'adm.signIn': 'Đăng nhập bằng tài khoản quản trị',
|
||||
'adm.notAdmin': 'Tài khoản này không có quyền quản trị.',
|
||||
'adm.empty': 'Chưa có ảnh đóng góp nào.',
|
||||
|
||||
@@ -440,6 +440,11 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.adm-thumb { margin: 0; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-sunken); }
|
||||
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.adm-thumb figcaption { padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; color: var(--text-dim); }
|
||||
/* The QR section's real artefact is the code, not the photo. Drawn on white:
|
||||
a scanner needs the quiet zone, and the dark theme has none. */
|
||||
.adm-qr { display: flex; align-items: center; justify-content: center; min-height: 88px; padding: 6px; background: #fff; }
|
||||
.adm-qr img { width: auto; aspect-ratio: 1 / 1; }
|
||||
.adm-qr span { color: #6b7280; font-size: 10.5px; text-align: center; }
|
||||
.adm-spacer { flex: 1 1 auto; }
|
||||
.adm-note { margin: 12px 0; }
|
||||
.adm-sub { margin: 0 0 14px; }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
// A QR code, drawn by the one public image service the landing already used:
|
||||
// no QR library and no extra bytes in the bundle. `url` is the whole payload —
|
||||
// whatever the phone reads on a scan is exactly this string.
|
||||
// ponytail: a remote image means a third-party request and a blank frame while
|
||||
// it loads. Render the matrix in-bundle only if the QR must work offline.
|
||||
export function QrCode({ url, size = 180, className }: { url: string; size?: number; className?: string }) {
|
||||
const [ok, setOk] = useState(true);
|
||||
if (!ok) return null;
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setOk(false)}
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(url)}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user