Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions src/components/BatchExportPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"use client";

import { PRESETS } from "@/lib/presets";
import { EditRecipe } from "@/lib/types";
import PresetSelector from "./PresetSelector";
import { Layers, SquareStack } from "lucide-react";

interface Props {
recipe: EditRecipe;
onRecipeChange: (patch: Partial<EditRecipe>) => void;
batchMode: boolean;
onBatchModeChange: (enabled: boolean) => void;
batchPresetIds: string[];
onToggleBatchPreset: (presetId: string) => void;
}

export default function BatchExportPanel({
recipe,
onRecipeChange,
batchMode,
onBatchModeChange,
batchPresetIds,
onToggleBatchPreset,
}: Props) {
const selectable = PRESETS.filter((p) => p.id !== "custom");

return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 p-3 rounded-lg border border-[var(--border)] bg-[var(--bg)]">
<div className="flex items-center gap-2 min-w-0">
<SquareStack size={18} className="text-film-500 shrink-0" />
<div className="min-w-0">
<p className="text-xs font-heading font-bold text-[var(--text)]">Batch export</p>
<p className="text-[10px] text-[var(--muted)] leading-tight mt-0.5">
Export the same edit to multiple sizes, one after another.
</p>
</div>
</div>
<button
type="button"
role="switch"
aria-checked={batchMode}
onClick={() => onBatchModeChange(!batchMode)}
className={`relative shrink-0 w-11 h-6 rounded-full transition-colors ${
batchMode ? "bg-film-600" : "bg-[var(--border)]"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
batchMode ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>

{!batchMode ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-film-500 opacity-80">
<Layers size={12} />
</span>
<h3 className="text-[10px] font-heading font-bold uppercase tracking-widest text-[var(--muted)]">
Output size
</h3>
<div className="flex-1 h-px bg-[var(--border)]" />
</div>
<PresetSelector recipe={recipe} onChange={onRecipeChange} />
</div>
) : (
<div className="space-y-3">
<p className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)]">
Select presets (2+)
</p>
<div className="grid grid-cols-1 gap-1.5 max-h-[280px] overflow-y-auto pr-0.5">
{selectable.map((preset) => {
const checked = batchPresetIds.includes(preset.id);
return (
<label
key={preset.id}
className={`
flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-all duration-150
${checked
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30"
}
`}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggleBatchPreset(preset.id)}
className="w-4 h-4 rounded border-[var(--border)] text-film-600 focus:ring-film-400"
/>
<div className="min-w-0 flex-1">
<p className={`text-xs font-heading font-bold leading-tight ${checked ? "text-film-700" : "text-[var(--text)]"}`}>
{preset.label}
</p>
<p className="text-[10px] text-[var(--muted)] leading-tight mt-0.5 truncate">
{preset.platform} · {preset.width}×{preset.height}
</p>
</div>
</label>
);
})}

<label
className={`
flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-all duration-150
${batchPresetIds.includes("custom")
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30"
}
`}
>
<input
type="checkbox"
checked={batchPresetIds.includes("custom")}
onChange={() => onToggleBatchPreset("custom")}
className="w-4 h-4 rounded border-[var(--border)] text-film-600 focus:ring-film-400"
/>
<div className="min-w-0 flex-1">
<p className={`text-xs font-heading font-bold ${batchPresetIds.includes("custom") ? "text-film-700" : "text-[var(--text)]"}`}>
Custom
</p>
<p className="text-[10px] text-[var(--muted)] mt-0.5">
{recipe.customWidth}×{recipe.customHeight}px
</p>
</div>
</label>
</div>

{batchPresetIds.includes("custom") && (
<div className="flex gap-3 items-center p-3 bg-[var(--surface)] rounded-lg border border-[var(--border)]">
<div className="flex-1">
<label className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)] block mb-1.5">
Width px
</label>
<input
type="number"
min={16}
max={7680}
step={2}
value={recipe.customWidth}
onChange={(e) => onRecipeChange({ customWidth: Number(e.target.value) })}
className="w-full text-sm px-3 py-1.5 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 transition-shadow"
/>
</div>
<span className="text-[var(--muted)] mt-5 font-heading text-sm">x</span>
<div className="flex-1">
<label className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)] block mb-1.5">
Height px
</label>
<input
type="number"
min={16}
max={7680}
step={2}
value={recipe.customHeight}
onChange={(e) => onRecipeChange({ customHeight: Number(e.target.value) })}
className="w-full text-sm px-3 py-1.5 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 transition-shadow"
/>
</div>
</div>
)}

{batchPresetIds.length < 2 && (
<p className="text-[10px] text-film-600 font-heading font-semibold">
Choose at least two presets to run a batch.
</p>
)}
</div>
)}
</div>
);
}
121 changes: 111 additions & 10 deletions src/components/DownloadResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { useState, useEffect } from "react";
import { ExportResult } from "@/lib/types";
import { formatBytes } from "@/lib/utils";
import { Download, RotateCcw, Share2, AlertCircle } from "lucide-react";
import { Download, RotateCcw, Share2, AlertCircle, Archive } from "lucide-react";
import JSZip from "jszip";
import LottiePlayer from "./LottiePlayer";
import successAnim from "@/lib/lottie/success.json";
import { cn } from "@/lib/utils";
Expand All @@ -12,33 +13,133 @@ const SHARE_TWEET_TEXT =
"I just edited my video with @reframevideo — free browser-based video editor! Check it out: https://github.com/magic-peach/reframe";

interface Props {
result: ExportResult;
result?: ExportResult | null;
batchResults?: ExportResult[] | null;
onReset: () => void;
soundOnCompletion: boolean;
}

export default function DownloadResult({ result, onReset, soundOnCompletion }: Props) {
const defaultName = `reframe_${result.width}x${result.height}`;
export default function DownloadResult({
result,
batchResults,
onReset,
soundOnCompletion,
}: Props) {
const isBatch = Boolean(batchResults && batchResults.length > 0);
const [zipBusy, setZipBusy] = useState(false);
const defaultName = result ? `reframe_${result.width}x${result.height}` : "";
const [name, setName] = useState(defaultName);

const invalidCharRegex = /[<>:"/\\|?*]/;
const isValid = !invalidCharRegex.test(name) && name.trim().length > 0;
const filename = `${name.trim() || "untitled"}.${result.format}`;

const filename = result ? `${name.trim() || "untitled"}.${result.format}` : "";
const shareHref = `https://x.com/intent/tweet?text=${encodeURIComponent(SHARE_TWEET_TEXT)}`;

useEffect(() => {
if (soundOnCompletion) {
if (soundOnCompletion && (result || isBatch)) {
const audio = new Audio("/sounds/export-complete.mp3");
audio.play().catch(console.error);
}
}, [soundOnCompletion]);
}, [soundOnCompletion, result, isBatch]);

useEffect(() => {
if (result) setName(`reframe_${result.width}x${result.height}`);
}, [result]);

const handleReset = () => {
if (window.confirm("This will clear the current video and all settings. Continue?")) {
onReset();
}
};

const handleZip = async () => {
if (!batchResults?.length) return;
setZipBusy(true);
try {
const zip = new JSZip();
for (const r of batchResults) {
const fileName = r.filename ?? `reframe_${r.width}x${r.height}.${r.format}`;
const buf = await fetch(r.blobUrl).then((res) => res.arrayBuffer());
zip.file(fileName, buf);
}
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "reframe_batch_export.zip";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error("zip failed:", e);
} finally {
setZipBusy(false);
}
};

if (isBatch && batchResults) {
return (
<div className="p-5 bg-[var(--surface)] border border-[var(--border)] rounded-xl space-y-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 shrink-0">
<LottiePlayer animationData={successAnim} loop={false} autoplay />
</div>
<div>
<p className="font-heading font-bold text-base text-[var(--text)]">Batch export complete</p>
<p className="text-xs text-[var(--muted)] mt-0.5">{batchResults.length} files ready</p>
</div>
</div>
<ul className="space-y-2 max-h-64 overflow-y-auto">
{batchResults.map((r) => {
const fileName = r.filename ?? `reframe_${r.width}x${r.height}.${r.format}`;
return (
<li
key={r.blobUrl}
className="flex items-center justify-between gap-2 p-2.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-sm"
>
<div className="min-w-0">
<p className="font-heading font-bold text-[var(--text)] truncate">{fileName}</p>
<p className="text-[10px] text-[var(--muted)]">
{r.width}×{r.height} · {formatBytes(r.size)} · {r.format}
</p>
</div>
<a
href={r.blobUrl}
download={fileName}
className="shrink-0 flex items-center gap-1.5 px-3 py-2 bg-film-600 hover:bg-film-700 text-white text-xs font-heading font-bold uppercase tracking-wide rounded-lg"
>
<Download size={14} />
</a>
</li>
);
})}
</ul>
<div className="flex flex-wrap gap-2 pt-2">
{batchResults.length > 1 && (
<button
type="button"
disabled={zipBusy}
onClick={handleZip}
className="flex-1 min-w-[10rem] flex items-center justify-center gap-2 py-3 bg-film-600 hover:bg-film-700 disabled:opacity-60 text-white text-sm font-heading font-bold uppercase tracking-wide rounded-lg"
>
<Archive size={15} />
{zipBusy ? "Zipping…" : "Download ZIP"}
</button>
)}
<button
type="button"
onClick={handleReset}
className="flex items-center gap-2 px-4 py-3 border border-[var(--border)] text-[var(--muted)] text-sm rounded-lg hover:bg-[var(--bg)]"
>
<RotateCcw size={14} />
New
</button>
</div>
</div>
);
}

if (!result) return null;

return (
<div className="p-5 bg-[var(--surface)] border border-[var(--border)] rounded-xl space-y-4">
<div className="flex items-center gap-4">
Expand Down Expand Up @@ -110,7 +211,7 @@ export default function DownloadResult({ result, onReset, soundOnCompletion }: P
if (!isValid) e.preventDefault();
}}
>
<Download size={15} aria-hidden="true" />
<Download size={15} aria-hidden="true" />
Download {result.format.toUpperCase()}
</a>
<a
Expand All @@ -129,7 +230,7 @@ export default function DownloadResult({ result, onReset, soundOnCompletion }: P
onClick={handleReset}
className="flex items-center gap-2 px-4 py-3 border border-[var(--border)] text-[var(--muted)] text-sm rounded-lg hover:bg-[var(--bg)] transition-colors"
>
<RotateCcw size={14} aria-hidden="true" />
<RotateCcw size={14} aria-hidden="true" />
New
</button>
<a
Expand Down
12 changes: 9 additions & 3 deletions src/components/ExportOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@

import FocusTrap from "focus-trap-react";
import { useEffect, useRef, useCallback } from "react";
import { ExportStatus } from "@/lib/types";
import { ExportStatus, BatchExportProgress } from "@/lib/types";
import LottiePlayer from "./LottiePlayer";
import spinnerAnim from "@/lib/lottie/spinner.json";
import TipCarousel from "./TipCarousel";

interface Props {
status: ExportStatus;
progress: number;
batchProgress?: BatchExportProgress | null;
onCancel?: () => void;
}

export default function ExportOverlay({ status, progress, onCancel }: Props) {
export default function ExportOverlay({ status, progress, batchProgress, onCancel }: Props) {
const visible = status === "loading-engine" || status === "exporting";
const previousFocusRef = useRef<HTMLElement | null>(null);
const focusAnchorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -97,6 +98,11 @@ export default function ExportOverlay({ status, progress, onCancel }: Props) {
? "Downloading the video engine. This only happens once."
: "Processing your video locally."}
</p>
{batchProgress && (
<p className="text-sm font-heading font-semibold text-film-700 mt-3">
Export {batchProgress.current} of {batchProgress.total}: {batchProgress.filename}
</p>
)}
<p className="text-xs font-heading font-semibold text-film-600 mt-2 uppercase tracking-wide">
Do not close or refresh this tab
</p>
Expand Down Expand Up @@ -141,4 +147,4 @@ export default function ExportOverlay({ status, progress, onCancel }: Props) {
</div>
</FocusTrap>
);
}
}
Loading
Loading