Add contribution page and Docker deployment

This commit is contained in:
achraf
2026-06-02 20:33:09 +02:00
parent be635b1828
commit 4f1af76658
22 changed files with 791 additions and 573 deletions

View File

@@ -62,6 +62,14 @@ const FORMAT_OPTIONS = [
{ label: "WebP", value: "image/webp" },
];
// On small screens the control panels collapse into a tabbed bottom dock.
type MobileTab = "style" | "layers" | "export";
const MOBILE_TABS: { id: MobileTab; label: string }[] = [
{ id: "style", label: "Style" },
{ id: "layers", label: "Layers" },
{ id: "export", label: "Export" },
];
function objectType(object: FabricObject | undefined): string {
if (!object) return "None";
if (object.type === "textbox" || object.type === "i-text") return "Text";
@@ -101,6 +109,9 @@ export default function ImageEditorUi({
const sourceFileRef = useRef<File | undefined>(undefined);
const isPanningRef = useRef(false);
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
// Once the user zooms manually we stop auto-fitting on every resize,
// otherwise mobile viewport reflows keep snapping the zoom back to "fit".
const userZoomedRef = useRef(false);
const { ref: workspaceRef, size: workspaceSize } = useElementSize<HTMLDivElement>();
const [sourceFile, setSourceFile] = useState<File | undefined>();
@@ -117,6 +128,7 @@ export default function ImageEditorUi({
useState<ImageEditorOptions["format"]>("image/png");
const [quality, setQuality] = useState(92);
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
const [mobileTab, setMobileTab] = useState<MobileTab>("style");
const [state, setState] = useState<EditorState>({
selectedType: "None",
fill: "#ffffff",
@@ -221,16 +233,22 @@ export default function ImageEditorUi({
const fitCanvasToWorkspace = useCallback(() => {
if (workspaceSize.width === 0 || workspaceSize.height === 0) return;
// Fit re-establishes the auto-fit baseline (resets manual-zoom lock).
userZoomedRef.current = false;
const margin = workspaceSize.width < 480 ? 24 : 56;
const zoom = Math.max(0.12, Math.min(
1,
(workspaceSize.width - 56) / canvasSize.width,
(workspaceSize.height - 56) / canvasSize.height
(workspaceSize.width - margin) / canvasSize.width,
(workspaceSize.height - margin) / canvasSize.height
));
applyDisplayZoom(zoom);
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
const setCanvasZoom = useCallback((zoom: number) => {
// Manual zoom — stop auto-fit from overriding the user on resize.
userZoomedRef.current = true;
applyDisplayZoom(zoom);
}, [applyDisplayZoom]);
@@ -273,6 +291,8 @@ export default function ImageEditorUi({
}, [pushHistory, updateActiveState]);
useEffect(() => {
// Only auto-fit while the user hasn't taken manual control of the zoom.
if (userZoomedRef.current) return;
fitCanvasToWorkspace();
}, [fitCanvasToWorkspace]);
@@ -328,10 +348,13 @@ export default function ImageEditorUi({
pushHistory();
refreshHistoryControls();
refreshLayerRows();
// New image — fit it fresh and re-enable auto-fit.
userZoomedRef.current = false;
const margin = workspaceSize.width > 0 && workspaceSize.width < 480 ? 24 : 56;
const zoom = Math.max(0.12, Math.min(
1,
workspaceSize.width > 0 ? (workspaceSize.width - 56) / width : 1,
workspaceSize.height > 0 ? (workspaceSize.height - 56) / height : 1
workspaceSize.width > 0 ? (workspaceSize.width - margin) / width : 1,
workspaceSize.height > 0 ? (workspaceSize.height - margin) / height : 1
));
applyDisplayZoom(zoom, { width, height });
} finally {
@@ -544,9 +567,165 @@ export default function ImageEditorUi({
setCanvasZoom(state.zoom + delta);
}, [setCanvasZoom, state.zoom]);
// Control panels are declared once and placed either in the desktop
// sidebar or the mobile tabbed dock — the single <canvas> is never cloned.
const propertiesPanel = (
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
properties
</div>
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
{state.selectedType}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Fill
<input
type="color"
value={state.fill}
onChange={(event) => {
setState((prev) => ({ ...prev, fill: event.target.value }));
applyToActive({ fill: event.target.value });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Stroke / text
<input
type="color"
value={state.stroke}
onChange={(event) => {
setState((prev) => ({ ...prev, stroke: event.target.value }));
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
</div>
<div className="mt-3 flex flex-col gap-3">
<Select
label="Font"
value={fontFamily}
options={FONT_OPTIONS}
onChange={(event) => {
setFontFamily(event.target.value);
applyToActive({ fontFamily: event.target.value });
}}
/>
<Slider
label="Text size"
min={12}
max={160}
value={state.fontSize}
onChange={(event) => {
const fontSize = Number(event.target.value);
setState((prev) => ({ ...prev, fontSize }));
applyToActive({ fontSize });
}}
/>
<Slider
label="Brush"
min={1}
max={48}
value={state.brushWidth}
onChange={(event) => {
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
}}
/>
</div>
</div>
</section>
);
const arrangePanel = (
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
arrange
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
</div>
<Button variant="secondary" onClick={clearObjects}>
Clear editable objects
</Button>
</section>
);
const layersPanel = (
<section className="flex min-h-0 flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
layers
</div>
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
{layerRows.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
No editable layers yet
</div>
) : (
layerRows.map((row) => (
<button
key={row.id}
onClick={() => {
canvasRef.current?.setActiveObject(row.object);
canvasRef.current?.requestRenderAll();
updateActiveState();
}}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
activeObject === row.object
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "bg-[var(--p-chip)] text-[var(--p-text)]"
}`}
>
{row.label}
</button>
))
)}
</div>
</section>
);
const exportPanel = (
<section className="flex flex-col gap-3 lg:mt-auto">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
export
</div>
<Select
label="Format"
value={exportFormat}
options={FORMAT_OPTIONS}
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
/>
<Slider
label="Quality"
min={10}
max={100}
value={quality}
onChange={(event) => setQuality(Number(event.target.value))}
/>
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
{isExecuting ? "Exporting..." : "Export image"}
</Button>
{sourceFile && (
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
source: {sourceFile.name}
</div>
)}
{error && <div className="text-sm text-red-700">{error}</div>}
{result && <ToolResultPanel result={result} />}
</section>
);
return (
<div className="flex h-full min-h-[620px] flex-col bg-[var(--p-surface)]">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--p-border)] px-4 py-3">
<div className="flex h-full min-h-[460px] flex-col bg-[var(--p-surface)]">
<div className="flex shrink-0 flex-col gap-2 border-b border-[var(--p-border)] px-3 py-2.5 lg:flex-row lg:flex-wrap lg:items-center lg:gap-2 lg:px-4 lg:py-3">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple={false}
@@ -554,55 +733,58 @@ export default function ImageEditorUi({
onFilesDrop={(files) => {
if (files[0]) void loadImageFile(files[0]);
}}
className="min-h-0 w-full p-3 sm:w-[260px]"
className="min-h-0 w-full p-2.5 sm:w-[240px]"
/>
<div className="flex flex-wrap gap-2">
<div className="flex gap-1.5 overflow-x-auto no-scrollbar -mx-3 px-3 lg:mx-0 lg:px-0 lg:flex-wrap lg:overflow-visible">
<Button
className="shrink-0"
variant={state.mode === "select" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
>
Select
</Button>
<Button
className="shrink-0"
variant={state.mode === "draw" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
>
Draw
</Button>
<Button
className="shrink-0"
variant={state.mode === "pan" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
>
Pan
</Button>
<Button variant="secondary" onClick={addText}>Text</Button>
<Button variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
<Button variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
<Button variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
<Button variant="secondary" onClick={() => addShape("line")}>Line</Button>
<Button className="shrink-0" variant="secondary" onClick={addText}>Text</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("line")}>Line</Button>
</div>
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
<div className="flex items-center gap-1.5 lg:ml-auto">
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
-
</Button>
<button
onClick={fitCanvasToWorkspace}
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
className="shrink-0 rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
>
{Math.round(state.zoom * 100)}%
</button>
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
+
</Button>
<Button variant="secondary" onClick={() => setCanvasZoom(1)}>
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(1)}>
100%
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<div
ref={workspaceRef}
onPointerDown={handleWorkspacePointerDown}
@@ -610,7 +792,7 @@ export default function ImageEditorUi({
onPointerUp={handleWorkspacePointerUp}
onPointerCancel={handleWorkspacePointerUp}
onWheel={handleWorkspaceWheel}
className={`min-h-[520px] overflow-auto bg-[var(--p-bg)] p-6 ${
className={`min-h-[200px] flex-1 overflow-auto bg-[var(--p-bg)] p-4 lg:min-h-0 lg:p-6 ${
state.mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
}`}
>
@@ -627,152 +809,35 @@ export default function ImageEditorUi({
</div>
</div>
<aside className="flex min-h-0 flex-col gap-4 overflow-y-auto border-l border-[var(--p-border)] bg-[var(--p-surface-2)] p-4">
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
properties
</div>
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
{state.selectedType}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Fill
<input
type="color"
value={state.fill}
onChange={(event) => {
setState((prev) => ({ ...prev, fill: event.target.value }));
applyToActive({ fill: event.target.value });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Stroke / text
<input
type="color"
value={state.stroke}
onChange={(event) => {
setState((prev) => ({ ...prev, stroke: event.target.value }));
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
</div>
<aside className="flex min-h-0 shrink-0 flex-col border-t border-[var(--p-border)] bg-[var(--p-surface-2)] lg:w-[300px] lg:border-l lg:border-t-0">
<div className="flex gap-1 border-b border-[var(--p-border)] p-1.5 lg:hidden">
{MOBILE_TABS.map((t) => (
<button
key={t.id}
onClick={() => setMobileTab(t.id)}
className={`flex-1 rounded-lg px-3 py-2 font-sans text-[13px] font-medium transition-colors ${
mobileTab === t.id
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "text-[var(--p-muted)] hover:text-[var(--p-text)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="mt-3 flex flex-col gap-3">
<Select
label="Font"
value={fontFamily}
options={FONT_OPTIONS}
onChange={(event) => {
setFontFamily(event.target.value);
applyToActive({ fontFamily: event.target.value });
}}
/>
<Slider
label="Text size"
min={12}
max={160}
value={state.fontSize}
onChange={(event) => {
const fontSize = Number(event.target.value);
setState((prev) => ({ ...prev, fontSize }));
applyToActive({ fontSize });
}}
/>
<Slider
label="Brush"
min={1}
max={48}
value={state.brushWidth}
onChange={(event) => {
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
}}
/>
</div>
<div className="flex min-h-0 max-h-[44vh] flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] lg:max-h-none lg:flex-1 lg:pb-4">
<div className={`${mobileTab === "style" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
{propertiesPanel}
</div>
</section>
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
arrange
<div className={`${mobileTab === "layers" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
{arrangePanel}
{layersPanel}
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
<div className={`${mobileTab === "export" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
{exportPanel}
</div>
<Button variant="secondary" onClick={clearObjects}>
Clear editable objects
</Button>
</section>
<section className="flex min-h-0 flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
layers
</div>
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
{layerRows.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
No editable layers yet
</div>
) : (
layerRows.map((row) => (
<button
key={row.id}
onClick={() => {
canvasRef.current?.setActiveObject(row.object);
canvasRef.current?.requestRenderAll();
updateActiveState();
}}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
activeObject === row.object
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "bg-[var(--p-chip)] text-[var(--p-text)]"
}`}
>
{row.label}
</button>
))
)}
</div>
</section>
<section className="mt-auto flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
export
</div>
<Select
label="Format"
value={exportFormat}
options={FORMAT_OPTIONS}
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
/>
<Slider
label="Quality"
min={10}
max={100}
value={quality}
onChange={(event) => setQuality(Number(event.target.value))}
/>
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
{isExecuting ? "Exporting..." : "Export image"}
</Button>
{sourceFile && (
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
source: {sourceFile.name}
</div>
)}
{error && <div className="text-sm text-red-700">{error}</div>}
{result && <ToolResultPanel result={result} />}
</section>
</div>
</aside>
</div>
</div>