fix: unify project on port 1349, improve strip-metadata and UI components

Consolidate all access to localhost:1349 — Vite dev server serves on 1349
and proxies API calls to an internal dev port (13490). Production API
defaults to 1349. Also includes strip-metadata improvements, UI component
updates, and compress operation fixes.
This commit is contained in:
Siddharth Kumar Sah
2026-03-23 16:38:27 +08:00
parent 8841dbb677
commit 0e7348a0be
18 changed files with 673 additions and 68 deletions
@@ -102,17 +102,11 @@ export function BeforeAfterSlider({
draggable={false}
/>
{/* After image (clipped, top layer) — checkerboard background shows transparency */}
{/* After image (clipped, top layer) */}
<div
className="absolute inset-0"
className="absolute inset-0 bg-muted/30"
style={{
clipPath: `inset(0 0 0 ${position}%)`,
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
}}
>
<img
@@ -27,15 +27,6 @@ export function SideBySideComparison({
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
const checkerboard = {
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
};
return (
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
{/* Side-by-side images */}
@@ -45,14 +36,11 @@ export function SideBySideComparison({
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Original
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<img
src={beforeSrc}
alt="Original"
className="max-w-full max-h-full object-contain"
className="max-w-full max-h-[56vh] object-contain rounded-sm"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
@@ -70,19 +58,16 @@ export function SideBySideComparison({
</div>
</div>
{/* Resized */}
{/* Processed */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Resized
Processed
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<img
src={afterSrc}
alt="Resized"
className="max-w-full max-h-full object-contain"
alt="Processed"
className="max-w-full max-h-[56vh] object-contain rounded-sm"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
@@ -22,7 +22,7 @@ export function ResizeSettings() {
const { processFiles, processing, error, downloadUrl, progress } =
useToolProcessor("resize");
const [tab, setTab] = useState<ResizeTab>("presets");
const [tab, setTab] = useState<ResizeTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>("");
const [height, setHeight] = useState<string>("");
@@ -80,15 +80,15 @@ export function ResizeSettings() {
{/* Tab selector */}
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size
</button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale
</button>
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
</div>
</div>
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import {
@@ -7,6 +7,7 @@ import {
RotateCw,
FlipHorizontal,
FlipVertical,
RotateCcw as ResetIcon,
} from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
@@ -34,12 +35,26 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
onPreviewTransform?.({ rotate: angle, flipH, flipV });
}, [angle, flipH, flipV, onPreviewTransform]);
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
const rotateRight = () => setAngle((a) => (a + 90) % 360);
const rotateLeft = () => setAngle((a) => {
const next = a - 90;
return next < -180 ? next + 360 : next;
});
const rotateRight = () => setAngle((a) => {
const next = a + 90;
return next > 180 ? next - 360 : next;
});
const setAngleClamped = useCallback((val: number) => {
// Clamp to -180..180
const clamped = Math.max(-180, Math.min(180, Math.round(val)));
setAngle(clamped);
}, []);
const handleProcess = () => {
// Convert -180..180 to 0..360 for the backend
const backendAngle = angle < 0 ? angle + 360 : angle;
processFiles(files, {
angle,
angle: backendAngle,
horizontal: flipH,
vertical: flipV,
});
@@ -53,6 +68,12 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
if (hasFile && hasChanges && !processing) handleProcess();
};
const handleReset = () => {
setAngle(0);
setFlipH(false);
setFlipV(false);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate buttons */}
@@ -65,7 +86,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCcw className="h-4 w-4" />
90 Left
90° Left
</button>
<button
type="button"
@@ -73,25 +94,51 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCw className="h-4 w-4" />
90 Right
90° Right
</button>
</div>
</div>
{/* Angle slider */}
{/* Angle control */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Angle</label>
<span className="text-xs font-mono text-foreground">{angle} deg</span>
<label className="text-xs text-muted-foreground">Fine Angle</label>
<div className="flex items-center gap-1.5">
<input
type="number"
value={angle}
onChange={(e) => setAngleClamped(Number(e.target.value))}
min={-180}
max={180}
className="w-16 px-1.5 py-0.5 rounded border border-border bg-background text-xs text-foreground text-right font-mono tabular-nums"
/>
<span className="text-xs text-muted-foreground">°</span>
{angle !== 0 && (
<button
type="button"
onClick={() => setAngle(0)}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Reset angle"
>
<ResetIcon className="h-3 w-3" />
</button>
)}
</div>
</div>
<input
type="range"
min={0}
max={360}
min={-180}
max={180}
step={1}
value={angle}
onChange={(e) => setAngle(Number(e.target.value))}
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>-180°</span>
<span>0°</span>
<span>180°</span>
</div>
</div>
{/* Flip buttons */}
@@ -125,6 +172,17 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
</div>
</div>
{/* Reset all */}
{hasChanges && (
<button
type="button"
onClick={handleReset}
className="w-full text-xs text-muted-foreground hover:text-foreground py-1"
>
Reset all changes
</button>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
@@ -1,9 +1,154 @@
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download } from "lucide-react";
import { Download, ChevronDown, ChevronRight, Loader2, MapPin, AlertTriangle } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
interface MetadataResult {
filename: string;
fileSize: number;
exif?: Record<string, unknown> | null;
exifError?: string;
gps?: Record<string, unknown> | null;
icc?: Record<string, string> | null;
xmp?: Record<string, string> | null;
}
/** Human-friendly labels for common EXIF keys */
const EXIF_LABELS: Record<string, string> = {
Make: "Camera Make",
Model: "Camera Model",
Software: "Software",
DateTime: "Date/Time",
DateTimeOriginal: "Date Taken",
DateTimeDigitized: "Date Digitized",
ExposureTime: "Exposure Time",
FNumber: "F-Number",
ISOSpeedRatings: "ISO",
FocalLength: "Focal Length",
FocalLengthIn35mmFilm: "Focal Length (35mm)",
ExposureBiasValue: "Exposure Bias",
MeteringMode: "Metering Mode",
Flash: "Flash",
WhiteBalance: "White Balance",
ExposureMode: "Exposure Mode",
SceneCaptureType: "Scene Type",
Contrast: "Contrast",
Saturation: "Saturation",
Sharpness: "Sharpness",
DigitalZoomRatio: "Digital Zoom",
ImageWidth: "Width",
ImageLength: "Height",
Orientation: "Orientation",
XResolution: "X Resolution",
YResolution: "Y Resolution",
ResolutionUnit: "Resolution Unit",
ColorSpace: "Color Space",
PixelXDimension: "Pixel Width",
PixelYDimension: "Pixel Height",
Artist: "Artist",
Copyright: "Copyright",
ImageDescription: "Description",
LensMake: "Lens Make",
LensModel: "Lens Model",
BodySerialNumber: "Body Serial",
CameraOwnerName: "Camera Owner",
};
/** Keys to skip in display (internal/binary/redundant) */
const SKIP_KEYS = new Set([
"ExifTag", "GPSTag", "InteroperabilityTag", "MakerNote",
"PrintImageMatching", "ComponentsConfiguration", "FlashpixVersion",
"ExifVersion", "FileSource", "SceneType", "UserComment",
"InteroperabilityIndex", "InteroperabilityVersion",
]);
function formatExifValue(key: string, value: unknown): string {
if (value === null || value === undefined) return "N/A";
if (typeof value === "string") return value;
if (typeof value === "number") {
if (key === "ExposureTime" && value > 0 && value < 1) {
return `1/${Math.round(1 / value)}s`;
}
if (key === "FNumber") return `f/${value}`;
if (key === "FocalLength") return `${value}mm`;
if (key === "FocalLengthIn35mmFilm") return `${value}mm`;
return String(value);
}
if (Array.isArray(value)) {
if (typeof value[0] === "number" && value.length <= 4) {
return value.join(", ");
}
return `[${value.length} values]`;
}
return String(value);
}
function CollapsibleSection({
title,
badge,
warning,
defaultOpen,
children,
}: {
title: string;
badge?: string;
warning?: boolean;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
>
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
<span className="flex-1 text-left">{title}</span>
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
{badge && (
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
{badge}
</span>
)}
</button>
{open && <div className="px-3 pb-2">{children}</div>}
</div>
);
}
function MetadataGrid({ data, labelMap }: { data: Record<string, unknown>; labelMap?: Record<string, string> }) {
const entries = Object.entries(data).filter(
([k, v]) => !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== ""
);
if (entries.length === 0) {
return <p className="text-[10px] text-muted-foreground italic">No data</p>;
}
return (
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-2 gap-y-0.5">
{entries.map(([k, v]) => (
<div key={k} className="contents">
<div className="text-[10px] text-muted-foreground truncate" title={k}>
{labelMap?.[k] ?? k}
</div>
<div className="text-[10px] text-foreground font-mono truncate" title={formatExifValue(k, v)}>
{formatExifValue(k, v)}
</div>
</div>
))}
</div>
);
}
export function StripMetadataSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
@@ -15,6 +160,56 @@ export function StripMetadataSettings() {
const [stripIcc, setStripIcc] = useState(false);
const [stripXmp, setStripXmp] = useState(false);
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
const [inspecting, setInspecting] = useState(false);
const [inspectError, setInspectError] = useState<string | null>(null);
const lastInspectedFile = useRef<string | null>(null);
// Auto-fetch metadata when files change
useEffect(() => {
if (files.length === 0) {
setMetadata(null);
setInspectError(null);
lastInspectedFile.current = null;
return;
}
const file = files[0];
const fileKey = `${file.name}-${file.size}-${file.lastModified}`;
if (lastInspectedFile.current === fileKey) return;
lastInspectedFile.current = fileKey;
const controller = new AbortController();
(async () => {
setInspecting(true);
setInspectError(null);
setMetadata(null);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
signal: controller.signal,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const data: MetadataResult = await res.json();
setMetadata(data);
} catch (err) {
if ((err as Error).name === "AbortError") return;
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
} finally {
setInspecting(false);
}
})();
return () => controller.abort();
}, [files]);
const handleStripAllChange = (checked: boolean) => {
setStripAll(checked);
if (checked) {
@@ -36,8 +231,91 @@ export function StripMetadataSettings() {
if (hasFile && !processing) handleProcess();
};
const hasExif = metadata?.exif && Object.keys(metadata.exif).length > 0;
const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0;
const hasIcc = metadata?.icc && Object.keys(metadata.icc).length > 0;
const hasXmp = metadata?.xmp && Object.keys(metadata.xmp).length > 0;
const hasAnyMetadata = hasExif || hasGps || hasIcc || hasXmp;
const sectionCount = [hasExif, hasGps, hasIcc, hasXmp].filter(Boolean).length;
// GPS coordinates for display
const gpsLat = metadata?.gps?.["_latitude"] as number | undefined;
const gpsLon = metadata?.gps?.["_longitude"] as number | undefined;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Metadata Display */}
{hasFile && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
{inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Reading metadata...
</div>
)}
{inspectError && (
<p className="text-[10px] text-red-500">{inspectError}</p>
)}
{metadata && !hasAnyMetadata && !inspecting && (
<p className="text-xs text-muted-foreground italic py-1">
No metadata found in this image.
</p>
)}
{metadata && hasAnyMetadata && (
<div className="space-y-1.5">
{/* GPS warning banner */}
{hasGps && gpsLat !== undefined && gpsLon !== undefined && (
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
<MapPin className="h-3 w-3 text-amber-500 shrink-0" />
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)}
</span>
</div>
)}
{hasExif && (
<CollapsibleSection
title="EXIF"
badge={`${Object.keys(metadata.exif!).filter(k => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
defaultOpen
>
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
</CollapsibleSection>
)}
{hasGps && (
<CollapsibleSection title="GPS" warning badge={`${Object.keys(metadata.gps!).filter(k => !k.startsWith("_")).length} fields`}>
<MetadataGrid data={metadata.gps!} />
</CollapsibleSection>
)}
{hasIcc && (
<CollapsibleSection title="ICC Profile" badge={`${Object.keys(metadata.icc!).length} fields`}>
<MetadataGrid data={metadata.icc!} />
</CollapsibleSection>
)}
{hasXmp && (
<CollapsibleSection title="XMP" badge={`${Object.keys(metadata.xmp!).length} fields`}>
<MetadataGrid data={metadata.xmp!} />
</CollapsibleSection>
)}
<p className="text-[10px] text-muted-foreground">
{sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found
</p>
</div>
)}
</div>
)}
{hasFile && hasAnyMetadata && <div className="border-t border-border" />}
{/* Strip All */}
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
<input
@@ -64,6 +342,9 @@ export function StripMetadataSettings() {
className="rounded"
/>
Strip EXIF (camera info, date, exposure)
{hasExif && !stripAll && (
<span className="ml-auto text-[10px] text-muted-foreground">{Object.keys(metadata!.exif!).filter(k => !SKIP_KEYS.has(k)).length} fields</span>
)}
</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
@@ -75,6 +356,9 @@ export function StripMetadataSettings() {
className="rounded"
/>
Strip GPS (location data)
{hasGps && !stripAll && (
<span className="ml-auto text-[10px] text-amber-500">location found</span>
)}
</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>