mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
chore: remove internal docs from repo, update public documentation
Remove docs/superpowers/, .claude/ config, and PRD.md from version control (kept locally via .gitignore). Update README, CHANGELOG, VitePress docs, and .env.example to reflect recent features: Files page, teams, admin settings, persistent storage, and various API improvements.
This commit is contained in:
@@ -62,6 +62,7 @@ export function BarcodeReadSettings() {
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -79,6 +80,7 @@ export function BarcodeReadSettings() {
|
||||
<p className="text-xs text-muted-foreground">Decoded Text:</p>
|
||||
<p className="text-sm text-foreground font-mono break-all">{result.text}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyText}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
|
||||
@@ -26,10 +26,13 @@ export function BlurFacesSettings() {
|
||||
{/* Blur radius */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Blur Radius</label>
|
||||
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
|
||||
Blur Radius
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
|
||||
</div>
|
||||
<input
|
||||
id="blur-faces-blur-radius"
|
||||
type="range"
|
||||
min={5}
|
||||
max={80}
|
||||
@@ -46,10 +49,13 @@ export function BlurFacesSettings() {
|
||||
{/* Sensitivity */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Detection Sensitivity</label>
|
||||
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
|
||||
Detection Sensitivity
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="blur-faces-sensitivity"
|
||||
type="range"
|
||||
min={10}
|
||||
max={90}
|
||||
@@ -91,6 +97,7 @@ export function BlurFacesSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -38,10 +38,13 @@ export function BorderSettings() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Border Width</label>
|
||||
<label htmlFor="border-border-width" className="text-xs text-muted-foreground">
|
||||
Border Width
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="border-border-width"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -52,8 +55,11 @@ export function BorderSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Border Color</label>
|
||||
<label htmlFor="border-border-color" className="text-xs text-muted-foreground">
|
||||
Border Color
|
||||
</label>
|
||||
<input
|
||||
id="border-border-color"
|
||||
type="color"
|
||||
value={borderColor}
|
||||
onChange={(e) => setBorderColor(e.target.value)}
|
||||
@@ -63,10 +69,13 @@ export function BorderSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Corner Radius</label>
|
||||
<label htmlFor="border-corner-radius" className="text-xs text-muted-foreground">
|
||||
Corner Radius
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{cornerRadius}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="border-corner-radius"
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
@@ -78,10 +87,13 @@ export function BorderSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Padding</label>
|
||||
<label htmlFor="border-padding" className="text-xs text-muted-foreground">
|
||||
Padding
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{padding}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="border-padding"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -93,10 +105,13 @@ export function BorderSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Shadow</label>
|
||||
<label htmlFor="border-shadow" className="text-xs text-muted-foreground">
|
||||
Shadow
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{shadowBlur}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="border-shadow"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
@@ -126,6 +141,7 @@ export function BorderSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -72,8 +72,11 @@ export function BulkRenameSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Pattern</label>
|
||||
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
|
||||
Pattern
|
||||
</label>
|
||||
<input
|
||||
id="bulk-rename-pattern"
|
||||
type="text"
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
@@ -85,8 +88,11 @@ export function BulkRenameSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Start Index</label>
|
||||
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
|
||||
Start Index
|
||||
</label>
|
||||
<input
|
||||
id="bulk-rename-start-index"
|
||||
type="number"
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(Number(e.target.value))}
|
||||
@@ -97,11 +103,11 @@ export function BulkRenameSettings() {
|
||||
|
||||
{previewNames.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Preview</label>
|
||||
<p className="text-xs text-muted-foreground">Preview</p>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{previewNames.map((name, i) => (
|
||||
{previewNames.map((name) => (
|
||||
<div
|
||||
key={i}
|
||||
key={name}
|
||||
className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate"
|
||||
>
|
||||
{name}
|
||||
@@ -117,6 +123,7 @@ export function BulkRenameSettings() {
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || processing || !pattern}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -71,10 +71,11 @@ export function CollageSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Layout</label>
|
||||
<p className="text-xs text-muted-foreground">Layout</p>
|
||||
<div className="grid grid-cols-3 gap-1 mt-1">
|
||||
{LAYOUTS.map((l) => (
|
||||
<button
|
||||
type="button"
|
||||
key={l.value}
|
||||
onClick={() => setLayout(l.value)}
|
||||
className={`text-xs py-1.5 rounded ${layout === l.value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
@@ -87,10 +88,13 @@ export function CollageSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Gap</label>
|
||||
<label htmlFor="collage-gap" className="text-xs text-muted-foreground">
|
||||
Gap
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{gap}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="collage-gap"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
@@ -101,8 +105,11 @@ export function CollageSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Background Color</label>
|
||||
<label htmlFor="collage-background-color" className="text-xs text-muted-foreground">
|
||||
Background Color
|
||||
</label>
|
||||
<input
|
||||
id="collage-background-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
@@ -120,6 +127,7 @@ export function CollageSettings() {
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -57,6 +57,7 @@ export function ColorPaletteSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -69,13 +70,14 @@ export function ColorPaletteSettings() {
|
||||
|
||||
{colors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Dominant Colors ({colors.length})
|
||||
</label>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{colors.map((color, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
key={color}
|
||||
onClick={() => copyColor(color, i)}
|
||||
className="flex items-center gap-2 p-1.5 rounded border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
|
||||
@@ -160,7 +160,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
{/* Effects */}
|
||||
{tab === "effects" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground">Color Effect</label>
|
||||
<p className="text-xs text-muted-foreground">Color Effect</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
|
||||
<button
|
||||
@@ -261,13 +261,17 @@ function SliderControl({
|
||||
max: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const id = `color-slider-${label.toLowerCase()}`;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className={`text-xs ${color || "text-muted-foreground"}`}>{label}</label>
|
||||
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
||||
{label}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{value}</span>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
|
||||
@@ -53,8 +53,11 @@ export function CompareSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Second Image</label>
|
||||
<label htmlFor="compare-second-image" className="text-xs text-muted-foreground">
|
||||
Second Image
|
||||
</label>
|
||||
<input
|
||||
id="compare-second-image"
|
||||
ref={secondInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -62,6 +65,7 @@ export function CompareSettings() {
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => secondInputRef.current?.click()}
|
||||
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -84,6 +88,7 @@ export function CompareSettings() {
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !secondFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -62,8 +62,11 @@ export function ComposeSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Overlay Image</label>
|
||||
<label htmlFor="compose-overlay-image" className="text-xs text-muted-foreground">
|
||||
Overlay Image
|
||||
</label>
|
||||
<input
|
||||
id="compose-overlay-image"
|
||||
ref={overlayInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -71,6 +74,7 @@ export function ComposeSettings() {
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => overlayInputRef.current?.click()}
|
||||
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -81,8 +85,11 @@ export function ComposeSettings() {
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">X Position</label>
|
||||
<label htmlFor="compose-x-position" className="text-xs text-muted-foreground">
|
||||
X Position
|
||||
</label>
|
||||
<input
|
||||
id="compose-x-position"
|
||||
type="number"
|
||||
value={x}
|
||||
onChange={(e) => setX(Number(e.target.value))}
|
||||
@@ -91,8 +98,11 @@ export function ComposeSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Y Position</label>
|
||||
<label htmlFor="compose-y-position" className="text-xs text-muted-foreground">
|
||||
Y Position
|
||||
</label>
|
||||
<input
|
||||
id="compose-y-position"
|
||||
type="number"
|
||||
value={y}
|
||||
onChange={(e) => setY(Number(e.target.value))}
|
||||
@@ -104,10 +114,13 @@ export function ComposeSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||
<label htmlFor="compose-opacity" className="text-xs text-muted-foreground">
|
||||
Opacity
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="compose-opacity"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -118,8 +131,11 @@ export function ComposeSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Blend Mode</label>
|
||||
<label htmlFor="compose-blend-mode" className="text-xs text-muted-foreground">
|
||||
Blend Mode
|
||||
</label>
|
||||
<select
|
||||
id="compose-blend-mode"
|
||||
value={blendMode}
|
||||
onChange={(e) => setBlendMode(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -147,6 +163,7 @@ export function ComposeSettings() {
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !overlayFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -49,7 +49,7 @@ export function CompressSettings() {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Compression Mode</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">Compression Mode</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -71,10 +71,13 @@ export function CompressSettings() {
|
||||
{mode === "quality" ? (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Quality</label>
|
||||
<label htmlFor="compress-quality" className="text-xs text-muted-foreground">
|
||||
Quality
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
id="compress-quality"
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
@@ -89,8 +92,11 @@ export function CompressSettings() {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Target Size (KB)</label>
|
||||
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
|
||||
Target Size (KB)
|
||||
</label>
|
||||
<input
|
||||
id="compress-target-size"
|
||||
type="number"
|
||||
value={targetSizeKb}
|
||||
onChange={(e) => setTargetSizeKb(e.target.value)}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ConvertSettings() {
|
||||
{/* Source format */}
|
||||
{hasFile && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Source Format</label>
|
||||
<p className="text-xs text-muted-foreground">Source Format</p>
|
||||
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
|
||||
{sourceExt}
|
||||
</div>
|
||||
@@ -64,8 +64,11 @@ export function ConvertSettings() {
|
||||
|
||||
{/* Target format */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Target Format</label>
|
||||
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
|
||||
Target Format
|
||||
</label>
|
||||
<select
|
||||
id="convert-target-format"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -82,10 +85,13 @@ export function ConvertSettings() {
|
||||
{isLossy && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Quality</label>
|
||||
<label htmlFor="convert-quality" className="text-xs text-muted-foreground">
|
||||
Quality
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
id="convert-quality"
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
|
||||
@@ -165,7 +165,7 @@ export function CropSettings({
|
||||
{/* Aspect Ratio */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
|
||||
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
|
||||
{aspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -197,13 +197,14 @@ export function CropSettings({
|
||||
|
||||
{/* Position & Size */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Position & Size</label>
|
||||
<p className="text-xs text-muted-foreground">Position & Size</p>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">
|
||||
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
|
||||
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
|
||||
</label>
|
||||
<input
|
||||
id="crop-x"
|
||||
type="number"
|
||||
value={pixels.left}
|
||||
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
|
||||
@@ -213,10 +214,11 @@ export function CropSettings({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">
|
||||
<label htmlFor="crop-y" className="text-[10px] text-muted-foreground">
|
||||
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
|
||||
</label>
|
||||
<input
|
||||
id="crop-y"
|
||||
type="number"
|
||||
value={pixels.top}
|
||||
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
|
||||
@@ -226,10 +228,11 @@ export function CropSettings({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">
|
||||
<label htmlFor="crop-width" className="text-[10px] text-muted-foreground">
|
||||
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
|
||||
</label>
|
||||
<input
|
||||
id="crop-width"
|
||||
type="number"
|
||||
value={pixels.width}
|
||||
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
|
||||
@@ -239,10 +242,11 @@ export function CropSettings({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">
|
||||
<label htmlFor="crop-height" className="text-[10px] text-muted-foreground">
|
||||
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
|
||||
</label>
|
||||
<input
|
||||
id="crop-height"
|
||||
type="number"
|
||||
value={pixels.height}
|
||||
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
|
||||
|
||||
@@ -114,17 +114,28 @@ export function EraseObjectSettings() {
|
||||
<div className="space-y-4">
|
||||
{/* Mask upload */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Mask Image</label>
|
||||
<label htmlFor="erase-object-mask" className="text-sm font-medium text-muted-foreground">
|
||||
Mask Image
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
|
||||
Upload a black & white mask where white areas will be erased. Create the mask in any
|
||||
image editor.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary">
|
||||
<label
|
||||
htmlFor="erase-object-mask"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary"
|
||||
>
|
||||
<Upload className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{maskFile ? maskFile.name : "Select mask image..."}
|
||||
</span>
|
||||
<input type="file" accept="image/*" onChange={handleMaskSelect} className="hidden" />
|
||||
<input
|
||||
id="erase-object-mask"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleMaskSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +173,7 @@ export function EraseObjectSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !maskFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -67,7 +67,7 @@ export function FaviconSettings() {
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Generated Sizes</label>
|
||||
<p className="text-xs font-medium text-muted-foreground">Generated Sizes</p>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{SIZES.map((s) => (
|
||||
<div key={s.name} className="flex justify-between text-xs text-foreground">
|
||||
@@ -82,6 +82,7 @@ export function FaviconSettings() {
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -62,6 +62,7 @@ export function FindDuplicatesSettings() {
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -84,10 +85,13 @@ export function FindDuplicatesSettings() {
|
||||
<p className="text-xs text-muted-foreground">No duplicates found.</p>
|
||||
) : (
|
||||
result.duplicateGroups.map((group, gi) => (
|
||||
<div key={gi} className="p-2 rounded border border-border space-y-1">
|
||||
<div
|
||||
key={group.files.map((f) => f.filename).join(",")}
|
||||
className="p-2 rounded border border-border space-y-1"
|
||||
>
|
||||
<p className="text-xs font-medium text-foreground">Group {gi + 1}</p>
|
||||
{group.files.map((f, fi) => (
|
||||
<div key={fi} className="flex justify-between text-xs">
|
||||
{group.files.map((f) => (
|
||||
<div key={f.filename} className="flex justify-between text-xs">
|
||||
<span className="text-foreground truncate">{f.filename}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">{f.similarity}%</span>
|
||||
</div>
|
||||
|
||||
@@ -32,15 +32,17 @@ export function GifToolsSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Mode</label>
|
||||
<p className="text-xs text-muted-foreground">Mode</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("resize")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "resize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Resize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("extract")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "extract" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
@@ -53,8 +55,11 @@ export function GifToolsSettings() {
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<label htmlFor="gif-tools-width" className="text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="gif-tools-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
@@ -63,8 +68,11 @@ export function GifToolsSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<label htmlFor="gif-tools-height" className="text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="gif-tools-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
@@ -74,8 +82,12 @@ export function GifToolsSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<label
|
||||
htmlFor="gif-tools-optimize"
|
||||
className="flex items-center gap-2 text-sm text-foreground"
|
||||
>
|
||||
<input
|
||||
id="gif-tools-optimize"
|
||||
type="checkbox"
|
||||
checked={optimize}
|
||||
onChange={(e) => setOptimize(e.target.checked)}
|
||||
@@ -86,8 +98,11 @@ export function GifToolsSettings() {
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Frame Number</label>
|
||||
<label htmlFor="gif-tools-frame" className="text-xs text-muted-foreground">
|
||||
Frame Number
|
||||
</label>
|
||||
<input
|
||||
id="gif-tools-frame"
|
||||
type="number"
|
||||
value={extractFrame}
|
||||
onChange={(e) => setExtractFrame(e.target.value)}
|
||||
@@ -118,6 +133,7 @@ export function GifToolsSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -57,8 +57,11 @@ export function ImageToPdfSettings() {
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Page Size</label>
|
||||
<label htmlFor="image-to-pdf-page-size" className="text-xs text-muted-foreground">
|
||||
Page Size
|
||||
</label>
|
||||
<select
|
||||
id="image-to-pdf-page-size"
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(e.target.value as typeof pageSize)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -71,15 +74,17 @@ export function ImageToPdfSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Orientation</label>
|
||||
<p className="text-xs text-muted-foreground">Orientation</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrientation("portrait")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${orientation === "portrait" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Portrait
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrientation("landscape")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${orientation === "landscape" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
@@ -90,10 +95,13 @@ export function ImageToPdfSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Margin</label>
|
||||
<label htmlFor="image-to-pdf-margin" className="text-xs text-muted-foreground">
|
||||
Margin
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{margin}pt</span>
|
||||
</div>
|
||||
<input
|
||||
id="image-to-pdf-margin"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -106,6 +114,7 @@ export function ImageToPdfSettings() {
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -79,6 +79,7 @@ export function InfoSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -122,7 +123,7 @@ export function InfoSettings() {
|
||||
|
||||
{/* Histogram */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Channel Stats</label>
|
||||
<p className="text-xs font-medium text-muted-foreground">Channel Stats</p>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{info.histogram.map((ch) => (
|
||||
<div key={ch.channel} className="space-y-0.5">
|
||||
|
||||
@@ -129,9 +129,10 @@ export function OcrSettings() {
|
||||
<div className="space-y-4">
|
||||
{/* Engine selector */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">OCR Engine</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">OCR Engine</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEngine("tesseract")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
engine === "tesseract"
|
||||
@@ -142,6 +143,7 @@ export function OcrSettings() {
|
||||
Tesseract
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEngine("paddleocr")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
engine === "paddleocr"
|
||||
@@ -156,8 +158,11 @@ export function OcrSettings() {
|
||||
|
||||
{/* Language selector */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Language</label>
|
||||
<label htmlFor="ocr-language" className="text-xs text-muted-foreground">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
id="ocr-language"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -185,6 +190,7 @@ export function OcrSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -197,10 +203,11 @@ export function OcrSettings() {
|
||||
{text !== null && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
<label htmlFor="ocr-result-text" className="text-xs font-medium text-muted-foreground">
|
||||
Extracted Text ({detectedEngine})
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -209,6 +216,7 @@ export function OcrSettings() {
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="ocr-result-text"
|
||||
readOnly
|
||||
value={text}
|
||||
rows={8}
|
||||
|
||||
@@ -158,7 +158,8 @@ export function PipelineBuilder({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* File Upload Area */}
|
||||
<div
|
||||
<section
|
||||
aria-label="File upload area"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleFileDrop}
|
||||
className={cn(
|
||||
@@ -178,6 +179,7 @@ export function PipelineBuilder({
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFile(null)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
@@ -186,6 +188,7 @@ export function PipelineBuilder({
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFileSelect}
|
||||
className="flex items-center gap-2 mx-auto px-4 py-2 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm"
|
||||
>
|
||||
@@ -193,7 +196,7 @@ export function PipelineBuilder({
|
||||
Upload image to process
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pipeline Steps */}
|
||||
<div className="space-y-2">
|
||||
@@ -226,6 +229,7 @@ export function PipelineBuilder({
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
title="Settings"
|
||||
@@ -235,6 +239,7 @@ export function PipelineBuilder({
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(step.id, "up")}
|
||||
disabled={idx === 0}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
|
||||
@@ -243,6 +248,7 @@ export function PipelineBuilder({
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(step.id, "down")}
|
||||
disabled={idx === steps.length - 1}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
|
||||
@@ -251,6 +257,7 @@ export function PipelineBuilder({
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="Remove"
|
||||
@@ -283,6 +290,7 @@ export function PipelineBuilder({
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-foreground">Add a step</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToolPicker(false)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
@@ -294,6 +302,7 @@ export function PipelineBuilder({
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => addStep(tool.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
|
||||
>
|
||||
@@ -308,6 +317,7 @@ export function PipelineBuilder({
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToolPicker(true)}
|
||||
className="flex items-center gap-2 w-full justify-center px-4 py-2.5 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
@@ -343,6 +353,7 @@ export function PipelineBuilder({
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExecute}
|
||||
disabled={steps.length === 0 || !file || executing}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -362,6 +373,7 @@ export function PipelineBuilder({
|
||||
|
||||
{!showSaveForm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveForm(true)}
|
||||
disabled={steps.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -386,6 +398,7 @@ export function PipelineBuilder({
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1 hidden sm:block"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!saveName.trim() || saving}
|
||||
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
@@ -393,6 +406,7 @@ export function PipelineBuilder({
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveForm(false)}
|
||||
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
|
||||
@@ -526,8 +526,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
|
||||
case "number":
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-xs text-muted-foreground">{field.label}</label>
|
||||
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`pipeline-${field.key}`}
|
||||
type="number"
|
||||
value={value != null && value !== "" ? Number(value) : ""}
|
||||
onChange={(e) =>
|
||||
@@ -548,8 +551,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
|
||||
case "text":
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-xs text-muted-foreground">{field.label}</label>
|
||||
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`pipeline-${field.key}`}
|
||||
type="text"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => updateField(field.key, e.target.value || undefined)}
|
||||
@@ -560,12 +566,12 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
|
||||
);
|
||||
|
||||
case "select": {
|
||||
const opts = field.options!;
|
||||
const opts = field.options ?? [];
|
||||
// Use button group for <= 4 options, dropdown for more
|
||||
if (opts.length <= 4) {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-xs text-muted-foreground">{field.label}</label>
|
||||
<p className="text-xs text-muted-foreground">{field.label}</p>
|
||||
<div className="flex gap-1 mt-0.5">
|
||||
{opts.map((opt) => (
|
||||
<button
|
||||
@@ -587,8 +593,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
|
||||
}
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-xs text-muted-foreground">{field.label}</label>
|
||||
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<select
|
||||
id={`pipeline-${field.key}`}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -623,15 +632,22 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
|
||||
case "color":
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-xs text-muted-foreground">{field.label}</label>
|
||||
<label
|
||||
htmlFor={`pipeline-${field.key}-text`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-0.5">
|
||||
<input
|
||||
id={`pipeline-${field.key}-picker`}
|
||||
type="color"
|
||||
value={String(value || "#000000").slice(0, 7)}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
className="h-8 w-8 rounded border border-border cursor-pointer bg-background"
|
||||
/>
|
||||
<input
|
||||
id={`pipeline-${field.key}-text`}
|
||||
type="text"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => updateField(field.key, e.target.value || undefined)}
|
||||
|
||||
@@ -52,8 +52,11 @@ export function QrGenerateSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Text / URL</label>
|
||||
<label htmlFor="qr-text" className="text-xs text-muted-foreground">
|
||||
Text / URL
|
||||
</label>
|
||||
<textarea
|
||||
id="qr-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Enter text or URL..."
|
||||
@@ -64,10 +67,13 @@ export function QrGenerateSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Size</label>
|
||||
<label htmlFor="qr-size" className="text-xs text-muted-foreground">
|
||||
Size
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{size}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="qr-size"
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
@@ -79,8 +85,11 @@ export function QrGenerateSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Error Correction</label>
|
||||
<label htmlFor="qr-error-correction" className="text-xs text-muted-foreground">
|
||||
Error Correction
|
||||
</label>
|
||||
<select
|
||||
id="qr-error-correction"
|
||||
value={errorCorrection}
|
||||
onChange={(e) => setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -94,8 +103,11 @@ export function QrGenerateSettings() {
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Foreground</label>
|
||||
<label htmlFor="qr-foreground" className="text-xs text-muted-foreground">
|
||||
Foreground
|
||||
</label>
|
||||
<input
|
||||
id="qr-foreground"
|
||||
type="color"
|
||||
value={foreground}
|
||||
onChange={(e) => setForeground(e.target.value)}
|
||||
@@ -103,8 +115,11 @@ export function QrGenerateSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Background</label>
|
||||
<label htmlFor="qr-background" className="text-xs text-muted-foreground">
|
||||
Background
|
||||
</label>
|
||||
<input
|
||||
id="qr-background"
|
||||
type="color"
|
||||
value={background}
|
||||
onChange={(e) => setBackground(e.target.value)}
|
||||
@@ -116,6 +131,7 @@ export function QrGenerateSettings() {
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={!text || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -133,7 +149,7 @@ export function QrGenerateSettings() {
|
||||
style={{ maxHeight: 200 }}
|
||||
/>
|
||||
<a
|
||||
href={downloadUrl!}
|
||||
href={downloadUrl ?? undefined}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
|
||||
@@ -65,13 +65,14 @@ export function RemoveBgSettings() {
|
||||
<div className="space-y-4">
|
||||
{/* Subject type */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">What's in the photo?</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">What's in the photo?</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
|
||||
{SUBJECT_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSubject(opt.value);
|
||||
if (opt.value !== "people") setIsPassport(false);
|
||||
@@ -105,11 +106,12 @@ export function RemoveBgSettings() {
|
||||
|
||||
{/* Quality */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Quality</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">Quality</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
|
||||
{QUALITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setQuality(opt.value)}
|
||||
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
quality === opt.value
|
||||
@@ -126,11 +128,12 @@ export function RemoveBgSettings() {
|
||||
|
||||
{/* Background color - intuitive preset buttons */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Output Background</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">Output Background</p>
|
||||
<div className="flex gap-1.5 mt-1.5 flex-wrap">
|
||||
{BG_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setBgColor(preset.color)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
|
||||
bgColor === preset.color
|
||||
@@ -197,6 +200,7 @@ export function RemoveBgSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -36,9 +36,12 @@ export function ReplaceColorSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Source Color (to replace)</label>
|
||||
<label htmlFor="replace-source-color" className="text-xs text-muted-foreground">
|
||||
Source Color (to replace)
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="replace-source-color"
|
||||
type="color"
|
||||
value={sourceColor}
|
||||
onChange={(e) => setSourceColor(e.target.value)}
|
||||
@@ -60,9 +63,12 @@ export function ReplaceColorSettings() {
|
||||
|
||||
{!makeTransparent && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Target Color (replacement)</label>
|
||||
<label htmlFor="replace-target-color" className="text-xs text-muted-foreground">
|
||||
Target Color (replacement)
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="replace-target-color"
|
||||
type="color"
|
||||
value={targetColor}
|
||||
onChange={(e) => setTargetColor(e.target.value)}
|
||||
@@ -75,10 +81,13 @@ export function ReplaceColorSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Tolerance</label>
|
||||
<label htmlFor="replace-tolerance" className="text-xs text-muted-foreground">
|
||||
Tolerance
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{tolerance}</span>
|
||||
</div>
|
||||
<input
|
||||
id="replace-tolerance"
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
@@ -112,6 +121,7 @@ export function ReplaceColorSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -144,8 +144,11 @@ export function ResizeSettings() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="resize-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
@@ -162,8 +165,11 @@ export function ResizeSettings() {
|
||||
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="resize-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
@@ -175,7 +181,7 @@ export function ResizeSettings() {
|
||||
|
||||
{/* Fit mode */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Fit Mode</label>
|
||||
<p className="text-xs text-muted-foreground">Fit Mode</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
|
||||
<button
|
||||
@@ -207,8 +213,11 @@ export function ResizeSettings() {
|
||||
{tab === "scale" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Scale (%)</label>
|
||||
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
|
||||
Scale (%)
|
||||
</label>
|
||||
<input
|
||||
id="resize-scale"
|
||||
type="number"
|
||||
value={percentage}
|
||||
onChange={(e) => setPercentage(e.target.value)}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Quick rotate */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Rotate</label>
|
||||
<p className="text-xs text-muted-foreground">Rotate</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -112,13 +112,16 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
{/* Straighten */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Straighten</label>
|
||||
<label htmlFor="rotate-straighten" className="text-xs text-muted-foreground">
|
||||
Straighten
|
||||
</label>
|
||||
<span className="text-xs font-mono tabular-nums text-muted-foreground">
|
||||
{straighten > 0 ? "+" : ""}
|
||||
{straighten}°
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="rotate-straighten"
|
||||
type="range"
|
||||
min={-45}
|
||||
max={45}
|
||||
@@ -136,7 +139,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
|
||||
{/* Flip buttons */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Flip</label>
|
||||
<p className="text-xs text-muted-foreground">Flip</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -46,8 +46,11 @@ export function SmartCropSettings() {
|
||||
<div className="space-y-4">
|
||||
{/* Aspect ratio preset */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Target Aspect Ratio</label>
|
||||
<label htmlFor="smart-crop-preset" className="text-sm font-medium text-muted-foreground">
|
||||
Target Aspect Ratio
|
||||
</label>
|
||||
<select
|
||||
id="smart-crop-preset"
|
||||
value={preset}
|
||||
onChange={(e) => handlePreset(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -63,8 +66,11 @@ export function SmartCropSettings() {
|
||||
{/* Width / Height */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<label htmlFor="smart-crop-width" className="text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="smart-crop-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => {
|
||||
@@ -76,8 +82,11 @@ export function SmartCropSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<label htmlFor="smart-crop-height" className="text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="smart-crop-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => {
|
||||
@@ -119,6 +128,7 @@ export function SmartCropSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !canProcess || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -63,11 +63,12 @@ export function SplitSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Grid Presets</label>
|
||||
<p className="text-xs text-muted-foreground">Grid Presets</p>
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setColumns(p.c);
|
||||
setRows(p.r);
|
||||
@@ -82,8 +83,11 @@ export function SplitSettings() {
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Columns</label>
|
||||
<label htmlFor="split-columns" className="text-xs text-muted-foreground">
|
||||
Columns
|
||||
</label>
|
||||
<input
|
||||
id="split-columns"
|
||||
type="number"
|
||||
value={columns}
|
||||
onChange={(e) => setColumns(Math.max(1, Number(e.target.value)))}
|
||||
@@ -93,8 +97,11 @@ export function SplitSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Rows</label>
|
||||
<label htmlFor="split-rows" className="text-xs text-muted-foreground">
|
||||
Rows
|
||||
</label>
|
||||
<input
|
||||
id="split-rows"
|
||||
type="number"
|
||||
value={rows}
|
||||
onChange={(e) => setRows(Math.max(1, Number(e.target.value)))}
|
||||
@@ -110,6 +117,7 @@ export function SplitSettings() {
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -229,7 +229,7 @@ export function StripMetadataSettings() {
|
||||
}
|
||||
const data: MetadataResult = await res.json();
|
||||
setMetadata(data);
|
||||
setMetadataCache((prev) => new Map(prev).set(fileKey!, data));
|
||||
if (fileKey) setMetadataCache((prev) => new Map(prev).set(fileKey, data));
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
|
||||
@@ -239,7 +239,7 @@ export function StripMetadataSettings() {
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [currentFile, fileKey, metadataCache.get]);
|
||||
}, [currentFile, fileKey, metadataCache]);
|
||||
|
||||
const handleStripAllChange = (checked: boolean) => {
|
||||
setStripAll(checked);
|
||||
@@ -278,7 +278,7 @@ export function StripMetadataSettings() {
|
||||
{/* Metadata Display */}
|
||||
{hasFile && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
|
||||
<p className="text-xs font-medium text-muted-foreground">Current Metadata</p>
|
||||
|
||||
{inspecting && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
@@ -307,13 +307,13 @@ export function StripMetadataSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasExif && (
|
||||
{hasExif && metadata.exif && (
|
||||
<CollapsibleSection
|
||||
title="EXIF"
|
||||
badge={`${Object.keys(metadata.exif!).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
|
||||
badge={`${Object.keys(metadata.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
|
||||
defaultOpen
|
||||
>
|
||||
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
|
||||
<MetadataGrid data={metadata.exif} labelMap={EXIF_LABELS} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
@@ -321,31 +321,31 @@ export function StripMetadataSettings() {
|
||||
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
|
||||
)}
|
||||
|
||||
{hasGps && (
|
||||
{hasGps && metadata.gps && (
|
||||
<CollapsibleSection
|
||||
title="GPS"
|
||||
warning
|
||||
badge={`${Object.keys(metadata.gps!).filter((k) => !k.startsWith("_")).length} fields`}
|
||||
badge={`${Object.keys(metadata.gps).filter((k) => !k.startsWith("_")).length} fields`}
|
||||
>
|
||||
<MetadataGrid data={metadata.gps!} />
|
||||
<MetadataGrid data={metadata.gps} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{hasIcc && (
|
||||
{hasIcc && metadata.icc && (
|
||||
<CollapsibleSection
|
||||
title="ICC Profile"
|
||||
badge={`${Object.keys(metadata.icc!).length} fields`}
|
||||
badge={`${Object.keys(metadata.icc).length} fields`}
|
||||
>
|
||||
<MetadataGrid data={metadata.icc!} />
|
||||
<MetadataGrid data={metadata.icc} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{hasXmp && (
|
||||
{hasXmp && metadata.xmp && (
|
||||
<CollapsibleSection
|
||||
title="XMP"
|
||||
badge={`${Object.keys(metadata.xmp!).length} fields`}
|
||||
badge={`${Object.keys(metadata.xmp).length} fields`}
|
||||
>
|
||||
<MetadataGrid data={metadata.xmp!} />
|
||||
<MetadataGrid data={metadata.xmp} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
@@ -374,7 +374,7 @@ export function StripMetadataSettings() {
|
||||
|
||||
{/* Individual options */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground">Or select specific metadata:</label>
|
||||
<p className="text-xs text-muted-foreground">Or select specific metadata:</p>
|
||||
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
@@ -389,7 +389,7 @@ export function StripMetadataSettings() {
|
||||
Strip EXIF (camera info, date, exposure)
|
||||
{hasExif && !stripAll && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{Object.keys(metadata?.exif!).filter((k) => !SKIP_KEYS.has(k)).length} fields
|
||||
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
@@ -67,8 +67,11 @@ export function SvgToRasterSettings() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<label htmlFor="svg-raster-width" className="text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="svg-raster-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
@@ -78,8 +81,11 @@ export function SvgToRasterSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<label htmlFor="svg-raster-height" className="text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="svg-raster-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
@@ -90,8 +96,11 @@ export function SvgToRasterSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Output Format</label>
|
||||
<label htmlFor="svg-raster-format" className="text-xs text-muted-foreground">
|
||||
Output Format
|
||||
</label>
|
||||
<select
|
||||
id="svg-raster-format"
|
||||
value={outputFormat}
|
||||
onChange={(e) => setOutputFormat(e.target.value as "png" | "jpg" | "webp")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -115,8 +124,11 @@ export function SvgToRasterSettings() {
|
||||
|
||||
{!transparent && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Background Color</label>
|
||||
<label htmlFor="svg-raster-bg-color" className="text-xs text-muted-foreground">
|
||||
Background Color
|
||||
</label>
|
||||
<input
|
||||
id="svg-raster-bg-color"
|
||||
type="color"
|
||||
value={backgroundColor.slice(0, 7)}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
@@ -135,6 +147,7 @@ export function SvgToRasterSettings() {
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -39,8 +39,11 @@ export function TextOverlaySettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Text</label>
|
||||
<label htmlFor="text-overlay-text" className="text-xs text-muted-foreground">
|
||||
Text
|
||||
</label>
|
||||
<input
|
||||
id="text-overlay-text"
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
@@ -50,10 +53,13 @@ export function TextOverlaySettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Font Size</label>
|
||||
<label htmlFor="text-overlay-font-size" className="text-xs text-muted-foreground">
|
||||
Font Size
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="text-overlay-font-size"
|
||||
type="range"
|
||||
min={8}
|
||||
max={200}
|
||||
@@ -64,8 +70,11 @@ export function TextOverlaySettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Text Color</label>
|
||||
<label htmlFor="text-overlay-color" className="text-xs text-muted-foreground">
|
||||
Text Color
|
||||
</label>
|
||||
<input
|
||||
id="text-overlay-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
@@ -74,8 +83,11 @@ export function TextOverlaySettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Position</label>
|
||||
<label htmlFor="text-overlay-position" className="text-xs text-muted-foreground">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
id="text-overlay-position"
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value as "top" | "center" | "bottom")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -108,8 +120,11 @@ export function TextOverlaySettings() {
|
||||
|
||||
{backgroundBox && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Box Color</label>
|
||||
<label htmlFor="text-overlay-box-color" className="text-xs text-muted-foreground">
|
||||
Box Color
|
||||
</label>
|
||||
<input
|
||||
id="text-overlay-box-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
@@ -138,6 +153,7 @@ export function TextOverlaySettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing || !text}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -21,11 +21,12 @@ export function UpscaleSettings() {
|
||||
<div className="space-y-4">
|
||||
{/* Scale factor */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Scale Factor</label>
|
||||
<p className="text-sm font-medium text-muted-foreground">Scale Factor</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{[2, 4].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScale(s)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
scale === s
|
||||
@@ -68,6 +69,7 @@ export function UpscaleSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -58,15 +58,17 @@ export function VectorizeSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Color Mode</label>
|
||||
<p className="text-xs text-muted-foreground">Color Mode</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorMode("bw")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "bw" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Black & White
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorMode("color")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
@@ -77,10 +79,13 @@ export function VectorizeSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Threshold</label>
|
||||
<label htmlFor="vectorize-threshold" className="text-xs text-muted-foreground">
|
||||
Threshold
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{threshold}</span>
|
||||
</div>
|
||||
<input
|
||||
id="vectorize-threshold"
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
@@ -91,8 +96,11 @@ export function VectorizeSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Detail Level</label>
|
||||
<label htmlFor="vectorize-detail" className="text-xs text-muted-foreground">
|
||||
Detail Level
|
||||
</label>
|
||||
<select
|
||||
id="vectorize-detail"
|
||||
value={detail}
|
||||
onChange={(e) => setDetail(e.target.value as "low" | "medium" | "high")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -113,6 +121,7 @@ export function VectorizeSettings() {
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -63,7 +63,7 @@ export function WatermarkImageSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Watermark Image</label>
|
||||
<p className="text-xs text-muted-foreground">Watermark Image</p>
|
||||
<input
|
||||
ref={watermarkInputRef}
|
||||
type="file"
|
||||
@@ -72,6 +72,7 @@ export function WatermarkImageSettings() {
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watermarkInputRef.current?.click()}
|
||||
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -81,8 +82,11 @@ export function WatermarkImageSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Position</label>
|
||||
<label htmlFor="watermark-image-position" className="text-xs text-muted-foreground">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
id="watermark-image-position"
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value as Position)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -97,10 +101,13 @@ export function WatermarkImageSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||
<label htmlFor="watermark-image-opacity" className="text-xs text-muted-foreground">
|
||||
Opacity
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="watermark-image-opacity"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -112,10 +119,13 @@ export function WatermarkImageSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Scale</label>
|
||||
<label htmlFor="watermark-image-scale" className="text-xs text-muted-foreground">
|
||||
Scale
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{scale}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="watermark-image-scale"
|
||||
type="range"
|
||||
min={5}
|
||||
max={100}
|
||||
@@ -135,6 +145,7 @@ export function WatermarkImageSettings() {
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !watermarkFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
@@ -40,8 +40,11 @@ export function WatermarkTextSettings() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Watermark Text</label>
|
||||
<label htmlFor="watermark-text-text" className="text-xs text-muted-foreground">
|
||||
Watermark Text
|
||||
</label>
|
||||
<input
|
||||
id="watermark-text-text"
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
@@ -51,10 +54,13 @@ export function WatermarkTextSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Font Size</label>
|
||||
<label htmlFor="watermark-text-font-size" className="text-xs text-muted-foreground">
|
||||
Font Size
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="watermark-text-font-size"
|
||||
type="range"
|
||||
min={8}
|
||||
max={200}
|
||||
@@ -66,8 +72,11 @@ export function WatermarkTextSettings() {
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Color</label>
|
||||
<label htmlFor="watermark-text-color" className="text-xs text-muted-foreground">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="watermark-text-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
@@ -76,10 +85,13 @@ export function WatermarkTextSettings() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Opacity</label>
|
||||
<label htmlFor="watermark-text-opacity" className="text-xs text-muted-foreground">
|
||||
Opacity
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{opacity}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="watermark-text-opacity"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
@@ -91,8 +103,11 @@ export function WatermarkTextSettings() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Position</label>
|
||||
<label htmlFor="watermark-text-position" className="text-xs text-muted-foreground">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
id="watermark-text-position"
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value as Position)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
@@ -108,10 +123,13 @@ export function WatermarkTextSettings() {
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Rotation</label>
|
||||
<label htmlFor="watermark-text-rotation" className="text-xs text-muted-foreground">
|
||||
Rotation
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{rotation}°</span>
|
||||
</div>
|
||||
<input
|
||||
id="watermark-text-rotation"
|
||||
type="range"
|
||||
min={-180}
|
||||
max={180}
|
||||
@@ -141,6 +159,7 @@ export function WatermarkTextSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing || !text}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
|
||||
Reference in New Issue
Block a user