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
120 changes: 120 additions & 0 deletions src/components/BeforeAfterSlider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

import { useRef, useState, useCallback, useEffect, RefObject } from "react";

interface Props {
videoRef: RefObject<HTMLVideoElement | null>;
filterStyle: string;
}

export default function BeforeAfterSlider({ videoRef, filterStyle }: Props) {
const [dividerX, setDividerX] = useState(50);
const containerRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);

const updatePosition = useCallback((clientX: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = Math.min(Math.max(clientX - rect.left, 0), rect.width);
setDividerX((x / rect.width) * 100);
}, []);

useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (dragging.current) updatePosition(e.clientX);
};
const onMouseUp = () => { dragging.current = false; };
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [updatePosition]);

useEffect(() => {
const onTouchMove = (e: TouchEvent) => {
if (dragging.current) updatePosition(e.touches[0].clientX);
};
const onTouchEnd = () => { dragging.current = false; };
window.addEventListener("touchmove", onTouchMove);
window.addEventListener("touchend", onTouchEnd);
return () => {
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
};
}, [updatePosition]);

const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") setDividerX((x) => Math.max(0, x - 1));
if (e.key === "ArrowRight") setDividerX((x) => Math.min(100, x + 1));
};

const video = videoRef.current;
if (!video) return null;

return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden pointer-events-none"
>
{/* After side: same video with filter, clipped to right of divider */}
<video
src={video.src}
muted
autoPlay
loop
playsInline
style={{
filter: filterStyle,
clipPath: `inset(0 0 0 ${dividerX}%)`,
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
pointerEvents: "none",
}}
/>

{/* Before label */}
<div
className="absolute top-2 px-2 py-0.5 text-[10px] font-heading font-bold uppercase tracking-wider bg-black/60 text-white/80 rounded"
style={{ left: "8px", opacity: dividerX > 10 ? 1 : 0 }}
>
Before
</div>

{/* After label */}
<div
className="absolute top-2 px-2 py-0.5 text-[10px] font-heading font-bold uppercase tracking-wider bg-black/60 text-white/80 rounded"
style={{ right: "8px", opacity: dividerX < 90 ? 1 : 0 }}
>
After
</div>

{/* Draggable divider line */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/90 shadow-lg pointer-events-auto cursor-ew-resize focus:outline-none focus-visible:ring-2 focus-visible:ring-film-400"
style={{ left: `${dividerX}%`, transform: "translateX(-50%)" }}
onMouseDown={(e) => { dragging.current = true; updatePosition(e.clientX); }}
onTouchStart={(e) => { dragging.current = true; updatePosition(e.touches[0].clientX); }}
onKeyDown={onKeyDown}
tabIndex={0}
role="slider"
aria-valuenow={Math.round(dividerX)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Before/after divider"
>
{/* Handle knob */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white shadow-lg flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M5 8H1M11 8h4M5 5l-4 3 4 3M11 5l4 3-4 3" stroke="#444" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
</div>
);
}
104 changes: 79 additions & 25 deletions src/components/RotateControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,87 @@ interface Props {
onChange: (patch: Partial<EditRecipe>) => void;
}

const ROTATIONS = [0, 90, 180, 270] as const;
const PRESETS = [0, 90, 180, 270] as const;

export default function RotateControl({ recipe, onChange }: Props) {
const rotation = recipe.rotate ?? 0;

const handleSlider = (val: number) => {
onChange({ rotate: val });
};

const handleInput = (val: string) => {
const n = parseFloat(val);
if (isNaN(n)) return;
const clamped = Math.min(180, Math.max(-180, n));
onChange({ rotate: clamped });
};

return (
<div className="flex gap-2">
{ROTATIONS.map((deg) => {
const active = recipe.rotate === deg;
return (
<button
type="button"
key={deg}
onClick={() => onChange({ rotate: deg })}
aria-label={`Rotate video to ${deg} degrees`}
aria-pressed={active}
className={cn(
"flex-1 min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 py-3 rounded-lg border text-xs transition-all duration-150 cursor-pointer hover:scale-[1.03] active:scale-[0.97]",
active
? "border-film-500 bg-film-50 text-film-700 font-heading font-semibold"
: "border-[var(--border)] text-[var(--muted)] hover:border-film-300 bg-[var(--surface)]"
)}
>
<RotateCw size={15} style={{ transform: `rotate(${deg}deg)`, transformOrigin: 'center' }} className="transition-transform" />
<span className="sr-only">Rotate video to {deg} degrees</span>
{deg}
</button>
);
})}
<div className="space-y-3">

{/* Preset buttons */}
<div className="flex gap-2">
{PRESETS.map((deg) => {
const active = rotation === deg;
return (
<button
type="button"
key={deg}
onClick={() => onChange({ rotate: deg })}
aria-label={`Rotate video to ${deg} degrees`}
aria-pressed={active}
className={cn(
"flex-1 min-h-[44px] min-w-[44px] flex flex-col items-center justify-center gap-1.5 py-3 rounded-lg border text-xs transition-all duration-150 cursor-pointer hover:scale-[1.03] active:scale-[0.97]",
active
? "border-film-500 bg-film-50 text-film-700 font-heading font-semibold"
: "border-[var(--border)] text-[var(--muted)] hover:border-film-300 bg-[var(--surface)]"
)}
>
<RotateCw size={15} style={{ transform: `rotate(${deg}deg)` }} className="transition-transform" />
<span className="sr-only">Rotate video to {deg} degrees</span>
{deg}
</button>
);
})}
</div>

{/* Custom rotation slider */}
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<label htmlFor="rotate-slider" className="text-[10px] font-heading font-semibold uppercase tracking-wider text-[var(--muted)]">
Custom Rotation
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={-180}
max={180}
step={1}
value={rotation}
onChange={(e) => handleInput(e.target.value)}
className="w-16 text-xs px-2 py-1 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading text-[var(--text)] focus:outline-none focus:ring-2 focus:ring-film-400 text-center"
/>
<span className="text-[10px] text-[var(--muted)] font-heading">°</span>
</div>
</div>
<input
id="rotate-slider"
type="range"
min={-180}
max={180}
step={1}
value={rotation}
onChange={(e) => handleSlider(parseFloat(e.target.value))}
className="w-full h-1.5 appearance-none bg-[var(--border)] rounded-full cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-film-400 [&::-webkit-slider-thumb]:shadow-md"
/>
<div className="flex justify-between text-[10px] text-[var(--muted)] font-heading">
<span>-180°</span>
<span>0°</span>
<span>180°</span>
</div>
</div>

</div>
);
}
}
82 changes: 65 additions & 17 deletions src/components/TrimControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,20 @@ import { EditRecipe } from "@/lib/types";
import { useState, useEffect } from "react";
import { AlertCircle } from "lucide-react";
import { formatDuration } from "@/lib/utils";
import { useAudioWaveform } from "@/hooks/useAudioWaveform";
import WaveformCanvas from "@/components/WaveformCanvas";

