web: accent the brand and add the studio's quick gestures
- the brand "Cam", the avatar and the signed-in name follow the theme accent - double-click a slider track resets that parameter; double-click the photo toggles 1:1 and the whole photo on the stage - `*`, or a recipe chip dragged onto the star, files the look under FAVORITED - RESET under CREATE clears the draft form, and the button now sits under a rule at the foot of the column
This commit is contained in:
@@ -292,6 +292,9 @@ export function Workspace() {
|
||||
const [savingPhoto, setSavingPhoto] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [saved, setSaved] = useState<SavedRecipe[]>([]);
|
||||
// Bumped by RESET while CREATE is open: the form above the button listens for
|
||||
// it and drops back to its defaults.
|
||||
const [createReset, setCreateReset] = useState(0);
|
||||
// This account's own folder. Only the count matters here; /photos owns the
|
||||
// management screen. The API is the real cap — this only labels the button.
|
||||
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
|
||||
@@ -579,6 +582,48 @@ export function Workspace() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recipe, user, t]);
|
||||
|
||||
// FAVORITED holds the account's own recipes. `*`, or dragging a recipe chip
|
||||
// onto the star in the rail, files the look on the stage there; a guest is
|
||||
// asked to sign in first, like every other write in the studio.
|
||||
const favoriteRecipe = useCallback(
|
||||
async (entry: Recipe) => {
|
||||
if (!user) {
|
||||
setAuthMode('login');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = await api.createRecipe(entry.name, { ...entry });
|
||||
setSaved((list) => [stored.recipe, ...list]);
|
||||
setTab('favorited');
|
||||
} catch (err) {
|
||||
setError(t('err.generic', { msg: String(err) }));
|
||||
}
|
||||
},
|
||||
[user, t]
|
||||
);
|
||||
|
||||
// The star reads the id the dragged chip carried back to its recipe.
|
||||
const favoriteById = useCallback(
|
||||
(id: string) => {
|
||||
const entry = [...created, ...DEFAULT_RECIPES].find((r) => r.id === id);
|
||||
if (entry) void favoriteRecipe(entry);
|
||||
},
|
||||
[created, favoriteRecipe]
|
||||
);
|
||||
|
||||
// `*` files the look on the stage without leaving the tab it was built in.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== '*' || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return;
|
||||
e.preventDefault();
|
||||
void favoriteRecipe(recipe);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [favoriteRecipe, recipe]);
|
||||
|
||||
// CREATE tab: a new recipe is applied at once and listed under RECIPES. A
|
||||
// signed-in account also stores it; a guest's copy stays in `created`, which
|
||||
// is exactly what "forgotten when you leave" means.
|
||||
@@ -847,6 +892,7 @@ export function Workspace() {
|
||||
edited: value !== def.defaultValue,
|
||||
swatch: SLIDER_SWATCH[def.key]?.(value),
|
||||
onChange: (value) => setAdjustmentOnce(def.set(value)),
|
||||
onReset: () => setAdjustmentOnce(def.set(def.defaultValue)),
|
||||
// Back reopens the strip the row was launched from (COLOR TEMP -> TEMP).
|
||||
onBack: () => {
|
||||
const g = PARAM_GROUP[def.key];
|
||||
@@ -1053,6 +1099,12 @@ export function Workspace() {
|
||||
setOpenParam(null);
|
||||
setOpenGroup(null);
|
||||
setShowRecipes(false);
|
||||
// Under CREATE the button belongs to the form above it: it clears the
|
||||
// draft, which live-previews its defaults straight onto the photo.
|
||||
if (tab === 'create') {
|
||||
setCreateReset((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
},
|
||||
});
|
||||
@@ -1167,12 +1219,14 @@ export function Workspace() {
|
||||
key: entry.id,
|
||||
label: entry.name,
|
||||
active: recipe.id === entry.id,
|
||||
drag: entry.id,
|
||||
onClick: () => applyRecipe(entry),
|
||||
})),
|
||||
...DEFAULT_RECIPES.map((preset) => ({
|
||||
key: preset.id,
|
||||
label: preset.name,
|
||||
active: recipe.name === preset.name && !recipe.isCustom,
|
||||
drag: preset.id,
|
||||
onClick: () => applyRecipe({ ...preset }),
|
||||
})),
|
||||
];
|
||||
@@ -1208,18 +1262,18 @@ export function Workspace() {
|
||||
/>
|
||||
|
||||
<div className="workspace">
|
||||
<ToolRail tab={tab} onTab={setTab} />
|
||||
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} />
|
||||
|
||||
<div className="chips">
|
||||
{/* column 1 — the tab's own chips, RESET pinned at its head */}
|
||||
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
|
||||
<div className={`col col-main${tab === 'create' ? ' col-create' : ''}`} data-col="main">
|
||||
<ChipRow chips={[resetChip()]} />
|
||||
{tab === 'create' ? (
|
||||
<>
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
resetToken={createReset}
|
||||
onPreview={previewCreatedRecipe}
|
||||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||||
onExport={exportRecipeFile}
|
||||
@@ -1240,6 +1294,11 @@ export function Workspace() {
|
||||
<ChipRow chips={chips} />
|
||||
)}
|
||||
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
{/* RESET is the way out of an edit, not one more chip: it sits under
|
||||
a rule at the foot of the column so it reads as its own thing. */}
|
||||
<div className="reset-row">
|
||||
<ChipRow chips={[resetChip()]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
.brand { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; letter-spacing: 0.04em; color: var(--text); text-decoration: none; }
|
||||
.brand img { border-radius: 8px; }
|
||||
.brand em { font-style: normal; font-weight: 400; color: var(--text-dim); }
|
||||
.brand em { font-style: normal; font-weight: 400; color: var(--accent); }
|
||||
/* The page name, top-left beside the mark — the same pair /admin and /profile
|
||||
carry, so every screen says where the visitor is. */
|
||||
.page-name { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); }
|
||||
@@ -96,6 +96,9 @@
|
||||
/* CREATE RECIPES draws a two-up form, so its column is the wide one. */
|
||||
.col-create { width: 300px; }
|
||||
.col-sub { background: var(--bg-elev); }
|
||||
/* RESET is the way out of an edit, not one more chip: a rule and some air keep
|
||||
it apart from the tab's own buttons at the foot of the column. */
|
||||
.reset-row { margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
/* In a column the chips stack: one chip per row, full width. */
|
||||
.col .chip-row { flex-direction: column; align-items: stretch; }
|
||||
@@ -330,7 +333,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
/* The mark and the page name, top-left, always a way back to the landing page —
|
||||
the same pair the studio's TopBar carries. */
|
||||
.adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; }
|
||||
.adm-brand em { font-style: normal; font-weight: 400; color: var(--text-dim); }
|
||||
.adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); }
|
||||
.adm-brand img { border-radius: 8px; }
|
||||
.adm-slot { display: flex; flex-direction: column; gap: 3px; letter-spacing: 0.04em; }
|
||||
.adm-slot select { width: 100%; }
|
||||
@@ -439,9 +442,9 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-line);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
@@ -451,6 +454,8 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.avatar-row { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar-row .avatar { width: 46px; height: 46px; font-size: 17px; }
|
||||
.account-btn { display: inline-flex; align-items: center; gap: 7px; }
|
||||
/* The signed-in name rides the accent with the face beside it. */
|
||||
.btn.account-btn { color: var(--accent); }
|
||||
|
||||
/* --- responsive --------------------------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface ChipDef {
|
||||
amberValue?: boolean;
|
||||
pro?: boolean;
|
||||
disabled?: boolean;
|
||||
// Present when the chip itself can be dragged somewhere: the payload the drop
|
||||
// target reads back (the recipe's id, for the FAVORITED rail button).
|
||||
drag?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -31,6 +34,8 @@ export interface SliderDef {
|
||||
// swatch under the ruler so the hue moves with the number. Absent = no swatch.
|
||||
swatch?: string;
|
||||
onChange: (value: number) => void;
|
||||
// Double-clicking the track puts the parameter back to its default.
|
||||
onReset: () => void;
|
||||
// "<" — closes the ruler and reopens the strip it was launched from.
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -46,6 +51,15 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) {
|
||||
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}`}
|
||||
aria-pressed={!!chip.active}
|
||||
disabled={chip.disabled}
|
||||
draggable={!!chip.drag}
|
||||
onDragStart={
|
||||
chip.drag
|
||||
? (e) => {
|
||||
e.dataTransfer.setData('text/plain', chip.drag!);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={chip.onClick}
|
||||
>
|
||||
{chip.label}
|
||||
@@ -92,6 +106,7 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
|
||||
step={slider.step}
|
||||
value={slider.value}
|
||||
onChange={(e) => slider.onChange(Number(e.target.value))}
|
||||
onDoubleClick={slider.onReset}
|
||||
/>
|
||||
{slider.swatch ? (
|
||||
<div
|
||||
|
||||
@@ -393,6 +393,24 @@ export function ImageStage({
|
||||
onPointerMove={movePan}
|
||||
onPointerUp={endPan}
|
||||
onPointerCancel={endPan}
|
||||
// Double click toggles the two sizes worth having: 1:1 (one photo
|
||||
// pixel per CSS pixel) and the whole photo back on the stage. The
|
||||
// crop editor owns the gesture instead — its wheel tightens the
|
||||
// frame, so the view stays put.
|
||||
onDoubleClick={() => {
|
||||
if (cropEditing) return;
|
||||
const v = viewRef.current;
|
||||
if (v.s !== 1) {
|
||||
setView({ s: 1, x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
const b = baseRect();
|
||||
const nw = imgRef.current?.naturalWidth ?? 0;
|
||||
if (!b || !(nw > 0)) return;
|
||||
const s = Math.min(ZOOM_MAX, Math.max(1, nw / b.w));
|
||||
if (s === 1) return;
|
||||
setView({ s, ...clampPan(s, 0, 0) });
|
||||
}}
|
||||
style={
|
||||
view.s === 1
|
||||
? undefined
|
||||
|
||||
@@ -58,10 +58,14 @@ export function RecipeCreatePanel({
|
||||
onPreview,
|
||||
onSave,
|
||||
onExport,
|
||||
resetToken = 0,
|
||||
}: {
|
||||
currentBase?: BaseFilter;
|
||||
current?: ColorAdjustments | null;
|
||||
signedIn: boolean;
|
||||
// Bumped by the RESET chip above the form: the draft drops back to the state
|
||||
// the form opened with.
|
||||
resetToken?: number;
|
||||
onPreview: (draft: Pick<Recipe, 'baseFilter' | 'adjustments'>) => void;
|
||||
onSave: (draft: RecipeDraft) => void;
|
||||
onExport: (draft: RecipeDraft) => void;
|
||||
@@ -155,6 +159,17 @@ export function RecipeCreatePanel({
|
||||
return { name: name.trim() || 'NEW RECIPE', baseFilter: sim, adjustments, frameId: 'none', useGeotag: true };
|
||||
};
|
||||
|
||||
// RESET clears the draft: back to the seed simulation, its standard values and
|
||||
// an empty name. The form previews live, so the photo follows it home.
|
||||
useEffect(() => {
|
||||
if (resetToken === 0) return;
|
||||
selectSim(seedBase);
|
||||
setName('');
|
||||
setWbRed(String(seedAdj.wbRed ?? 0));
|
||||
setWbBlue(String(seedAdj.wbBlue ?? 0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetToken]);
|
||||
|
||||
// Realtime: every knob applies to the photo on screen as it is typed, so the
|
||||
// effect of the recipe is visible before saving it. Opening the tab is not an
|
||||
// edit — the first run (the seeded values) is skipped.
|
||||
|
||||
@@ -17,8 +17,18 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'create', label: 'tab.create', glyph: '✎' },
|
||||
];
|
||||
|
||||
export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) {
|
||||
export function ToolRail({
|
||||
tab,
|
||||
onTab,
|
||||
onDropRecipe,
|
||||
}: {
|
||||
tab: TabId;
|
||||
onTab: (tab: TabId) => void;
|
||||
// A recipe chip dragged onto FAVORITED is filed there (see App's favorite()).
|
||||
onDropRecipe?: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const favorited = (id: TabId) => id === 'favorited';
|
||||
return (
|
||||
<nav className="rail" aria-label="tool tabs">
|
||||
{TABS.map((entry) => (
|
||||
@@ -27,6 +37,19 @@ export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => vo
|
||||
type="button"
|
||||
aria-pressed={tab === entry.id}
|
||||
onClick={() => onTab(entry.id)}
|
||||
// The star is a drop target: recipes are dragged onto it from the
|
||||
// RECIPES strip rather than retyped.
|
||||
onDragOver={(e) => {
|
||||
if (!favorited(entry.id) || !onDropRecipe) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!favorited(entry.id) || !onDropRecipe) return;
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData('text/plain');
|
||||
if (id) onDropRecipe(id);
|
||||
}}
|
||||
>
|
||||
<span className="glyph" aria-hidden="true">{entry.glyph}</span>
|
||||
{t(entry.label)}
|
||||
|
||||
Reference in New Issue
Block a user