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
6 changes: 4 additions & 2 deletions src/components/DownloadResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface Props {
}

export default function DownloadResult({ result, onReset, soundOnCompletion }: Props) {
const defaultName = `reframe_${result.width}x${result.height}`;
const defaultName = result.format === "zip" ? "reframe_batch" : `reframe_${result.width}x${result.height}`;
const [name, setName] = useState(defaultName);

const invalidCharRegex = /[<>:"/\\|?*]/;
Expand Down Expand Up @@ -54,7 +54,9 @@ export default function DownloadResult({ result, onReset, soundOnCompletion }: P
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="bg-[var(--bg)] rounded-lg p-3 border border-[var(--border)]">
<p className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)] mb-1">Resolution</p>
<p className="font-heading font-bold text-[var(--text)]">{result.width} × {result.height}</p>
<p className="font-heading font-bold text-[var(--text)]">
{result.format === "zip" ? "Multiple files" : `${result.width} × ${result.height}`}
</p>
</div>
<div className="bg-[var(--bg)] rounded-lg p-3 border border-[var(--border)]">
<p className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)] mb-1">File size</p>
Expand Down
8 changes: 7 additions & 1 deletion src/components/ExportOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import TipCarousel from "./TipCarousel";
interface Props {
status: ExportStatus;
progress: number;
progressText?: string;
onCancel?: () => void;
}

