chore: remove internal docs from repo, update public documentation

Remove docs/superpowers/, .claude/ config, and PRD.md from version
control (kept locally via .gitignore). Update README, CHANGELOG,
VitePress docs, and .env.example to reflect recent features: Files
page, teams, admin settings, persistent storage, and various API
improvements.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 01:11:40 +08:00
parent 3b4f522bf4
commit 627ff8a82c
99 changed files with 853 additions and 15277 deletions
+1
View File
@@ -36,6 +36,7 @@ class ErrorBoundary extends Component<
{this.state.error?.message || "An unexpected error occurred."}
</p>
<button
type="button"
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
@@ -84,12 +84,22 @@ export function BeforeAfterSlider({
{/* Slider container */}
<div
ref={containerRef}
role="slider"
aria-label="Before/after comparison slider"
aria-valuenow={Math.round(position)}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none"
style={{ cursor: isDragging ? "ew-resize" : "default" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1));
else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1));
}}
>
{/* Before image (full width, bottom layer) */}
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
@@ -116,7 +126,14 @@ export function BeforeAfterSlider({
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-primary">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
aria-hidden="true"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
+11 -7
View File
@@ -46,14 +46,14 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
const hasMultipleFiles = currentFiles.length > 1;
return (
<div
<section
aria-label="File drop zone"
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
className={cn(
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors cursor-pointer min-h-[400px] mx-auto max-w-2xl w-full",
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors min-h-[400px] mx-auto max-w-2xl w-full",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
@@ -63,7 +63,11 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
<div className="text-3xl font-bold text-muted-foreground/30">
Stirling <span className="text-primary/30">Image</span>
</div>
<button className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium">
<button
type="button"
onClick={handleClick}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium"
>
<Upload className="h-4 w-4" />
Upload from computer
</button>
@@ -77,9 +81,9 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
{currentFiles.length} files selected
</span>
<div className="max-h-32 overflow-y-auto w-full max-w-xs">
{currentFiles.map((f, i) => (
{currentFiles.map((f) => (
<div
key={i}
key={f.name}
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
>
<span className="truncate">{f.name}</span>
@@ -90,6 +94,6 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
</div>
)}
</div>
</div>
</section>
);
}
@@ -102,6 +102,7 @@ export function ImageViewer({
{/* Toolbar */}
<div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0">
<button
type="button"
onClick={zoomOut}
disabled={zoom <= ZOOM_STEPS[0]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
@@ -113,6 +114,7 @@ export function ImageViewer({
{fitMode === "fit" ? "Fit" : `${zoom}%`}
</span>
<button
type="button"
onClick={zoomIn}
disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
@@ -122,6 +124,7 @@ export function ImageViewer({
</button>
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={fitToContainer}
className={`px-2 py-1 rounded text-xs ${fitMode === "fit" ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Fit to view"
@@ -129,6 +132,7 @@ export function ImageViewer({
<Maximize className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={actualSize}
className={`px-2 py-1 rounded text-xs ${fitMode === "actual" && zoom === 100 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Actual size (100%)"
@@ -7,12 +7,6 @@ import { useFileStore } from "@/stores/file-store";
export function MultiImageViewer() {
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
const currentEntry = entries[selectedIndex];
if (!currentEntry) return null;
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -27,10 +21,18 @@ export function MultiImageViewer() {
[navigateNext, navigatePrev],
);
const currentEntry = entries[selectedIndex];
if (!currentEntry) return null;
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const hasProcessed = !!currentEntry.processedUrl;
return (
<div
<section
aria-label="Image viewer"
className="flex flex-col w-full h-full min-h-0"
onKeyDown={hasMultiple ? handleKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -38,6 +40,7 @@ export function MultiImageViewer() {
<div className="flex-1 relative flex items-center justify-center min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -49,7 +52,7 @@ export function MultiImageViewer() {
{hasProcessed ? (
<BeforeAfterSlider
beforeSrc={currentEntry.blobUrl}
afterSrc={currentEntry.processedUrl!}
afterSrc={currentEntry.processedUrl ?? ""}
beforeSize={currentEntry.originalSize}
afterSize={currentEntry.processedSize ?? undefined}
/>
@@ -63,6 +66,7 @@ export function MultiImageViewer() {
</div>
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -77,6 +81,6 @@ export function MultiImageViewer() {
)}
</div>
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
</div>
</section>
);
}
@@ -54,6 +54,7 @@ export function ReviewPanel({
{/* Review header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground"
>
@@ -85,6 +86,7 @@ export function ReviewPanel({
{/* Action buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={onUndo}
className="flex-1 py-2 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted flex items-center justify-center gap-1.5 text-xs font-medium"
>
@@ -92,6 +94,7 @@ export function ReviewPanel({
Undo
</button>
<button
type="button"
onClick={handleDownload}
className="flex-1 py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
@@ -105,6 +108,7 @@ export function ReviewPanel({
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
type="button"
onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)}
className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground"
>
@@ -129,6 +133,7 @@ export function ReviewPanel({
return (
<button
key={tool.id}
type="button"
onClick={() => navigate(tool.route)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-muted group"
>
@@ -32,7 +32,8 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
const isFailed = entry.status === "failed";
return (
<button
key={`${entry.file.name}-${i}`}
key={entry.file.name}
type="button"
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect(i)}
className={`relative shrink-0 rounded overflow-hidden transition-all ${
@@ -15,6 +15,7 @@ export function ToolCard({ tool }: ToolCardProps) {
return (
<div className="group flex items-center gap-3 relative">
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
title="Add to favourites"
>
+32 -11
View File
@@ -48,19 +48,40 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
async function handleOpenFile() {
if (!details) return;
const res = await fetch(getFileDownloadUrl(details.id), {
headers: { Authorization: `Bearer ${localStorage.getItem("stirling-token") || ""}` },
});
if (!res.ok) return;
const blob = await res.blob();
const file = new File([blob], details.originalName, { type: details.mimeType });
setFiles([file]);
const { checkedIds, files: allFiles } = useFilesPageStore.getState();
// If multiple files are checked, open all of them; otherwise just the selected one
const filesToOpen =
checkedIds.size > 1
? allFiles.filter((f) => checkedIds.has(f.id))
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
const token = localStorage.getItem("stirling-token") || "";
const downloaded = await Promise.all(
filesToOpen.map(async (f) => {
const res = await fetch(getFileDownloadUrl(f.id), {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const blob = await res.blob();
return { file: new File([blob], f.originalName, { type: f.mimeType }), serverId: f.id };
}),
);
const valid = downloaded.filter((d): d is NonNullable<typeof d> => d !== null);
if (valid.length === 0) return;
setFiles(valid.map((d) => d.file));
navigate("/");
// Set serverFileId so tool processing creates a new version
// Set serverFileId on each entry so tool processing creates new versions
setTimeout(() => {
const entries = useFileStore.getState().entries;
if (entries.length > 0) {
useFileStore.getState().updateEntry(0, { serverFileId: details.id });
const store = useFileStore.getState();
for (let i = 0; i < valid.length; i++) {
if (store.entries[i]) {
store.updateEntry(i, { serverFileId: valid[i].serverId });
}
}
}, 0);
}
+6 -1
View File
@@ -36,13 +36,18 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold text-foreground">Help</h2>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
+17 -14
View File
@@ -44,7 +44,8 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{isMobile && mobileSidebarOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
onClick={() => setMobileSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
@@ -61,25 +62,25 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
</span>
)}
<button
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="p-1.5 rounded-lg hover:bg-muted"
>
<X className="h-4 w-4" />
</button>
</div>
<div onClick={() => setMobileSidebarOpen(false)}>
<Sidebar
onSettingsClick={() => {
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
expanded
/>
</div>
<Sidebar
onSettingsClick={() => {
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
onNavClick={() => setMobileSidebarOpen(false)}
expanded
/>
</div>
</>
)}
@@ -88,6 +89,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{isMobile && (
<div className="fixed top-0 left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3">
<button
type="button"
onClick={() => setMobileSidebarOpen(true)}
className="p-1.5 rounded-lg hover:bg-muted"
>
@@ -129,6 +131,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
<MobileNavItem icon={Workflow} label="Automate" href="/automate" />
<MobileNavItem icon={FolderOpen} label="Files" href="/files" />
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground"
>
@@ -7,6 +7,7 @@ export function Footer() {
return (
<div className="fixed bottom-4 right-4 flex items-center gap-2 z-50">
<button
type="button"
onClick={toggleTheme}
className="p-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors"
title="Toggle Theme"
@@ -14,6 +15,7 @@ export function Footer() {
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors text-sm"
title="Language"
>
+11 -4
View File
@@ -24,11 +24,18 @@ const bottomItems: SidebarItem[] = [
interface SidebarProps {
onSettingsClick: () => void;
onHelpClick: () => void;
/** Called when a nav link is clicked (e.g., to close mobile sidebar). */
onNavClick?: () => void;
/** When true, renders in expanded mode (for mobile overlay). */
expanded?: boolean;
}
export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: SidebarProps) {
export function Sidebar({
onSettingsClick,
onHelpClick,
onNavClick,
expanded = false,
}: SidebarProps) {
const location = useLocation();
const renderItem = (item: SidebarItem, isActive: boolean) => {
@@ -60,20 +67,20 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
if (item.label === "Settings") {
return (
<button key={item.label} onClick={onSettingsClick} className="w-full">
<button key={item.label} type="button" onClick={onSettingsClick} className="w-full">
{content}
</button>
);
}
if (item.label === "Help") {
return (
<button key={item.label} onClick={onHelpClick} className="w-full">
<button key={item.label} type="button" onClick={onHelpClick} className="w-full">
{content}
</button>
);
}
return (
<Link key={item.label} to={item.href || "/"}>
<Link key={item.label} to={item.href || "/"} onClick={onNavClick}>
{content}
</Link>
);
@@ -76,7 +76,11 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85vh] flex overflow-hidden">
@@ -88,6 +92,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSection(item.id)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors",
@@ -105,6 +110,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<button
type="button"
onClick={onClose}
className="absolute top-3 right-3 p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
@@ -207,6 +213,7 @@ function GeneralSection() {
</div>
</div>
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
@@ -336,9 +343,13 @@ function SystemSection() {
alt="Logo"
/>
)}
<label className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors">
<label
htmlFor="system-logo-upload"
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors"
>
Upload
<input
id="system-logo-upload"
type="file"
accept="image/png,image/jpeg,image/svg+xml"
className="hidden"
@@ -346,7 +357,11 @@ function SystemSection() {
/>
</label>
{settings.customLogo === "true" && (
<button onClick={handleLogoDelete} className="text-sm text-destructive hover:underline">
<button
type="button"
onClick={handleLogoDelete}
className="text-sm text-destructive hover:underline"
>
Remove
</button>
)}
@@ -409,6 +424,7 @@ function SystemSection() {
description="Show tools that are still in development. These may be unstable."
>
<button
type="button"
onClick={() =>
updateSetting(
"enableExperimentalTools",
@@ -449,6 +465,7 @@ function SystemSection() {
description="Clean up old temporary files when the server starts"
>
<button
type="button"
onClick={() =>
updateSetting("startupCleanup", settings.startupCleanup === "false" ? "true" : "false")
}
@@ -468,6 +485,7 @@ function SystemSection() {
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -830,6 +848,7 @@ function PeopleSection() {
/>
</div>
<button
type="button"
onClick={() => {
setShowAddForm(!showAddForm);
setAddError(null);
@@ -1051,6 +1070,7 @@ function PeopleSection() {
{/* Actions */}
<div className="flex items-center gap-1 justify-end relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === u.id ? null : u.id);
@@ -1064,10 +1084,11 @@ function PeopleSection() {
{/* Dropdown menu */}
{openMenuId === u.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-44 rounded-lg border border-border bg-background shadow-lg py-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => {
setEditingUser(u);
setEditRole(u.role);
@@ -1080,6 +1101,7 @@ function PeopleSection() {
Edit Role / Team
</button>
<button
type="button"
onClick={() => {
setResetPasswordUser(u);
setResetPassword("");
@@ -1092,6 +1114,7 @@ function PeopleSection() {
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDeleteUser(u.id, u.username)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -1198,6 +1221,7 @@ function ApiKeysSection() {
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48"
/>
<button
type="button"
onClick={generateKey}
disabled={generating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -1215,6 +1239,7 @@ function ApiKeysSection() {
{newKey}
</code>
<button
type="button"
onClick={() => copyKey(newKey)}
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground shrink-0"
title="Copy"
@@ -1244,6 +1269,7 @@ function ApiKeysSection() {
</p>
</div>
<button
type="button"
onClick={() => deleteKey(k.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete key"
@@ -1397,6 +1423,7 @@ function TeamsSection() {
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
@@ -1469,12 +1496,14 @@ function TeamsSection() {
}}
/>
<button
type="button"
onClick={() => handleRename(t.id)}
className="text-xs text-primary hover:underline"
>
Save
</button>
<button
type="button"
onClick={() => setEditingTeamId(null)}
className="text-xs text-muted-foreground hover:underline"
>
@@ -1488,6 +1517,7 @@ function TeamsSection() {
<span className="text-sm text-muted-foreground">{t.memberCount}</span>
<div className="flex items-center gap-1 justify-end relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === t.id ? null : t.id);
@@ -1498,10 +1528,11 @@ function TeamsSection() {
</button>
{openMenuId === t.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-36 rounded-lg border border-border bg-background shadow-lg py-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => {
setEditingTeamId(t.id);
setEditingTeamName(t.name);
@@ -1514,6 +1545,7 @@ function TeamsSection() {
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDelete(t.id, t.name)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -1640,6 +1672,7 @@ function ToolsSection() {
<p className="text-xs text-muted-foreground truncate">{tool.description}</p>
</div>
<button
type="button"
onClick={() => toggleTool(tool.id)}
className={cn(
"w-11 h-6 rounded-full transition-colors relative shrink-0 ml-3",
@@ -1669,6 +1702,7 @@ function ToolsSection() {
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -62,6 +62,7 @@ export function BarcodeReadSettings() {
</p>
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -79,6 +80,7 @@ export function BarcodeReadSettings() {
<p className="text-xs text-muted-foreground">Decoded Text:</p>
<p className="text-sm text-foreground font-mono break-all">{result.text}</p>
<button
type="button"
onClick={copyText}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
>
@@ -26,10 +26,13 @@ export function BlurFacesSettings() {
{/* Blur radius */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Blur Radius</label>
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
Blur Radius
</label>
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
</div>
<input
id="blur-faces-blur-radius"
type="range"
min={5}
max={80}
@@ -46,10 +49,13 @@ export function BlurFacesSettings() {
{/* Sensitivity */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Detection Sensitivity</label>
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
Detection Sensitivity
</label>
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
</div>
<input
id="blur-faces-sensitivity"
type="range"
min={10}
max={90}
@@ -91,6 +97,7 @@ export function BlurFacesSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -38,10 +38,13 @@ export function BorderSettings() {
<div className="space-y-4">
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Border Width</label>
<label htmlFor="border-border-width" className="text-xs text-muted-foreground">
Border Width
</label>
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
</div>
<input
id="border-border-width"
type="range"
min={0}
max={100}
@@ -52,8 +55,11 @@ export function BorderSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Border Color</label>
<label htmlFor="border-border-color" className="text-xs text-muted-foreground">
Border Color
</label>
<input
id="border-border-color"
type="color"
value={borderColor}
onChange={(e) => setBorderColor(e.target.value)}
@@ -63,10 +69,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Corner Radius</label>
<label htmlFor="border-corner-radius" className="text-xs text-muted-foreground">
Corner Radius
</label>
<span className="text-xs font-mono text-foreground">{cornerRadius}px</span>
</div>
<input
id="border-corner-radius"
type="range"
min={0}
max={200}
@@ -78,10 +87,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Padding</label>
<label htmlFor="border-padding" className="text-xs text-muted-foreground">
Padding
</label>
<span className="text-xs font-mono text-foreground">{padding}px</span>
</div>
<input
id="border-padding"
type="range"
min={0}
max={100}
@@ -93,10 +105,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Shadow</label>
<label htmlFor="border-shadow" className="text-xs text-muted-foreground">
Shadow
</label>
<span className="text-xs font-mono text-foreground">{shadowBlur}px</span>
</div>
<input
id="border-shadow"
type="range"
min={0}
max={50}
@@ -126,6 +141,7 @@ export function BorderSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -72,8 +72,11 @@ export function BulkRenameSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Pattern</label>
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
Pattern
</label>
<input
id="bulk-rename-pattern"
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
@@ -85,8 +88,11 @@ export function BulkRenameSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Start Index</label>
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
Start Index
</label>
<input
id="bulk-rename-start-index"
type="number"
value={startIndex}
onChange={(e) => setStartIndex(Number(e.target.value))}
@@ -97,11 +103,11 @@ export function BulkRenameSettings() {
{previewNames.length > 0 && (
<div>
<label className="text-xs text-muted-foreground">Preview</label>
<p className="text-xs text-muted-foreground">Preview</p>
<div className="mt-1 space-y-0.5">
{previewNames.map((name, i) => (
{previewNames.map((name) => (
<div
key={i}
key={name}
className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate"
>
{name}
@@ -117,6 +123,7 @@ export function BulkRenameSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing || !pattern}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -71,10 +71,11 @@ export function CollageSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Layout</label>
<p className="text-xs text-muted-foreground">Layout</p>
<div className="grid grid-cols-3 gap-1 mt-1">
{LAYOUTS.map((l) => (
<button
type="button"
key={l.value}
onClick={() => setLayout(l.value)}
className={`text-xs py-1.5 rounded ${layout === l.value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
@@ -87,10 +88,13 @@ export function CollageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Gap</label>
<label htmlFor="collage-gap" className="text-xs text-muted-foreground">
Gap
</label>
<span className="text-xs font-mono text-foreground">{gap}px</span>
</div>
<input
id="collage-gap"
type="range"
min={0}
max={50}
@@ -101,8 +105,11 @@ export function CollageSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<label htmlFor="collage-background-color" className="text-xs text-muted-foreground">
Background Color
</label>
<input
id="collage-background-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -120,6 +127,7 @@ export function CollageSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -57,6 +57,7 @@ export function ColorPaletteSettings() {
return (
<div className="space-y-4">
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -69,13 +70,14 @@ export function ColorPaletteSettings() {
{colors.length > 0 && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<p className="text-xs font-medium text-muted-foreground">
Dominant Colors ({colors.length})
</label>
</p>
<div className="grid grid-cols-2 gap-1.5">
{colors.map((color, i) => (
<button
key={i}
type="button"
key={color}
onClick={() => copyColor(color, i)}
className="flex items-center gap-2 p-1.5 rounded border border-border hover:bg-muted transition-colors"
>
@@ -160,7 +160,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
{/* Effects */}
{tab === "effects" && (
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Color Effect</label>
<p className="text-xs text-muted-foreground">Color Effect</p>
<div className="grid grid-cols-2 gap-1">
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
<button
@@ -261,13 +261,17 @@ function SliderControl({
max: number;
color?: string;
}) {
const id = `color-slider-${label.toLowerCase()}`;
return (
<div>
<div className="flex justify-between items-center">
<label className={`text-xs ${color || "text-muted-foreground"}`}>{label}</label>
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
</label>
<span className="text-xs font-mono text-foreground">{value}</span>
</div>
<input
id={id}
type="range"
min={min}
max={max}
@@ -53,8 +53,11 @@ export function CompareSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Second Image</label>
<label htmlFor="compare-second-image" className="text-xs text-muted-foreground">
Second Image
</label>
<input
id="compare-second-image"
ref={secondInputRef}
type="file"
accept="image/*"
@@ -62,6 +65,7 @@ export function CompareSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => secondInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -84,6 +88,7 @@ export function CompareSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !secondFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -62,8 +62,11 @@ export function ComposeSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Overlay Image</label>
<label htmlFor="compose-overlay-image" className="text-xs text-muted-foreground">
Overlay Image
</label>
<input
id="compose-overlay-image"
ref={overlayInputRef}
type="file"
accept="image/*"
@@ -71,6 +74,7 @@ export function ComposeSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => overlayInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -81,8 +85,11 @@ export function ComposeSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">X Position</label>
<label htmlFor="compose-x-position" className="text-xs text-muted-foreground">
X Position
</label>
<input
id="compose-x-position"
type="number"
value={x}
onChange={(e) => setX(Number(e.target.value))}
@@ -91,8 +98,11 @@ export function ComposeSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Y Position</label>
<label htmlFor="compose-y-position" className="text-xs text-muted-foreground">
Y Position
</label>
<input
id="compose-y-position"
type="number"
value={y}
onChange={(e) => setY(Number(e.target.value))}
@@ -104,10 +114,13 @@ export function ComposeSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="compose-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="compose-opacity"
type="range"
min={0}
max={100}
@@ -118,8 +131,11 @@ export function ComposeSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Blend Mode</label>
<label htmlFor="compose-blend-mode" className="text-xs text-muted-foreground">
Blend Mode
</label>
<select
id="compose-blend-mode"
value={blendMode}
onChange={(e) => setBlendMode(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -147,6 +163,7 @@ export function ComposeSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !overlayFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -49,7 +49,7 @@ export function CompressSettings() {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Mode toggle */}
<div>
<label className="text-sm font-medium text-muted-foreground">Compression Mode</label>
<p className="text-sm font-medium text-muted-foreground">Compression Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
@@ -71,10 +71,13 @@ export function CompressSettings() {
{mode === "quality" ? (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<label htmlFor="compress-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="compress-quality"
type="range"
min={1}
max={100}
@@ -89,8 +92,11 @@ export function CompressSettings() {
</div>
) : (
<div>
<label className="text-xs text-muted-foreground">Target Size (KB)</label>
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
Target Size (KB)
</label>
<input
id="compress-target-size"
type="number"
value={targetSizeKb}
onChange={(e) => setTargetSizeKb(e.target.value)}
@@ -55,7 +55,7 @@ export function ConvertSettings() {
{/* Source format */}
{hasFile && (
<div>
<label className="text-xs text-muted-foreground">Source Format</label>
<p className="text-xs text-muted-foreground">Source Format</p>
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
{sourceExt}
</div>
@@ -64,8 +64,11 @@ export function ConvertSettings() {
{/* Target format */}
<div>
<label className="text-xs text-muted-foreground">Target Format</label>
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
Target Format
</label>
<select
id="convert-target-format"
value={format}
onChange={(e) => setFormat(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -82,10 +85,13 @@ export function ConvertSettings() {
{isLossy && (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<label htmlFor="convert-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="convert-quality"
type="range"
min={1}
max={100}
@@ -165,7 +165,7 @@ export function CropSettings({
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
{aspect !== undefined && (
<button
type="button"
@@ -197,13 +197,14 @@ export function CropSettings({
{/* Position & Size */}
<div>
<label className="text-xs text-muted-foreground">Position & Size</label>
<p className="text-xs text-muted-foreground">Position & Size</p>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-x"
type="number"
value={pixels.left}
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
@@ -213,10 +214,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-y" className="text-[10px] text-muted-foreground">
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-y"
type="number"
value={pixels.top}
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
@@ -226,10 +228,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-width" className="text-[10px] text-muted-foreground">
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-width"
type="number"
value={pixels.width}
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
@@ -239,10 +242,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-height" className="text-[10px] text-muted-foreground">
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-height"
type="number"
value={pixels.height}
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
@@ -114,17 +114,28 @@ export function EraseObjectSettings() {
<div className="space-y-4">
{/* Mask upload */}
<div>
<label className="text-sm font-medium text-muted-foreground">Mask Image</label>
<label htmlFor="erase-object-mask" className="text-sm font-medium text-muted-foreground">
Mask Image
</label>
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
Upload a black &amp; white mask where white areas will be erased. Create the mask in any
image editor.
</p>
<label className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary">
<label
htmlFor="erase-object-mask"
className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary"
>
<Upload className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{maskFile ? maskFile.name : "Select mask image..."}
</span>
<input type="file" accept="image/*" onChange={handleMaskSelect} className="hidden" />
<input
id="erase-object-mask"
type="file"
accept="image/*"
onChange={handleMaskSelect}
className="hidden"
/>
</label>
</div>
@@ -162,6 +173,7 @@ export function EraseObjectSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !maskFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -67,7 +67,7 @@ export function FaviconSettings() {
</p>
<div>
<label className="text-xs font-medium text-muted-foreground">Generated Sizes</label>
<p className="text-xs font-medium text-muted-foreground">Generated Sizes</p>
<div className="mt-1 space-y-0.5">
{SIZES.map((s) => (
<div key={s.name} className="flex justify-between text-xs text-foreground">
@@ -82,6 +82,7 @@ export function FaviconSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -62,6 +62,7 @@ export function FindDuplicatesSettings() {
</p>
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -84,10 +85,13 @@ export function FindDuplicatesSettings() {
<p className="text-xs text-muted-foreground">No duplicates found.</p>
) : (
result.duplicateGroups.map((group, gi) => (
<div key={gi} className="p-2 rounded border border-border space-y-1">
<div
key={group.files.map((f) => f.filename).join(",")}
className="p-2 rounded border border-border space-y-1"
>
<p className="text-xs font-medium text-foreground">Group {gi + 1}</p>
{group.files.map((f, fi) => (
<div key={fi} className="flex justify-between text-xs">
{group.files.map((f) => (
<div key={f.filename} className="flex justify-between text-xs">
<span className="text-foreground truncate">{f.filename}</span>
<span className="text-muted-foreground shrink-0 ml-2">{f.similarity}%</span>
</div>
@@ -32,15 +32,17 @@ export function GifToolsSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Mode</label>
<p className="text-xs text-muted-foreground">Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setMode("resize")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "resize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Resize
</button>
<button
type="button"
onClick={() => setMode("extract")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "extract" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -53,8 +55,11 @@ export function GifToolsSettings() {
<>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="gif-tools-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="gif-tools-width"
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
@@ -63,8 +68,11 @@ export function GifToolsSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="gif-tools-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="gif-tools-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -74,8 +82,12 @@ export function GifToolsSettings() {
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<label
htmlFor="gif-tools-optimize"
className="flex items-center gap-2 text-sm text-foreground"
>
<input
id="gif-tools-optimize"
type="checkbox"
checked={optimize}
onChange={(e) => setOptimize(e.target.checked)}
@@ -86,8 +98,11 @@ export function GifToolsSettings() {
</>
) : (
<div>
<label className="text-xs text-muted-foreground">Frame Number</label>
<label htmlFor="gif-tools-frame" className="text-xs text-muted-foreground">
Frame Number
</label>
<input
id="gif-tools-frame"
type="number"
value={extractFrame}
onChange={(e) => setExtractFrame(e.target.value)}
@@ -118,6 +133,7 @@ export function GifToolsSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -57,8 +57,11 @@ export function ImageToPdfSettings() {
</p>
<div>
<label className="text-xs text-muted-foreground">Page Size</label>
<label htmlFor="image-to-pdf-page-size" className="text-xs text-muted-foreground">
Page Size
</label>
<select
id="image-to-pdf-page-size"
value={pageSize}
onChange={(e) => setPageSize(e.target.value as typeof pageSize)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -71,15 +74,17 @@ export function ImageToPdfSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Orientation</label>
<p className="text-xs text-muted-foreground">Orientation</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setOrientation("portrait")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "portrait" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Portrait
</button>
<button
type="button"
onClick={() => setOrientation("landscape")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "landscape" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -90,10 +95,13 @@ export function ImageToPdfSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Margin</label>
<label htmlFor="image-to-pdf-margin" className="text-xs text-muted-foreground">
Margin
</label>
<span className="text-xs font-mono text-foreground">{margin}pt</span>
</div>
<input
id="image-to-pdf-margin"
type="range"
min={0}
max={100}
@@ -106,6 +114,7 @@ export function ImageToPdfSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -79,6 +79,7 @@ export function InfoSettings() {
return (
<div className="space-y-4">
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -122,7 +123,7 @@ export function InfoSettings() {
{/* Histogram */}
<div>
<label className="text-xs font-medium text-muted-foreground">Channel Stats</label>
<p className="text-xs font-medium text-muted-foreground">Channel Stats</p>
<div className="mt-1 space-y-1.5">
{info.histogram.map((ch) => (
<div key={ch.channel} className="space-y-0.5">
+11 -3
View File
@@ -129,9 +129,10 @@ export function OcrSettings() {
<div className="space-y-4">
{/* Engine selector */}
<div>
<label className="text-sm font-medium text-muted-foreground">OCR Engine</label>
<p className="text-sm font-medium text-muted-foreground">OCR Engine</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setEngine("tesseract")}
className={`flex-1 text-xs py-1.5 rounded ${
engine === "tesseract"
@@ -142,6 +143,7 @@ export function OcrSettings() {
Tesseract
</button>
<button
type="button"
onClick={() => setEngine("paddleocr")}
className={`flex-1 text-xs py-1.5 rounded ${
engine === "paddleocr"
@@ -156,8 +158,11 @@ export function OcrSettings() {
{/* Language selector */}
<div>
<label className="text-xs text-muted-foreground">Language</label>
<label htmlFor="ocr-language" className="text-xs text-muted-foreground">
Language
</label>
<select
id="ocr-language"
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -185,6 +190,7 @@ export function OcrSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -197,10 +203,11 @@ export function OcrSettings() {
{text !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor="ocr-result-text" className="text-xs font-medium text-muted-foreground">
Extracted Text ({detectedEngine})
</label>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
@@ -209,6 +216,7 @@ export function OcrSettings() {
</button>
</div>
<textarea
id="ocr-result-text"
readOnly
value={text}
rows={8}
@@ -158,7 +158,8 @@ export function PipelineBuilder({
return (
<div className="space-y-6">
{/* File Upload Area */}
<div
<section
aria-label="File upload area"
onDragOver={(e) => e.preventDefault()}
onDrop={handleFileDrop}
className={cn(
@@ -178,6 +179,7 @@ export function PipelineBuilder({
</span>
</div>
<button
type="button"
onClick={() => setFile(null)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
@@ -186,6 +188,7 @@ export function PipelineBuilder({
</div>
) : (
<button
type="button"
onClick={handleFileSelect}
className="flex items-center gap-2 mx-auto px-4 py-2 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm"
>
@@ -193,7 +196,7 @@ export function PipelineBuilder({
Upload image to process
</button>
)}
</div>
</section>
{/* Pipeline Steps */}
<div className="space-y-2">
@@ -226,6 +229,7 @@ export function PipelineBuilder({
{/* Controls */}
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
title="Settings"
@@ -235,6 +239,7 @@ export function PipelineBuilder({
/>
</button>
<button
type="button"
onClick={() => moveStep(step.id, "up")}
disabled={idx === 0}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
@@ -243,6 +248,7 @@ export function PipelineBuilder({
<ChevronUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(step.id, "down")}
disabled={idx === steps.length - 1}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
@@ -251,6 +257,7 @@ export function PipelineBuilder({
<ChevronDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(step.id)}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Remove"
@@ -283,6 +290,7 @@ export function PipelineBuilder({
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Add a step</span>
<button
type="button"
onClick={() => setShowToolPicker(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
@@ -294,6 +302,7 @@ export function PipelineBuilder({
return (
<button
key={tool.id}
type="button"
onClick={() => addStep(tool.id)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
>
@@ -308,6 +317,7 @@ export function PipelineBuilder({
</div>
) : (
<button
type="button"
onClick={() => setShowToolPicker(true)}
className="flex items-center gap-2 w-full justify-center px-4 py-2.5 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
@@ -343,6 +353,7 @@ export function PipelineBuilder({
{/* Action Buttons */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleExecute}
disabled={steps.length === 0 || !file || executing}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -362,6 +373,7 @@ export function PipelineBuilder({
{!showSaveForm ? (
<button
type="button"
onClick={() => setShowSaveForm(true)}
disabled={steps.length === 0}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -386,6 +398,7 @@ export function PipelineBuilder({
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1 hidden sm:block"
/>
<button
type="button"
onClick={handleSave}
disabled={!saveName.trim() || saving}
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
@@ -393,6 +406,7 @@ export function PipelineBuilder({
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={() => setShowSaveForm(false)}
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
>
@@ -526,8 +526,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "number":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<input
id={`pipeline-${field.key}`}
type="number"
value={value != null && value !== "" ? Number(value) : ""}
onChange={(e) =>
@@ -548,8 +551,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "text":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<input
id={`pipeline-${field.key}`}
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
@@ -560,12 +566,12 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
);
case "select": {
const opts = field.options!;
const opts = field.options ?? [];
// Use button group for <= 4 options, dropdown for more
if (opts.length <= 4) {
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<p className="text-xs text-muted-foreground">{field.label}</p>
<div className="flex gap-1 mt-0.5">
{opts.map((opt) => (
<button
@@ -587,8 +593,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
}
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<select
id={`pipeline-${field.key}`}
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -623,15 +632,22 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "color":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label
htmlFor={`pipeline-${field.key}-text`}
className="text-xs text-muted-foreground"
>
{field.label}
</label>
<div className="flex gap-2 mt-0.5">
<input
id={`pipeline-${field.key}-picker`}
type="color"
value={String(value || "#000000").slice(0, 7)}
onChange={(e) => updateField(field.key, e.target.value)}
className="h-8 w-8 rounded border border-border cursor-pointer bg-background"
/>
<input
id={`pipeline-${field.key}-text`}
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
@@ -52,8 +52,11 @@ export function QrGenerateSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Text / URL</label>
<label htmlFor="qr-text" className="text-xs text-muted-foreground">
Text / URL
</label>
<textarea
id="qr-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text or URL..."
@@ -64,10 +67,13 @@ export function QrGenerateSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Size</label>
<label htmlFor="qr-size" className="text-xs text-muted-foreground">
Size
</label>
<span className="text-xs font-mono text-foreground">{size}px</span>
</div>
<input
id="qr-size"
type="range"
min={100}
max={2000}
@@ -79,8 +85,11 @@ export function QrGenerateSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Error Correction</label>
<label htmlFor="qr-error-correction" className="text-xs text-muted-foreground">
Error Correction
</label>
<select
id="qr-error-correction"
value={errorCorrection}
onChange={(e) => setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -94,8 +103,11 @@ export function QrGenerateSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Foreground</label>
<label htmlFor="qr-foreground" className="text-xs text-muted-foreground">
Foreground
</label>
<input
id="qr-foreground"
type="color"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
@@ -103,8 +115,11 @@ export function QrGenerateSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Background</label>
<label htmlFor="qr-background" className="text-xs text-muted-foreground">
Background
</label>
<input
id="qr-background"
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
@@ -116,6 +131,7 @@ export function QrGenerateSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleGenerate}
disabled={!text || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -133,7 +149,7 @@ export function QrGenerateSettings() {
style={{ maxHeight: 200 }}
/>
<a
href={downloadUrl!}
href={downloadUrl ?? undefined}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
@@ -65,13 +65,14 @@ export function RemoveBgSettings() {
<div className="space-y-4">
{/* Subject type */}
<div>
<label className="text-sm font-medium text-muted-foreground">What's in the photo?</label>
<p className="text-sm font-medium text-muted-foreground">What's in the photo?</p>
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
{SUBJECT_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
onClick={() => {
setSubject(opt.value);
if (opt.value !== "people") setIsPassport(false);
@@ -105,11 +106,12 @@ export function RemoveBgSettings() {
{/* Quality */}
<div>
<label className="text-sm font-medium text-muted-foreground">Quality</label>
<p className="text-sm font-medium text-muted-foreground">Quality</p>
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
quality === opt.value
@@ -126,11 +128,12 @@ export function RemoveBgSettings() {
{/* Background color - intuitive preset buttons */}
<div>
<label className="text-sm font-medium text-muted-foreground">Output Background</label>
<p className="text-sm font-medium text-muted-foreground">Output Background</p>
<div className="flex gap-1.5 mt-1.5 flex-wrap">
{BG_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setBgColor(preset.color)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
bgColor === preset.color
@@ -197,6 +200,7 @@ export function RemoveBgSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -36,9 +36,12 @@ export function ReplaceColorSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Source Color (to replace)</label>
<label htmlFor="replace-source-color" className="text-xs text-muted-foreground">
Source Color (to replace)
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="replace-source-color"
type="color"
value={sourceColor}
onChange={(e) => setSourceColor(e.target.value)}
@@ -60,9 +63,12 @@ export function ReplaceColorSettings() {
{!makeTransparent && (
<div>
<label className="text-xs text-muted-foreground">Target Color (replacement)</label>
<label htmlFor="replace-target-color" className="text-xs text-muted-foreground">
Target Color (replacement)
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="replace-target-color"
type="color"
value={targetColor}
onChange={(e) => setTargetColor(e.target.value)}
@@ -75,10 +81,13 @@ export function ReplaceColorSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Tolerance</label>
<label htmlFor="replace-tolerance" className="text-xs text-muted-foreground">
Tolerance
</label>
<span className="text-xs font-mono text-foreground">{tolerance}</span>
</div>
<input
id="replace-tolerance"
type="range"
min={0}
max={255}
@@ -112,6 +121,7 @@ export function ReplaceColorSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -144,8 +144,11 @@ export function ResizeSettings() {
<div className="space-y-3">
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="resize-width"
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
@@ -162,8 +165,11 @@ export function ResizeSettings() {
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
</button>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="resize-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -175,7 +181,7 @@ export function ResizeSettings() {
{/* Fit mode */}
<div>
<label className="text-xs text-muted-foreground">Fit Mode</label>
<p className="text-xs text-muted-foreground">Fit Mode</p>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
<button
@@ -207,8 +213,11 @@ export function ResizeSettings() {
{tab === "scale" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Scale (%)</label>
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
Scale (%)
</label>
<input
id="resize-scale"
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
@@ -83,7 +83,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate */}
<div>
<label className="text-xs text-muted-foreground">Rotate</label>
<p className="text-xs text-muted-foreground">Rotate</p>
<div className="flex items-center gap-2 mt-1">
<button
type="button"
@@ -112,13 +112,16 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{/* Straighten */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Straighten</label>
<label htmlFor="rotate-straighten" className="text-xs text-muted-foreground">
Straighten
</label>
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{straighten > 0 ? "+" : ""}
{straighten}°
</span>
</div>
<input
id="rotate-straighten"
type="range"
min={-45}
max={45}
@@ -136,7 +139,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{/* Flip buttons */}
<div>
<label className="text-xs text-muted-foreground">Flip</label>
<p className="text-xs text-muted-foreground">Flip</p>
<div className="flex gap-2 mt-1">
<button
type="button"
@@ -46,8 +46,11 @@ export function SmartCropSettings() {
<div className="space-y-4">
{/* Aspect ratio preset */}
<div>
<label className="text-sm font-medium text-muted-foreground">Target Aspect Ratio</label>
<label htmlFor="smart-crop-preset" className="text-sm font-medium text-muted-foreground">
Target Aspect Ratio
</label>
<select
id="smart-crop-preset"
value={preset}
onChange={(e) => handlePreset(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -63,8 +66,11 @@ export function SmartCropSettings() {
{/* Width / Height */}
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="smart-crop-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="smart-crop-width"
type="number"
value={width}
onChange={(e) => {
@@ -76,8 +82,11 @@ export function SmartCropSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="smart-crop-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="smart-crop-height"
type="number"
value={height}
onChange={(e) => {
@@ -119,6 +128,7 @@ export function SmartCropSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -63,11 +63,12 @@ export function SplitSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Grid Presets</label>
<p className="text-xs text-muted-foreground">Grid Presets</p>
<div className="flex gap-1 mt-1 flex-wrap">
{presets.map((p) => (
<button
key={p.label}
type="button"
onClick={() => {
setColumns(p.c);
setRows(p.r);
@@ -82,8 +83,11 @@ export function SplitSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Columns</label>
<label htmlFor="split-columns" className="text-xs text-muted-foreground">
Columns
</label>
<input
id="split-columns"
type="number"
value={columns}
onChange={(e) => setColumns(Math.max(1, Number(e.target.value)))}
@@ -93,8 +97,11 @@ export function SplitSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Rows</label>
<label htmlFor="split-rows" className="text-xs text-muted-foreground">
Rows
</label>
<input
id="split-rows"
type="number"
value={rows}
onChange={(e) => setRows(Math.max(1, Number(e.target.value)))}
@@ -110,6 +117,7 @@ export function SplitSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -229,7 +229,7 @@ export function StripMetadataSettings() {
}
const data: MetadataResult = await res.json();
setMetadata(data);
setMetadataCache((prev) => new Map(prev).set(fileKey!, data));
if (fileKey) setMetadataCache((prev) => new Map(prev).set(fileKey, data));
} catch (err) {
if ((err as Error).name === "AbortError") return;
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
@@ -239,7 +239,7 @@ export function StripMetadataSettings() {
})();
return () => controller.abort();
}, [currentFile, fileKey, metadataCache.get]);
}, [currentFile, fileKey, metadataCache]);
const handleStripAllChange = (checked: boolean) => {
setStripAll(checked);
@@ -278,7 +278,7 @@ export function StripMetadataSettings() {
{/* Metadata Display */}
{hasFile && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
<p className="text-xs font-medium text-muted-foreground">Current Metadata</p>
{inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
@@ -307,13 +307,13 @@ export function StripMetadataSettings() {
</div>
)}
{hasExif && (
{hasExif && metadata.exif && (
<CollapsibleSection
title="EXIF"
badge={`${Object.keys(metadata.exif!).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
badge={`${Object.keys(metadata.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
defaultOpen
>
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
<MetadataGrid data={metadata.exif} labelMap={EXIF_LABELS} />
</CollapsibleSection>
)}
@@ -321,31 +321,31 @@ export function StripMetadataSettings() {
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
)}
{hasGps && (
{hasGps && metadata.gps && (
<CollapsibleSection
title="GPS"
warning
badge={`${Object.keys(metadata.gps!).filter((k) => !k.startsWith("_")).length} fields`}
badge={`${Object.keys(metadata.gps).filter((k) => !k.startsWith("_")).length} fields`}
>
<MetadataGrid data={metadata.gps!} />
<MetadataGrid data={metadata.gps} />
</CollapsibleSection>
)}
{hasIcc && (
{hasIcc && metadata.icc && (
<CollapsibleSection
title="ICC Profile"
badge={`${Object.keys(metadata.icc!).length} fields`}
badge={`${Object.keys(metadata.icc).length} fields`}
>
<MetadataGrid data={metadata.icc!} />
<MetadataGrid data={metadata.icc} />
</CollapsibleSection>
)}
{hasXmp && (
{hasXmp && metadata.xmp && (
<CollapsibleSection
title="XMP"
badge={`${Object.keys(metadata.xmp!).length} fields`}
badge={`${Object.keys(metadata.xmp).length} fields`}
>
<MetadataGrid data={metadata.xmp!} />
<MetadataGrid data={metadata.xmp} />
</CollapsibleSection>
)}
@@ -374,7 +374,7 @@ export function StripMetadataSettings() {
{/* Individual options */}
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Or select specific metadata:</label>
<p className="text-xs text-muted-foreground">Or select specific metadata:</p>
<label
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
@@ -389,7 +389,7 @@ export function StripMetadataSettings() {
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
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
</span>
)}
</label>
@@ -67,8 +67,11 @@ export function SvgToRasterSettings() {
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="svg-raster-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="svg-raster-width"
type="number"
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
@@ -78,8 +81,11 @@ export function SvgToRasterSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="svg-raster-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="svg-raster-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -90,8 +96,11 @@ export function SvgToRasterSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Output Format</label>
<label htmlFor="svg-raster-format" className="text-xs text-muted-foreground">
Output Format
</label>
<select
id="svg-raster-format"
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value as "png" | "jpg" | "webp")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -115,8 +124,11 @@ export function SvgToRasterSettings() {
{!transparent && (
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<label htmlFor="svg-raster-bg-color" className="text-xs text-muted-foreground">
Background Color
</label>
<input
id="svg-raster-bg-color"
type="color"
value={backgroundColor.slice(0, 7)}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -135,6 +147,7 @@ export function SvgToRasterSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -39,8 +39,11 @@ export function TextOverlaySettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Text</label>
<label htmlFor="text-overlay-text" className="text-xs text-muted-foreground">
Text
</label>
<input
id="text-overlay-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
@@ -50,10 +53,13 @@ export function TextOverlaySettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Font Size</label>
<label htmlFor="text-overlay-font-size" className="text-xs text-muted-foreground">
Font Size
</label>
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
</div>
<input
id="text-overlay-font-size"
type="range"
min={8}
max={200}
@@ -64,8 +70,11 @@ export function TextOverlaySettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Text Color</label>
<label htmlFor="text-overlay-color" className="text-xs text-muted-foreground">
Text Color
</label>
<input
id="text-overlay-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
@@ -74,8 +83,11 @@ export function TextOverlaySettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="text-overlay-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="text-overlay-position"
value={position}
onChange={(e) => setPosition(e.target.value as "top" | "center" | "bottom")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -108,8 +120,11 @@ export function TextOverlaySettings() {
{backgroundBox && (
<div>
<label className="text-xs text-muted-foreground">Box Color</label>
<label htmlFor="text-overlay-box-color" className="text-xs text-muted-foreground">
Box Color
</label>
<input
id="text-overlay-box-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -138,6 +153,7 @@ export function TextOverlaySettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing || !text}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -21,11 +21,12 @@ export function UpscaleSettings() {
<div className="space-y-4">
{/* Scale factor */}
<div>
<label className="text-sm font-medium text-muted-foreground">Scale Factor</label>
<p className="text-sm font-medium text-muted-foreground">Scale Factor</p>
<div className="flex gap-1 mt-1">
{[2, 4].map((s) => (
<button
key={s}
type="button"
onClick={() => setScale(s)}
className={`flex-1 text-xs py-1.5 rounded ${
scale === s
@@ -68,6 +69,7 @@ export function UpscaleSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -58,15 +58,17 @@ export function VectorizeSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Color Mode</label>
<p className="text-xs text-muted-foreground">Color Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setColorMode("bw")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "bw" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Black & White
</button>
<button
type="button"
onClick={() => setColorMode("color")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -77,10 +79,13 @@ export function VectorizeSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Threshold</label>
<label htmlFor="vectorize-threshold" className="text-xs text-muted-foreground">
Threshold
</label>
<span className="text-xs font-mono text-foreground">{threshold}</span>
</div>
<input
id="vectorize-threshold"
type="range"
min={0}
max={255}
@@ -91,8 +96,11 @@ export function VectorizeSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Detail Level</label>
<label htmlFor="vectorize-detail" className="text-xs text-muted-foreground">
Detail Level
</label>
<select
id="vectorize-detail"
value={detail}
onChange={(e) => setDetail(e.target.value as "low" | "medium" | "high")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -113,6 +121,7 @@ export function VectorizeSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -63,7 +63,7 @@ export function WatermarkImageSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Watermark Image</label>
<p className="text-xs text-muted-foreground">Watermark Image</p>
<input
ref={watermarkInputRef}
type="file"
@@ -72,6 +72,7 @@ export function WatermarkImageSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => watermarkInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -81,8 +82,11 @@ export function WatermarkImageSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="watermark-image-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="watermark-image-position"
value={position}
onChange={(e) => setPosition(e.target.value as Position)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -97,10 +101,13 @@ export function WatermarkImageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="watermark-image-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="watermark-image-opacity"
type="range"
min={0}
max={100}
@@ -112,10 +119,13 @@ export function WatermarkImageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Scale</label>
<label htmlFor="watermark-image-scale" className="text-xs text-muted-foreground">
Scale
</label>
<span className="text-xs font-mono text-foreground">{scale}%</span>
</div>
<input
id="watermark-image-scale"
type="range"
min={5}
max={100}
@@ -135,6 +145,7 @@ export function WatermarkImageSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !watermarkFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -40,8 +40,11 @@ export function WatermarkTextSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Watermark Text</label>
<label htmlFor="watermark-text-text" className="text-xs text-muted-foreground">
Watermark Text
</label>
<input
id="watermark-text-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
@@ -51,10 +54,13 @@ export function WatermarkTextSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Font Size</label>
<label htmlFor="watermark-text-font-size" className="text-xs text-muted-foreground">
Font Size
</label>
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
</div>
<input
id="watermark-text-font-size"
type="range"
min={8}
max={200}
@@ -66,8 +72,11 @@ export function WatermarkTextSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Color</label>
<label htmlFor="watermark-text-color" className="text-xs text-muted-foreground">
Color
</label>
<input
id="watermark-text-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
@@ -76,10 +85,13 @@ export function WatermarkTextSettings() {
</div>
<div className="flex-1">
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="watermark-text-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="watermark-text-opacity"
type="range"
min={0}
max={100}
@@ -91,8 +103,11 @@ export function WatermarkTextSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="watermark-text-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="watermark-text-position"
value={position}
onChange={(e) => setPosition(e.target.value as Position)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -108,10 +123,13 @@ export function WatermarkTextSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Rotation</label>
<label htmlFor="watermark-text-rotation" className="text-xs text-muted-foreground">
Rotation
</label>
<span className="text-xs font-mono text-foreground">{rotation}&deg;</span>
</div>
<input
id="watermark-text-rotation"
type="range"
min={-180}
max={180}
@@ -141,6 +159,7 @@ export function WatermarkTextSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing || !text}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
+3 -1
View File
@@ -3,7 +3,9 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
+3
View File
@@ -236,6 +236,7 @@ export function AutomatePage() {
{TEMPLATES.map((tpl) => (
<button
key={tpl.name}
type="button"
onClick={() => loadTemplate(tpl)}
className="w-full text-left p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors"
>
@@ -265,6 +266,7 @@ export function AutomatePage() {
>
<div className="flex items-center justify-between mb-1">
<button
type="button"
onClick={() => loadSaved(pipeline)}
className="text-sm font-medium text-foreground hover:text-primary flex items-center gap-1.5"
>
@@ -272,6 +274,7 @@ export function AutomatePage() {
{pipeline.name}
</button>
<button
type="button"
onClick={() => handleDelete(pipeline.id)}
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
>
@@ -81,6 +81,7 @@ export function FullscreenGridPage() {
{/* Toggle details */}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm transition-colors",
@@ -97,6 +98,7 @@ export function FullscreenGridPage() {
{/* Switch to sidebar view */}
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
title="Switch to sidebar view"
+3
View File
@@ -55,6 +55,7 @@ export function HomePage() {
{files.length > 1 && `${files.length} files`}
</p>
<button
type="button"
onClick={reset}
className="text-xs text-muted-foreground hover:text-foreground mt-2"
>
@@ -78,6 +79,7 @@ export function HomePage() {
return (
<button
key={id}
type="button"
onClick={() => handleToolClick(tool.route)}
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
@@ -119,6 +121,7 @@ export function HomePage() {
return (
<button
key={tool.id}
type="button"
onClick={() => handleToolClick(tool.route)}
className={cn(
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
+22 -6
View File
@@ -161,7 +161,11 @@ function FileSelectionInfo({
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">Files ({files.length})</span>
<button onClick={onAddMore} className="text-xs text-primary hover:text-primary/80">
<button
type="button"
onClick={onAddMore}
className="text-xs text-primary hover:text-primary/80"
>
+ Add more
</button>
</div>
@@ -172,7 +176,11 @@ function FileSelectionInfo({
{formatFileSize(selectedFileSize ?? files[0].size)}
</span>
</div>
<button onClick={onClear} className="text-xs text-muted-foreground hover:text-foreground">
<button
type="button"
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
@@ -322,6 +330,7 @@ export function ToolPage() {
</div>
<h2 className="font-semibold text-lg text-foreground flex-1">{tool.name}</h2>
<button
type="button"
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
>
@@ -384,7 +393,8 @@ export function ToolPage() {
)}
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -392,6 +402,7 @@ export function ToolPage() {
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -460,6 +471,7 @@ export function ToolPage() {
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -480,7 +492,7 @@ export function ToolPage() {
onSelect={setSelectedIndex}
/>
)}
</div>
</section>
</div>
</AppLayout>
);
@@ -552,6 +564,7 @@ export function ToolPage() {
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
type="button"
onClick={handleDownloadAll}
className="w-full py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
@@ -563,7 +576,8 @@ export function ToolPage() {
</div>
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -571,6 +585,7 @@ export function ToolPage() {
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -639,6 +654,7 @@ export function ToolPage() {
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -659,7 +675,7 @@ export function ToolPage() {
onSelect={setSelectedIndex}
/>
)}
</div>
</section>
</div>
</AppLayout>
);