mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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}°</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,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>,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user