feat(tools)!: SnapOtter 2.0 phase 4 wave 1: 45 core tools across all modalities (#219)

This commit is contained in:
SnapOtter
2026-06-13 10:18:49 +08:00
parent d647d8ed19
commit ae1337901d
115 changed files with 10026 additions and 924 deletions
@@ -0,0 +1,73 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Preset = "screen" | "ebook" | "printer";
export function CompressPdfSettings() {
const { t } = useTranslation();
const s = t.toolSettings["compress-pdf"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("compress-pdf");
const [preset, setPreset] = useState<Preset>("ebook");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { preset };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="cpdf-preset" className="text-xs text-muted-foreground">
{s.preset}
</label>
<select
id="cpdf-preset"
value={preset}
onChange={(e) => setPreset(e.target.value as Preset)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="screen">{s.screen}</option>
<option value="ebook">{s.ebook}</option>
<option value="printer">{s.printer}</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="compress-pdf-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,92 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Quality = "light" | "balanced" | "strong";
type Resolution = "original" | "1080p" | "720p" | "480p";
export function CompressVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["compress-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("compress-video");
const [quality, setQuality] = useState<Quality>("balanced");
const [resolution, setResolution] = useState<Resolution>("original");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { quality, resolution };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="cpv-quality" className="text-xs text-muted-foreground">
{s.quality}
</label>
<select
id="cpv-quality"
value={quality}
onChange={(e) => setQuality(e.target.value as Quality)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="light">{s.light}</option>
<option value="balanced">{s.balanced}</option>
<option value="strong">{s.strong}</option>
</select>
</div>
<div>
<label htmlFor="cpv-resolution" className="text-xs text-muted-foreground">
{s.resolution}
</label>
<select
id="cpv-resolution"
value={resolution}
onChange={(e) => setResolution(e.target.value as Resolution)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="original">{s.original}</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="compress-video-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type AudioFormat = "mp3" | "wav" | "ogg" | "flac" | "m4a";
const BITRATE_OPTIONS = [96, 128, 192, 256, 320] as const;
export function ConvertAudioSettings() {
const { t } = useTranslation();
const s = t.toolSettings["convert-audio"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("convert-audio");
const [outFormat, setOutFormat] = useState<AudioFormat>("mp3");
const [bitrateKbps, setBitrateKbps] = useState(192);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { format: outFormat, bitrateKbps };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="ca-format" className="text-xs text-muted-foreground">
{s.format}
</label>
<select
id="ca-format"
value={outFormat}
onChange={(e) => setOutFormat(e.target.value as AudioFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="mp3">MP3</option>
<option value="wav">WAV</option>
<option value="ogg">OGG</option>
<option value="flac">FLAC</option>
<option value="m4a">M4A</option>
</select>
</div>
<div>
<label htmlFor="ca-bitrate" className="text-xs text-muted-foreground">
{s.bitrate}
</label>
<select
id="ca-bitrate"
value={bitrateKbps}
onChange={(e) => setBitrateKbps(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
{BITRATE_OPTIONS.map((br) => (
<option key={br} value={br}>
{br} kbps
</option>
))}
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="convert-audio-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,91 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type VideoFormat = "mp4" | "mov" | "webm";
type Quality = "high" | "balanced" | "small";
export function ConvertVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["convert-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("convert-video");
const [outFormat, setOutFormat] = useState<VideoFormat>("mp4");
const [quality, setQuality] = useState<Quality>("balanced");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { format: outFormat, quality };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="cv-format" className="text-xs text-muted-foreground">
{s.format}
</label>
<select
id="cv-format"
value={outFormat}
onChange={(e) => setOutFormat(e.target.value as VideoFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="mp4">MP4</option>
<option value="mov">MOV</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label htmlFor="cv-quality" className="text-xs text-muted-foreground">
{s.quality}
</label>
<select
id="cv-quality"
value={quality}
onChange={(e) => setQuality(e.target.value as Quality)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="high">{s.high}</option>
<option value="balanced">{s.balanced}</option>
<option value="small">{s.small}</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="convert-video-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,75 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function CsvExcelSettings() {
const { t } = useTranslation();
const s = t.toolSettings["csv-excel"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("csv-excel");
const [sheet, setSheet] = useState(1);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const firstFileName = files[0]?.name ?? "";
const isXlsx = firstFileName.toLowerCase().endsWith(".xlsx");
const handleProcess = () => {
const settings = isXlsx ? { sheet } : {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
{isXlsx && (
<div>
<label htmlFor="ce-sheet" className="text-xs text-muted-foreground">
{s.sheet}
</label>
<input
id="ce-sheet"
type="number"
min={1}
step={1}
value={sheet}
onChange={(e) => setSheet(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="csv-excel-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function CsvJsonSettings() {
const { t } = useTranslation();
const s = t.toolSettings["csv-json"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("csv-json");
const [pretty, setPretty] = useState(true);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { pretty };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={pretty}
onChange={(e) => setPretty(e.target.checked)}
className="rounded"
/>
{s.pretty}
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="csv-json-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,73 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type AudioFormat = "mp3" | "wav" | "m4a";
export function ExtractAudioSettings() {
const { t } = useTranslation();
const s = t.toolSettings["extract-audio"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("extract-audio");
const [outFormat, setOutFormat] = useState<AudioFormat>("mp3");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { format: outFormat };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="ea-format" className="text-xs text-muted-foreground">
{s.format}
</label>
<select
id="ea-format"
value={outFormat}
onChange={(e) => setOutFormat(e.target.value as AudioFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="mp3">MP3</option>
<option value="wav">WAV</option>
<option value="m4a">M4A</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="extract-audio-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function JsonXmlSettings() {
const { t } = useTranslation();
const s = t.toolSettings["json-xml"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("json-xml");
const [pretty, setPretty] = useState(true);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { pretty };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={pretty}
onChange={(e) => setPretty(e.target.checked)}
className="rounded"
/>
{s.pretty}
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="json-xml-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,51 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function MergePdfSettings() {
const { t } = useTranslation();
const s = t.toolSettings["merge-pdf"];
const { files } = useFileStore();
const { processFiles, processing, error, progress } = useToolProcessor("merge-pdf");
const hasEnough = files.length >= 2;
const handleProcess = () => {
processFiles(files, {});
};
return (
<div className="space-y-4">
{files.length > 0 && (
<p className="text-xs text-muted-foreground">
{hasEnough ? format(s.orderHint, { count: files.length }) : s.needTwo}
</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="merge-pdf-submit"
onClick={handleProcess}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,52 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function MuteVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["mute-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("mute-video");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="mute-video-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Angle = 90 | 180 | 270;
export function RotatePdfSettings() {
const { t } = useTranslation();
const s = t.toolSettings["rotate-pdf"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("rotate-pdf");
const [angle, setAngle] = useState<Angle>(90);
const [range, setRange] = useState("1-z");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { angle, range };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="rpdf-angle" className="text-xs text-muted-foreground">
{s.angle}
</label>
<select
id="rpdf-angle"
value={angle}
onChange={(e) => setAngle(Number(e.target.value) as Angle)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value={90}>90</option>
<option value={180}>180</option>
<option value={270}>270</option>
</select>
</div>
<div>
<label htmlFor="rpdf-range" className="text-xs text-muted-foreground">
{s.range}
</label>
<input
id="rpdf-range"
type="text"
value={range}
onChange={(e) => setRange(e.target.value)}
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">{s.rangeHint}</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="rotate-pdf-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,81 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function SplitCsvSettings() {
const { t } = useTranslation();
const s = t.toolSettings["split-csv"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("split-csv");
const [rowsPerFile, setRowsPerFile] = useState(1000);
const [keepHeader, setKeepHeader] = useState(true);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { rowsPerFile, keepHeader };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="sc-rows" className="text-xs text-muted-foreground">
{s.rowsPerFile}
</label>
<input
id="sc-rows"
type="number"
min={1}
step={1}
value={rowsPerFile}
onChange={(e) => setRowsPerFile(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={keepHeader}
onChange={(e) => setKeepHeader(e.target.checked)}
className="rounded"
/>
{s.keepHeader}
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="split-csv-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,108 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type SplitMode = "range" | "every";
export function SplitPdfSettings() {
const { t } = useTranslation();
const s = t.toolSettings["split-pdf"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("split-pdf");
const [mode, setMode] = useState<SplitMode>("range");
const [range, setRange] = useState("1-3,5");
const [everyN, setEveryN] = useState(1);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = mode === "range" ? { mode, range } : { mode, everyN };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="sp-mode" className="text-xs text-muted-foreground">
{s.mode}
</label>
<select
id="sp-mode"
value={mode}
onChange={(e) => setMode(e.target.value as SplitMode)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="range">{s.range}</option>
<option value="every">{s.everyN}</option>
</select>
</div>
{mode === "range" && (
<div>
<label htmlFor="sp-range" className="text-xs text-muted-foreground">
{s.range}
</label>
<input
id="sp-range"
type="text"
value={range}
placeholder="1-3,5"
onChange={(e) => setRange(e.target.value)}
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">{s.rangeHint}</p>
</div>
)}
{mode === "every" && (
<div>
<label htmlFor="sp-every" className="text-xs text-muted-foreground">
{s.everyN}
</label>
<input
id="sp-every"
type="number"
min={1}
step={1}
value={everyN}
onChange={(e) => setEveryN(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="split-pdf-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,90 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function TrimAudioSettings() {
const { t } = useTranslation();
const s = t.toolSettings["trim-audio"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("trim-audio");
const [startS, setStartS] = useState(0);
const [endS, setEndS] = useState(10);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const rangeValid = endS > startS;
const handleProcess = () => {
const settings = { startS, endS };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="ta-start" className="text-xs text-muted-foreground">
{s.start}
</label>
<input
id="ta-start"
type="number"
min={0}
step={0.1}
value={startS}
onChange={(e) => setStartS(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="ta-end" className="text-xs text-muted-foreground">
{s.end}
</label>
<input
id="ta-end"
type="number"
min={0}
step={0.1}
value={endS}
onChange={(e) => setEndS(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
{!rangeValid && (
<p className="text-[10px] text-amber-500 mt-0.5">End time must be after start time</p>
)}
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="trim-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing || !rangeValid}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function TrimVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["trim-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("trim-video");
const [startS, setStartS] = useState(0);
const [endS, setEndS] = useState(10);
const [precise, setPrecise] = useState(false);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const rangeValid = endS > startS;
const handleProcess = () => {
const settings = { startS, endS, precise };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="tv-start" className="text-xs text-muted-foreground">
{s.start}
</label>
<input
id="tv-start"
type="number"
min={0}
step={0.1}
value={startS}
onChange={(e) => setStartS(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="tv-end" className="text-xs text-muted-foreground">
{s.end}
</label>
<input
id="tv-end"
type="number"
min={0}
step={0.1}
value={endS}
onChange={(e) => setEndS(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
{!rangeValid && (
<p className="text-[10px] text-amber-500 mt-0.5">End time must be after start time</p>
)}
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={precise}
onChange={(e) => setPrecise(e.target.checked)}
className="rounded"
/>
{s.precise}
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="trim-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing || !rangeValid}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,121 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function VideoToGifSettings() {
const { t } = useTranslation();
const s = t.toolSettings["video-to-gif"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("video-to-gif");
const [fps, setFps] = useState(12);
const [width, setWidth] = useState(480);
const [startS, setStartS] = useState(0);
const [durationS, setDurationS] = useState(5);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { fps, width, startS, durationS };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="vtg-fps" className="text-xs text-muted-foreground">
{s.fps}
</label>
<input
id="vtg-fps"
type="number"
min={1}
max={30}
step={1}
value={fps}
onChange={(e) => setFps(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="vtg-width" className="text-xs text-muted-foreground">
{s.width}
</label>
<input
id="vtg-width"
type="number"
min={64}
max={1280}
step={1}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="vtg-start" className="text-xs text-muted-foreground">
{s.start}
</label>
<input
id="vtg-start"
type="number"
min={0}
step={0.1}
value={startS}
onChange={(e) => setStartS(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="vtg-duration" className="text-xs text-muted-foreground">
{s.duration}
</label>
<input
id="vtg-duration"
type="number"
min={0.1}
max={60}
step={0.1}
value={durationS}
onChange={(e) => setDurationS(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="video-to-gif-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,52 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function WordToPdfSettings() {
const { t } = useTranslation();
const s = t.toolSettings["word-to-pdf"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("word-to-pdf");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="word-to-pdf-submit"
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"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}