web: the landing page hands out no more pixels than it draws

A save cannot be beaten — the bytes are already on the machine — so the page
stops handing over the uploader's 4000px original: each photo is decoded,
redrawn at the size of the box it sits in times the screen's pixel ratio (2x at
most) and only that smaller copy reaches the tag. A visitor who saves one gets
a screen-sized file.

Right-click, drag and long-press are turned off on top of it, and the QR code
card — a link image, not a contribution — is left as it was.
This commit is contained in:
2026-09-18 16:47:15 +07:00
parent 260517c547
commit a35ecf4f1c
2 changed files with 62 additions and 4 deletions
+59 -4
View File
@@ -18,6 +18,53 @@ type Txt = { en: string; vi: string };
const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`;
// No page can really stop a save — the bytes are already on the machine. What it
// can do is refuse to hand over more pixels than it draws: the photo is decoded,
// redrawn at the size of its box times the screen's pixel ratio (2x at most) and
// that smaller bitmap is what the tag carries, so a saved copy is a screen-sized
// one and not the uploader's 4000px original. The save gestures are turned off
// on top of it. `data-full` keeps the original address for tests and debugging.
function Shot({ src, alt, filter }: { src: string; alt: string; filter?: string }) {
const tag = useRef<HTMLImageElement>(null);
const [shown, setShown] = useState(src);
useEffect(() => {
setShown(src);
let alive = true;
const source = new Image();
source.onload = () => {
const box = tag.current?.clientWidth ?? 0;
const scale = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
const width = Math.min(source.naturalWidth, Math.round(box * scale));
if (!alive || box === 0 || width >= source.naturalWidth) return;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = Math.max(1, Math.round((source.naturalHeight * width) / source.naturalWidth));
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
setShown(canvas.toDataURL('image/jpeg', 0.85));
};
source.src = src;
return () => {
alive = false;
};
}, [src]);
return (
<img
ref={tag}
className="lp-photo"
src={shown}
data-full={src}
alt={alt}
loading="lazy"
style={filter ? { filter } : undefined}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
/>
);
}
interface Look {
tag: string;
title: Txt;
@@ -517,7 +564,7 @@ export function Landing() {
<i /><i /><i /><i /><i /><i />
</div>
<div className="lp-shot">
<img src={look.photo} alt={c(look.title)} loading="lazy" style={{ filter: look.filter }} />
<Shot src={look.photo} alt={c(look.title)} filter={look.filter} />
<span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">{look.tag}</span>
</div>
@@ -551,7 +598,7 @@ export function Landing() {
))}
</div>
<div className="lp-view">
<img src={slotPhoto.tester} alt={`${preset.name} preview`} style={{ filter: preset.filter }} />
<Shot src={slotPhoto.tester} alt={`${preset.name} preview`} filter={preset.filter} />
<div className="lp-hud">
<span>f/<b>1.8</b></span>
<span>SS <b>1/500s</b></span>
@@ -588,7 +635,11 @@ export function Landing() {
</div>
<div className="lp-lab">
<div className="lp-view">
<img src={slotPhoto.creator} alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })} style={{ filter: labFilter }} />
<Shot
src={slotPhoto.creator}
alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })}
filter={labFilter}
/>
<div className="lp-hud"><span>{c({ en: 'CUSTOM RECIPE', vi: 'CÔNG THỨC RIÊNG' })}</span><span>TEMP <b>{temp > 0 ? `+${temp}` : temp}</b></span><span>SAT <b>{sat}%</b></span><span>CON <b>{con}%</b></span></div>
</div>
<div className="lp-knobs">
@@ -647,7 +698,11 @@ export function Landing() {
<div className="lp-qr">
<div className="lp-recipe-card">
<div className="lp-shot">
<img src={slotPhoto.qr} alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })} style={{ filter: PRESETS[2].filter }} />
<Shot
src={slotPhoto.qr}
alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })}
filter={PRESETS[2].filter}
/>
<span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">#SUNSET_GLOW</span>
</div>
+3
View File
@@ -293,6 +293,9 @@ html { scroll-behavior: smooth; scroll-padding-top: 116px; }
}
.lp-shot { position: relative; aspect-ratio: 4 / 5; border-radius: 5px; overflow: hidden; background: #000; }
.lp-shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Contributed photos, whatever the section: the page draws them but does not
invite a save — no drag image, no long-press callout, no text selection. */
.lp-photo { -webkit-user-drag: none; user-select: none; -webkit-touch-callout: none; }
.lp-grain {
position: absolute;
inset: 0;