export default function ExportOverlay({ status, progress, onCancel }: Props) {
export default function ExportOverlay({ status, progress, progressText, 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 @@ -92,6 +93,11 @@ export default function ExportOverlay({ status, progress, onCancel }: Props) {
<h2 className="font-heading font-bold text-xl tracking-tight text-[var(--text)]">
{isLoading ? "Loading engine" : "Exporting"}
</h2>
{progressText && (
<p className="text-sm font-semibold text-film-600 mt-1">
{progressText}
</p>
)}
<p className="text-sm text-[var(--muted)] mt-1">
{isLoading
? "Downloading the video engine. This only happens once."
Expand Down
5 changes: 5 additions & 0 deletions src/components/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ interface Props {
currentFile: File | null;
fileError: string;
duration: number;
heroFileInputId?: string;
}

export default function FileUpload({
onFileSelect,
currentFile,
fileError,
duration,
heroFileInputId,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -140,6 +142,7 @@ export default function FileUpload({
type="file"
accept="video/*"
className="hidden"
id={heroFileInputId}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
Expand All @@ -160,6 +163,7 @@ export default function FileUpload({
type="file"
accept="video/*"
className="hidden"
id={heroFileInputId}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
Expand Down Expand Up @@ -240,6 +244,7 @@ export default function FileUpload({
type="file"
accept="video/*"
className="hidden"
id={heroFileInputId}
onChange={(e) => {
const f = e.target.files?.[0];

Expand Down
88 changes: 88 additions & 0 deletions src/components/Hero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { Lock, Zap, Gift, Wifi, Play } from "lucide-react";

interface HeroProps {
onUploadClick: () => void;
}

export default function Hero({ onUploadClick }: HeroProps) {
const features = [
{
icon: Lock,
title: "Private",
description: "100% local processing",
},
{
icon: Zap,
title: "Fast",
description: "Lightning quick edits",
},
{
icon: Gift,
title: "Free",
description: "No hidden costs",
},
{
icon: Wifi,
title: "Works Offline",
description: "No internet needed",
},
];

return (
<div className="w-full bg-gradient-to-b from-[var(--surface)] to-[var(--bg)] rounded-xl border border-[var(--border)] p-8 sm:p-12 mb-6 animate-fade-in">
{/* Main Content */}
<div className="max-w-3xl mx-auto text-center">
{/* Headline */}
<h1 className="font-display text-4xl sm:text-5xl lg:text-6xl leading-tight tracking-widest text-[var(--text)] mb-4">
Resize, trim & export videos — entirely in your browser
</h1>

{/* Subheadline */}
<p className="font-heading text-lg sm:text-xl text-[var(--muted)] mb-8">
No upload. No account. No limits. Powered by FFmpeg.wasm.
</p>

{/* Feature Pills */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-8">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.title}
className="flex flex-col items-center gap-2 p-3 sm:p-4 rounded-lg bg-[var(--bg)] border border-[var(--border)] hover:border-film-500/50 transition-colors duration-200"
>
<div className="text-film-500">
<Icon size={20} className="sm:w-6 sm:h-6" />
</div>
<div>
<p className="font-heading font-semibold text-sm text-[var(--text)]">
{feature.title}
</p>
<p className="text-xs text-[var(--muted)] mt-0.5">
{feature.description}
</p>
</div>
</div>
);
})}
</div>

{/* CTA Section */}
<div className="flex flex-col items-center gap-3">
<button
onClick={onUploadClick}
className="group flex items-center justify-center gap-2 px-6 sm:px-8 py-3 sm:py-4 bg-film-500 hover:bg-film-600 text-white font-heading font-bold uppercase tracking-wider rounded-lg transition-all duration-200 hover:shadow-[0_0_20px_rgba(230,57,70,0.3)] hover:scale-105"
>
<Play size={18} className="group-hover:scale-110 transition-transform" />
Choose a video
</button>
<p className="text-sm text-[var(--muted)] font-heading">
or drag and drop anywhere
</p>
</div>
</div>
</div>
);
}
82 changes: 62 additions & 20 deletions src/components/PresetSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,19 @@ export default function PresetSelector({ recipe, onChange }: Props) {

const handlePresetSelect = useCallback(
(presetId: string) => {
onChange({ preset: presetId });
setSearch("");
if (recipe.isBatchExport) {
if (presetId === "custom") return;
const currentBatch = recipe.batchPresets || [];
const newBatch = currentBatch.includes(presetId)
? currentBatch.filter((id) => id !== presetId)
: [...currentBatch, presetId];
onChange({ batchPresets: newBatch });
} else {
onChange({ preset: presetId });
setSearch("");
}
},
[onChange],
[onChange, recipe.isBatchExport, recipe.batchPresets],
);

const handleWidthChange = useCallback(
Expand All @@ -83,17 +92,35 @@ export default function PresetSelector({ recipe, onChange }: Props) {

return (
<div id="preset-selector" className="space-y-3">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={14} className="text-[var(--muted)]" />
<div className="flex items-center justify-between mb-3">
<div className="relative flex-1 mr-4">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={14} className="text-[var(--muted)]" />
</div>
<input
type="text"
placeholder="Search formats..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400"
/>
</div>
<input
type="text"
placeholder="Search formats..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400"
/>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={recipe.isBatchExport || false}
onChange={(e) => {
const isBatchExport = e.target.checked;
const currentBatch = recipe.batchPresets || [];
const batchPresets = isBatchExport && currentBatch.length === 0 && recipe.preset !== "custom"
? [recipe.preset]
: currentBatch;
onChange({ isBatchExport, batchPresets });
}}
className="w-4 h-4 rounded border-gray-300 text-film-600 focus:ring-film-500"
/>
<span className="text-sm font-semibold text-[var(--text)]">Batch mode</span>
</label>
</div>

<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
Expand All @@ -103,7 +130,9 @@ export default function PresetSelector({ recipe, onChange }: Props) {
</div>
) : (
filteredPresets.map((preset) => {
const active = recipe.preset === preset.id;
const active = recipe.isBatchExport
? (recipe.batchPresets || []).includes(preset.id)
: recipe.preset === preset.id;

return (
<button
Expand All @@ -114,12 +143,20 @@ export default function PresetSelector({ recipe, onChange }: Props) {
aria-label={`Select ${preset.label} preset, ${preset.width} by ${preset.height} pixels`}
aria-pressed={active}
className={cn(
"min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
"relative min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
active
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30",
)}
>
{recipe.isBatchExport && (
<div className={cn(
"absolute top-2 right-2 w-4 h-4 rounded-sm border flex items-center justify-center",
active ? "bg-film-500 border-film-500" : "border-[var(--muted)] bg-transparent"
)}>
{active && <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
</div>
)}
<RatioBox
width={preset.width}
height={preset.height}
Expand Down Expand Up @@ -150,12 +187,17 @@ export default function PresetSelector({ recipe, onChange }: Props) {
title="Custom — Set your own dimensions"
aria-label="Select custom dimensions preset"
aria-pressed={recipe.preset === "custom"}
onClick={() => handlePresetSelect("custom")}
onClick={() => {
if (!recipe.isBatchExport) {
handlePresetSelect("custom")
}
}}
className={cn(
"min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150 cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
recipe.preset === "custom"
"min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border text-center transition-all duration-150",
recipe.isBatchExport ? "opacity-50 cursor-not-allowed bg-[var(--surface)] border-[var(--border)]" : "cursor-pointer hover:scale-[1.02] active:scale-[0.98]",
!recipe.isBatchExport && recipe.preset === "custom"
? "border-film-500 bg-film-50"
: "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30",
: (!recipe.isBatchExport ? "border-[var(--border)] bg-[var(--surface)] hover:border-film-300 hover:bg-film-50/30" : ""),
)}
>
<Settings2
Expand Down Expand Up @@ -185,7 +227,7 @@ export default function PresetSelector({ recipe, onChange }: Props) {
</button>
</div>

{recipe.preset === "custom" && (
{recipe.preset === "custom" && !recipe.isBatchExport && (
<div className="mt-2 flex items-center gap-4 rounded-lg border border-[var(--border)] bg-[var(--surface)] p-4 shadow-sm animate-fade-in">
<div className="flex-1">
<label
Expand Down
8 changes: 6 additions & 2 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { useState, useRef, useEffect, useMemo } from "react";
import { useVideoEditor } from "@/hooks/useVideoEditor";
import Hero from "./Hero";
import FileUpload from "./FileUpload";
import VideoPreview from "./VideoPreview";
import ThumbnailStrip from "./ThumbnailStrip";
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function VideoEditor() {
overlaySize, setOverlaySize,
overlayOpacity, setOverlayOpacity,
recommendedPreset,
exportProgressText,
} = useVideoEditor();
const [copied, setCopied] = useState(false);
const downloadRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -89,7 +91,7 @@ export default function VideoEditor() {

return (
<div className="min-h-screen relative flex flex-col" style={{ background: "var(--bg)" }}>
<ExportOverlay status={status} progress={progress} onCancel={cancelExport} />
<ExportOverlay status={status} progress={progress} progressText={exportProgressText} onCancel={cancelExport} />
<OnboardingTour />

<div aria-live="polite" aria-atomic="true" className="sr-only">
Expand Down Expand Up @@ -118,8 +120,10 @@ export default function VideoEditor() {
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-5">

<div className="space-y-4 min-w-0">
{!file && <Hero onUploadClick={() => document.getElementById("hero-file-input")?.click()} />}

<div className="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)] animate-fade-in">
<FileUpload onFileSelect={handleFileSelect} currentFile={file} fileError={fileError} duration={duration} />
<FileUpload onFileSelect={handleFileSelect} currentFile={file} fileError={fileError} duration={duration} heroFileInputId="hero-file-input" />

{!file && (
<div className="text-center text-[var(--muted)] py-6">
Expand Down
Loading
Loading