From b9ac7746aab6b60dba51c49aa3377e0f76f6d423 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 22 Sep 2026 21:33:11 +0700 Subject: [PATCH] web: gate the newest film sims and the HSL mixer behind PRO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last three PHOTO STYLE looks (B&W HIGH CONTRAST, LC STREETLIFE CLASSIC and LC STREETLIFE VIVID) and the whole mixer now belong to the account, the way PRO frames and the geotag already do: the chip wears the PRO badge, a guest who picks it is shown the way in, and the look stays off. The HSL tab keeps its place in the rail but offers the one PRO chip while locked, so the tab itself is not a dead end; a look that arrives without the chips — an imported .recipe, or a photo saved before the gate — is still caught where the gate bites, at export. STRAIGHTEN's scale turns with the wheel, one degree a notch, because the ruler is where the angle is being judged and reaching for a slider elsewhere loses the thread. The listener is native and stops the notch before the stage sees it, so the photo does not zoom under the pointer. The scale gives up its opaque card, its blur and its shadow: the frame it is levelling has to stay readable through it, so legibility comes from a text shadow on the heading and a drop shadow on the graduations instead. --- docker/frontend/src/App.tsx | 43 +++++++++++++++++++--- docker/frontend/src/i18n/en.ts | 1 + docker/frontend/src/i18n/vi.ts | 1 + docker/frontend/src/styles/app.css | 8 ++-- docker/frontend/src/ui/StraightenRuler.tsx | 32 ++++++++++++++-- 5 files changed, 70 insertions(+), 15 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 932b6be..49e682c 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -99,6 +99,10 @@ const BASE_RECIPE: Recipe = { // gate owns lives where it is used: the GPS stamp in wmChips, the HDF effect in // paramDefs (its own `pro` flag), and the export tier in TIERS. const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe', 'wallframe-landscape']; +// The three newest looks on the PHOTO STYLE strip are the account's own: the +// pushed monochrome and the Leica rangefinder pair, which are the sims the +// studio sells as the reason to prove the address. +const PRO_SIMS: string[] = ['sim-bw-high-contrast', 'sim-leica', 'sim-leica-vivid']; // FREE draws the frame on the canvas and lets the user drag its corners, so // every ratio is offered here — no ratio is applied until APPLY. @@ -1167,9 +1171,25 @@ export function Workspace() { const proLookInUse = useCallback( () => PRO_FRAMES.includes(frameId) || + PRO_SIMS.includes(simId ?? '') || useGeotag || - (recipe.adjustments.hdf ?? 0) > 0, - [frameId, useGeotag, recipe.adjustments.hdf] + (recipe.adjustments.hdf ?? 0) > 0 || + // The mixer, on either of its two axes: a band's HUE/SAT/LUM, or the + // image-wide three that sit under the IMAGE divider. + Object.keys(recipe.adjustments.hslBands ?? {}).length > 0 || + (recipe.adjustments.hslHue ?? 0) !== 0 || + (recipe.adjustments.hslSat ?? 0) !== 0 || + (recipe.adjustments.hslLum ?? 0) !== 0, + [ + frameId, + simId, + useGeotag, + recipe.adjustments.hdf, + recipe.adjustments.hslBands, + recipe.adjustments.hslHue, + recipe.adjustments.hslSat, + recipe.adjustments.hslLum, + ] ); // `true` when a file was handed over, so CLEAR knows the export it offered @@ -1705,7 +1725,7 @@ export function Workspace() { label: string; off: string; value: string; - options: { v: string; d: string }[]; + options: { v: string; d: string; pro?: boolean }[]; onPick: (v: string) => void; }> = { cx: { @@ -1760,10 +1780,16 @@ export function Workspace() { style: { label: 'PHOTO STYLE', off: 'sim-provia', value: simId && FILM_SIMS.some((s) => s.id === simId) ? simId : '', - options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name })), + options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name, pro: PRO_SIMS.includes(s.id) })), onPick: (v) => { const sim = FILM_SIMS.find((s) => s.id === v); if (!sim) return; + // The newest three looks are the account's; the strip shows the badge + // and the click says why (see the PRO gate at the top of the file). + if (PRO_SIMS.includes(sim.id) && !pro) { + promptPro(); + return; + } setShowRecipes(false); applyRecipe(filmSimToRecipe(sim)); }, @@ -1840,7 +1866,7 @@ export function Workspace() { const choiceChips = ( prefix: string, - options: { v: string; d: string }[], + options: { v: string; d: string; pro?: boolean }[], current: string, onPick: (v: string) => void ): ChipDef[] => @@ -1848,6 +1874,7 @@ export function Workspace() { key: `${prefix}:${o.v}`, label: o.d, active: current === o.v, + pro: o.pro, onClick: () => onPick(o.v), })); @@ -2068,6 +2095,10 @@ export function Workspace() { ...paramChips(PARAM_DEFS.filters), ]; case 'hsl': { + // The mixer is PRO: the picker, the eight bands and the three + // image-wide knobs all come with the account, so a guest is offered the + // reason rather than a wall of dead chips. + if (!pro) return [{ key: 'hsl-pro', label: t('pro.hslChip'), pro: true, onClick: promptPro }]; // PICK arms the eyedropper; the eight band chips choose which band the // ruler edits, and the ruler itself is opened by the three knobs below. // Those three are the image's own move, not the band's: the band chips @@ -2321,7 +2352,7 @@ export function Workspace() { {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} {/* The mixer's readout: the colour the eyedropper last read, as the RGB bytes and the HSL the band is indexed by. */} - {tab === 'hsl' ? ( + {tab === 'hsl' && pro ? (
{sample && picked ? ( <> diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 4ae6e82..13d2a97 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -128,6 +128,7 @@ export const en: Dict = { // The PRO gate. Signed in but unproven is served as a guest, so the studio // needs one place that says why and one way to ask for the letter again. 'pro.verifyChip': 'VERIFY EMAIL TO USE PRO', + 'pro.hslChip': 'HSL IS A PRO FEATURE', 'pro.unverifiedBar': 'Account {email} is not verified — it works like a guest.', 'pro.resend': 'RESEND VERIFICATION EMAIL', 'pro.resent': 'SENT', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index f458e95..fd604b1 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -136,6 +136,7 @@ export const vi = { // The PRO gate. Signed in but unproven is served as a guest, so the studio // needs one place that says why and one way to ask for the letter again. 'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO', + 'pro.hslChip': 'HSL LÀ TÍNH NĂNG PRO', 'pro.unverifiedBar': 'Tài khoản {email} chưa xác thực email — đang dùng như khách.', 'pro.resend': 'GỬI LẠI EMAIL XÁC THỰC', 'pro.resent': 'ĐÃ GỬI', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 64f1fba..d6ce8ba 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -394,16 +394,14 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } pointer-events: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--bg-elev) 90%, transparent); - backdrop-filter: blur(6px); - box-shadow: var(--shadow); + background: transparent; user-select: none; touch-action: none; } -.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); } +.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); text-shadow: 0 1px 2px var(--bg); } .straighten-head b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); font-variant-numeric: tabular-nums; } .straighten-head b.amber { color: var(--accent); } -.straighten-scale { position: relative; height: 30px; cursor: ew-resize; } +.straighten-scale { position: relative; height: 30px; cursor: ew-resize; filter: drop-shadow(0 0 2px var(--bg)); } .straighten-tick { position: absolute; bottom: 12px; width: 1px; height: 7px; background: var(--border); transform: translateX(-50%); } .straighten-tick.major { height: 12px; background: var(--text-dim); } .straighten-tick.zero { height: 16px; background: var(--text); } diff --git a/docker/frontend/src/ui/StraightenRuler.tsx b/docker/frontend/src/ui/StraightenRuler.tsx index 65d6793..5bde40c 100644 --- a/docker/frontend/src/ui/StraightenRuler.tsx +++ b/docker/frontend/src/ui/StraightenRuler.tsx @@ -1,10 +1,14 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; // FRAME > ROTATE > STRAIGHTEN's ruler is not one of the chip row's stacked // knobs: it is a graduated scale lying across the foot of the photo, so the // angle is read off the picture it is levelling. Dragging anywhere on the scale -// turns the photo by whole degrees; leaving the tool (another tab or button) -// keeps the angle and takes the scale away — see App's dismiss effect. +// turns the photo by whole degrees, and the wheel does the same one degree a +// notch while the pointer is on the ruler — the photo underneath stays visible +// through it (see `.straighten-ruler` in styles/app.css), because the whole +// point of the scale is to be read against the frame it is levelling. Leaving +// the tool (another tab or button) keeps the angle and takes the scale away — +// see App's dismiss effect. const RANGE = 180; // degrees either side of 0, the whole circle across the width const TICK = 5; // minor tick spacing const MAJOR = 45; // labelled tick spacing @@ -19,7 +23,27 @@ export function StraightenRuler({ onReset: () => void; }) { const scaleRef = useRef(null); + const rootRef = useRef(null); const dragging = useRef(false); + const live = useRef({ value, onChange }); + live.current = { value, onChange }; + + // The wheel belongs to the ruler while the pointer is on it: one notch is one + // degree. The listener is native because React's is passive, and the notch + // must not reach the stage — whose own wheel zooms the photo under it. + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + const s = live.current; + const next = Math.max(-RANGE, Math.min(RANGE, s.value + (e.deltaY < 0 ? 1 : -1))); + if (next !== s.value) s.onChange(next); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); // A pixel of travel is well under a degree at any sane width, so the reading // snaps to whole degrees however far the pointer has been dragged. @@ -35,7 +59,7 @@ export function StraightenRuler({ const pctOf = (d: number) => ((d + RANGE) / (2 * RANGE)) * 100; return ( -
e.stopPropagation()}> +
e.stopPropagation()}>
STRAIGHTEN