web: gate the newest film sims and the HSL mixer behind PRO
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.
This commit is contained in:
@@ -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 ? <p className="hint">{t('sec.savedEmpty')}</p> : 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 ? (
|
||||
<div className="hsl-readout" data-key="hsl-readout">
|
||||
{sample && picked ? (
|
||||
<>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="straighten-ruler" data-key="straighten-ruler" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div ref={rootRef} className="straighten-ruler" data-key="straighten-ruler" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="straighten-head">
|
||||
<span className="straighten-label">STRAIGHTEN</span>
|
||||
<b className={value !== 0 ? 'amber' : ''} data-key="straighten-value">
|
||||
|
||||
Reference in New Issue
Block a user