interface Props {
recipe: EditRecipe;
onChange: (patch: Partial<EditRecipe>) => void;
duration: number;
file: File | null;
}

export default function TrimControl({ recipe, onChange, duration, file }: Props) {
export default function TrimControl({ recipe, onChange, duration }: Props) {
const [invalidStart, setStart] = useState(false);
const [invalidEnd, setEnd] = useState(false);
const [startErrorMsg, setStartErrorMsg] = useState("");
const [endErrorMsg, setEndErrorMsg] = useState("");
const [startInput, setStartInput] = useState(recipe.trimStart.toString());

const { waveform, isLoading: waveformLoading } = useAudioWaveform(file);
const hasAudio = waveform.length > 0;

useEffect(() => {
setStartInput(recipe.trimStart.toString());
}, [recipe.trimStart]);
Expand Down Expand Up @@ -114,19 +108,73 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
setEndErrorMsg("");
};

const inputClass =
"w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";
const handleSliderStart = (val: number) => {
const end = recipe.trimEnd ?? duration;
if (val >= end) return;
setStart(false);
onChange({ trimStart: val });
};

const handleSliderEnd = (val: number) => {
if (val <= recipe.trimStart) return;
setEnd(false);
onChange({ trimEnd: val });
};

const inputClass = "w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow";

const thumbClass = [
"absolute w-full h-1.5 appearance-none bg-transparent cursor-pointer",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:w-4",
"[&::-webkit-slider-thumb]:h-4",
"[&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:bg-white",
"[&::-webkit-slider-thumb]:border-2",
"[&::-webkit-slider-thumb]:border-film-400",
"[&::-webkit-slider-thumb]:shadow-md",
].join(" ");

const trimEnd = recipe.trimEnd ?? duration;
const startPercent = duration > 0 ? (recipe.trimStart / duration) * 100 : 0;
const endPercent = duration > 0 ? (trimEnd / duration) * 100 : 100;

return (
<div id="trim-control" className="space-y-3">
{/* Waveform — shown while loading or when file is present */}
{(file && (waveformLoading || hasAudio)) && (
<div className="relative w-full rounded-md overflow-hidden bg-[var(--surface)]">
<WaveformCanvas
samples={waveform}
loading={waveformLoading}
hasAudio={hasAudio}
/>

{duration > 0 && (
<div className="px-1">
<div className="relative h-6 flex items-center">
<div className="absolute w-full h-1.5 bg-[var(--border)] rounded-full" />
<div
className="absolute h-1.5 bg-film-400 rounded-full"
style={{ left: `${startPercent}%`, width: `${endPercent - startPercent}%` }}
/>
<input
type="range"
min={0}
max={duration}
step={0.1}
value={recipe.trimStart}
onChange={(e) => handleSliderStart(parseFloat(e.target.value))}
className={thumbClass}
style={{ zIndex: 3 }}
/>
<input
type="range"
min={0}
max={duration}
step={0.1}
value={trimEnd}
onChange={(e) => handleSliderEnd(parseFloat(e.target.value))}
className={thumbClass}
style={{ zIndex: 4 }}
/>
</div>
<div className="flex justify-between text-[10px] text-[var(--muted)] font-heading mt-1">
<span>0s</span>
<span>{duration.toFixed(1)}s</span>
</div>
</div>
)}

Expand Down
Loading