mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
refactor: rename Tool.alpha to Tool.experimental
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useCallback, useEffect, type PointerEvent } from "react";
|
||||
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface BeforeAfterSliderProps {
|
||||
/** URL or data URL of original image. */
|
||||
@@ -34,17 +34,14 @@ export function BeforeAfterSlider({
|
||||
const [position, setPosition] = useState(50); // percentage 0-100
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(clientX: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setPosition(pct);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const updatePosition = useCallback((clientX: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setPosition(pct);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
@@ -95,12 +92,7 @@ export function BeforeAfterSlider({
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{/* Before image (full width, bottom layer) */}
|
||||
<img
|
||||
src={beforeSrc}
|
||||
alt="Original"
|
||||
className="block w-full h-auto"
|
||||
draggable={false}
|
||||
/>
|
||||
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
|
||||
|
||||
{/* After image (clipped, top layer) */}
|
||||
<div
|
||||
@@ -124,13 +116,7 @@ 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">
|
||||
<path
|
||||
d="M4 3L1 7L4 11"
|
||||
stroke="currentColor"
|
||||
@@ -170,9 +156,7 @@ export function BeforeAfterSlider({
|
||||
<span className="ml-1">({savingsPercent}% smaller)</span>
|
||||
)}
|
||||
{savingsPercent !== null && Number(savingsPercent) < 0 && (
|
||||
<span className="ml-1">
|
||||
({Math.abs(Number(savingsPercent))}% larger)
|
||||
</span>
|
||||
<span className="ml-1">({Math.abs(Number(savingsPercent))}% larger)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState, type DragEvent } from "react";
|
||||
import { Upload, FileImage } from "lucide-react";
|
||||
import { FileImage, Upload } from "lucide-react";
|
||||
import { type DragEvent, useCallback, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DropzoneProps {
|
||||
@@ -28,7 +28,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) onFiles?.(files);
|
||||
},
|
||||
[onFiles]
|
||||
[onFiles],
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
@@ -56,7 +56,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
|
||||
"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",
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50"
|
||||
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 p-8">
|
||||
@@ -67,9 +67,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload from computer
|
||||
</button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drop files here or click the upload button
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Drop files here or click the upload button</p>
|
||||
|
||||
{/* Show file count badge and list when multiple files are dropped */}
|
||||
{hasMultipleFiles && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { ZoomIn, ZoomOut, Maximize, Minimize2 } from "lucide-react";
|
||||
import { Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
|
||||
interface ImageViewerProps {
|
||||
@@ -14,7 +14,14 @@ interface ImageViewerProps {
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200, 300];
|
||||
const DEFAULT_ZOOM = 100;
|
||||
|
||||
export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) {
|
||||
export function ImageViewer({
|
||||
src,
|
||||
filename,
|
||||
fileSize,
|
||||
cssRotate,
|
||||
cssFlipH,
|
||||
cssFlipV,
|
||||
}: ImageViewerProps) {
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
|
||||
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
|
||||
@@ -63,7 +70,7 @@ export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssF
|
||||
setFitMode("fit");
|
||||
setNaturalWidth(null);
|
||||
setNaturalHeight(null);
|
||||
}, [src]);
|
||||
}, []);
|
||||
|
||||
const previewTransform = [
|
||||
cssRotate ? `rotate(${cssRotate}deg)` : "",
|
||||
|
||||
@@ -5,11 +5,7 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
* Must be rendered inside a <BrowserRouter> so that
|
||||
* useNavigate() works inside the hook.
|
||||
*/
|
||||
export function KeyboardShortcutProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function KeyboardShortcutProvider({ children }: { children: React.ReactNode }) {
|
||||
useKeyboardShortcuts();
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { useCallback } from "react";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
@@ -14,30 +14,59 @@ export function MultiImageViewer() {
|
||||
const hasPrev = selectedIndex > 0;
|
||||
const hasNext = selectedIndex < entries.length - 1;
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); }
|
||||
else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); }
|
||||
}, [navigateNext, navigatePrev]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
navigatePrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
navigateNext();
|
||||
}
|
||||
},
|
||||
[navigateNext, navigatePrev],
|
||||
);
|
||||
|
||||
const hasProcessed = !!currentEntry.processedUrl;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full min-h-0" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
|
||||
<div
|
||||
className="flex flex-col w-full h-full min-h-0"
|
||||
onKeyDown={hasMultiple ? handleKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-1 relative flex items-center justify-center min-h-0">
|
||||
{hasMultiple && hasPrev && (
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-full h-full min-h-0">
|
||||
{hasProcessed ? (
|
||||
<BeforeAfterSlider beforeSrc={currentEntry.blobUrl} afterSrc={currentEntry.processedUrl!} beforeSize={currentEntry.originalSize} afterSize={currentEntry.processedSize ?? undefined} />
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={currentEntry.blobUrl}
|
||||
afterSrc={currentEntry.processedUrl!}
|
||||
beforeSize={currentEntry.originalSize}
|
||||
afterSize={currentEntry.processedSize ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<ImageViewer src={currentEntry.blobUrl} filename={currentEntry.file.name} fileSize={currentEntry.file.size} />
|
||||
<ImageViewer
|
||||
src={currentEntry.blobUrl}
|
||||
filename={currentEntry.file.name}
|
||||
fileSize={currentEntry.file.size}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasMultiple && hasNext && (
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Upload, Loader2 } from "lucide-react";
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
|
||||
interface ProgressCardProps {
|
||||
active: boolean;
|
||||
@@ -9,14 +9,7 @@ interface ProgressCardProps {
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
export function ProgressCard({
|
||||
active,
|
||||
phase,
|
||||
label,
|
||||
stage,
|
||||
percent,
|
||||
elapsed,
|
||||
}: ProgressCardProps) {
|
||||
export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) {
|
||||
if (!active) return null;
|
||||
|
||||
const icon =
|
||||
@@ -35,12 +28,8 @@ export function ProgressCard({
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{sublabel}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground truncate">{label}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">{sublabel}</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-primary font-mono tabular-nums">
|
||||
{Math.round(percent)}%
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import {
|
||||
Download,
|
||||
Undo2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import * as icons from "lucide-react";
|
||||
import { triggerDownload, formatFileSize } from "@/lib/download";
|
||||
import { ArrowRight, ChevronDown, ChevronRight, Download, Undo2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { formatFileSize, triggerDownload } from "@/lib/download";
|
||||
import { getSuggestedTools } from "@/lib/suggested-tools";
|
||||
|
||||
interface ReviewPanelProps {
|
||||
@@ -35,18 +29,13 @@ export function ReviewPanel({
|
||||
const [isSuggestionsExpanded, setIsSuggestionsExpanded] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const suggestedToolIds = useMemo(
|
||||
() => getSuggestedTools(currentToolId),
|
||||
[currentToolId],
|
||||
);
|
||||
const suggestedToolIds = useMemo(() => getSuggestedTools(currentToolId), [currentToolId]);
|
||||
|
||||
const suggestedTools = useMemo(
|
||||
() =>
|
||||
suggestedToolIds
|
||||
.map((id) => TOOLS.find((t) => t.id === id))
|
||||
.filter(
|
||||
(t): t is (typeof TOOLS)[number] => t !== undefined,
|
||||
),
|
||||
.filter((t): t is (typeof TOOLS)[number] => t !== undefined),
|
||||
[suggestedToolIds],
|
||||
);
|
||||
|
||||
@@ -69,11 +58,7 @@ export function ReviewPanel({
|
||||
className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span>Review</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
@@ -120,9 +105,7 @@ export function ReviewPanel({
|
||||
<div className="space-y-2">
|
||||
<div className="border-t border-border pt-2" />
|
||||
<button
|
||||
onClick={() =>
|
||||
setIsSuggestionsExpanded(!isSuggestionsExpanded)
|
||||
}
|
||||
onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)}
|
||||
className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span>Continue editing</span>
|
||||
|
||||
@@ -6,11 +6,7 @@ interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search tools...",
|
||||
}: SearchBarProps) {
|
||||
export function SearchBar({ value, onChange, placeholder = "Search tools..." }: SearchBarProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { FileEntry } from "@/stores/file-store";
|
||||
|
||||
interface ThumbnailStripProps {
|
||||
@@ -17,12 +17,15 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
|
||||
inline: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
}, []);
|
||||
|
||||
if (entries.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5 px-3 py-2 overflow-x-auto border-t border-border bg-muted/30" style={{ scrollBehavior: "smooth" }}>
|
||||
<div
|
||||
className="flex gap-1.5 px-3 py-2 overflow-x-auto border-t border-border bg-muted/30"
|
||||
style={{ scrollBehavior: "smooth" }}
|
||||
>
|
||||
{entries.map((entry, i) => {
|
||||
const isSelected = i === selectedIndex;
|
||||
const isCompleted = entry.status === "completed";
|
||||
@@ -33,12 +36,19 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
onClick={() => onSelect(i)}
|
||||
className={`relative shrink-0 rounded overflow-hidden transition-all ${
|
||||
isSelected ? "outline outline-2 outline-primary outline-offset-1" : "hover:outline hover:outline-1 hover:outline-border"
|
||||
isSelected
|
||||
? "outline outline-2 outline-primary outline-offset-1"
|
||||
: "hover:outline hover:outline-1 hover:outline-border"
|
||||
}`}
|
||||
style={{ width: 52, height: 38 }}
|
||||
title={entry.file.name}
|
||||
>
|
||||
<img src={entry.processedUrl ?? entry.blobUrl} alt={entry.file.name} className="w-full h-full object-cover" draggable={false} />
|
||||
<img
|
||||
src={entry.processedUrl ?? entry.blobUrl}
|
||||
alt={entry.file.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
{isCompleted && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<CheckCircle2 className="h-2.5 w-2.5 text-white" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Star, FileImage } from "lucide-react";
|
||||
import * as icons from "lucide-react";
|
||||
import type { Tool } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { FileImage, Star } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToolCardProps {
|
||||
@@ -9,10 +9,7 @@ interface ToolCardProps {
|
||||
}
|
||||
|
||||
export function ToolCard({ tool }: ToolCardProps) {
|
||||
const iconsMap = icons as unknown as Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
>;
|
||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
const IconComponent = iconsMap[tool.icon] || FileImage;
|
||||
|
||||
return (
|
||||
@@ -28,14 +25,14 @@ export function ToolCard({ tool }: ToolCardProps) {
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors",
|
||||
"hover:bg-muted",
|
||||
tool.disabled && "opacity-50 pointer-events-none"
|
||||
tool.disabled && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
||||
{tool.alpha && (
|
||||
{tool.experimental && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
|
||||
Alpha
|
||||
Experimental
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { X, Keyboard, BookOpen, Github, ExternalLink } from "lucide-react";
|
||||
import { APP_VERSION } from "@stirling-image/shared";
|
||||
import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { formatShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
interface HelpDialogProps {
|
||||
@@ -36,10 +36,7 @@ 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 className="absolute inset-0 bg-black/50 backdrop-blur-sm" 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 */}
|
||||
@@ -62,10 +59,9 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
<h3 className="text-sm font-semibold">Getting Started</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Select a tool from the sidebar or search for one with{" "}
|
||||
<Kbd keys="mod+k" />. Upload an image by dragging it onto the
|
||||
page or clicking the upload area. Adjust settings and download
|
||||
your result.
|
||||
Select a tool from the sidebar or search for one with <Kbd keys="mod+k" />. Upload an
|
||||
image by dragging it onto the page or clicking the upload area. Adjust settings and
|
||||
download your result.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -80,14 +76,10 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
<div
|
||||
key={s.keys}
|
||||
className={`flex items-center justify-between px-3 py-2 text-sm ${
|
||||
i !== SHORTCUTS.length - 1
|
||||
? "border-b border-border"
|
||||
: ""
|
||||
i !== SHORTCUTS.length - 1 ? "border-b border-border" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{s.description}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{s.description}</span>
|
||||
<Kbd keys={s.keys} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Moon, Sun, Globe } from "lucide-react";
|
||||
import { Globe, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "@/hooks/use-theme";
|
||||
|
||||
export function Footer() {
|
||||
@@ -11,11 +11,7 @@ export function Footer() {
|
||||
className="p-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors"
|
||||
title="Toggle Theme"
|
||||
>
|
||||
{resolvedTheme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
<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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
|
||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||
import { useMemo, useState } from "react";
|
||||
import { SearchBar } from "../common/search-bar";
|
||||
import { ToolCard } from "../common/tool-card";
|
||||
|
||||
@@ -10,9 +10,7 @@ export function ToolPanel() {
|
||||
if (!search) return TOOLS;
|
||||
const q = search.toLowerCase();
|
||||
return TOOLS.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q)
|
||||
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
@@ -32,24 +30,20 @@ export function ToolPanel() {
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
</div>
|
||||
<div className="px-3 pb-4 flex-1">
|
||||
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map(
|
||||
(category) => (
|
||||
<div key={category.id} className="mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
{category.name}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{groupedTools.get(category.id)!.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
|
||||
<div key={category.id} className="mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
{category.name}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{groupedTools.get(category.id)?.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredTools.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No tools found
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { APP_VERSION } from "@stirling-image/shared";
|
||||
import {
|
||||
X,
|
||||
Settings,
|
||||
Shield,
|
||||
Key,
|
||||
Info,
|
||||
Check,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Check,
|
||||
Info,
|
||||
Key,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Users,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
Plus,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, clearToken } from "@/lib/api";
|
||||
import { APP_VERSION } from "@stirling-image/shared";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
@@ -60,10 +64,7 @@ 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 className="absolute inset-0 bg-black/50 backdrop-blur-sm" 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">
|
||||
@@ -80,7 +81,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
"flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors",
|
||||
section === item.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
@@ -126,9 +127,10 @@ interface ApiKeyEntry {
|
||||
}
|
||||
|
||||
interface UserEntry {
|
||||
id: number;
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
team: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -165,16 +167,18 @@ function GeneralSection() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">General</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
User preferences and display settings.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">User preferences and display settings.</p>
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-semibold">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : username.charAt(0).toUpperCase()}
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
username.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{loading ? "Loading..." : username}</p>
|
||||
@@ -224,17 +228,15 @@ function SystemSection() {
|
||||
fileUploadLimitMb: "100",
|
||||
defaultTheme: "system",
|
||||
defaultLocale: "en",
|
||||
loginAttemptLimit: "5",
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(key: string, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
const updateSetting = useCallback((key: string, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
@@ -262,9 +264,7 @@ function SystemSection() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">System Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Server-side configuration and limits.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Server-side configuration and limits.</p>
|
||||
</div>
|
||||
|
||||
<SettingRow label="App Name" description="Display name for the application">
|
||||
@@ -298,7 +298,7 @@ function SystemSection() {
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Default Locale" description="Language for the interface">
|
||||
<SettingRow label="Language" description="Language for the interface">
|
||||
<select
|
||||
value={settings.defaultLocale || "en"}
|
||||
onChange={(e) => updateSetting("defaultLocale", e.target.value)}
|
||||
@@ -313,6 +313,20 @@ function SystemSection() {
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Login Attempt Limit"
|
||||
description="Max failed login attempts per minute before lockout"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.loginAttemptLimit || "5"}
|
||||
onChange={(e) => updateSetting("loginAttemptLimit", e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
@@ -323,7 +337,14 @@ function SystemSection() {
|
||||
Save Settings
|
||||
</button>
|
||||
{saveMsg && (
|
||||
<span className={cn("text-sm", saveMsg.includes("Failed") ? "text-destructive" : "text-green-600 dark:text-green-400")}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
saveMsg.includes("Failed")
|
||||
? "text-destructive"
|
||||
: "text-green-600 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
{saveMsg}
|
||||
</span>
|
||||
)}
|
||||
@@ -365,21 +386,22 @@ function SecuritySection() {
|
||||
setConfirmPassword("");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to change password";
|
||||
setMessage({ type: "error", text: msg.includes("401") ? "Current password is incorrect" : msg });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: msg.includes("401") ? "Current password is incorrect" : msg,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[currentPassword, newPassword, confirmPassword]
|
||||
[currentPassword, newPassword, confirmPassword],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Security</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Password and authentication settings.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Password and authentication settings.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
@@ -435,7 +457,9 @@ function SecuritySection() {
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
message.type === "error" ? "text-destructive" : "text-green-600 dark:text-green-400"
|
||||
message.type === "error"
|
||||
? "text-destructive"
|
||||
: "text-green-600 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
@@ -454,9 +478,9 @@ function SecuritySection() {
|
||||
</form>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<SettingRow label="Login Attempt Limit" description="Max failed attempts before lockout">
|
||||
<span className="text-sm font-mono text-muted-foreground">5 attempts</span>
|
||||
</SettingRow>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Login attempt limits can be configured in System Settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -466,18 +490,31 @@ function SecuritySection() {
|
||||
|
||||
function PeopleSection() {
|
||||
const [users, setUsers] = useState<UserEntry[]>([]);
|
||||
const [maxUsers, setMaxUsers] = useState(5);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [newTeam, setNewTeam] = useState("Default");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [editingUser, setEditingUser] = useState<UserEntry | null>(null);
|
||||
const [editRole, setEditRole] = useState("");
|
||||
const [editTeam, setEditTeam] = useState("");
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState<UserEntry | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiGet<{ users: UserEntry[] }>("/auth/users");
|
||||
const data = await apiGet<{ users: UserEntry[]; maxUsers: number }>("/auth/users");
|
||||
setUsers(data.users);
|
||||
setMaxUsers(data.maxUsers);
|
||||
} catch {
|
||||
setUsers([]);
|
||||
} finally {
|
||||
@@ -489,6 +526,20 @@ function PeopleSection() {
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!openMenuId) return;
|
||||
const handler = () => setOpenMenuId(null);
|
||||
window.addEventListener("click", handler);
|
||||
return () => window.removeEventListener("click", handler);
|
||||
}, [openMenuId]);
|
||||
|
||||
const filteredUsers = users.filter((u) =>
|
||||
u.username.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const atLimit = users.length >= maxUsers;
|
||||
|
||||
const handleAddUser = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -499,32 +550,84 @@ function PeopleSection() {
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
role: newRole,
|
||||
team: newTeam,
|
||||
});
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
setNewRole("user");
|
||||
setNewTeam("Default");
|
||||
setShowAddForm(false);
|
||||
setActionMsg({ type: "success", text: "User created successfully" });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
setAddError(err instanceof Error ? err.message : "Failed to create user");
|
||||
const msg = err instanceof Error ? err.message : "Failed to create user";
|
||||
setAddError(msg.includes("403") ? `User limit reached (${maxUsers} max)` : msg);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
setTimeout(() => setActionMsg(null), 3000);
|
||||
}
|
||||
},
|
||||
[newUsername, newPassword, newRole, loadUsers]
|
||||
[newUsername, newPassword, newRole, newTeam, maxUsers, loadUsers],
|
||||
);
|
||||
|
||||
const handleDeleteUser = useCallback(
|
||||
async (id: number, username: string) => {
|
||||
async (id: string, username: string) => {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await apiDelete(`/auth/users/${id}`);
|
||||
setActionMsg({ type: "success", text: `User "${username}" deleted` });
|
||||
await loadUsers();
|
||||
} catch {
|
||||
// Silently fail - user likely lacks permission
|
||||
setActionMsg({ type: "error", text: "Failed to delete user" });
|
||||
}
|
||||
setOpenMenuId(null);
|
||||
setTimeout(() => setActionMsg(null), 3000);
|
||||
},
|
||||
[loadUsers]
|
||||
[loadUsers],
|
||||
);
|
||||
|
||||
const handleUpdateUser = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingUser) return;
|
||||
try {
|
||||
await apiPut(`/auth/users/${editingUser.id}`, {
|
||||
role: editRole,
|
||||
team: editTeam,
|
||||
});
|
||||
setEditingUser(null);
|
||||
setActionMsg({ type: "success", text: "User updated" });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to update user";
|
||||
setActionMsg({
|
||||
type: "error",
|
||||
text: msg.includes("400") ? "Cannot remove your own admin role" : msg,
|
||||
});
|
||||
}
|
||||
setTimeout(() => setActionMsg(null), 3000);
|
||||
},
|
||||
[editingUser, editRole, editTeam, loadUsers],
|
||||
);
|
||||
|
||||
const handleResetPassword = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!resetPasswordUser) return;
|
||||
try {
|
||||
await apiPost(`/auth/users/${resetPasswordUser.id}/reset-password`, {
|
||||
newPassword: resetPassword,
|
||||
});
|
||||
setResetPasswordUser(null);
|
||||
setResetPassword("");
|
||||
setActionMsg({ type: "success", text: "Password reset successfully" });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to reset password";
|
||||
setActionMsg({ type: "error", text: msg });
|
||||
}
|
||||
setTimeout(() => setActionMsg(null), 3000);
|
||||
},
|
||||
[resetPasswordUser, resetPassword],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -536,35 +639,80 @@ function PeopleSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">People</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage users and their roles.
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">People</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage workspace members and their permissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User count */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{users.length} / {maxUsers} users
|
||||
</p>
|
||||
|
||||
{/* Action message */}
|
||||
{actionMsg && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm px-3 py-2 rounded-lg",
|
||||
actionMsg.type === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
{actionMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search + Add Members */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search members..."
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
onClick={() => {
|
||||
setShowAddForm(!showAddForm);
|
||||
setAddError(null);
|
||||
}}
|
||||
disabled={atLimit && !showAddForm}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
atLimit && !showAddForm
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
)}
|
||||
title={atLimit ? `User limit reached (${maxUsers} max)` : "Add a new member"}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add User
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add Members
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add user form */}
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleAddUser} className="p-4 rounded-lg border border-border bg-muted/20 space-y-3">
|
||||
<h4 className="text-sm font-medium text-foreground">New User</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<form
|
||||
onSubmit={handleAddUser}
|
||||
className="p-4 rounded-lg border border-border bg-muted/20 space-y-3"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground">New Member</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
required
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-40"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
@@ -572,8 +720,8 @@ function PeopleSection() {
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
required
|
||||
minLength={4}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-40"
|
||||
minLength={8}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
@@ -583,47 +731,216 @@ function PeopleSection() {
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newTeam}
|
||||
onChange={(e) => setNewTeam(e.target.value)}
|
||||
placeholder="Team"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding}
|
||||
disabled={adding || atLimit}
|
||||
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"
|
||||
>
|
||||
{adding && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{addError && (
|
||||
<p className="text-sm text-destructive">{addError}</p>
|
||||
)}
|
||||
{addError && <p className="text-sm text-destructive">{addError}</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* User list */}
|
||||
<div className="space-y-1">
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No users found.</p>
|
||||
{/* Edit user modal */}
|
||||
{editingUser && (
|
||||
<form
|
||||
onSubmit={handleUpdateUser}
|
||||
className="p-4 rounded-lg border border-primary/30 bg-primary/5 space-y-3"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground">Edit {editingUser.username}</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={editTeam}
|
||||
onChange={(e) => setEditTeam(e.target.value)}
|
||||
placeholder="Team"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Reset password modal */}
|
||||
{resetPasswordUser && (
|
||||
<form
|
||||
onSubmit={handleResetPassword}
|
||||
className="p-4 rounded-lg border border-orange-500/30 bg-orange-500/5 space-y-3"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
Reset password for {resetPasswordUser.username}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
type="password"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
placeholder="New password (min 8 chars)"
|
||||
required
|
||||
minLength={8}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-60"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded-lg bg-orange-500 text-white text-sm font-medium hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setResetPasswordUser(null);
|
||||
setResetPassword("");
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will invalidate all sessions and API keys for this user.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Users table */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[1fr_100px_120px_60px] gap-2 px-4 py-2.5 bg-muted/40 border-b border-border text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
<span>User</span>
|
||||
<span>Role</span>
|
||||
<span>Team</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* Table rows */}
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{search ? "No members match your search." : "No users found."}
|
||||
</div>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
filteredUsers.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border bg-muted/20"
|
||||
className="grid grid-cols-[1fr_100px_120px_60px] gap-2 items-center px-4 py-3 border-b border-border last:border-0 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{/* User cell */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-semibold text-sm shrink-0">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{u.username}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{u.role}</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground truncate">{u.username}</span>
|
||||
</div>
|
||||
|
||||
{/* Role badge */}
|
||||
<div>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide",
|
||||
u.role === "admin"
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
<span className="text-sm text-foreground truncate">{u.team}</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 justify-end relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenuId(openMenuId === u.id ? null : u.id);
|
||||
}}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Actions"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{openMenuId === u.id && (
|
||||
<div
|
||||
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
|
||||
onClick={() => {
|
||||
setEditingUser(u);
|
||||
setEditRole(u.role);
|
||||
setEditTeam(u.team);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Role / Team
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setResetPasswordUser(u);
|
||||
setResetPassword("");
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reset Password
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<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"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u.id, u.username)}
|
||||
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={`Delete ${u.username}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -691,7 +1008,7 @@ function ApiKeysSection() {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
[loadKeys]
|
||||
[loadKeys],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -779,7 +1096,9 @@ function ApiKeysSection() {
|
||||
)}
|
||||
|
||||
{keys.length === 0 && !newKey && (
|
||||
<p className="text-sm text-muted-foreground">No API keys yet. Generate one to get started.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No API keys yet. Generate one to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -801,9 +1120,8 @@ function AboutSection() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A self-hosted, privacy-first image processing suite with 37+ tools.
|
||||
Resize, compress, convert, watermark, and automate your image workflows
|
||||
without sending data to the cloud.
|
||||
A self-hosted, privacy-first image processing suite with 37+ tools. Resize, compress,
|
||||
convert, watermark, and automate your image workflows without sending data to the cloud.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Loader2, Copy, Check } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -83,9 +83,13 @@ export function BarcodeReadSettings() {
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
{copied ? (
|
||||
<><Check className="h-3 w-3" /> Copied</>
|
||||
<>
|
||||
<Check className="h-3 w-3" /> Copied
|
||||
</>
|
||||
) : (
|
||||
<><Copy className="h-3 w-3" /> Copy to clipboard</>
|
||||
<>
|
||||
<Copy className="h-3 w-3" /> Copy to clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function BlurFacesSettings() {
|
||||
const { files } = useFileStore();
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function BorderSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("border");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("border");
|
||||
|
||||
const [borderWidth, setBorderWidth] = useState(10);
|
||||
const [borderColor, setBorderColor] = useState("#000000");
|
||||
@@ -33,12 +41,24 @@ export function BorderSettings() {
|
||||
<label className="text-xs text-muted-foreground">Border Width</label>
|
||||
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={borderWidth} onChange={(e) => setBorderWidth(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={borderWidth}
|
||||
onChange={(e) => setBorderWidth(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Border Color</label>
|
||||
<input type="color" value={borderColor} onChange={(e) => setBorderColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={borderColor}
|
||||
onChange={(e) => setBorderColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -46,7 +66,14 @@ export function BorderSettings() {
|
||||
<label className="text-xs text-muted-foreground">Corner Radius</label>
|
||||
<span className="text-xs font-mono text-foreground">{cornerRadius}px</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={200} value={cornerRadius} onChange={(e) => setCornerRadius(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={cornerRadius}
|
||||
onChange={(e) => setCornerRadius(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -54,7 +81,14 @@ export function BorderSettings() {
|
||||
<label className="text-xs text-muted-foreground">Padding</label>
|
||||
<span className="text-xs font-mono text-foreground">{padding}px</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={padding} onChange={(e) => setPadding(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -62,7 +96,14 @@ export function BorderSettings() {
|
||||
<label className="text-xs text-muted-foreground">Shadow</label>
|
||||
<span className="text-xs font-mono text-foreground">{shadowBlur}px</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={50} value={shadowBlur} onChange={(e) => setShadowBlur(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={shadowBlur}
|
||||
onChange={(e) => setShadowBlur(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -94,7 +135,11 @@ export function BorderSettings() {
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -60,10 +60,12 @@ export function BulkRenameSettings() {
|
||||
const ext = f.name.includes(".") ? f.name.slice(f.name.lastIndexOf(".")) : "";
|
||||
const idx = startIndex + i;
|
||||
const padded = String(idx).padStart(String(files.length + startIndex).length, "0");
|
||||
return pattern
|
||||
.replace(/\{\{index\}\}/g, String(idx))
|
||||
.replace(/\{\{padded\}\}/g, padded)
|
||||
.replace(/\{\{original\}\}/g, f.name.replace(ext, "")) + ext;
|
||||
return (
|
||||
pattern
|
||||
.replace(/\{\{index\}\}/g, String(idx))
|
||||
.replace(/\{\{padded\}\}/g, padded)
|
||||
.replace(/\{\{original\}\}/g, f.name.replace(ext, "")) + ext
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -84,8 +86,13 @@ export function BulkRenameSettings() {
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Start Index</label>
|
||||
<input type="number" value={startIndex} onChange={(e) => setStartIndex(Number(e.target.value))} min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(Number(e.target.value))}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{previewNames.length > 0 && (
|
||||
@@ -93,7 +100,10 @@ export function BulkRenameSettings() {
|
||||
<label className="text-xs text-muted-foreground">Preview</label>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{previewNames.map((name, i) => (
|
||||
<div key={i} className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate">
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -18,7 +18,8 @@ const LAYOUTS: { value: Layout; label: string }[] = [
|
||||
];
|
||||
|
||||
export function CollageSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
const [layout, setLayout] = useState<Layout>("2x2");
|
||||
const [gap, setGap] = useState(4);
|
||||
const [backgroundColor, setBackgroundColor] = useState("#FFFFFF");
|
||||
@@ -89,12 +90,24 @@ export function CollageSettings() {
|
||||
<label className="text-xs text-muted-foreground">Gap</label>
|
||||
<span className="text-xs font-mono text-foreground">{gap}px</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={50} value={gap} onChange={(e) => setGap(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={gap}
|
||||
onChange={(e) => setGap(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Background Color</label>
|
||||
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -116,7 +129,11 @@ export function CollageSettings() {
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Collage
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Loader2, Copy, Check } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -83,9 +83,7 @@ export function ColorPaletteSettings() {
|
||||
className="w-6 h-6 rounded border border-border shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-xs font-mono text-foreground flex-1 text-left">
|
||||
{color}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-foreground flex-1 text-left">{color}</span>
|
||||
{copiedIdx === i ? (
|
||||
<Check className="h-3 w-3 text-green-500 shrink-0" />
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Tab = "basic" | "channels" | "effects";
|
||||
type Effect = "none" | "grayscale" | "sepia" | "invert";
|
||||
@@ -14,8 +14,16 @@ interface ColorSettingsProps {
|
||||
|
||||
export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor(toolId);
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor(toolId);
|
||||
|
||||
const [tab, setTab] = useState<Tab>(() => {
|
||||
if (toolId === "color-channels") return "channels";
|
||||
@@ -84,9 +92,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
tab === t.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
tab === t.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -78,10 +78,7 @@ export function CompareSettings() {
|
||||
Similarity: {similarity.toFixed(1)}%
|
||||
</p>
|
||||
<div className="mt-1 h-2 bg-background rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${similarity}%` }}
|
||||
/>
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${similarity}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -96,7 +93,11 @@ export function CompareSettings() {
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Diff Image
|
||||
</a>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function ComposeSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
const [overlayFile, setOverlayFile] = useState<File | null>(null);
|
||||
const [x, setX] = useState(0);
|
||||
const [y, setY] = useState(0);
|
||||
@@ -81,13 +82,23 @@ export function ComposeSettings() {
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">X Position</label>
|
||||
<input type="number" value={x} onChange={(e) => setX(Number(e.target.value))} min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={x}
|
||||
onChange={(e) => setX(Number(e.target.value))}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Y Position</label>
|
||||
<input type="number" value={y} onChange={(e) => setY(Number(e.target.value))} min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={y}
|
||||
onChange={(e) => setY(Number(e.target.value))}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +107,14 @@ export function ComposeSettings() {
|
||||
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -138,7 +156,11 @@ export function ComposeSettings() {
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type CompressMode = "quality" | "targetSize";
|
||||
|
||||
export function CompressSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("compress");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("compress");
|
||||
|
||||
const [mode, setMode] = useState<CompressMode>("quality");
|
||||
const [quality, setQuality] = useState(75);
|
||||
@@ -30,8 +38,7 @@ export function CompressSettings() {
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess =
|
||||
mode === "quality" || (mode === "targetSize" && Number(targetSizeKb) > 0);
|
||||
const canProcess = mode === "quality" || (mode === "targetSize" && Number(targetSizeKb) > 0);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -103,11 +110,7 @@ export function CompressSettings() {
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
<p className="font-medium text-foreground">
|
||||
Saved:{" "}
|
||||
{originalSize > 0
|
||||
? ((1 - processedSize / originalSize) * 100).toFixed(1)
|
||||
: "0"}
|
||||
%
|
||||
Saved: {originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
|
||||
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
|
||||
|
||||
export function ConvertSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("convert");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("convert");
|
||||
|
||||
const [format, setFormat] = useState<string>("png");
|
||||
const [quality, setQuality] = useState(85);
|
||||
@@ -98,10 +106,7 @@ export function ConvertSettings() {
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
<p>
|
||||
Savings:{" "}
|
||||
{originalSize > 0
|
||||
? ((1 - processedSize / originalSize) * 100).toFixed(1)
|
||||
: "0"}
|
||||
%
|
||||
{originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import ReactCrop, { type Crop } from "react-image-crop";
|
||||
import "react-image-crop/dist/ReactCrop.css";
|
||||
|
||||
@@ -68,9 +68,7 @@ export function CropCanvas({
|
||||
return;
|
||||
} else if (e.key === "Enter") {
|
||||
// Submit the crop form (find and submit closest form)
|
||||
const form = document.querySelector<HTMLFormElement>(
|
||||
'form[data-crop-form]',
|
||||
);
|
||||
const form = document.querySelector<HTMLFormElement>("form[data-crop-form]");
|
||||
if (form) form.requestSubmit();
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -94,17 +92,11 @@ export function CropCanvas({
|
||||
}, []);
|
||||
|
||||
// Calculate pixel dimensions for the badge
|
||||
const pixelWidth =
|
||||
imgDimensions ? Math.round((crop.width / 100) * imgDimensions.width) : 0;
|
||||
const pixelHeight =
|
||||
imgDimensions ? Math.round((crop.height / 100) * imgDimensions.height) : 0;
|
||||
const pixelWidth = imgDimensions ? Math.round((crop.width / 100) * imgDimensions.width) : 0;
|
||||
const pixelHeight = imgDimensions ? Math.round((crop.height / 100) * imgDimensions.height) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col w-full h-full max-w-4xl mx-auto outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div ref={containerRef} className="flex flex-col w-full h-full max-w-4xl mx-auto outline-none">
|
||||
{/* Crop area */}
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden bg-muted/20 p-4">
|
||||
<ReactCrop
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, ArrowLeftRight, Grid3x3 } from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import type { Crop } from "react-image-crop";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const ASPECT_PRESETS = [
|
||||
{ label: "Free", value: undefined as number | undefined },
|
||||
@@ -35,14 +35,8 @@ export function CropSettings({
|
||||
onGridToggle,
|
||||
}: CropSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
progress,
|
||||
} = useToolProcessor("crop");
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("crop");
|
||||
|
||||
const { crop, aspect, showGrid, imgDimensions } = cropState;
|
||||
|
||||
@@ -66,10 +60,7 @@ export function CropSettings({
|
||||
if (!imgDimensions) return;
|
||||
const newCrop = { ...crop };
|
||||
if (field === "left") {
|
||||
newCrop.x = Math.max(
|
||||
0,
|
||||
Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width),
|
||||
);
|
||||
newCrop.x = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width));
|
||||
} else if (field === "top") {
|
||||
newCrop.y = Math.max(
|
||||
0,
|
||||
@@ -116,7 +107,7 @@ export function CropSettings({
|
||||
} else {
|
||||
// Desired ratio is taller than image — use full height, shrink width
|
||||
newHeight = 100;
|
||||
newWidth = (imgDimensions.height * value / imgDimensions.width) * 100;
|
||||
newWidth = ((imgDimensions.height * value) / imgDimensions.width) * 100;
|
||||
}
|
||||
onCropChange({
|
||||
unit: "%",
|
||||
@@ -215,9 +206,7 @@ export function CropSettings({
|
||||
<input
|
||||
type="number"
|
||||
value={pixels.left}
|
||||
onChange={(e) =>
|
||||
handlePixelChange("left", Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
|
||||
min={0}
|
||||
max={imgDimensions ? imgDimensions.width - 1 : undefined}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||
@@ -230,9 +219,7 @@ export function CropSettings({
|
||||
<input
|
||||
type="number"
|
||||
value={pixels.top}
|
||||
onChange={(e) =>
|
||||
handlePixelChange("top", Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
|
||||
min={0}
|
||||
max={imgDimensions ? imgDimensions.height - 1 : undefined}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||
@@ -245,9 +232,7 @@ export function CropSettings({
|
||||
<input
|
||||
type="number"
|
||||
value={pixels.width}
|
||||
onChange={(e) =>
|
||||
handlePixelChange("width", Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
|
||||
min={1}
|
||||
max={imgDimensions ? imgDimensions.width : undefined}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||
@@ -260,9 +245,7 @@ export function CropSettings({
|
||||
<input
|
||||
type="number"
|
||||
value={pixels.height}
|
||||
onChange={(e) =>
|
||||
handlePixelChange("height", Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
|
||||
min={1}
|
||||
max={imgDimensions ? imgDimensions.height : undefined}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -116,19 +116,15 @@ export function EraseObjectSettings() {
|
||||
<div>
|
||||
<label 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.
|
||||
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">
|
||||
<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 type="file" accept="image/*" onChange={handleMaskSelect} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -62,8 +62,8 @@ export function FaviconSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload a square image (recommended 512x512 or larger) to generate all
|
||||
favicon and app icon sizes.
|
||||
Upload a square image (recommended 512x512 or larger) to generate all favicon and app icon
|
||||
sizes.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
@@ -76,9 +76,7 @@ export function FaviconSettings() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
+ manifest.json + HTML snippet
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">+ manifest.json + HTML snippet</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function GifToolsSettings() {
|
||||
const { files } = useFileStore();
|
||||
@@ -54,26 +54,46 @@ export function GifToolsSettings() {
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input type="number" value={width} onChange={(e) => setWidth(e.target.value)} placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={optimize} onChange={(e) => setOptimize(e.target.checked)} className="rounded" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={optimize}
|
||||
onChange={(e) => setOptimize(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Optimize file size
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Frame Number</label>
|
||||
<input type="number" value={extractFrame} onChange={(e) => setExtractFrame(e.target.value)} min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={extractFrame}
|
||||
onChange={(e) => setExtractFrame(e.target.value)}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">Frame 0 is the first frame</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -107,7 +127,11 @@ export function GifToolsSettings() {
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -52,8 +52,8 @@ export function ImageToPdfSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{files.length} image{files.length !== 1 ? "s" : ""} will be combined
|
||||
into a PDF, one image per page.
|
||||
{files.length} image{files.length !== 1 ? "s" : ""} will be combined into a PDF, one image
|
||||
per page.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
@@ -93,7 +93,14 @@ export function ImageToPdfSettings() {
|
||||
<label className="text-xs text-muted-foreground">Margin</label>
|
||||
<span className="text-xs font-mono text-foreground">{margin}pt</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={margin} onChange={(e) => setMargin(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={margin}
|
||||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -108,7 +115,11 @@ export function ImageToPdfSettings() {
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -93,7 +93,9 @@ export function InfoSettings() {
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||
<div className="text-muted-foreground">Dimensions</div>
|
||||
<div className="text-foreground font-mono">{info.width} x {info.height}</div>
|
||||
<div className="text-foreground font-mono">
|
||||
{info.width} x {info.height}
|
||||
</div>
|
||||
<div className="text-muted-foreground">Format</div>
|
||||
<div className="text-foreground font-mono">{info.format}</div>
|
||||
<div className="text-muted-foreground">File Size</div>
|
||||
@@ -125,7 +127,9 @@ export function InfoSettings() {
|
||||
{info.histogram.map((ch) => (
|
||||
<div key={ch.channel} className="space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${channelColors[ch.channel] ?? "bg-gray-400"}`} />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${channelColors[ch.channel] ?? "bg-gray-400"}`}
|
||||
/>
|
||||
<span className="text-xs text-foreground capitalize">{ch.channel}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground font-mono">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -204,11 +204,7 @@ export function OcrSettings() {
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -219,9 +215,7 @@ export function OcrSettings() {
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-muted text-xs text-foreground font-mono resize-y"
|
||||
/>
|
||||
{text.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{text.length} characters extracted
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{text.length} characters extracted</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
FileImage,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Save,
|
||||
Upload,
|
||||
Loader2,
|
||||
FileImage,
|
||||
Download,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as icons from "lucide-react";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { useCallback, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PipelineStepSettings } from "./pipeline-step-settings";
|
||||
|
||||
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
|
||||
const PIPELINE_TOOLS = TOOLS.filter(
|
||||
(t) =>
|
||||
!["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id)
|
||||
(t) => !["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
|
||||
);
|
||||
|
||||
export interface PipelineStep {
|
||||
@@ -71,7 +70,7 @@ export function PipelineBuilder({
|
||||
setShowToolPicker(false);
|
||||
setExpandedStep(step.id);
|
||||
},
|
||||
[steps, onStepsChange]
|
||||
[steps, onStepsChange],
|
||||
);
|
||||
|
||||
const removeStep = useCallback(
|
||||
@@ -79,7 +78,7 @@ export function PipelineBuilder({
|
||||
onStepsChange(steps.filter((s) => s.id !== id));
|
||||
if (expandedStep === id) setExpandedStep(null);
|
||||
},
|
||||
[steps, onStepsChange, expandedStep]
|
||||
[steps, onStepsChange, expandedStep],
|
||||
);
|
||||
|
||||
const moveStep = useCallback(
|
||||
@@ -92,14 +91,14 @@ export function PipelineBuilder({
|
||||
[newSteps[idx], newSteps[newIdx]] = [newSteps[newIdx], newSteps[idx]];
|
||||
onStepsChange(newSteps);
|
||||
},
|
||||
[steps, onStepsChange]
|
||||
[steps, onStepsChange],
|
||||
);
|
||||
|
||||
const updateStepSettings = useCallback(
|
||||
(id: string, newSettings: Record<string, unknown>) => {
|
||||
onStepsChange(steps.map((s) => (s.id === id ? { ...s, settings: newSettings } : s)));
|
||||
},
|
||||
[steps, onStepsChange]
|
||||
[steps, onStepsChange],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
@@ -132,10 +131,7 @@ export function PipelineBuilder({
|
||||
onExecute(file);
|
||||
}, [file, onExecute]);
|
||||
|
||||
const iconsMap = icons as unknown as Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
>;
|
||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -147,7 +143,7 @@ export function PipelineBuilder({
|
||||
"rounded-xl border-2 border-dashed p-6 text-center transition-colors",
|
||||
file
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border bg-muted/20 hover:border-primary/30"
|
||||
: "border-border bg-muted/20 hover:border-primary/30",
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
@@ -203,9 +199,7 @@ export function PipelineBuilder({
|
||||
|
||||
{/* Tool icon + name */}
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground flex-1">
|
||||
{tool.name}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground flex-1">{tool.name}</span>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
@@ -247,9 +241,7 @@ export function PipelineBuilder({
|
||||
{/* Expanded settings */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-3 bg-muted/10 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{tool.description}</p>
|
||||
<PipelineStepSettings
|
||||
toolId={step.toolId}
|
||||
settings={step.settings}
|
||||
@@ -286,9 +278,7 @@ export function PipelineBuilder({
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-foreground">{tool.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{tool.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{tool.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -365,7 +355,6 @@ export function PipelineBuilder({
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="Pipeline name"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -17,7 +17,13 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
resize: [
|
||||
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Auto" },
|
||||
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Auto" },
|
||||
{ key: "percentage", label: "Scale (%)", type: "number", min: 1, placeholder: "Use instead of width/height" },
|
||||
{
|
||||
key: "percentage",
|
||||
label: "Scale (%)",
|
||||
type: "number",
|
||||
min: 1,
|
||||
placeholder: "Use instead of width/height",
|
||||
},
|
||||
{
|
||||
key: "fit",
|
||||
label: "Fit Mode",
|
||||
@@ -40,7 +46,15 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
],
|
||||
|
||||
rotate: [
|
||||
{ key: "angle", label: "Angle (degrees)", type: "number", min: -360, max: 360, step: 90, defaultValue: 0 },
|
||||
{
|
||||
key: "angle",
|
||||
label: "Angle (degrees)",
|
||||
type: "number",
|
||||
min: -360,
|
||||
max: 360,
|
||||
step: 90,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ key: "horizontal", label: "Flip horizontal", type: "boolean", defaultValue: false },
|
||||
{ key: "vertical", label: "Flip vertical", type: "boolean", defaultValue: false },
|
||||
],
|
||||
@@ -59,7 +73,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
{ value: "gif", label: "GIF" },
|
||||
],
|
||||
},
|
||||
{ key: "quality", label: "Quality (1-100)", type: "number", min: 1, max: 100, placeholder: "Auto" },
|
||||
{
|
||||
key: "quality",
|
||||
label: "Quality (1-100)",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 100,
|
||||
placeholder: "Auto",
|
||||
},
|
||||
],
|
||||
|
||||
compress: [
|
||||
@@ -125,12 +146,26 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
],
|
||||
|
||||
"brightness-contrast": [
|
||||
{ key: "brightness", label: "Brightness", type: "number", min: -100, max: 100, defaultValue: 0 },
|
||||
{
|
||||
key: "brightness",
|
||||
label: "Brightness",
|
||||
type: "number",
|
||||
min: -100,
|
||||
max: 100,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ key: "contrast", label: "Contrast", type: "number", min: -100, max: 100, defaultValue: 0 },
|
||||
],
|
||||
|
||||
saturation: [
|
||||
{ key: "saturation", label: "Saturation", type: "number", min: -100, max: 100, defaultValue: 0 },
|
||||
{
|
||||
key: "saturation",
|
||||
label: "Saturation",
|
||||
type: "number",
|
||||
min: -100,
|
||||
max: 100,
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
|
||||
"color-channels": [
|
||||
@@ -157,8 +192,20 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
"replace-color": [
|
||||
{ key: "sourceColor", label: "Source color", type: "color", defaultValue: "#FF0000" },
|
||||
{ key: "targetColor", label: "Target color", type: "color", defaultValue: "#00FF00" },
|
||||
{ key: "makeTransparent", label: "Make transparent instead", type: "boolean", defaultValue: false },
|
||||
{ key: "tolerance", label: "Tolerance (0-255)", type: "number", min: 0, max: 255, defaultValue: 30 },
|
||||
{
|
||||
key: "makeTransparent",
|
||||
label: "Make transparent instead",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: "tolerance",
|
||||
label: "Tolerance (0-255)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 255,
|
||||
defaultValue: 30,
|
||||
},
|
||||
],
|
||||
|
||||
"watermark-text": [
|
||||
@@ -180,7 +227,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
{ value: "tiled", label: "Tiled" },
|
||||
],
|
||||
},
|
||||
{ key: "rotation", label: "Rotation (degrees)", type: "number", min: -360, max: 360, defaultValue: 0 },
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation (degrees)",
|
||||
type: "number",
|
||||
min: -360,
|
||||
max: 360,
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
|
||||
"watermark-image": [
|
||||
@@ -230,7 +284,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
border: [
|
||||
{ key: "borderWidth", label: "Width (px)", type: "number", min: 0, max: 200, defaultValue: 10 },
|
||||
{ key: "borderColor", label: "Color", type: "color", defaultValue: "#000000" },
|
||||
{ key: "cornerRadius", label: "Corner radius", type: "number", min: 0, max: 500, defaultValue: 0 },
|
||||
{
|
||||
key: "cornerRadius",
|
||||
label: "Corner radius",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 500,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ key: "padding", label: "Padding (px)", type: "number", min: 0, max: 200, defaultValue: 0 },
|
||||
{ key: "shadowBlur", label: "Shadow blur", type: "number", min: 0, max: 50, defaultValue: 0 },
|
||||
{ key: "shadowColor", label: "Shadow color", type: "color", defaultValue: "#00000080" },
|
||||
@@ -243,7 +304,15 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
|
||||
"blur-faces": [
|
||||
{ key: "blurRadius", label: "Blur radius", type: "number", min: 1, max: 100, defaultValue: 30 },
|
||||
{ key: "sensitivity", label: "Sensitivity (0-1)", type: "number", min: 0, max: 1, step: 0.1, defaultValue: 0.5 },
|
||||
{
|
||||
key: "sensitivity",
|
||||
label: "Sensitivity (0-1)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
defaultValue: 0.5,
|
||||
},
|
||||
],
|
||||
|
||||
upscale: [
|
||||
@@ -276,7 +345,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
{ value: "color", label: "Color" },
|
||||
],
|
||||
},
|
||||
{ key: "threshold", label: "Threshold (0-255)", type: "number", min: 0, max: 255, defaultValue: 128 },
|
||||
{
|
||||
key: "threshold",
|
||||
label: "Threshold (0-255)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 255,
|
||||
defaultValue: 128,
|
||||
},
|
||||
{
|
||||
key: "detail",
|
||||
label: "Detail",
|
||||
@@ -309,7 +385,13 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
"gif-tools": [
|
||||
{ key: "width", label: "Width (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
|
||||
{ key: "height", label: "Height (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
|
||||
{ key: "extractFrame", label: "Extract frame #", type: "number", min: 0, placeholder: "All frames" },
|
||||
{
|
||||
key: "extractFrame",
|
||||
label: "Extract frame #",
|
||||
type: "number",
|
||||
min: 0,
|
||||
placeholder: "All frames",
|
||||
},
|
||||
{ key: "optimize", label: "Optimize", type: "boolean", defaultValue: false },
|
||||
],
|
||||
|
||||
@@ -340,7 +422,13 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
],
|
||||
|
||||
"bulk-rename": [
|
||||
{ key: "pattern", label: "Pattern", type: "text", placeholder: "image-{{index}}", defaultValue: "image-{{index}}" },
|
||||
{
|
||||
key: "pattern",
|
||||
label: "Pattern",
|
||||
type: "text",
|
||||
placeholder: "image-{{index}}",
|
||||
defaultValue: "image-{{index}}",
|
||||
},
|
||||
{ key: "startIndex", label: "Start index", type: "number", min: 0, defaultValue: 1 },
|
||||
],
|
||||
|
||||
@@ -443,7 +531,10 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
|
||||
type="number"
|
||||
value={value != null && value !== "" ? Number(value) : ""}
|
||||
onChange={(e) =>
|
||||
updateField(field.key, e.target.value === "" ? undefined : Number(e.target.value))
|
||||
updateField(
|
||||
field.key,
|
||||
e.target.value === "" ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
@@ -515,7 +606,10 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<label key={field.key} className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<label
|
||||
key={field.key}
|
||||
className="flex items-center gap-2 text-sm text-foreground cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -67,7 +67,15 @@ export function QrGenerateSettings() {
|
||||
<label className="text-xs text-muted-foreground">Size</label>
|
||||
<span className="text-xs font-mono text-foreground">{size}px</span>
|
||||
</div>
|
||||
<input type="range" min={100} max={2000} step={50} value={size} onChange={(e) => setSize(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={size}
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -87,11 +95,21 @@ export function QrGenerateSettings() {
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Foreground</label>
|
||||
<input type="color" value={foreground} onChange={(e) => setForeground(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={foreground}
|
||||
onChange={(e) => setForeground(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Background</label>
|
||||
<input type="color" value={background} onChange={(e) => setBackground(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={background}
|
||||
onChange={(e) => setBackground(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +126,17 @@ export function QrGenerateSettings() {
|
||||
|
||||
{previewUrl && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img src={previewUrl} alt="QR Code" className="max-w-full rounded border border-border" style={{ maxHeight: 200 }} />
|
||||
<a href={downloadUrl!} 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">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code"
|
||||
className="max-w-full rounded border border-border"
|
||||
style={{ maxHeight: 200 }}
|
||||
/>
|
||||
<a
|
||||
href={downloadUrl!}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download QR Code
|
||||
</a>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Download, ImageIcon, Package, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { Download, User, Package, ImageIcon } from "lucide-react";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type SubjectType = "people" | "products" | "general";
|
||||
type Quality = "fast" | "balanced" | "best";
|
||||
@@ -15,9 +15,9 @@ type BgModel =
|
||||
| "u2net";
|
||||
|
||||
const MODEL_MAP: Record<SubjectType, Record<Quality, BgModel>> = {
|
||||
people: { fast: "u2net", balanced: "birefnet-portrait", best: "birefnet-portrait" },
|
||||
products: { fast: "u2net", balanced: "bria-rmbg", best: "birefnet-general" },
|
||||
general: { fast: "u2net", balanced: "birefnet-general-lite", best: "birefnet-general" },
|
||||
people: { fast: "u2net", balanced: "birefnet-portrait", best: "birefnet-portrait" },
|
||||
products: { fast: "u2net", balanced: "bria-rmbg", best: "birefnet-general" },
|
||||
general: { fast: "u2net", balanced: "birefnet-general-lite", best: "birefnet-general" },
|
||||
};
|
||||
|
||||
const SUBJECT_OPTIONS: { value: SubjectType; label: string; icon: typeof User }[] = [
|
||||
@@ -126,9 +126,7 @@ export function RemoveBgSettings() {
|
||||
|
||||
{/* Background color - intuitive preset buttons */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Output Background
|
||||
</label>
|
||||
<label className="text-sm font-medium text-muted-foreground">Output Background</label>
|
||||
<div className="flex gap-1.5 mt-1.5 flex-wrap">
|
||||
{BG_PRESETS.map((preset) => (
|
||||
<button
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function ReplaceColorSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("replace-color");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("replace-color");
|
||||
|
||||
const [sourceColor, setSourceColor] = useState("#FF0000");
|
||||
const [targetColor, setTargetColor] = useState("#00FF00");
|
||||
@@ -30,13 +38,23 @@ export function ReplaceColorSettings() {
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Source Color (to replace)</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input type="color" value={sourceColor} onChange={(e) => setSourceColor(e.target.value)} className="w-10 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={sourceColor}
|
||||
onChange={(e) => setSourceColor(e.target.value)}
|
||||
className="w-10 h-8 rounded border border-border"
|
||||
/>
|
||||
<span className="text-xs font-mono text-foreground">{sourceColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={makeTransparent} onChange={(e) => setMakeTransparent(e.target.checked)} className="rounded" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={makeTransparent}
|
||||
onChange={(e) => setMakeTransparent(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Make transparent instead
|
||||
</label>
|
||||
|
||||
@@ -44,7 +62,12 @@ export function ReplaceColorSettings() {
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Target Color (replacement)</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input type="color" value={targetColor} onChange={(e) => setTargetColor(e.target.value)} className="w-10 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={targetColor}
|
||||
onChange={(e) => setTargetColor(e.target.value)}
|
||||
className="w-10 h-8 rounded border border-border"
|
||||
/>
|
||||
<span className="text-xs font-mono text-foreground">{targetColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +78,14 @@ export function ReplaceColorSettings() {
|
||||
<label className="text-xs text-muted-foreground">Tolerance</label>
|
||||
<span className="text-xs font-mono text-foreground">{tolerance}</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={255} value={tolerance} onChange={(e) => setTolerance(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
value={tolerance}
|
||||
onChange={(e) => setTolerance(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>Exact match</span>
|
||||
<span>Wide range</span>
|
||||
@@ -91,7 +121,11 @@ export function ReplaceColorSettings() {
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Link, Unlink } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type ResizeTab = "presets" | "custom" | "scale";
|
||||
type FitMode = "cover" | "contain" | "fill";
|
||||
@@ -67,9 +67,7 @@ export function ResizeSettings() {
|
||||
const canProcess =
|
||||
hasFile &&
|
||||
!processing &&
|
||||
(tab === "scale"
|
||||
? Number(percentage) > 0
|
||||
: Boolean(width) || Boolean(height));
|
||||
(tab === "scale" ? Number(percentage) > 0 : Boolean(width) || Boolean(height));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import {
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
} from "lucide-react";
|
||||
import { FlipHorizontal, FlipVertical, RotateCcw, RotateCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export interface PreviewTransform {
|
||||
rotate: number;
|
||||
@@ -21,8 +16,7 @@ interface RotateSettingsProps {
|
||||
|
||||
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("rotate");
|
||||
const { processFiles, processAllFiles, processing, error, progress } = useToolProcessor("rotate");
|
||||
|
||||
// Quick rotation in 90° steps: 0, 90, 180, 270
|
||||
const [rotation, setRotation] = useState(0);
|
||||
@@ -101,9 +95,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
Left
|
||||
</button>
|
||||
<div className="px-3 py-1.5 rounded-md bg-background border border-border text-center min-w-[4rem]">
|
||||
<span className="text-sm font-mono font-medium tabular-nums">
|
||||
{displayAngle}°
|
||||
</span>
|
||||
<span className="text-sm font-mono font-medium tabular-nums">{displayAngle}°</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const ASPECT_PRESETS = [
|
||||
{ label: "1:1 Square", w: 1080, h: 1080 },
|
||||
@@ -92,7 +92,8 @@ export function SmartCropSettings() {
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Uses entropy-based attention detection to find the most interesting region of the image and crops to it.
|
||||
Uses entropy-based attention detection to find the most interesting region of the image and
|
||||
crops to it.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -68,7 +68,10 @@ export function SplitSettings() {
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => { setColumns(p.c); setRows(p.r); }}
|
||||
onClick={() => {
|
||||
setColumns(p.c);
|
||||
setRows(p.r);
|
||||
}}
|
||||
className={`text-xs px-2 py-1 rounded ${columns === p.c && rows === p.r ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{p.label}
|
||||
@@ -80,19 +83,29 @@ export function SplitSettings() {
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Columns</label>
|
||||
<input type="number" value={columns} onChange={(e) => setColumns(Math.max(1, Number(e.target.value)))} min={1} max={10}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={columns}
|
||||
onChange={(e) => setColumns(Math.max(1, Number(e.target.value)))}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Rows</label>
|
||||
<input type="number" value={rows} onChange={(e) => setRows(Math.max(1, Number(e.target.value)))} min={1} max={10}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={rows}
|
||||
onChange={(e) => setRows(Math.max(1, Number(e.target.value)))}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will produce {columns * rows} parts
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Will produce {columns * rows} parts</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, ChevronDown, ChevronRight, Loader2, MapPin, AlertTriangle } from "lucide-react";
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
@@ -61,10 +61,19 @@ const EXIF_LABELS: Record<string, string> = {
|
||||
|
||||
/** Keys to skip in display (internal/binary/redundant) */
|
||||
const SKIP_KEYS = new Set([
|
||||
"ExifTag", "GPSTag", "InteroperabilityTag", "MakerNote",
|
||||
"PrintImageMatching", "ComponentsConfiguration", "FlashpixVersion",
|
||||
"ExifVersion", "FileSource", "SceneType", "UserComment",
|
||||
"InteroperabilityIndex", "InteroperabilityVersion",
|
||||
"ExifTag",
|
||||
"GPSTag",
|
||||
"InteroperabilityTag",
|
||||
"MakerNote",
|
||||
"PrintImageMatching",
|
||||
"ComponentsConfiguration",
|
||||
"FlashpixVersion",
|
||||
"ExifVersion",
|
||||
"FileSource",
|
||||
"SceneType",
|
||||
"UserComment",
|
||||
"InteroperabilityIndex",
|
||||
"InteroperabilityVersion",
|
||||
]);
|
||||
|
||||
function formatExifValue(key: string, value: unknown): string {
|
||||
@@ -110,7 +119,11 @@ function CollapsibleSection({
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
{open ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
||||
{badge && (
|
||||
@@ -124,9 +137,16 @@ function CollapsibleSection({
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataGrid({ data, labelMap }: { data: Record<string, unknown>; labelMap?: Record<string, string> }) {
|
||||
function MetadataGrid({
|
||||
data,
|
||||
labelMap,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
labelMap?: Record<string, string>;
|
||||
}) {
|
||||
const entries = Object.entries(data).filter(
|
||||
([k, v]) => !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== ""
|
||||
([k, v]) =>
|
||||
!SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== "",
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -140,7 +160,10 @@ function MetadataGrid({ data, labelMap }: { data: Record<string, unknown>; label
|
||||
<div className="text-[10px] text-muted-foreground truncate" title={k}>
|
||||
{labelMap?.[k] ?? k}
|
||||
</div>
|
||||
<div className="text-[10px] text-foreground font-mono truncate" title={formatExifValue(k, v)}>
|
||||
<div
|
||||
className="text-[10px] text-foreground font-mono truncate"
|
||||
title={formatExifValue(k, v)}
|
||||
>
|
||||
{formatExifValue(k, v)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,7 +190,9 @@ export function StripMetadataSettings() {
|
||||
const [inspectError, setInspectError] = useState<string | null>(null);
|
||||
|
||||
const currentFile = entries[selectedIndex]?.file ?? null;
|
||||
const fileKey = currentFile ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` : null;
|
||||
const fileKey = currentFile
|
||||
? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`
|
||||
: null;
|
||||
|
||||
// Auto-fetch metadata for the selected file (with per-file caching)
|
||||
useEffect(() => {
|
||||
@@ -214,7 +239,7 @@ export function StripMetadataSettings() {
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [currentFile, fileKey]);
|
||||
}, [currentFile, fileKey, metadataCache.get]);
|
||||
|
||||
const handleStripAllChange = (checked: boolean) => {
|
||||
setStripAll(checked);
|
||||
@@ -245,8 +270,8 @@ export function StripMetadataSettings() {
|
||||
const sectionCount = [hasExif, hasGps, hasIcc, hasXmp].filter(Boolean).length;
|
||||
|
||||
// GPS coordinates for display
|
||||
const gpsLat = metadata?.gps?.["_latitude"] as number | null | undefined;
|
||||
const gpsLon = metadata?.gps?.["_longitude"] as number | null | undefined;
|
||||
const gpsLat = metadata?.gps?._latitude as number | null | undefined;
|
||||
const gpsLon = metadata?.gps?._longitude as number | null | undefined;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -262,9 +287,7 @@ export function StripMetadataSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inspectError && (
|
||||
<p className="text-[10px] text-red-500">{inspectError}</p>
|
||||
)}
|
||||
{inspectError && <p className="text-[10px] text-red-500">{inspectError}</p>}
|
||||
|
||||
{metadata && !hasAnyMetadata && !inspecting && (
|
||||
<p className="text-xs text-muted-foreground italic py-1">
|
||||
@@ -287,7 +310,7 @@ export function StripMetadataSettings() {
|
||||
{hasExif && (
|
||||
<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} />
|
||||
@@ -299,19 +322,29 @@ export function StripMetadataSettings() {
|
||||
)}
|
||||
|
||||
{hasGps && (
|
||||
<CollapsibleSection title="GPS" warning badge={`${Object.keys(metadata.gps!).filter(k => !k.startsWith("_")).length} fields`}>
|
||||
<CollapsibleSection
|
||||
title="GPS"
|
||||
warning
|
||||
badge={`${Object.keys(metadata.gps!).filter((k) => !k.startsWith("_")).length} fields`}
|
||||
>
|
||||
<MetadataGrid data={metadata.gps!} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{hasIcc && (
|
||||
<CollapsibleSection title="ICC Profile" badge={`${Object.keys(metadata.icc!).length} fields`}>
|
||||
<CollapsibleSection
|
||||
title="ICC Profile"
|
||||
badge={`${Object.keys(metadata.icc!).length} fields`}
|
||||
>
|
||||
<MetadataGrid data={metadata.icc!} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{hasXmp && (
|
||||
<CollapsibleSection title="XMP" badge={`${Object.keys(metadata.xmp!).length} fields`}>
|
||||
<CollapsibleSection
|
||||
title="XMP"
|
||||
badge={`${Object.keys(metadata.xmp!).length} fields`}
|
||||
>
|
||||
<MetadataGrid data={metadata.xmp!} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
@@ -343,7 +376,9 @@ export function StripMetadataSettings() {
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground">Or select specific metadata:</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripExif}
|
||||
@@ -353,11 +388,15 @@ 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</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{Object.keys(metadata?.exif!).filter((k) => !SKIP_KEYS.has(k)).length} fields
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripGps}
|
||||
@@ -371,7 +410,9 @@ export function StripMetadataSettings() {
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripIcc}
|
||||
@@ -382,7 +423,9 @@ export function StripMetadataSettings() {
|
||||
Strip ICC (color profile)
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripXmp}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function SvgToRasterSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
const [width, setWidth] = useState(1024);
|
||||
const [height, setHeight] = useState("");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#00000000");
|
||||
@@ -67,13 +68,24 @@ export function SvgToRasterSettings() {
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input type="number" value={width} onChange={(e) => setWidth(Number(e.target.value))} min={1} max={8192}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
min={1}
|
||||
max={8192}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +116,12 @@ export function SvgToRasterSettings() {
|
||||
{!transparent && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Background Color</label>
|
||||
<input type="color" value={backgroundColor.slice(0, 7)} onChange={(e) => setBackgroundColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={backgroundColor.slice(0, 7)}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -127,7 +144,11 @@ export function SvgToRasterSettings() {
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function TextOverlaySettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("text-overlay");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("text-overlay");
|
||||
|
||||
const [text, setText] = useState("Your Text Here");
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
@@ -45,12 +53,24 @@ export function TextOverlaySettings() {
|
||||
<label className="text-xs text-muted-foreground">Font Size</label>
|
||||
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
|
||||
</div>
|
||||
<input type="range" min={8} max={200} value={fontSize} onChange={(e) => setFontSize(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={8}
|
||||
max={200}
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Text Color</label>
|
||||
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -67,19 +87,34 @@ export function TextOverlaySettings() {
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={shadow} onChange={(e) => setShadow(e.target.checked)} className="rounded" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shadow}
|
||||
onChange={(e) => setShadow(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Drop Shadow
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={backgroundBox} onChange={(e) => setBackgroundBox(e.target.checked)} className="rounded" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backgroundBox}
|
||||
onChange={(e) => setBackgroundBox(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Background Box
|
||||
</label>
|
||||
|
||||
{backgroundBox && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Box Color</label>
|
||||
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -112,7 +147,11 @@ export function TextOverlaySettings() {
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function UpscaleSettings() {
|
||||
const { files } = useFileStore();
|
||||
@@ -41,7 +41,8 @@ export function UpscaleSettings() {
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Uses Real-ESRGAN for AI upscaling when available, otherwise falls back to high-quality Lanczos interpolation.
|
||||
Uses Real-ESRGAN for AI upscaling when available, otherwise falls back to high-quality
|
||||
Lanczos interpolation.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function VectorizeSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
const [colorMode, setColorMode] = useState<"bw" | "color">("bw");
|
||||
const [threshold, setThreshold] = useState(128);
|
||||
const [detail, setDetail] = useState<"low" | "medium" | "high">("medium");
|
||||
@@ -79,7 +80,14 @@ export function VectorizeSettings() {
|
||||
<label className="text-xs text-muted-foreground">Threshold</label>
|
||||
<span className="text-xs font-mono text-foreground">{threshold}</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={255} value={threshold} onChange={(e) => setThreshold(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -114,7 +122,11 @@ export function VectorizeSettings() {
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download SVG
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
|
||||
@@ -9,7 +9,8 @@ function getToken(): string {
|
||||
}
|
||||
|
||||
export function WatermarkImageSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||
useFileStore();
|
||||
const [position, setPosition] = useState<Position>("bottom-right");
|
||||
const [opacity, setOpacity] = useState(50);
|
||||
const [scale, setScale] = useState(25);
|
||||
@@ -99,7 +100,14 @@ export function WatermarkImageSettings() {
|
||||
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -107,7 +115,14 @@ export function WatermarkImageSettings() {
|
||||
<label className="text-xs text-muted-foreground">Scale</label>
|
||||
<span className="text-xs font-mono text-foreground">{scale}%</span>
|
||||
</div>
|
||||
<input type="range" min={5} max={100} value={scale} onChange={(e) => setScale(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={100}
|
||||
value={scale}
|
||||
onChange={(e) => setScale(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -129,7 +144,11 @@ export function WatermarkImageSettings() {
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
||||
|
||||
export function WatermarkTextSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("watermark-text");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("watermark-text");
|
||||
|
||||
const [text, setText] = useState("Sample Watermark");
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
@@ -46,20 +54,39 @@ export function WatermarkTextSettings() {
|
||||
<label className="text-xs text-muted-foreground">Font Size</label>
|
||||
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
|
||||
</div>
|
||||
<input type="range" min={8} max={200} value={fontSize} onChange={(e) => setFontSize(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={8}
|
||||
max={200}
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Color</label>
|
||||
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +111,14 @@ export function WatermarkTextSettings() {
|
||||
<label className="text-xs text-muted-foreground">Rotation</label>
|
||||
<span className="text-xs font-mono text-foreground">{rotation}°</span>
|
||||
</div>
|
||||
<input type="range" min={-180} max={180} value={rotation} onChange={(e) => setRotation(Number(e.target.value))} className="w-full mt-1" />
|
||||
<input
|
||||
type="range"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -116,7 +150,11 @@ export function WatermarkTextSettings() {
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a href={downloadUrl} 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">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user