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`;
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user