Skip to content
Merged
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
289 changes: 178 additions & 111 deletions src/components/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useRef, useState, useEffect} from "react";
import { useRef, useState, useEffect, useCallback } from "react";
import { Film, FolderOpen } from "lucide-react";
import LottiePlayer from "./LottiePlayer";
import uploadAnim from "@/lib/lottie/upload.json";
Expand All @@ -23,9 +23,12 @@ export default function FileUpload({
const inputRef = useRef<HTMLInputElement>(null);

const [dragging, setDragging] = useState(false);
const [pageDragging, setPageDragging] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");

const dragCounterRef = useRef(0);

// ── Keyboard shortcut Ctrl+O ──────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "o") {
Expand All @@ -37,13 +40,55 @@ export default function FileUpload({
return () => document.removeEventListener("keydown", handler);
}, []);

const handleFile = (file: File) => {
// ── Page-level drag overlay ───────────────────────────
// Uses a counter so nested dragenter/dragleave don't flicker
useEffect(() => {
const onDragEnter = (e: DragEvent) => {
e.preventDefault();
dragCounterRef.current += 1;
if (dragCounterRef.current === 1) setPageDragging(true);
};

const onDragLeave = (e: DragEvent) => {
e.preventDefault();
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) setPageDragging(false);
};

const onDragOver = (e: DragEvent) => {
e.preventDefault(); // required to allow drop
};

const onDrop = (e: DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setPageDragging(false);

const file = e.dataTransfer?.files?.[0];
if (file) handleFile(file);
};

document.addEventListener("dragenter", onDragEnter);
document.addEventListener("dragleave", onDragLeave);
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop);

return () => {
document.removeEventListener("dragenter", onDragEnter);
document.removeEventListener("dragleave", onDragLeave);
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// ── File validation ───────────────────────────────────
const handleFile = useCallback((file: File) => {
setError("");
setWarning("");

// Validate type
if (!file.type.startsWith("video/")) {
setError("Only video files are allowed.");
setError("Please drop a valid video file (MP4, MOV, AVI, WebM, etc.)");
return;
}

Expand All @@ -52,110 +97,105 @@ export default function FileUpload({
return;
}

// Hard limit
if (file.size > MAX_FILE_SIZE) {
setError(
`File too large (${formatBytes(
file.size
)}). Maximum allowed size is 2GB.`
`File too large (${formatBytes(file.size)}). Maximum allowed size is 2GB.`
);
return;
}

// Soft warning
if (file.size > WARNING_FILE_SIZE) {
const estimatedMinutes = Math.max(1, Math.round(file.size / (100 * 1024 * 1024)));
const estimatedMinutes = Math.max(
1,
Math.round(file.size / (100 * 1024 * 1024))
);
setWarning(
`Large file detected (${formatBytes(
file.size
)}). Processing may take ~${estimatedMinutes} minutes and affect performance on low-memory devices.`
`Large file detected (${formatBytes(file.size)}). Processing may take ~${estimatedMinutes} minutes.`
);
}

onFileSelect(file);
};
}, [onFileSelect]);

// ── Drop zone (inner) handler ─────────────────────────
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);

const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
};

const FileInfo = () => (
<div className="px-4 py-3 bg-film-50 border border-film-200 rounded-lg">
<div className="flex flex-col lg:flex-row lg:items-center gap-3">

<div className="flex items-start gap-3 flex-1 min-w-0">

<div className="hidden lg:flex items-center justify-center w-9 h-9 rounded-lg bg-[var(--surface)] border border-[var(--border)] shrink-0">
<Film size={16} className="text-film-600" />
</div>

<Film size={18} className="lg:hidden text-film-600 shrink-0 mt-0.5" />

<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-0.5">
<p className="text-sm font-semibold text-film-700 truncate max-w-[320px] xl:max-w-[420px]">
{currentFile?.name}
</p>
{currentFile && (
<span className="px-2 py-0.5 bg-gray-700 text-white font-bold tracking-wider rounded text-[10px] uppercase shrink-0">
{currentFile.name.includes(".")
? currentFile.name.split(".").pop()
: "VIDEO"}
</span>
)}
// ── File info (shown after upload) ───────────────────
const FileInfo = () => (
<div className="px-4 py-3 bg-film-50 border border-film-200 rounded-lg">
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="hidden lg:flex items-center justify-center w-9 h-9 rounded-lg bg-[var(--surface)] border border-[var(--border)] shrink-0">
<Film size={16} className="text-film-600" />
</div>
<div className="text-xs text-[var(--muted)] mt-1 space-y-0.5">
<p>{formatBytes(currentFile?.size ?? 0)}</p>

<p>
{duration > 0
? `Duration: ${formatDuration(duration)}`
: "Loading duration..."}
</p>
<Film size={18} className="lg:hidden text-film-600 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-0.5">
<p className="text-sm font-semibold text-film-700 truncate max-w-[320px] xl:max-w-[420px]">
{currentFile?.name}
</p>
{currentFile && (
<span className="px-2 py-0.5 bg-gray-700 text-white font-bold tracking-wider rounded text-[10px] uppercase shrink-0">
{currentFile.name.includes(".")
? currentFile.name.split(".").pop()
: "VIDEO"}
</span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
<p>{formatBytes(currentFile?.size ?? 0)}</p>
<p>
{duration > 0
? `Duration: ${formatDuration(duration)}`
: "Loading duration..."}
</p>
</div>
</div>
</div>

<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-xs font-semibold text-film-600 hover:text-film-700 uppercase tracking-wide"
>
Change
<span className="text-[var(--muted)] ml-1">(Ctrl+O)</span>
</button>
</div>

<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-xs font-semibold text-film-600 hover:text-film-700 uppercase tracking-wide"
>
Change
<span className="text-[var(--muted)] ml-1">(Ctrl+O)</span>
</button>
<p className="text-xs text-gray-500 mt-3 break-words">
Supports: MP4, MOV, AVI, MKV, WebM, and most video formats
</p>

{fileError && (
<p className="text-xs text-red-500 mt-2 font-medium">{fileError}</p>
)}

<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>
);

<p className="text-xs text-[var(--muted)] mt-3 break-words">
Supports: MP4, MOV, AVI, MKV, WebM, and most video formats
</p>

{fileError && (
<p className="text-xs text-red-500 mt-2 font-medium">{fileError}</p>
)}

<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>
);
// ── Drop zone (inner) ─────────────────────────────────
const DropZone = () => (
<div
id="upload-zone"
role="button"
aria-label="Upload video — drag and drop or click to browse"
tabIndex={0}
aria-label="Video upload area. Drag and drop a video file or press Enter to browse."
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
Expand All @@ -176,25 +216,19 @@ export default function FileUpload({
: "border-[var(--border)] bg-[var(--bg)] hover:border-film-400 hover:bg-film-50/40"
)}
>
{/* Premium Light Beam Shimmer Effect */}
{dragging && (
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-film-500/20 to-transparent pointer-events-none" />
)}

<div className="w-20 h-20 opacity-80 group-hover:opacity-100 transition-opacity group-hover:scale-110 duration-200">
<LottiePlayer animationData={uploadAnim} loop autoplay />
</div>

<div className="text-center">
<p className="font-heading font-semibold text-[var(--text)] text-base">
{dragging
? "Release to upload"
: "Drag & Drop your video in here"}
</p>

<p className="text-sm text-[var(--muted)] mt-1">
or click to browse
{dragging ? "Release to upload" : "Drag & Drop your video here"}
</p>

<p className="text-sm text-[var(--muted)] mt-1">or click to browse</p>
<p className="text-xs text-[var(--muted)] mt-2 font-heading">
Ctrl+O / Cmd+O
</p>
Expand All @@ -205,41 +239,74 @@ export default function FileUpload({
MP4 / MOV / AVI / WebM
</div>

<p className="text-xs text-[var(--muted)] text-center">
<p className="text-xs text-gray-500 text-center">
Supports: MP4, MOV, AVI, MKV, WebM, and most video formats up to 2GB
</p>

{fileError && (
<p className="text-sm text-red-500 text-center">{fileError}</p>
)}

{currentFile && (
<p className="text-xs text-[var(--muted)] mt-2">
Selected: {formatBytes(currentFile.size)}
</p>
)}

<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];

if (f) handleFile(f);
}}
/>
</div>
<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>
);

return (
<div className="space-y-2">
{error && <p className="text-sm text-red-500">{error}</p>}
<>
{/* ── Page-level drag overlay ── */}
{pageDragging && (
<div
aria-live="polite"
aria-label="Drop your video file anywhere on the page"
className={cn(
"fixed inset-0 z-50 flex flex-col items-center justify-center gap-4",
"bg-black/60 backdrop-blur-sm",
"border-4 border-dashed border-film-500",
"transition-all duration-200 pointer-events-none"
)}
>
{/* Animated ring */}
<div className="relative flex items-center justify-center">
<div className="absolute w-32 h-32 rounded-full border-4 border-film-500/40 animate-ping" />
<div className="w-24 h-24 rounded-full bg-film-500/10 border-2 border-film-500 flex items-center justify-center">
<Film size={40} className="text-film-400" />
</div>
</div>

{warning && <p className="text-sm text-yellow-500">{warning}</p>}
<div className="text-center">
<p className="text-2xl font-bold text-white">
Drop your video anywhere
</p>
<p className="text-film-300 mt-1 text-sm">
Release to start uploading
</p>
</div>
</div>
)}

{currentFile ? <FileInfo /> : <DropZone />}
</div>
{/* ── Normal upload UI ── */}
<div className="space-y-2">
{error && (
<p role="alert" className="text-sm text-red-500">
{error}
</p>
)}
{warning && (
<p role="alert" className="text-sm text-yellow-500">
{warning}
</p>
)}
{currentFile ? <FileInfo /> : <DropZone />}
</div>
</>
);
}
}
Loading