feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)

This commit is contained in:
Soroush.Asadi
2026-05-24 17:37:21 +03:30
parent d962483359
commit c61f587767
295 changed files with 29797 additions and 265 deletions
@@ -0,0 +1,109 @@
"use client";
import { Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ExportFormat } from "@/lib/trimmer-types";
import { cn } from "@/lib/utils";
interface TrimmerExportSectionProps {
exportFormat: ExportFormat;
onExportFormatChange: (format: ExportFormat) => void;
isProcessing: boolean;
progress: number;
ffmpegReady: boolean;
hasVideo: boolean;
outputUrl: string | null;
onProcess: () => void;
}
export function TrimmerExportSection({
exportFormat,
onExportFormatChange,
isProcessing,
progress,
ffmpegReady,
hasVideo,
outputUrl,
onProcess,
}: TrimmerExportSectionProps) {
return (
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-white">Export</h2>
<div className="mb-4 flex gap-2">
{(["mp4", "webm"] as const).map((format) => (
<button
key={format}
type="button"
disabled={isProcessing}
onClick={() => onExportFormatChange(format)}
className={cn(
"rounded-lg border px-4 py-2 text-sm font-medium uppercase transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50",
exportFormat === format
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
)}
>
{format}
</button>
))}
</div>
<Button
type="button"
className="w-full bg-blue-600 hover:bg-blue-700"
disabled={!hasVideo || isProcessing || !ffmpegReady}
onClick={onProcess}
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Processing
</>
) : (
"Trim & Crop"
)}
</Button>
{!ffmpegReady && hasVideo ? (
<p className="mt-2 text-center text-xs text-gray-400">
Loading FFmpeg engine
</p>
) : null}
{isProcessing ? (
<div className="mt-4">
<div className="mb-1 flex justify-between text-xs text-gray-400">
<span>Progress</span>
<span>{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-800">
<div
className="h-full w-full origin-left rounded-full bg-blue-600 transition-transform duration-200"
style={{ transform: `scaleX(${progress / 100})` }}
/>
</div>
</div>
) : null}
{outputUrl ? (
<div className="mt-6 space-y-4">
<video
src={outputUrl}
controls
playsInline
className="w-full rounded-lg bg-black"
/>
<a
href={outputUrl}
download={`trimmed-${Date.now()}.${exportFormat}`}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<Download className="h-4 w-4" aria-hidden />
Download {exportFormat.toUpperCase()}
</a>
</div>
) : null}
</section>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useCallback, useRef } from "react";
import { useVideoThumbnails } from "@/hooks/useVideoThumbnails";
import { formatTime } from "@/lib/trimmer-utils";
interface TrimmerStripProps {
videoUrl: string | null;
duration: number;
trimStart: number;
trimEnd: number;
onTrimChange: (start: number, end: number) => void;
}
export function TrimmerStrip({
videoUrl,
duration,
trimStart,
trimEnd,
onTrimChange,
}: TrimmerStripProps) {
const thumbnails = useVideoThumbnails(videoUrl, duration);
const trackRef = useRef<HTMLDivElement>(null);
const minGap = 0.5;
const startDrag = useCallback(
(handle: "start" | "end", clientX: number) => {
const track = trackRef.current;
if (!track || duration <= 0) return;
const rect = track.getBoundingClientRect();
const handleMove = (event: MouseEvent) => {
const ratio = Math.min(
1,
Math.max(0, (event.clientX - rect.left) / rect.width)
);
const time = ratio * duration;
if (handle === "start") {
onTrimChange(
Math.min(time, trimEnd - minGap),
trimEnd
);
} else {
onTrimChange(
trimStart,
Math.max(time, trimStart + minGap)
);
}
};
const handleUp = () => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
};
handleMove({ clientX } as MouseEvent);
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
},
[duration, trimStart, trimEnd, onTrimChange]
);
if (!videoUrl || duration <= 0) return null;
const startPct = (trimStart / duration) * 100;
const endPct = (trimEnd / duration) * 100;
const clipDuration = trimEnd - trimStart;
return (
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-white">Trim</h2>
<div
ref={trackRef}
className="relative h-16 overflow-hidden rounded-lg bg-gray-900"
>
<div className="flex h-full w-full">
{thumbnails.map((src, index) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`${src}-${index}`}
src={src}
alt=""
className="h-full flex-1 object-cover opacity-80"
/>
))}
</div>
<div
className="absolute inset-y-0 bg-black/55"
style={{ left: 0, width: `${startPct}%` }}
/>
<div
className="absolute inset-y-0 bg-black/55"
style={{ left: `${endPct}%`, right: 0 }}
/>
<div
className="absolute inset-y-0 border-y-2 border-blue-500 bg-blue-500/10"
style={{ left: `${startPct}%`, width: `${endPct - startPct}%` }}
/>
<button
type="button"
aria-label="Trim start"
onMouseDown={(event) => {
event.preventDefault();
startDrag("start", event.clientX);
}}
className="absolute top-0 z-10 h-full w-3 -translate-x-1/2 cursor-ew-resize rounded-sm bg-blue-600 hover:bg-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
style={{ left: `${startPct}%` }}
/>
<button
type="button"
aria-label="Trim end"
onMouseDown={(event) => {
event.preventDefault();
startDrag("end", event.clientX);
}}
className="absolute top-0 z-10 h-full w-3 -translate-x-1/2 cursor-ew-resize rounded-sm bg-blue-600 hover:bg-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
style={{ left: `${endPct}%` }}
/>
</div>
<p className="mt-3 text-center text-sm tabular-nums text-gray-400">
{formatTime(trimStart)} {formatTime(trimEnd)} (
<span className="font-medium text-white">
{Math.round(clipDuration)}s
</span>
)
</p>
</section>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Film, Upload } from "lucide-react";
import { formatFileSize } from "@/lib/trimmer-utils";
import { cn } from "@/lib/utils";
interface TrimmerUploadZoneProps {
uploadedFile: File | null;
onFileSelect: (file: File) => void;
}
export function TrimmerUploadZone({
uploadedFile,
onFileSelect,
}: TrimmerUploadZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const handleFiles = useCallback(
(files: FileList | null) => {
const file = files?.[0];
if (file && file.type.startsWith("video/")) {
onFileSelect(file);
}
},
[onFileSelect]
);
return (
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(event) => handleFiles(event.target.files)}
/>
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
handleFiles(event.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-12 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
isDragging
? "border-blue-500 bg-blue-950/40"
: "border-gray-700 bg-gray-800 hover:border-blue-500/60 hover:bg-gray-800/80"
)}
>
<Upload className="mb-3 h-10 w-10 text-blue-500" aria-hidden />
<p className="text-sm font-semibold text-white">
Drag & drop a video, or click to browse
</p>
<p className="mt-1 text-xs text-gray-400">MP4, WebM, MOV and other video formats</p>
</div>
{uploadedFile ? (
<div className="mt-4 flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-800 px-4 py-3">
<Film className="h-5 w-5 shrink-0 text-blue-500" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">
{uploadedFile.name}
</p>
<p className="text-xs text-gray-400">
{formatFileSize(uploadedFile.size)}
</p>
</div>
</div>
) : null}
</section>
);
}
@@ -0,0 +1,142 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Rnd } from "react-rnd";
import type { AspectRatioPreset, CropBox } from "@/lib/trimmer-types";
import { getAspectRatioValue } from "@/lib/trimmer-utils";
import { cn } from "@/lib/utils";
const ASPECT_OPTIONS: { id: AspectRatioPreset; label: string }[] = [
{ id: "free", label: "Free" },
{ id: "16:9", label: "16:9" },
{ id: "9:16", label: "9:16" },
{ id: "1:1", label: "1:1" },
{ id: "4:3", label: "4:3" },
];
interface TrimmerVideoPreviewProps {
videoUrl: string | null;
aspectRatio: AspectRatioPreset;
cropBox: CropBox;
onCropChange: (crop: CropBox) => void;
onAspectRatioChange: (preset: AspectRatioPreset) => void;
onVideoMetadata: (duration: number, size: { width: number; height: number }) => void;
onDisplaySize: (size: { width: number; height: number }) => void;
}
export function TrimmerVideoPreview({
videoUrl,
aspectRatio,
cropBox,
onCropChange,
onAspectRatioChange,
onVideoMetadata,
onDisplaySize,
}: TrimmerVideoPreviewProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const syncDisplaySize = useCallback(() => {
const video = videoRef.current;
if (!video) return;
onDisplaySize({
width: video.clientWidth,
height: video.clientHeight,
});
}, [onDisplaySize]);
useEffect(() => {
if (!videoUrl) return;
const video = videoRef.current;
if (!video) return;
const handleLoaded = () => {
const w = video.clientWidth;
const h = video.clientHeight;
onVideoMetadata(video.duration, {
width: video.videoWidth,
height: video.videoHeight,
});
onCropChange({ x: 0, y: 0, w, h });
onDisplaySize({ width: w, height: h });
};
video.addEventListener("loadedmetadata", handleLoaded);
window.addEventListener("resize", syncDisplaySize);
return () => {
video.removeEventListener("loadedmetadata", handleLoaded);
window.removeEventListener("resize", syncDisplaySize);
};
}, [videoUrl, onVideoMetadata, onCropChange, onDisplaySize, syncDisplaySize]);
if (!videoUrl) return null;
const lockRatio = getAspectRatioValue(aspectRatio);
return (
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-white">Preview & crop</h2>
<div className="mb-4 flex flex-wrap gap-2">
{ASPECT_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => onAspectRatioChange(option.id)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
aspectRatio === option.id
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
)}
>
{option.label}
</button>
))}
</div>
<div ref={containerRef} className="relative mx-auto w-full max-w-3xl overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
src={videoUrl}
controls
playsInline
className="block h-auto w-full"
onLoadedData={syncDisplaySize}
/>
<Rnd
size={{ width: cropBox.w, height: cropBox.h }}
position={{ x: cropBox.x, y: cropBox.y }}
bounds="parent"
lockAspectRatio={lockRatio}
onDragStop={(_event, data) => {
onCropChange({
...cropBox,
x: data.x,
y: data.y,
});
}}
onResizeStop={(_event, _dir, ref, _delta, position) => {
onCropChange({
x: position.x,
y: position.y,
w: ref.offsetWidth,
h: ref.offsetHeight,
});
}}
className="border-2 border-blue-500 bg-blue-500/20 shadow-lg"
enableResizing={{
top: true,
right: true,
bottom: true,
left: true,
topRight: true,
bottomRight: true,
bottomLeft: true,
topLeft: true,
}}
/>
</div>
</section>
);
}