[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:
[dyad]
2026-01-30 08:35:15 +01:00
parent f131a681ab
commit b3b98070d7
9 changed files with 478 additions and 155 deletions

View File

@@ -0,0 +1,63 @@
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
type ClipPageProps = {
params: {
id: string;
};
};
export default async function ClipPage({ params }: ClipPageProps) {
const { data: clip, error } = await supabase
.from('clips')
.select('storage_path, original_file_name')
.eq('short_id', params.id)
.single();
if (error || !clip) {
return (
<div className="flex flex-col items-center justify-center min-h-screen text-center">
<h1 className="text-4xl font-bold mb-4">Clip Not Found</h1>
<p className="text-muted-foreground mb-8">The link may be broken or the clip may have been removed.</p>
<Button asChild>
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Editor
</Link>
</Button>
</div>
);
}
const { data: video } = supabase.storage.from('clips').getPublicUrl(clip.storage_path);
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-4">
<Card className="w-full max-w-3xl">
<CardHeader>
<CardTitle className="truncate">
{clip.original_file_name || "Shared Clip"}
</CardTitle>
</CardHeader>
<CardContent>
<div className="aspect-video w-full overflow-hidden rounded-lg border bg-black">
<video
src={video.publicUrl}
controls
autoPlay
className="w-full h-full object-contain"
/>
</div>
</CardContent>
</Card>
<Button asChild variant="link" className="mt-8">
<Link href="/">
Create your own clip
</Link>
</Button>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/header";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Video Clip Cutter",
description: "Trim and share video clips",
};
export default function RootLayout({
@@ -27,6 +28,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Header />
{children}
</body>
</html>

38
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,38 @@
"use client";
import { supabase } from "@/integrations/supabase/client";
import { Auth } from "@supabase/auth-ui-react";
import { ThemeSupa } from "@supabase/auth-ui-shared";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if (session) {
router.push('/');
}
});
return () => subscription.unsubscribe();
}, [router]);
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="w-full max-w-md p-8 space-y-8">
<div>
<h1 className="text-3xl font-bold text-center">Welcome Back</h1>
<p className="text-center text-muted-foreground">Sign in to continue to the Video Editor</p>
</div>
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
providers={[]}
theme="dark"
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,44 @@
"use client";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { supabase } from '@/integrations/supabase/client';
import { VideoEditor } from "@/components/video-editor";
import { Loader2 } from 'lucide-react';
export default function Home() {
const router = useRouter();
const [isAuthenticating, setIsAuthenticating] = useState(true);
useEffect(() => {
const checkSession = async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
router.push('/login');
} else {
setIsAuthenticating(false);
}
};
checkSession();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_OUT') {
router.push('/login');
}
});
return () => subscription.unsubscribe();
}, [router]);
if (isAuthenticating) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
<Loader2 className="w-12 h-12 animate-spin text-primary" />
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-4 sm:p-6 md:p-8 font-[family-name:var(--font-geist-sans)]">
<main className="w-full max-w-4xl">

45
src/components/header.tsx Normal file
View 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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = "https://namdprfashsbjyzwmidk.supabase.co";
const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5hbWRwcmZhc2hzYmp5endtaWRrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQwNDEwMDIsImV4cCI6MjA3OTYxNzAwMn0.fO5Cael49Q4RDeXcYMmdhld65qAgND9SWn6pnwjx63g";
export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);