Add self-contained docker/ stack for the web UI
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ToolRail, type TabId } from './ui/ToolRail';
|
||||
import { ChipRow, Section, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
|
||||
import { ImageStage } from './ui/ImageStage';
|
||||
import { TopBar } from './ui/TopBar';
|
||||
import { AuthModal } from './ui/AuthModal';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { loadEngine } from './engine/assets';
|
||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
import { download, fileToBytes, readGps, resizedJpeg } from './engine/imageOps';
|
||||
import { api, type SavedRecipe, type User } from './api';
|
||||
import { TIERS } from './config/tiers';
|
||||
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
|
||||
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
|
||||
import { writeJpegExif } from '../shared/utils/exifWrite';
|
||||
import { CROP_W_H, type CropRatio, type FrameId, type GPSInfo, type Recipe } from '../shared/types';
|
||||
import type { MsgKey } from './i18n/vi';
|
||||
|
||||
// Preview renders on a downscaled copy; export always re-renders the original
|
||||
// bytes, so nothing shown here is baked into the file.
|
||||
const PREVIEW_MAX = 1600;
|
||||
const PREVIEW_DEBOUNCE_MS = 120;
|
||||
|
||||
const SECTION_TITLE: Record<TabId, MsgKey> = {
|
||||
presets: 'sec.filmSims',
|
||||
favorited: 'sec.saved',
|
||||
light: 'tab.light',
|
||||
wb: 'tab.wb',
|
||||
fx: 'tab.fx',
|
||||
frame: 'sec.frame',
|
||||
};
|
||||
|
||||
const FRAMES: { id: FrameId; label: MsgKey }[] = [
|
||||
{ id: 'none', label: 'frame.none' },
|
||||
{ id: 'polaroid', label: 'frame.polaroid' },
|
||||
{ id: 'classic-white', label: 'frame.classic' },
|
||||
{ id: 'cinematic', label: 'frame.cinematic' },
|
||||
{ id: 'wallframe', label: 'frame.wall' },
|
||||
];
|
||||
|
||||
// ponytail: nothing here is a rectangle the user drags yet, so only the fixed
|
||||
// ratios are listed — 'free' (DEFAULT_CROP_RECT + drag on the canvas) lands
|
||||
// with the crop overlay in W3. The type already carries it.
|
||||
const CROPS: Exclude<CropRatio, 'free'>[] = ['none', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9'];
|
||||
const ROTATIONS = [0, 90, 180, 270] as const;
|
||||
const TEMP_PRESETS: { label: string; kelvin: number }[] = [
|
||||
{ label: 'AUTO', kelvin: 5500 },
|
||||
{ label: 'DAYLIGHT', kelvin: 5600 },
|
||||
{ label: 'DAYLIGHT -3R', kelvin: 5800 },
|
||||
{ label: 'CLOUDY', kelvin: 6500 },
|
||||
{ label: 'SHADE', kelvin: 7500 },
|
||||
{ label: 'TUNGSTEN', kelvin: 3200 },
|
||||
{ label: 'FLUOR', kelvin: 4000 },
|
||||
];
|
||||
|
||||
interface LoadedSource {
|
||||
bytes: Uint8Array;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function Workspace() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [engine, setEngine] = useState<{ assets: RenderAssets; gpu: boolean } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const [source, setSource] = useState<LoadedSource | null>(null);
|
||||
const [previewBytes, setPreviewBytes] = useState<Uint8Array | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [rendered, setRendered] = useState<{ w: number; h: number; ms: number } | null>(null);
|
||||
|
||||
const [recipe, setRecipe] = useState<Recipe>(() => ({
|
||||
...DEFAULT_RECIPES[0],
|
||||
id: 'web-current',
|
||||
name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA',
|
||||
}));
|
||||
const [simId, setSimId] = useState<string | null>(null);
|
||||
const [frameId, setFrameId] = useState<FrameId>('none');
|
||||
const [crop, setCrop] = useState<Exclude<CropRatio, 'free'>>('none');
|
||||
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0);
|
||||
const [openParam, setOpenParam] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<TabId>('presets');
|
||||
const [peek, setPeek] = useState(false);
|
||||
|
||||
const [gps, setGps] = useState<GPSInfo | null>(null);
|
||||
const [gpsManual, setGpsManual] = useState({ lat: '', lng: '' });
|
||||
const [place, setPlace] = useState('');
|
||||
const [useGeotag, setUseGeotag] = useState(false);
|
||||
const [markText, setMarkText] = useState('');
|
||||
const [markOn, setMarkOn] = useState(false);
|
||||
|
||||
const [undo, setUndo] = useState<Recipe[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [saved, setSaved] = useState<SavedRecipe[]>([]);
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
|
||||
const renderToken = useRef(0);
|
||||
|
||||
// --- boot -----------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadEngine()
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
setEngine(loaded);
|
||||
if (!loaded.gpu) setError(t('err.noWebgl'));
|
||||
})
|
||||
.catch((err) => alive && setError(String(err)));
|
||||
api
|
||||
.me()
|
||||
.then((r) => alive && setUser(r.user))
|
||||
.catch(() => undefined); // signed out is a valid state — the demo needs no account
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setSaved([]);
|
||||
return;
|
||||
}
|
||||
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
|
||||
}, [user]);
|
||||
|
||||
// --- preview render -------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (!engine || !previewBytes) return;
|
||||
const token = ++renderToken.current;
|
||||
const timer = setTimeout(async () => {
|
||||
setBusy(t('act.exporting'));
|
||||
const started = performance.now();
|
||||
try {
|
||||
const out = await renderPhoto({
|
||||
source: previewBytes,
|
||||
recipe,
|
||||
frameId,
|
||||
useGeotag,
|
||||
gpsInfo: gps,
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
sharpen: true,
|
||||
photoRotation: rotation,
|
||||
cropWH: crop === 'none' ? undefined : CROP_W_H[crop],
|
||||
watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 } : null,
|
||||
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null,
|
||||
},
|
||||
});
|
||||
if (token !== renderToken.current) return;
|
||||
if (!out) throw new Error('render returned null');
|
||||
const url = URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' }));
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
setRendered({ w: out.width, h: out.height, ms: Math.round(performance.now() - started) });
|
||||
} catch (err) {
|
||||
if (token === renderToken.current) setError(t('err.generic', { msg: String(err) }));
|
||||
} finally {
|
||||
if (token === renderToken.current) setBusy(null);
|
||||
}
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, markOn, markText]);
|
||||
|
||||
// --- input ---------------------------------------------------------------
|
||||
const loadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setError(null);
|
||||
setBusy(t('auth.busy'));
|
||||
try {
|
||||
const bytes = await fileToBytes(file);
|
||||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||||
const found = await readGps(bytes);
|
||||
setSource((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.url);
|
||||
return { bytes, name: file.name, url: URL.createObjectURL(file) };
|
||||
});
|
||||
setPreviewBytes(preview);
|
||||
setGps(found);
|
||||
setUseGeotag(!!found);
|
||||
if (!found) setGpsManual({ lat: '', lng: '' });
|
||||
} catch {
|
||||
setError(t('err.decode'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const file = e.clipboardData?.files?.[0];
|
||||
if (file) void loadFile(file);
|
||||
};
|
||||
window.addEventListener('paste', onPaste);
|
||||
return () => window.removeEventListener('paste', onPaste);
|
||||
}, [loadFile]);
|
||||
|
||||
// --- recipe edits ---------------------------------------------------------
|
||||
const remember = useCallback(() => setUndo((stack) => [...stack.slice(-19), recipe]), [recipe]);
|
||||
|
||||
const applyRecipe = useCallback(
|
||||
(next: Recipe) => {
|
||||
remember();
|
||||
setRecipe(next);
|
||||
setSimId(next.isCustom ? null : next.id);
|
||||
},
|
||||
[remember]
|
||||
);
|
||||
|
||||
const setAdjustment = useCallback(
|
||||
(patch: Partial<Recipe['adjustments']>) => {
|
||||
setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...prev.adjustments, ...patch } }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
remember();
|
||||
setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...DEFAULT_ADJUSTMENTS }, baseFilter: 'none', cinema: null }));
|
||||
setSimId(null);
|
||||
setFrameId('none');
|
||||
setCrop('none');
|
||||
setRotation(0);
|
||||
setMarkOn(false);
|
||||
setOpenParam(null);
|
||||
}, [remember]);
|
||||
|
||||
const undoLast = useCallback(() => {
|
||||
setUndo((stack) => {
|
||||
const last = stack[stack.length - 1];
|
||||
if (last) setRecipe(last);
|
||||
return stack.slice(0, -1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveRecipe = useCallback(async () => {
|
||||
if (!user) {
|
||||
setAuthOpen(true);
|
||||
return;
|
||||
}
|
||||
const name = window.prompt(t('save.name'), recipe.name);
|
||||
if (!name) return;
|
||||
try {
|
||||
const created = await api.createRecipe(name, { ...recipe, name });
|
||||
setSaved((list) => [created.recipe, ...list]);
|
||||
setRecipe((prev) => ({ ...prev, name }));
|
||||
} catch (err) {
|
||||
setError(t('err.generic', { msg: String(err) }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recipe, user, t]);
|
||||
|
||||
const exportPhoto = useCallback(async () => {
|
||||
if (!engine || !source) return;
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tier = TIERS[user ? 'user' : 'guest'];
|
||||
const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes;
|
||||
const out = await renderPhoto({
|
||||
source: src,
|
||||
recipe,
|
||||
frameId,
|
||||
useGeotag,
|
||||
gpsInfo: gps,
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
dpi: 300,
|
||||
sharpen: true,
|
||||
photoRotation: rotation,
|
||||
cropWH: crop === 'none' ? undefined : CROP_W_H[crop],
|
||||
watermark: markOn && markText
|
||||
? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 }
|
||||
: tier.watermark
|
||||
? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 }
|
||||
: null,
|
||||
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null,
|
||||
},
|
||||
});
|
||||
if (!out) throw new Error('render returned null');
|
||||
|
||||
// Encode → JFIF density 300 (inside renderPhoto) → EXIF rebuild, the same
|
||||
// order the app uses. The source photo donates its camera/lens tags.
|
||||
const bytes = writeJpegExif(
|
||||
out.bytes,
|
||||
{
|
||||
dateTime: new Date(),
|
||||
gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null,
|
||||
width: out.width,
|
||||
height: out.height,
|
||||
dpi: 300,
|
||||
locationName: place || gps?.locality || null,
|
||||
},
|
||||
source.bytes
|
||||
);
|
||||
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
|
||||
} catch (err) {
|
||||
setError(t('err.generic', { msg: String(err) }));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [engine, source, recipe, frameId, useGeotag, gps, rotation, crop, markOn, markText, place, user, t]);
|
||||
|
||||
// --- chips ----------------------------------------------------------------
|
||||
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
|
||||
defs.map((def) => ({
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
value: def.display(def.get(recipe.adjustments)),
|
||||
active: openParam === def.key,
|
||||
pro: def.pro && !user,
|
||||
disabled: !!def.pro && !user,
|
||||
onClick: () => setOpenParam(openParam === def.key ? null : def.key),
|
||||
}));
|
||||
|
||||
const slider: SliderDef | null = useMemo(() => {
|
||||
if (!openParam) return null;
|
||||
const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters];
|
||||
const def = all.find((d) => d.key === openParam);
|
||||
if (!def) return null;
|
||||
return {
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
value: def.get(recipe.adjustments),
|
||||
min: def.min,
|
||||
max: def.max,
|
||||
step: def.step,
|
||||
display: def.display(def.get(recipe.adjustments)),
|
||||
onChange: (value) => setAdjustment(def.set(value)),
|
||||
onClose: () => setOpenParam(null),
|
||||
};
|
||||
}, [openParam, recipe.adjustments, setAdjustment]);
|
||||
|
||||
const chips: ChipDef[] = useMemo(() => {
|
||||
if (tab === 'presets') {
|
||||
return [
|
||||
...FILM_SIMS.map((sim) => ({
|
||||
key: sim.id,
|
||||
label: sim.name,
|
||||
active: simId === sim.id,
|
||||
onClick: () => applyRecipe(filmSimToRecipe(sim)),
|
||||
})),
|
||||
...DEFAULT_RECIPES.slice(0, 6).map((preset) => ({
|
||||
key: preset.id,
|
||||
label: preset.name,
|
||||
active: recipe.name === preset.name && !recipe.isCustom,
|
||||
onClick: () => applyRecipe({ ...preset }),
|
||||
})),
|
||||
];
|
||||
}
|
||||
if (tab === 'favorited') {
|
||||
return saved.map((entry) => ({
|
||||
key: String(entry.id),
|
||||
label: entry.name,
|
||||
active: false,
|
||||
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
|
||||
}));
|
||||
}
|
||||
if (tab === 'light') return paramChips(PARAM_DEFS.iq);
|
||||
if (tab === 'wb') {
|
||||
return [
|
||||
...TEMP_PRESETS.map((preset) => ({
|
||||
key: preset.label,
|
||||
label: preset.label,
|
||||
active: Math.round(recipe.adjustments.temperature) === preset.kelvin,
|
||||
onClick: () => setAdjustment({ temperature: preset.kelvin }),
|
||||
})),
|
||||
...paramChips(PARAM_DEFS.wb),
|
||||
];
|
||||
}
|
||||
if (tab === 'fx') return paramChips(PARAM_DEFS.filters);
|
||||
return [];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, saved, simId, recipe, openParam, user, applyRecipe, setAdjustment]);
|
||||
|
||||
const status = busy ? busy : rendered ? t('meta.rendered', { ms: rendered.ms, w: rendered.w, h: rendered.h }) : t('meta.ready');
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<TopBar
|
||||
recipeName={recipe.name}
|
||||
user={user}
|
||||
exporting={exporting}
|
||||
canUndo={undo.length > 0}
|
||||
onUndo={undoLast}
|
||||
onReset={reset}
|
||||
onExport={exportPhoto}
|
||||
onSave={saveRecipe}
|
||||
onAuth={() => setAuthOpen(true)}
|
||||
onLogout={async () => {
|
||||
await api.logout().catch(() => undefined);
|
||||
setUser(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="workspace">
|
||||
<ToolRail tab={tab} onTab={setTab} />
|
||||
|
||||
<div className="chips">
|
||||
{tab === 'frame' ? (
|
||||
<>
|
||||
<Section title={t('sec.frame')}>
|
||||
<ChipRow
|
||||
chips={FRAMES.map((frame) => ({
|
||||
key: frame.id,
|
||||
label: t(frame.label),
|
||||
active: frameId === frame.id,
|
||||
onClick: () => {
|
||||
remember();
|
||||
setFrameId(frame.id);
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t('sec.crop')}>
|
||||
<ChipRow
|
||||
chips={CROPS.map((ratio) => ({
|
||||
key: ratio,
|
||||
label: t(`crop.${ratio}` as 'crop.none'),
|
||||
active: crop === ratio,
|
||||
onClick: () => setCrop(ratio),
|
||||
}))}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t('sec.rotate')}>
|
||||
<ChipRow
|
||||
chips={ROTATIONS.map((deg) => ({
|
||||
key: String(deg),
|
||||
label: t(`rot.${deg}` as 'rot.0'),
|
||||
active: rotation === deg,
|
||||
onClick: () => setRotation(deg),
|
||||
}))}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t('sec.watermark')}>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{
|
||||
key: 'mark',
|
||||
label: t('wm.text'),
|
||||
active: markOn,
|
||||
onClick: () => setMarkOn(!markOn),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{markOn ? (
|
||||
<input
|
||||
className="field"
|
||||
style={{ marginTop: 6 }}
|
||||
value={markText}
|
||||
placeholder="RECIPESCAM"
|
||||
onChange={(e) => setMarkText(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
</Section>
|
||||
<Section title={t('sec.gps')}>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{
|
||||
key: 'gps',
|
||||
label: t('wm.gps'),
|
||||
active: useGeotag,
|
||||
disabled: !gps && !(gpsManual.lat && gpsManual.lng),
|
||||
onClick: () => setUseGeotag(!useGeotag),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
style={{ marginTop: 6 }}
|
||||
value={place}
|
||||
placeholder={t('wm.placePh')}
|
||||
onChange={(e) => setPlace(e.target.value)}
|
||||
/>
|
||||
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
|
||||
{!gps ? (
|
||||
<div className="chip-row" style={{ marginTop: 6 }}>
|
||||
<input
|
||||
className="field"
|
||||
style={{ width: '48%' }}
|
||||
placeholder={t('wm.lat')}
|
||||
value={gpsManual.lat}
|
||||
onChange={(e) => setGpsManual((prev) => ({ ...prev, lat: e.target.value }))}
|
||||
onBlur={() => {
|
||||
const lat = Number(gpsManual.lat);
|
||||
const lng = Number(gpsManual.lng);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) {
|
||||
setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' });
|
||||
setUseGeotag(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
style={{ width: '48%' }}
|
||||
placeholder={t('wm.lng')}
|
||||
value={gpsManual.lng}
|
||||
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Section title={t(SECTION_TITLE[tab])}>
|
||||
<ChipRow chips={chips} />
|
||||
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||||
</Section>
|
||||
{slider ? <SliderRow slider={slider} /> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageStage
|
||||
previewUrl={previewUrl}
|
||||
originalUrl={source?.url ?? null}
|
||||
busy={status}
|
||||
error={error}
|
||||
peek={peek}
|
||||
onPeek={setPeek}
|
||||
onFile={(file) => void loadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authOpen ? (
|
||||
<AuthModal
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onDone={async () => {
|
||||
setAuthOpen(false);
|
||||
const me = await api.me().catch(() => null);
|
||||
if (me) setUser(me.user);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user