From b3b98070d7fcb2b1c7df65b5d3836f0e1647b335 Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Fri, 30 Jan 2026 08:35:15 +0100 Subject: [PATCH] [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 --- package.json | 2 + pnpm-lock.yaml | 154 +++++++++++++++ src/app/clips/[id]/page.tsx | 63 +++++++ src/app/layout.tsx | 6 +- src/app/login/page.tsx | 38 ++++ src/app/page.tsx | 38 ++++ src/components/header.tsx | 45 +++++ src/components/video-editor.tsx | 281 +++++++++++++--------------- src/integrations/supabase/client.ts | 6 + 9 files changed, 478 insertions(+), 155 deletions(-) create mode 100644 src/app/clips/[id]/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/components/header.tsx create mode 100644 src/integrations/supabase/client.ts diff --git a/package.json b/package.json index 529cc84..ebd95ee 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", + "@supabase/auth-ui-react": "^0.4.7", + "@supabase/auth-ui-shared": "^0.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7188cb..68676f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,12 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.7 version: 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@supabase/auth-ui-react': + specifier: ^0.4.7 + version: 0.4.7(@supabase/supabase-js@2.93.3) + '@supabase/auth-ui-shared': + specifier: ^0.1.8 + version: 0.1.8(@supabase/supabase-js@2.93.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1068,6 +1074,43 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stitches/core@1.2.8': + resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==} + + '@supabase/auth-js@2.93.3': + resolution: {integrity: sha512-JdnkHZPKexVGSNONtu89RHU4bxz3X9kxx+f5ZnR5osoCIX+vs/MckwWRPZEybAEvlJXt5xjomDb3IB876QCxWQ==} + engines: {node: '>=20.0.0'} + + '@supabase/auth-ui-react@0.4.7': + resolution: {integrity: sha512-Lp4FQGFh7BMX1Y/BFaUKidbryL7eskj1fl6Lby7BeHrTctbdvDbCMjVKS8wZ2rxuI8FtPS2iU900fSb70FHknQ==} + peerDependencies: + '@supabase/supabase-js': ^2.21.0 + + '@supabase/auth-ui-shared@0.1.8': + resolution: {integrity: sha512-ouQ0DjKcEFg+0gZigFIEgu01V3e6riGZPzgVD0MJsCBNsMsiDT74+GgCEIElMUpTGkwSja3xLwdFRFgMNFKcjg==} + peerDependencies: + '@supabase/supabase-js': ^2.21.0 + + '@supabase/functions-js@2.93.3': + resolution: {integrity: sha512-qWO0gHNDm/5jRjROv/nv9L6sYabCWS1kzorOLUv3kqCwRvEJLYZga93ppJPrZwOgoZfXmJzvpjY8fODA4HQfBw==} + engines: {node: '>=20.0.0'} + + '@supabase/postgrest-js@2.93.3': + resolution: {integrity: sha512-+iJ96g94skO2e4clsRSmEXg22NUOjh9BziapsJSAvnB1grOBf/BA8vGtCHjNOA+Z6lvKXL1jwBqcL9+fS1W/Lg==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.93.3': + resolution: {integrity: sha512-gnYpcFzwy8IkezRP4CDbT5I8jOsiOjrWrqTY1B+7jIriXsnpifmlM6RRjLBm9oD7OwPG0/WksniGPdKW67sXOA==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.93.3': + resolution: {integrity: sha512-cw4qXiLrx3apglDM02Tx/w/stvFlrkKocC6vCvuFAz3JtVEl1zH8MUfDQDTH59kJAQVaVdbewrMWSoBob7REnA==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.93.3': + resolution: {integrity: sha512-paUqEqdBI9ztr/4bbMoCgeJ6M8ZTm2fpfjSOlzarPuzYveKFM20ZfDZqUpi9CFfYagYj5Iv3m3ztUjaI9/tM1w==} + engines: {node: '>=20.0.0'} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -1116,6 +1159,9 @@ packages: '@types/node@20.17.50': resolution: {integrity: sha512-Mxiq0ULv/zo1OzOhwPqOA13I81CV/W3nvd3ChtQZRT5Cwz3cr0FKo/wMSsbTqL3EXpaBAEQhva2B8ByRkOIh9A==} + '@types/phoenix@1.6.7': + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + '@types/react-dom@19.1.5': resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==} peerDependencies: @@ -1124,6 +1170,9 @@ packages: '@types/react@19.1.5': resolution: {integrity: sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1507,6 +1556,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + input-otp@1.4.2: resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} peerDependencies: @@ -1765,6 +1818,11 @@ packages: date-fns: ^2.28.0 || ^3.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react-dom@19.2.1: resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} peerDependencies: @@ -1830,6 +1888,10 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + react@19.2.1: resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} @@ -1870,6 +1932,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2107,6 +2172,18 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} @@ -2984,6 +3061,59 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stitches/core@1.2.8': {} + + '@supabase/auth-js@2.93.3': + dependencies: + tslib: 2.8.1 + + '@supabase/auth-ui-react@0.4.7(@supabase/supabase-js@2.93.3)': + dependencies: + '@stitches/core': 1.2.8 + '@supabase/auth-ui-shared': 0.1.8(@supabase/supabase-js@2.93.3) + '@supabase/supabase-js': 2.93.3 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@supabase/auth-ui-shared@0.1.8(@supabase/supabase-js@2.93.3)': + dependencies: + '@supabase/supabase-js': 2.93.3 + + '@supabase/functions-js@2.93.3': + dependencies: + tslib: 2.8.1 + + '@supabase/postgrest-js@2.93.3': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.93.3': + dependencies: + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.93.3': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.93.3': + dependencies: + '@supabase/auth-js': 2.93.3 + '@supabase/functions-js': 2.93.3 + '@supabase/postgrest-js': 2.93.3 + '@supabase/realtime-js': 2.93.3 + '@supabase/storage-js': 2.93.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': @@ -3032,6 +3162,8 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/phoenix@1.6.7': {} + '@types/react-dom@19.1.5(@types/react@19.1.5)': dependencies: '@types/react': 19.1.5 @@ -3040,6 +3172,10 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.17.50 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -3421,6 +3557,8 @@ snapshots: dependencies: function-bind: 1.1.2 + iceberg-js@0.8.1: {} + input-otp@1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: react: 19.2.1 @@ -3641,6 +3779,12 @@ snapshots: date-fns: 3.6.0 react: 19.2.1 + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-dom@19.2.1(react@19.2.1): dependencies: react: 19.2.1 @@ -3703,6 +3847,10 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + react@19.2.1: {} read-cache@1.0.0: @@ -3746,6 +3894,10 @@ snapshots: safe-buffer@5.2.1: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scheduler@0.27.0: {} schema-utils@4.3.2: @@ -4041,6 +4193,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + ws@8.19.0: {} + yaml@2.8.0: {} zod@3.25.28: {} diff --git a/src/app/clips/[id]/page.tsx b/src/app/clips/[id]/page.tsx new file mode 100644 index 0000000..34581c3 --- /dev/null +++ b/src/app/clips/[id]/page.tsx @@ -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 ( +
+

Clip Not Found

+

The link may be broken or the clip may have been removed.

+ +
+ ); + } + + const { data: video } = supabase.storage.from('clips').getPublicUrl(clip.storage_path); + + return ( +
+ + + + {clip.original_file_name || "Shared Clip"} + + + +
+
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b925c2d..8086539 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ +
{children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..728d5c1 --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( +
+
+
+

Welcome Back

+

Sign in to continue to the Video Editor

+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index b7fa5fa..20479c1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( +
+ +
+ ); + } + return (
diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 0000000..f2f7c44 --- /dev/null +++ b/src/components/header.tsx @@ -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(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 ( +
+
+ {user.email} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor.tsx b/src/components/video-editor.tsx index ebf7a21..b6259e9 100644 --- a/src/components/video-editor.tsx +++ b/src/components/video-editor.tsx @@ -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(null); const [videoFile, setVideoFile] = useState(null); const [duration, setDuration] = useState(0); const [trimValues, setTrimValues] = useState([0, 0]); - const [isTrimming, setIsTrimming] = useState(false); - const [trimmedVideoUrl, setTrimmedVideoUrl] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [shareableLink, setShareableLink] = useState(null); const [progress, setProgress] = useState(0); + const [hasCopied, setHasCopied] = useState(false); const videoRef = useRef(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 ( - - - - Video Clip Cutter - - - Upload, trim, and export your video in seconds. - - - - {videoSrc ? ( -
-
-
-
-

Trim Video

-
- -
- Start: {formatTime(trimValues[0])} - End: {formatTime(trimValues[1])} + <> + + + + Video Clip Cutter + Upload, trim, and share your video in seconds. + + + {videoSrc ? ( +
+
+
+
+

Trim Video

+
+ +
+ Start: {formatTime(trimValues[0])} + End: {formatTime(trimValues[1])} +
+ {isProcessing && ( +
+ + +

{progress}% complete

+
+ )} + {shareableLink && ( +
+ +
+ + +
+
+ )}
- {isTrimming && ( -
- - -

{progress}% complete

+ ) : ( +
+ +
+ )} + + {videoSrc && ( + + +
+ +
- )} -
- ) : ( -
- -
+ )} -
- {videoSrc && ( - - -
- - - -
-
- )} -
+ + ); } \ No newline at end of file diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts new file mode 100644 index 0000000..2cdbf99 --- /dev/null +++ b/src/integrations/supabase/client.ts @@ -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); \ No newline at end of file