mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools)!: SnapOtter 2.0 phase 4 wave 1: 45 core tools across all modalities (#219)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders, parseApiError } from "@/lib/api";
|
||||
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
@@ -343,7 +344,11 @@ export function useToolProcessor(toolId: string) {
|
||||
delete cleanSettings._bgImageFile;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[capturedIndex] ?? files[0]);
|
||||
if (MULTI_FILE_TOOLS.has(toolId) && files.length > 1) {
|
||||
for (const f of files) formData.append("file", f);
|
||||
} else {
|
||||
formData.append("file", files[capturedIndex] ?? files[0]);
|
||||
}
|
||||
formData.append("settings", JSON.stringify(cleanSettings));
|
||||
if (bgImageFile) {
|
||||
formData.append("backgroundImage", bgImageFile);
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
AppWindow,
|
||||
AudioLines,
|
||||
BookImage,
|
||||
Braces,
|
||||
CheckCircle2,
|
||||
Code,
|
||||
Columns2,
|
||||
Combine,
|
||||
Copy,
|
||||
Crop,
|
||||
Crosshair,
|
||||
@@ -15,11 +17,13 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
FilePen,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
Film,
|
||||
Focus,
|
||||
Frame,
|
||||
@@ -44,17 +48,21 @@ import {
|
||||
ScanFace,
|
||||
ScanLine,
|
||||
ScanText,
|
||||
Scissors,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Split,
|
||||
Stamp,
|
||||
Star,
|
||||
Table,
|
||||
TextCursorInput,
|
||||
Type,
|
||||
Undo2,
|
||||
UserCheck,
|
||||
Video,
|
||||
VolumeX,
|
||||
Wand,
|
||||
Wrench,
|
||||
Zap,
|
||||
@@ -67,9 +75,11 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
AppWindow,
|
||||
AudioLines,
|
||||
BookImage,
|
||||
Braces,
|
||||
CheckCircle2,
|
||||
Code,
|
||||
Columns2,
|
||||
Combine,
|
||||
Copy,
|
||||
Crop,
|
||||
Crosshair,
|
||||
@@ -79,9 +89,11 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FilePen,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
FileVideo,
|
||||
FileText,
|
||||
FileType,
|
||||
Film,
|
||||
@@ -104,6 +116,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
QrCode,
|
||||
RotateCw,
|
||||
Scaling,
|
||||
Scissors,
|
||||
ScanEye,
|
||||
ScanFace,
|
||||
ScanLine,
|
||||
@@ -112,13 +125,16 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
ShieldOff,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Split,
|
||||
Stamp,
|
||||
Star,
|
||||
Table,
|
||||
TextCursorInput,
|
||||
Type,
|
||||
Undo2,
|
||||
UserCheck,
|
||||
Video,
|
||||
VolumeX,
|
||||
Wand,
|
||||
Wrench,
|
||||
Zap,
|
||||
|
||||
@@ -90,4 +90,35 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
"transparency-fixer": "before-after",
|
||||
"content-aware-resize": "side-by-side",
|
||||
"ai-canvas-expand": "before-after",
|
||||
|
||||
// Video tools
|
||||
"convert-video": "media-player",
|
||||
"compress-video": "media-player",
|
||||
"trim-video": "media-player",
|
||||
"mute-video": "media-player",
|
||||
"video-to-gif": "side-by-side",
|
||||
|
||||
// Audio tools
|
||||
"convert-audio": "media-player",
|
||||
"trim-audio": "media-player",
|
||||
"extract-audio": "media-player",
|
||||
|
||||
// PDF & Document tools
|
||||
"merge-pdf": "document",
|
||||
"split-pdf": "no-comparison",
|
||||
"compress-pdf": "document",
|
||||
"rotate-pdf": "document",
|
||||
"word-to-pdf": "document",
|
||||
|
||||
// Data tools
|
||||
"csv-excel": "no-comparison",
|
||||
"csv-json": "no-comparison",
|
||||
"json-xml": "no-comparison",
|
||||
"split-csv": "no-comparison",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tools whose selected files all post in ONE request as repeated "file" parts.
|
||||
* Consumed by use-tool-processor; backend routes declare maxInputs.
|
||||
*/
|
||||
export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set(["merge-pdf"]);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Maps each toolId to its settings component, display mode, and capabilities.
|
||||
* Adding a new tool means adding one entry here instead of editing a 750-line file.
|
||||
*/
|
||||
|
||||
import { AUDIO_INPUTS, VIDEO_INPUTS } from "@snapotter/shared";
|
||||
import type React from "react";
|
||||
import { lazy } from "react";
|
||||
import type { Crop } from "react-image-crop";
|
||||
@@ -342,6 +344,91 @@ const ColorBlindnessSettings = lazy(() =>
|
||||
default: m.ColorBlindnessSettings,
|
||||
})),
|
||||
);
|
||||
const ConvertVideoSettings = lazy(() =>
|
||||
import("@/components/tools/convert-video-settings").then((m) => ({
|
||||
default: m.ConvertVideoSettings,
|
||||
})),
|
||||
);
|
||||
const CompressVideoSettings = lazy(() =>
|
||||
import("@/components/tools/compress-video-settings").then((m) => ({
|
||||
default: m.CompressVideoSettings,
|
||||
})),
|
||||
);
|
||||
const TrimVideoSettings = lazy(() =>
|
||||
import("@/components/tools/trim-video-settings").then((m) => ({
|
||||
default: m.TrimVideoSettings,
|
||||
})),
|
||||
);
|
||||
const MuteVideoSettings = lazy(() =>
|
||||
import("@/components/tools/mute-video-settings").then((m) => ({
|
||||
default: m.MuteVideoSettings,
|
||||
})),
|
||||
);
|
||||
const VideoToGifSettings = lazy(() =>
|
||||
import("@/components/tools/video-to-gif-settings").then((m) => ({
|
||||
default: m.VideoToGifSettings,
|
||||
})),
|
||||
);
|
||||
const ConvertAudioSettings = lazy(() =>
|
||||
import("@/components/tools/convert-audio-settings").then((m) => ({
|
||||
default: m.ConvertAudioSettings,
|
||||
})),
|
||||
);
|
||||
const TrimAudioSettings = lazy(() =>
|
||||
import("@/components/tools/trim-audio-settings").then((m) => ({
|
||||
default: m.TrimAudioSettings,
|
||||
})),
|
||||
);
|
||||
const ExtractAudioSettings = lazy(() =>
|
||||
import("@/components/tools/extract-audio-settings").then((m) => ({
|
||||
default: m.ExtractAudioSettings,
|
||||
})),
|
||||
);
|
||||
const MergePdfSettings = lazy(() =>
|
||||
import("@/components/tools/merge-pdf-settings").then((m) => ({
|
||||
default: m.MergePdfSettings,
|
||||
})),
|
||||
);
|
||||
const SplitPdfSettings = lazy(() =>
|
||||
import("@/components/tools/split-pdf-settings").then((m) => ({
|
||||
default: m.SplitPdfSettings,
|
||||
})),
|
||||
);
|
||||
const CompressPdfSettings = lazy(() =>
|
||||
import("@/components/tools/compress-pdf-settings").then((m) => ({
|
||||
default: m.CompressPdfSettings,
|
||||
})),
|
||||
);
|
||||
const RotatePdfSettings = lazy(() =>
|
||||
import("@/components/tools/rotate-pdf-settings").then((m) => ({
|
||||
default: m.RotatePdfSettings,
|
||||
})),
|
||||
);
|
||||
const WordToPdfSettings = lazy(() =>
|
||||
import("@/components/tools/word-to-pdf-settings").then((m) => ({
|
||||
default: m.WordToPdfSettings,
|
||||
})),
|
||||
);
|
||||
const CsvExcelSettings = lazy(() =>
|
||||
import("@/components/tools/csv-excel-settings").then((m) => ({
|
||||
default: m.CsvExcelSettings,
|
||||
})),
|
||||
);
|
||||
const CsvJsonSettings = lazy(() =>
|
||||
import("@/components/tools/csv-json-settings").then((m) => ({
|
||||
default: m.CsvJsonSettings,
|
||||
})),
|
||||
);
|
||||
const JsonXmlSettings = lazy(() =>
|
||||
import("@/components/tools/json-xml-settings").then((m) => ({
|
||||
default: m.JsonXmlSettings,
|
||||
})),
|
||||
);
|
||||
const SplitCsvSettings = lazy(() =>
|
||||
import("@/components/tools/split-csv-settings").then((m) => ({
|
||||
default: m.SplitCsvSettings,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── Color tool wrapper ─────────────────────────────────────────────
|
||||
// Color tools share a single component but differ by toolId.
|
||||
@@ -428,7 +515,10 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["favicon", { Settings: FaviconSettings }],
|
||||
["image-to-pdf", { Settings: ImageToPdfSettings }],
|
||||
["optimize-for-web", { Settings: OptimizeForWebSettings }],
|
||||
["pdf-to-image", { Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview }],
|
||||
[
|
||||
"pdf-to-image",
|
||||
{ accept: ".pdf", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview },
|
||||
],
|
||||
|
||||
// Adjustments extra
|
||||
["replace-color", { Settings: ReplaceColorSettings }],
|
||||
@@ -451,6 +541,31 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["transparency-fixer", { Settings: TransparencyFixerSettings }],
|
||||
["content-aware-resize", { Settings: ContentAwareResizeSettings }],
|
||||
["ai-canvas-expand", { Settings: AiCanvasExpandSettings }],
|
||||
|
||||
// Video tools
|
||||
["convert-video", { accept: VIDEO_INPUTS.join(","), Settings: ConvertVideoSettings }],
|
||||
["compress-video", { accept: VIDEO_INPUTS.join(","), Settings: CompressVideoSettings }],
|
||||
["trim-video", { accept: VIDEO_INPUTS.join(","), Settings: TrimVideoSettings }],
|
||||
["mute-video", { accept: VIDEO_INPUTS.join(","), Settings: MuteVideoSettings }],
|
||||
["video-to-gif", { accept: VIDEO_INPUTS.join(","), Settings: VideoToGifSettings }],
|
||||
|
||||
// Audio tools
|
||||
["convert-audio", { accept: AUDIO_INPUTS.join(","), Settings: ConvertAudioSettings }],
|
||||
["trim-audio", { accept: AUDIO_INPUTS.join(","), Settings: TrimAudioSettings }],
|
||||
["extract-audio", { accept: VIDEO_INPUTS.join(","), Settings: ExtractAudioSettings }],
|
||||
|
||||
// PDF & Document tools
|
||||
["merge-pdf", { accept: ".pdf", Settings: MergePdfSettings }],
|
||||
["split-pdf", { accept: ".pdf", Settings: SplitPdfSettings }],
|
||||
["compress-pdf", { accept: ".pdf", Settings: CompressPdfSettings }],
|
||||
["rotate-pdf", { accept: ".pdf", Settings: RotatePdfSettings }],
|
||||
["word-to-pdf", { accept: ".docx,.doc,.odt,.rtf,.txt", Settings: WordToPdfSettings }],
|
||||
|
||||
// Data tools
|
||||
["csv-excel", { accept: ".csv,.xlsx", Settings: CsvExcelSettings }],
|
||||
["csv-json", { accept: ".csv,.json", Settings: CsvJsonSettings }],
|
||||
["json-xml", { accept: ".json,.xml", Settings: JsonXmlSettings }],
|
||||
["split-csv", { accept: ".csv", Settings: SplitCsvSettings }],
|
||||
];
|
||||
|
||||
export const toolRegistry = new Map<string, ToolRegistryEntry>(
|
||||
|
||||
Reference in New Issue
Block a user