feat: the camera's own colour, a reading that keeps its place, and cloud links in Settings
- RAW opens at the colour the camera wrote into it: the develop carries the difference against the file's own preview (previewResidual) instead of the per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid. - Immich reading: keyset position kept across visits, one reading per node, a page in flight retried, timeline buckets for the years on offer. - The wall draws a server frame at the address the proxy serves it (no blob minted for it), and the 80-thumbnail pre-cache on open is gone. - Settings lists the cloud links, flags two that answer at the same address, and forgets one. - Custom frames imported from a file (engine/customFrame.ts, IndexedDB). - Upload an edited frame from Studio to Immich Cloud.
This commit is contained in:
+440
-10
@@ -22,9 +22,11 @@ import { sharePhoto, type ShareSite } from './engine/share';
|
||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||
import { getPhoto, loadEdit, makeTile, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
|
||||
import { immichConfig, uploadToImmich, type ImmichServer } from './engine/immich';
|
||||
import { offerInstall } from './pwa/install';
|
||||
import { cameraSupported } from './engine/camera';
|
||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
|
||||
import { analyzeCustomFrame, clearCustomFrame, loadCustomFrame, saveCustomFrame, type CustomFrameData } from './engine/customFrame';
|
||||
import { keep } from './remember';
|
||||
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||
import { api, type Photo, type SavedRecipe } from './api';
|
||||
@@ -122,6 +124,7 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [
|
||||
{ id: 'wallframe-landscape', label: 'frame.wallLand' },
|
||||
{ id: 'old-film', label: 'frame.oldFilm' },
|
||||
{ id: 'old-film-portrait', label: 'frame.oldFilmPort' },
|
||||
{ id: 'custom', label: 'frame.custom' },
|
||||
];
|
||||
|
||||
// The recipe a saved photo's base is rendered through: the neutral stock —
|
||||
@@ -348,11 +351,7 @@ interface Look {
|
||||
|
||||
export function Workspace() {
|
||||
const { t } = useI18n();
|
||||
const [forceView] = useState<'auto' | 'mobile' | 'desktop'>(() => {
|
||||
return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto';
|
||||
});
|
||||
const detectedMobile = useIsMobile(860);
|
||||
const isMobile = forceView === 'mobile' || (forceView === 'auto' && detectedMobile);
|
||||
const isMobile = useIsMobile(860);
|
||||
|
||||
// Session restore: read once, synchronously, so the first paint already holds
|
||||
// the user's own settings instead of a flash of the defaults. The photo comes
|
||||
@@ -397,6 +396,76 @@ export function Workspace() {
|
||||
const [preview, setPreview] = useState<Recipe | null>(null);
|
||||
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
|
||||
const [framePanX, setFramePanX] = useState<number>(() => session.framePanX ?? 0);
|
||||
const [customFrame, setCustomFrame] = useState<CustomFrameData | null>(null);
|
||||
const frameFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const saveCustomFrameTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadCustomFrame().then((saved) => {
|
||||
if (alive && saved) setCustomFrame(saved);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateCustomFrame = useCallback((updater: (prev: CustomFrameData) => CustomFrameData) => {
|
||||
setCustomFrame((prev) => {
|
||||
if (!prev) return null;
|
||||
const next = updater(prev);
|
||||
if (saveCustomFrameTimer.current) clearTimeout(saveCustomFrameTimer.current);
|
||||
saveCustomFrameTimer.current = setTimeout(() => {
|
||||
void saveCustomFrame(next);
|
||||
}, 400);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onImportFrameFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setBusy(t('frame.analyzing'));
|
||||
const analyzed = await analyzeCustomFrame(file);
|
||||
await saveCustomFrame(analyzed);
|
||||
setCustomFrame(analyzed);
|
||||
setFrameId('custom');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to load custom frame');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (e.target) e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveCustomFrame = async () => {
|
||||
await clearCustomFrame();
|
||||
setCustomFrame(null);
|
||||
if (frameId === 'custom') setFrameId('none');
|
||||
};
|
||||
|
||||
const onCustomFramePan = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
updateCustomFrame((cf) => {
|
||||
const nextPanX = Math.max(-1000, Math.min(1000, Math.round(cf.panX + dx)));
|
||||
const nextPanY = Math.max(-1000, Math.min(1000, Math.round(cf.panY + dy)));
|
||||
return { ...cf, panX: nextPanX, panY: nextPanY };
|
||||
});
|
||||
},
|
||||
[updateCustomFrame]
|
||||
);
|
||||
|
||||
const onCustomFrameZoom = useCallback(
|
||||
(delta: number) => {
|
||||
updateCustomFrame((cf) => {
|
||||
const factor = delta > 0 ? 0.95 : 1.05;
|
||||
const nextScale = Math.max(0.2, Math.min(5.0, Number((cf.scale * factor).toFixed(3))));
|
||||
return { ...cf, scale: nextScale };
|
||||
});
|
||||
},
|
||||
[updateCustomFrame]
|
||||
);
|
||||
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
|
||||
const [cropRect, setCropRect] = useState<CropRect>(() => session.cropRect ?? DEFAULT_CROP_RECT);
|
||||
const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false);
|
||||
@@ -678,6 +747,26 @@ export function Workspace() {
|
||||
// for them (server.ts), so the studio stops counting at the same place. null
|
||||
// means "no cap" all the way down to SAVE PHOTO's label.
|
||||
const photoCap = user?.admin ? null : MAX_PHOTOS;
|
||||
|
||||
// Immich Cloud servers connected to this account
|
||||
const [immichServers, setImmichServers] = useState<ImmichServer[]>([]);
|
||||
const [uploadingImmich, setUploadingImmich] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
immichConfig().then((cfg) => setImmichServers(cfg.servers)).catch(() => {});
|
||||
} else {
|
||||
setImmichServers([]);
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
const hasImmich = immichServers.length > 0;
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
}, []);
|
||||
// What the stamp's name line prints: the visitor's own PLACE NAME wins over
|
||||
// whatever the fix carried, because neither a phone's position nor a photo's
|
||||
// EXIF ever brings one, and typing it after locating must still show up.
|
||||
@@ -838,7 +927,7 @@ export function Workspace() {
|
||||
const parked = await readRaw();
|
||||
if (!alive) return;
|
||||
if (!parked) return photo ? show(photo) : undefined;
|
||||
const jpeg = await developRaw(parked.bytes);
|
||||
const jpeg = await developRaw(parked.bytes, parked.name);
|
||||
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@@ -881,6 +970,16 @@ export function Workspace() {
|
||||
const stageOptions = useMemo(
|
||||
() => ({
|
||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||
customFrame:
|
||||
frameId === 'custom' && customFrame
|
||||
? {
|
||||
bytes: customFrame.bytes,
|
||||
hole: customFrame.hole,
|
||||
scale: customFrame.scale,
|
||||
panX: customFrame.panX,
|
||||
panY: customFrame.panY,
|
||||
}
|
||||
: null,
|
||||
sharpen: true,
|
||||
photoRotation: rotation,
|
||||
photoStraighten: straighten,
|
||||
@@ -891,7 +990,7 @@ export function Workspace() {
|
||||
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
|
||||
[frameId, customFrame, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
|
||||
);
|
||||
|
||||
// What the stage paints: the hovered look while the pointer is on a sim or a
|
||||
@@ -1248,7 +1347,7 @@ export function Workspace() {
|
||||
// ponytail: the reload pays one develop. Stamp the session copy with
|
||||
// the build and keep it while the stamp holds, if that ever hurts.
|
||||
await storeRaw(bytes, file.name);
|
||||
const jpeg = await developRaw(bytes);
|
||||
const jpeg = await developRaw(bytes, file.name);
|
||||
await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes);
|
||||
void forgetPhoto();
|
||||
return;
|
||||
@@ -1925,6 +2024,16 @@ export function Workspace() {
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||
customFrame:
|
||||
frameId === 'custom' && customFrame
|
||||
? {
|
||||
bytes: customFrame.bytes,
|
||||
hole: customFrame.hole,
|
||||
scale: customFrame.scale,
|
||||
panX: customFrame.panX,
|
||||
panY: customFrame.panY,
|
||||
}
|
||||
: null,
|
||||
dpi: 300,
|
||||
sharpen: true,
|
||||
photoRotation: rotation,
|
||||
@@ -1980,6 +2089,107 @@ export function Workspace() {
|
||||
}
|
||||
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, exportCap, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, member, promptPro, proLookInUse, t, strip, photoSettings]);
|
||||
|
||||
// Upload the currently edited photo from Studio straight to Immich Cloud
|
||||
const uploadPhotoToImmich = useCallback(async (target: number | null = null): Promise<boolean> => {
|
||||
if (!engine || !source) return false;
|
||||
if (!immichServers.length) {
|
||||
setError(t('immich.noServer'));
|
||||
return false;
|
||||
}
|
||||
if (!pro && proLookInUse()) {
|
||||
promptPro();
|
||||
return false;
|
||||
}
|
||||
setUploadingImmich(true);
|
||||
setBusy(t('immich.uploading'));
|
||||
setError(null);
|
||||
try {
|
||||
const tier = pro ? TIERS.pro : member ? TIERS.member : TIERS.guest;
|
||||
const wanted = target === null ? null : Math.min(target, exportCap ?? target);
|
||||
const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest);
|
||||
let src = source.bytes;
|
||||
if (wanted === null) {
|
||||
if (tier.maxExportDim && sourceLongest > tier.maxExportDim) {
|
||||
src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95);
|
||||
}
|
||||
} else if (needLongest > sourceLongest * MODEL_GAIN) {
|
||||
src = await upscaleJpeg(source.bytes, needLongest, ({ done, total }) =>
|
||||
setBusy(t('act.upscaling', { done, total }))
|
||||
);
|
||||
} else if (needLongest !== sourceLongest) {
|
||||
src = await scaledJpeg(source.bytes, needLongest);
|
||||
}
|
||||
const out = await renderPhoto({
|
||||
source: src,
|
||||
recipe,
|
||||
frameId: cropEditing ? 'none' : frameId,
|
||||
useGeotag,
|
||||
gpsInfo,
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||
customFrame:
|
||||
frameId === 'custom' && customFrame
|
||||
? {
|
||||
bytes: customFrame.bytes,
|
||||
hole: customFrame.hole,
|
||||
scale: customFrame.scale,
|
||||
panX: customFrame.panX,
|
||||
panY: customFrame.panY,
|
||||
}
|
||||
: null,
|
||||
dpi: 300,
|
||||
sharpen: true,
|
||||
photoRotation: rotation,
|
||||
photoStraighten: straighten,
|
||||
photoFlipH: flipH,
|
||||
photoFlipV: flipV,
|
||||
cropRect: cropLive ? cropRect : null,
|
||||
watermark: markOn && markText
|
||||
? { text: markText, ...markStyle }
|
||||
: tier.watermark
|
||||
? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 }
|
||||
: null,
|
||||
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
|
||||
},
|
||||
});
|
||||
if (!out) throw new Error('render returned null');
|
||||
|
||||
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,
|
||||
imageDescription: strip.tag,
|
||||
userComment: JSON.stringify(photoSettings),
|
||||
},
|
||||
source.bytes
|
||||
);
|
||||
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
const name = `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`;
|
||||
|
||||
const primaryServer = immichServers[0];
|
||||
const res = await uploadToImmich(bytes, name, primaryServer?.id);
|
||||
if (res.ok) {
|
||||
showToast(t('immich.uploadSuccess'));
|
||||
setChoosingExport(false);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(res.error || 'Upload error');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('immich.uploadFailed', { msg: String(err) }));
|
||||
return false;
|
||||
} finally {
|
||||
setUploadingImmich(false);
|
||||
setBusy(null);
|
||||
}
|
||||
}, [engine, source, immichServers, pro, proLookInUse, promptPro, member, exportCap, sourceLongest, editedLongest, recipe, cropEditing, frameId, useGeotag, gpsInfo, customFrame, rotation, straighten, flipH, flipV, cropLive, cropRect, markOn, markText, markStyle, gps, place, strip, photoSettings, t, showToast]);
|
||||
|
||||
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
|
||||
// The upload is left clean (no burned strip) so the landing reel can draw its
|
||||
// own `.lp-shot` frame around it; the labels are stored alongside instead.
|
||||
@@ -2026,6 +2236,16 @@ export function Workspace() {
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||
customFrame:
|
||||
frameId === 'custom' && customFrame
|
||||
? {
|
||||
bytes: customFrame.bytes,
|
||||
hole: customFrame.hole,
|
||||
scale: customFrame.scale,
|
||||
panX: customFrame.panX,
|
||||
panY: customFrame.panY,
|
||||
}
|
||||
: null,
|
||||
dpi: 300,
|
||||
sharpen: true,
|
||||
photoRotation: rotation,
|
||||
@@ -2950,10 +3170,10 @@ export function Workspace() {
|
||||
// The frame row is the frames alone. CROP, ROTATE and WATERMARK used to close
|
||||
// it — a frame is the look, and cutting, levelling and signing the print are
|
||||
// moves made on the frame, which is what TOOLS is (see `shapeChips`).
|
||||
const framesRow = (): ChipDef[] =>
|
||||
const framesRow = (): ChipDef[] => {
|
||||
// NO FRAME is not a chip of its own: clicking the frame already on the
|
||||
// photo takes it off again, so the row carries one entry per frame.
|
||||
FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({
|
||||
const list: ChipDef[] = FRAMES.filter((frame) => frame.id !== 'none' && frame.id !== 'custom').map((frame) => ({
|
||||
key: `fr:${frame.id}`,
|
||||
label: t(frame.label),
|
||||
active: frameId === frame.id,
|
||||
@@ -2970,6 +3190,32 @@ export function Workspace() {
|
||||
},
|
||||
}));
|
||||
|
||||
if (customFrame) {
|
||||
list.push({
|
||||
key: 'fr:custom',
|
||||
label: customFrame.name || t('frame.custom'),
|
||||
active: frameId === 'custom',
|
||||
onClick: () => {
|
||||
setOpenParam(null);
|
||||
setOpenGroup(null);
|
||||
remember();
|
||||
setFrameId(frameId === 'custom' ? 'none' : 'custom');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
list.push({
|
||||
key: 'fr:import-custom',
|
||||
label: t('frame.import'),
|
||||
active: false,
|
||||
onClick: () => {
|
||||
frameFileInputRef.current?.click();
|
||||
},
|
||||
});
|
||||
|
||||
return list;
|
||||
};
|
||||
|
||||
// The three moves that shape the print rather than tint it: the frame is cut
|
||||
// to a shape, turned to the straight, and signed. Each opens its own strip in
|
||||
// the last column, the same strip it opened from the frame row.
|
||||
@@ -3693,6 +3939,9 @@ export function Workspace() {
|
||||
onReset={reset}
|
||||
onSavePhoto={() => void saveThenMaybeClear()}
|
||||
onExport={openExportPicker}
|
||||
hasImmich={hasImmich}
|
||||
uploadingImmich={uploadingImmich}
|
||||
onUploadImmich={() => void uploadPhotoToImmich()}
|
||||
photoCount={myPhotos.length}
|
||||
photoMax={photoCap}
|
||||
onAuth={() => setAuthMode('login')}
|
||||
@@ -3706,6 +3955,33 @@ export function Workspace() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '56px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(24, 24, 27, 0.95)',
|
||||
border: '1px solid #4285f4',
|
||||
color: '#fff',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
zIndex: 9999,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>☁️</span>
|
||||
<span>{toast}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The trial notice: one band under the header, the same shape the verify
|
||||
bar below takes. Copy only — nothing here is dismissible, because the
|
||||
date IS the message. */}
|
||||
@@ -4075,6 +4351,153 @@ export function Workspace() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* CUSTOM FRAME controls column: thumbnail, fit/fill/center, scale & pan sliders, replace & delete */}
|
||||
{tab === 'frame' && frameId === 'custom' && customFrame ? (
|
||||
<div className="col col-sub strip custom-frame-controls-col" data-col="custom-frame">
|
||||
<div className="custom-frame-header">
|
||||
<div className="custom-frame-thumb-wrap">
|
||||
<img src={customFrame.dataUrl} alt={customFrame.name} className="custom-frame-thumb" />
|
||||
</div>
|
||||
<div className="custom-frame-meta">
|
||||
<div className="custom-frame-title">{customFrame.name}</div>
|
||||
<div className="custom-frame-res">
|
||||
{customFrame.width} × {customFrame.height}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-frame-action-btns">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
if (!photoDims) {
|
||||
updateCustomFrame((cf) => ({ ...cf, scale: 1, panX: 0, panY: 0 }));
|
||||
return;
|
||||
}
|
||||
const pa = photoDims.w / photoDims.h;
|
||||
const ha = (customFrame.hole.w * customFrame.width) / (customFrame.hole.h * customFrame.height);
|
||||
const fitScale = Number(Math.min(1, pa > ha ? ha / pa : pa / ha).toFixed(3));
|
||||
updateCustomFrame((cf) => ({ ...cf, scale: fitScale, panX: 0, panY: 0 }));
|
||||
}}
|
||||
>
|
||||
{t('frame.fit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
updateCustomFrame((cf) => ({ ...cf, scale: 1, panX: 0, panY: 0 }));
|
||||
}}
|
||||
>
|
||||
{t('frame.fill')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
updateCustomFrame((cf) => ({ ...cf, panX: 0, panY: 0 }));
|
||||
}}
|
||||
>
|
||||
{t('frame.center')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="custom-frame-slider-group">
|
||||
<div className="custom-frame-slider-header">
|
||||
<span className="lbl">{t('frame.zoom')}</span>
|
||||
<span className="val">{Math.round(customFrame.scale * 100)}%</span>
|
||||
</div>
|
||||
<div className="custom-frame-slider-row">
|
||||
<input
|
||||
type="range"
|
||||
className="custom-frame-range"
|
||||
min="0.2"
|
||||
max="4.0"
|
||||
step="0.02"
|
||||
value={customFrame.scale}
|
||||
onChange={(e) => {
|
||||
const sc = parseFloat(e.target.value);
|
||||
updateCustomFrame((cf) => ({ ...cf, scale: sc }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-frame-slider-group">
|
||||
<div className="custom-frame-slider-header">
|
||||
<span className="lbl">{t('frame.panX')}</span>
|
||||
<span className="val">{Math.round(customFrame.panX)} px</span>
|
||||
</div>
|
||||
<div className="custom-frame-slider-row">
|
||||
<input
|
||||
type="range"
|
||||
className="custom-frame-range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="1"
|
||||
value={customFrame.panX}
|
||||
onChange={(e) => {
|
||||
const px = parseFloat(e.target.value);
|
||||
updateCustomFrame((cf) => ({ ...cf, panX: px }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-frame-slider-group">
|
||||
<div className="custom-frame-slider-header">
|
||||
<span className="lbl">{t('frame.panY')}</span>
|
||||
<span className="val">{Math.round(customFrame.panY)} px</span>
|
||||
</div>
|
||||
<div className="custom-frame-slider-row">
|
||||
<input
|
||||
type="range"
|
||||
className="custom-frame-range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="1"
|
||||
value={customFrame.panY}
|
||||
onChange={(e) => {
|
||||
const py = parseFloat(e.target.value);
|
||||
updateCustomFrame((cf) => ({ ...cf, panY: py }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="custom-frame-hint">{t('frame.dragHint')}</p>
|
||||
|
||||
<div className="custom-frame-footer-btns">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
frameFileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{t('frame.replace')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void onRemoveCustomFrame()}
|
||||
>
|
||||
{t('frame.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Hidden input for importing custom frames */}
|
||||
<input
|
||||
type="file"
|
||||
ref={frameFileInputRef}
|
||||
accept="image/png,image/webp,image/svg+xml,image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => void onImportFrameFile(e)}
|
||||
/>
|
||||
|
||||
{/* column 4 — the open ruler, sliders stacked */}
|
||||
{slider ? (
|
||||
<div className="col col-slider" data-col="slider">
|
||||
@@ -4131,6 +4554,9 @@ export function Workspace() {
|
||||
cropWH={cropWH}
|
||||
dims={stageDims}
|
||||
frameId={cropEditing ? 'none' : frameId}
|
||||
customFrame={frameId === 'custom' ? customFrame : null}
|
||||
onCustomFramePan={onCustomFramePan}
|
||||
onCustomFrameZoom={onCustomFrameZoom}
|
||||
framePanX={framePanX}
|
||||
onFramePanXChange={(pan) => {
|
||||
setFramePanX(pan);
|
||||
@@ -4304,6 +4730,10 @@ export function Workspace() {
|
||||
void exportPhoto(null, site);
|
||||
}}
|
||||
onClose={() => setChoosingExport(false)}
|
||||
hasImmich={hasImmich}
|
||||
onUploadImmich={(target) => {
|
||||
void uploadPhotoToImmich(target);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user