[dyad] Added user accounts and video sharing - wrote 9 file(s), added @supabase/auth-ui-react, @supabase/auth-ui-shared package(s), executed 1 SQL queries
This commit is contained in:
45
src/components/header.tsx
Normal file
45
src/components/header.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { Button } from './ui/button';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function Header() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
});
|
||||
|
||||
supabase.auth.getUser().then(({ data }) => {
|
||||
setUser(data.user);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="absolute top-0 right-0 p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-muted-foreground">{user.email}</span>
|
||||
<Button variant="ghost" size="icon" onClick={handleSignOut}>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -7,22 +7,25 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { UploadCloud, Scissors, Download, Loader2, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { UploadCloud, Share2, Copy, Loader2, RotateCcw, Trash2, Check } from "lucide-react";
|
||||
import { getFFmpeg } from "@/lib/ffmpeg";
|
||||
import { fetchFile } from "@ffmpeg/util";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export function VideoEditor() {
|
||||
const [videoSrc, setVideoSrc] = useState<string | null>(null);
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [trimValues, setTrimValues] = useState([0, 0]);
|
||||
const [isTrimming, setIsTrimming] = useState(false);
|
||||
const [trimmedVideoUrl, setTrimmedVideoUrl] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [shareableLink, setShareableLink] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Eagerly load FFmpeg
|
||||
getFFmpeg();
|
||||
}, []);
|
||||
|
||||
@@ -31,14 +34,11 @@ export function VideoEditor() {
|
||||
if (!video) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
// When the video plays past the end of the trim selection, loop back to the start
|
||||
if (video.currentTime >= trimValues[1]) {
|
||||
video.currentTime = trimValues[0];
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
// If the video is outside the trim range when play is clicked, jump to the start of the trim
|
||||
if (video.currentTime < trimValues[0] || video.currentTime >= trimValues[1]) {
|
||||
video.currentTime = trimValues[0];
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function VideoEditor() {
|
||||
setVideoFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setVideoSrc(url);
|
||||
setTrimmedVideoUrl(null);
|
||||
setShareableLink(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
@@ -74,25 +74,18 @@ export function VideoEditor() {
|
||||
|
||||
const handleTrimValueChange = (newValues: number[]) => {
|
||||
if (videoRef.current) {
|
||||
if (newValues[0] !== trimValues[0]) {
|
||||
videoRef.current.currentTime = newValues[0];
|
||||
} else if (newValues[1] !== trimValues[1]) {
|
||||
videoRef.current.currentTime = newValues[1];
|
||||
}
|
||||
if (newValues[0] !== trimValues[0]) videoRef.current.currentTime = newValues[0];
|
||||
else if (newValues[1] !== trimValues[1]) videoRef.current.currentTime = newValues[1];
|
||||
}
|
||||
setTrimValues(newValues);
|
||||
};
|
||||
|
||||
const formatTime = (timeInSeconds: number) => {
|
||||
if (isNaN(timeInSeconds) || timeInSeconds < 0) {
|
||||
return "00:00.000";
|
||||
}
|
||||
if (isNaN(timeInSeconds) || timeInSeconds < 0) return "00:00.000";
|
||||
const minutes = Math.floor(timeInSeconds / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const milliseconds = Math.floor((timeInSeconds % 1) * 1000);
|
||||
return `${String(minutes).padStart(2, "0")}:${String(
|
||||
seconds
|
||||
).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
const formatTimeForFFmpeg = (timeInSeconds: number) => {
|
||||
@@ -100,166 +93,148 @@ export function VideoEditor() {
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const milliseconds = Math.floor((timeInSeconds % 1) * 1000);
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(
|
||||
seconds
|
||||
).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
|
||||
};
|
||||
|
||||
const handleCutVideo = async () => {
|
||||
const handleCutAndShare = async () => {
|
||||
if (!videoFile) return;
|
||||
|
||||
setIsTrimming(true);
|
||||
setTrimmedVideoUrl(null);
|
||||
setIsProcessing(true);
|
||||
setShareableLink(null);
|
||||
setProgress(0);
|
||||
|
||||
const ffmpeg = await getFFmpeg();
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error("User not authenticated.");
|
||||
|
||||
ffmpeg.on('progress', ({ progress }) => {
|
||||
setProgress(Math.round(progress * 100));
|
||||
});
|
||||
const ffmpeg = await getFFmpeg();
|
||||
ffmpeg.on('progress', ({ progress }) => setProgress(Math.round(progress * 100)));
|
||||
await ffmpeg.writeFile(videoFile.name, await fetchFile(videoFile));
|
||||
|
||||
await ffmpeg.writeFile(videoFile.name, await fetchFile(videoFile));
|
||||
const [startTime, endTime] = trimValues;
|
||||
await ffmpeg.exec(['-i', videoFile.name, '-ss', formatTimeForFFmpeg(startTime), '-to', formatTimeForFFmpeg(endTime), '-c', 'copy', 'output.mp4']);
|
||||
|
||||
const [startTime, endTime] = trimValues;
|
||||
const data = await ffmpeg.readFile('output.mp4');
|
||||
const trimmedBlob = new Blob([(data as any).buffer], { type: 'video/mp4' });
|
||||
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
videoFile.name,
|
||||
'-ss',
|
||||
formatTimeForFFmpeg(startTime),
|
||||
'-to',
|
||||
formatTimeForFFmpeg(endTime),
|
||||
'-c',
|
||||
'copy',
|
||||
'output.mp4',
|
||||
]);
|
||||
const shortId = Math.random().toString(36).substring(2, 8);
|
||||
const storagePath = `${user.id}/${shortId}-${videoFile.name}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage.from('clips').upload(storagePath, trimmedBlob);
|
||||
if (uploadError) throw new Error(`Storage Error: ${uploadError.message}`);
|
||||
|
||||
const data = await ffmpeg.readFile('output.mp4');
|
||||
const url = URL.createObjectURL(new Blob([(data as any).buffer], { type: 'video/mp4' }));
|
||||
|
||||
setTrimmedVideoUrl(url);
|
||||
setIsTrimming(false);
|
||||
const { error: dbError } = await supabase.from('clips').insert({
|
||||
user_id: user.id,
|
||||
storage_path: storagePath,
|
||||
short_id: shortId,
|
||||
original_file_name: videoFile.name,
|
||||
});
|
||||
if (dbError) throw new Error(`Database Error: ${dbError.message}`);
|
||||
|
||||
const newShareableLink = `${window.location.origin}/clips/${shortId}`;
|
||||
setShareableLink(newShareableLink);
|
||||
toast.success("Your clip is ready to be shared!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error((error as Error).message || "An unexpected error occurred.");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setTrimValues([0, duration]);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
}
|
||||
if (videoRef.current) videoRef.current.currentTime = 0;
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (videoSrc) {
|
||||
URL.revokeObjectURL(videoSrc);
|
||||
if (videoSrc) URL.revokeObjectURL(videoSrc);
|
||||
setVideoSrc(null); setVideoFile(null); setDuration(0);
|
||||
setTrimValues([0, 0]); setIsProcessing(false);
|
||||
setShareableLink(null); setProgress(0);
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (shareableLink) {
|
||||
navigator.clipboard.writeText(shareableLink);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
}
|
||||
setVideoSrc(null);
|
||||
setVideoFile(null);
|
||||
setDuration(0);
|
||||
setTrimValues([0, 0]);
|
||||
setIsTrimming(false);
|
||||
setTrimmedVideoUrl(null);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full shadow-lg rounded-2xl border">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
Video Clip Cutter
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg text-muted-foreground pt-2">
|
||||
Upload, trim, and export your video in seconds.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{videoSrc ? (
|
||||
<div className="space-y-6">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-lg border">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
controls
|
||||
className="w-full h-full object-contain bg-black"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Trim Video</h3>
|
||||
<div className="space-y-4">
|
||||
<Slider
|
||||
value={trimValues}
|
||||
onValueChange={handleTrimValueChange}
|
||||
max={duration}
|
||||
step={0.1}
|
||||
aria-label="Video trimmer"
|
||||
disabled={isTrimming}
|
||||
/>
|
||||
<div className="flex justify-between text-sm font-mono text-muted-foreground">
|
||||
<span>Start: {formatTime(trimValues[0])}</span>
|
||||
<span>End: {formatTime(trimValues[1])}</span>
|
||||
<>
|
||||
<Toaster />
|
||||
<Card className="w-full shadow-lg rounded-2xl border">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">Video Clip Cutter</CardTitle>
|
||||
<CardDescription className="text-lg text-muted-foreground pt-2">Upload, trim, and share your video in seconds.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{videoSrc ? (
|
||||
<div className="space-y-6">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-lg border">
|
||||
<video ref={videoRef} src={videoSrc} controls className="w-full h-full object-contain bg-black" onLoadedMetadata={handleLoadedMetadata} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Trim Video</h3>
|
||||
<div className="space-y-4">
|
||||
<Slider value={trimValues} onValueChange={handleTrimValueChange} max={duration} step={0.1} aria-label="Video trimmer" disabled={isProcessing} />
|
||||
<div className="flex justify-between text-sm font-mono text-muted-foreground">
|
||||
<span>Start: {formatTime(trimValues[0])}</span>
|
||||
<span>End: {formatTime(trimValues[1])}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isProcessing && (
|
||||
<div className="space-y-2">
|
||||
<Label>Processing your clip...</Label>
|
||||
<Progress value={progress} />
|
||||
<p className="text-sm text-muted-foreground text-center">{progress}% complete</p>
|
||||
</div>
|
||||
)}
|
||||
{shareableLink && (
|
||||
<div className="space-y-2">
|
||||
<Label>Share your clip!</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input value={shareableLink} readOnly className="font-mono" />
|
||||
<Button size="icon" variant="outline" onClick={copyToClipboard}>
|
||||
{hasCopied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isTrimming && (
|
||||
<div className="space-y-2">
|
||||
<Label>Trimming in progress...</Label>
|
||||
<Progress value={progress} />
|
||||
<p className="text-sm text-muted-foreground text-center">{progress}% complete</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<Label htmlFor="video-upload" className="flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-lg cursor-pointer bg-transparent hover:bg-secondary transition-colors">
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<UploadCloud className="w-10 h-10 mb-4 text-muted-foreground" />
|
||||
<p className="mb-2 text-sm text-muted-foreground"><span className="font-semibold text-foreground">Click to upload</span> or drag and drop</p>
|
||||
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
|
||||
</div>
|
||||
<Input id="video-upload" type="file" className="hidden" accept="video/*" onChange={handleFileChange} />
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
{videoSrc && (
|
||||
<CardFooter className="flex justify-between items-center p-6">
|
||||
<Button variant="destructive" size="lg" onClick={handleClear} disabled={isProcessing}>
|
||||
<Trash2 className="mr-2 h-5 w-5" /> Clear
|
||||
</Button>
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="ghost" size="lg" onClick={handleReset} disabled={isProcessing}>
|
||||
<RotateCcw className="mr-2 h-5 w-5" /> Reset
|
||||
</Button>
|
||||
<Button variant="default" size="lg" onClick={handleCutAndShare} disabled={isProcessing}>
|
||||
{isProcessing ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : <Share2 className="mr-2 h-5 w-5" />}
|
||||
{isProcessing ? "Processing..." : "Cut & Share"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<Label
|
||||
htmlFor="video-upload"
|
||||
className="flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-lg cursor-pointer bg-transparent hover:bg-secondary transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<UploadCloud className="w-10 h-10 mb-4 text-muted-foreground" />
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
|
||||
</div>
|
||||
<Input
|
||||
id="video-upload"
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="video/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</CardContent>
|
||||
{videoSrc && (
|
||||
<CardFooter className="flex justify-between items-center p-6">
|
||||
<Button variant="destructive" size="lg" onClick={handleClear} disabled={isTrimming}>
|
||||
<Trash2 className="mr-2 h-5 w-5" />
|
||||
Clear
|
||||
</Button>
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="ghost" size="lg" onClick={handleReset} disabled={isTrimming}>
|
||||
<RotateCcw className="mr-2 h-5 w-5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" onClick={handleCutVideo} disabled={isTrimming}>
|
||||
{isTrimming ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
{isTrimming ? "Trimming..." : "Cut Video"}
|
||||
</Button>
|
||||
<Button asChild variant="default" size="lg" disabled={!trimmedVideoUrl || isTrimming}>
|
||||
<a href={trimmedVideoUrl!} download={`trimmed-${videoFile?.name || 'video.mp4'}`}>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Export
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user