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:
@@ -18,6 +18,53 @@ type Txt = { en: string; vi: string };
|
|||||||
|
|
||||||
const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`;
|
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 {
|
interface Look {
|
||||||
tag: string;
|
tag: string;
|
||||||
title: Txt;
|
title: Txt;
|
||||||
@@ -517,7 +564,7 @@ export function Landing() {
|
|||||||
<i /><i /><i /><i /><i /><i />
|
<i /><i /><i /><i /><i /><i />
|
||||||
</div>
|
</div>
|
||||||
<div className="lp-shot">
|
<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-grain" aria-hidden="true" />
|
||||||
<span className="lp-tagline">{look.tag}</span>
|
<span className="lp-tagline">{look.tag}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +598,7 @@ export function Landing() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="lp-view">
|
<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">
|
<div className="lp-hud">
|
||||||
<span>f/<b>1.8</b></span>
|
<span>f/<b>1.8</b></span>
|
||||||
<span>SS <b>1/500s</b></span>
|
<span>SS <b>1/500s</b></span>
|
||||||
@@ -588,7 +635,11 @@ export function Landing() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="lp-lab">
|
<div className="lp-lab">
|
||||||
<div className="lp-view">
|
<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 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>
|
||||||
<div className="lp-knobs">
|
<div className="lp-knobs">
|
||||||
@@ -647,7 +698,11 @@ export function Landing() {
|
|||||||
<div className="lp-qr">
|
<div className="lp-qr">
|
||||||
<div className="lp-recipe-card">
|
<div className="lp-recipe-card">
|
||||||
<div className="lp-shot">
|
<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-grain" aria-hidden="true" />
|
||||||
<span className="lp-tagline">#SUNSET_GLOW</span>
|
<span className="lp-tagline">#SUNSET_GLOW</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { 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; }
|
.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 {
|
.lp-grain {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user