[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

@@ -38,6 +38,8 @@
"@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7", "@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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",

154
pnpm-lock.yaml generated
View File

@@ -95,6 +95,12 @@ importers:
'@radix-ui/react-tooltip': '@radix-ui/react-tooltip':
specifier: ^1.2.7 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) 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: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -1068,6 +1074,43 @@ packages:
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} 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': '@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -1116,6 +1159,9 @@ packages:
'@types/node@20.17.50': '@types/node@20.17.50':
resolution: {integrity: sha512-Mxiq0ULv/zo1OzOhwPqOA13I81CV/W3nvd3ChtQZRT5Cwz3cr0FKo/wMSsbTqL3EXpaBAEQhva2B8ByRkOIh9A==} 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': '@types/react-dom@19.1.5':
resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==} resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==}
peerDependencies: peerDependencies:
@@ -1124,6 +1170,9 @@ packages:
'@types/react@19.1.5': '@types/react@19.1.5':
resolution: {integrity: sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==} resolution: {integrity: sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@webassemblyjs/ast@1.14.1': '@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -1507,6 +1556,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} 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: input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies: peerDependencies:
@@ -1765,6 +1818,11 @@ packages:
date-fns: ^2.28.0 || ^3.0.0 date-fns: ^2.28.0 || ^3.0.0
react: ^16.8.0 || ^17.0.0 || ^18.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: react-dom@19.2.1:
resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==}
peerDependencies: peerDependencies:
@@ -1830,6 +1888,10 @@ packages:
react: '>=16.6.0' react: '>=16.6.0'
react-dom: '>=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: react@19.2.1:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1870,6 +1932,9 @@ packages:
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -2107,6 +2172,18 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'} 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: yaml@2.8.0:
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
@@ -2984,6 +3061,59 @@ snapshots:
'@standard-schema/utils@0.3.0': {} '@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/counter@0.1.3': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
@@ -3032,6 +3162,8 @@ snapshots:
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.19.8
'@types/phoenix@1.6.7': {}
'@types/react-dom@19.1.5(@types/react@19.1.5)': '@types/react-dom@19.1.5(@types/react@19.1.5)':
dependencies: dependencies:
'@types/react': 19.1.5 '@types/react': 19.1.5
@@ -3040,6 +3172,10 @@ snapshots:
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.17.50
'@webassemblyjs/ast@1.14.1': '@webassemblyjs/ast@1.14.1':
dependencies: dependencies:
'@webassemblyjs/helper-numbers': 1.13.2 '@webassemblyjs/helper-numbers': 1.13.2
@@ -3421,6 +3557,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 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): input-otp@1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies: dependencies:
react: 19.2.1 react: 19.2.1
@@ -3641,6 +3779,12 @@ snapshots:
date-fns: 3.6.0 date-fns: 3.6.0
react: 19.2.1 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): react-dom@19.2.1(react@19.2.1):
dependencies: dependencies:
react: 19.2.1 react: 19.2.1
@@ -3703,6 +3847,10 @@ snapshots:
react: 19.2.1 react: 19.2.1
react-dom: 19.2.1(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: {} react@19.2.1: {}
read-cache@1.0.0: read-cache@1.0.0:
@@ -3746,6 +3894,10 @@ snapshots:
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
scheduler@0.27.0: {} scheduler@0.27.0: {}
schema-utils@4.3.2: schema-utils@4.3.2:
@@ -4041,6 +4193,8 @@ snapshots:
string-width: 5.1.2 string-width: 5.1.2
strip-ansi: 7.1.0 strip-ansi: 7.1.0
ws@8.19.0: {}
yaml@2.8.0: {} yaml@2.8.0: {}
zod@3.25.28: {} zod@3.25.28: {}

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 type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Header } from "@/components/header";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Video Clip Cutter",
description: "Generated by create next app", description: "Trim and share video clips",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -27,6 +28,7 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<Header />
{children} {children}
</body> </body>
</html> </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 { VideoEditor } from "@/components/video-editor";
import { Loader2 } from 'lucide-react';
export default function Home() { 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 ( 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)]"> <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"> <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 { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Progress } from "@/components/ui/progress"; 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 { getFFmpeg } from "@/lib/ffmpeg";
import { fetchFile } from "@ffmpeg/util"; import { fetchFile } from "@ffmpeg/util";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
export function VideoEditor() { export function VideoEditor() {
const [videoSrc, setVideoSrc] = useState<string | null>(null); const [videoSrc, setVideoSrc] = useState<string | null>(null);
const [videoFile, setVideoFile] = useState<File | null>(null); const [videoFile, setVideoFile] = useState<File | null>(null);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [trimValues, setTrimValues] = useState([0, 0]); const [trimValues, setTrimValues] = useState([0, 0]);
const [isTrimming, setIsTrimming] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [trimmedVideoUrl, setTrimmedVideoUrl] = useState<string | null>(null); const [shareableLink, setShareableLink] = useState<string | null>(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [hasCopied, setHasCopied] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => { useEffect(() => {
// Eagerly load FFmpeg
getFFmpeg(); getFFmpeg();
}, []); }, []);
@@ -31,14 +34,11 @@ export function VideoEditor() {
if (!video) return; if (!video) return;
const handleTimeUpdate = () => { const handleTimeUpdate = () => {
// When the video plays past the end of the trim selection, loop back to the start
if (video.currentTime >= trimValues[1]) { if (video.currentTime >= trimValues[1]) {
video.currentTime = trimValues[0]; video.currentTime = trimValues[0];
} }
}; };
const handlePlay = () => { 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]) { if (video.currentTime < trimValues[0] || video.currentTime >= trimValues[1]) {
video.currentTime = trimValues[0]; video.currentTime = trimValues[0];
} }
@@ -61,7 +61,7 @@ export function VideoEditor() {
setVideoFile(file); setVideoFile(file);
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
setVideoSrc(url); setVideoSrc(url);
setTrimmedVideoUrl(null); setShareableLink(null);
setProgress(0); setProgress(0);
} }
}; };
@@ -74,25 +74,18 @@ export function VideoEditor() {
const handleTrimValueChange = (newValues: number[]) => { const handleTrimValueChange = (newValues: number[]) => {
if (videoRef.current) { if (videoRef.current) {
if (newValues[0] !== trimValues[0]) { if (newValues[0] !== trimValues[0]) videoRef.current.currentTime = newValues[0];
videoRef.current.currentTime = newValues[0]; else if (newValues[1] !== trimValues[1]) videoRef.current.currentTime = newValues[1];
} else if (newValues[1] !== trimValues[1]) {
videoRef.current.currentTime = newValues[1];
}
} }
setTrimValues(newValues); setTrimValues(newValues);
}; };
const formatTime = (timeInSeconds: number) => { const formatTime = (timeInSeconds: number) => {
if (isNaN(timeInSeconds) || timeInSeconds < 0) { if (isNaN(timeInSeconds) || timeInSeconds < 0) return "00:00.000";
return "00:00.000";
}
const minutes = Math.floor(timeInSeconds / 60); const minutes = Math.floor(timeInSeconds / 60);
const seconds = Math.floor(timeInSeconds % 60); const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.floor((timeInSeconds % 1) * 1000); const milliseconds = Math.floor((timeInSeconds % 1) * 1000);
return `${String(minutes).padStart(2, "0")}:${String( return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
seconds
).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
}; };
const formatTimeForFFmpeg = (timeInSeconds: number) => { const formatTimeForFFmpeg = (timeInSeconds: number) => {
@@ -100,166 +93,148 @@ export function VideoEditor() {
const minutes = Math.floor((timeInSeconds % 3600) / 60); const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60); const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.floor((timeInSeconds % 1) * 1000); const milliseconds = Math.floor((timeInSeconds % 1) * 1000);
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String( return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
seconds
).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
}; };
const handleCutVideo = async () => { const handleCutAndShare = async () => {
if (!videoFile) return; if (!videoFile) return;
setIsTrimming(true); setIsProcessing(true);
setTrimmedVideoUrl(null); setShareableLink(null);
setProgress(0); setProgress(0);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("User not authenticated.");
const ffmpeg = await getFFmpeg(); const ffmpeg = await getFFmpeg();
ffmpeg.on('progress', ({ progress }) => setProgress(Math.round(progress * 100)));
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; const [startTime, endTime] = trimValues;
await ffmpeg.exec(['-i', videoFile.name, '-ss', formatTimeForFFmpeg(startTime), '-to', formatTimeForFFmpeg(endTime), '-c', 'copy', 'output.mp4']);
await ffmpeg.exec([
'-i',
videoFile.name,
'-ss',
formatTimeForFFmpeg(startTime),
'-to',
formatTimeForFFmpeg(endTime),
'-c',
'copy',
'output.mp4',
]);
const data = await ffmpeg.readFile('output.mp4'); const data = await ffmpeg.readFile('output.mp4');
const url = URL.createObjectURL(new Blob([(data as any).buffer], { type: 'video/mp4' })); const trimmedBlob = new Blob([(data as any).buffer], { type: 'video/mp4' });
setTrimmedVideoUrl(url); const shortId = Math.random().toString(36).substring(2, 8);
setIsTrimming(false); 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 { 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 = () => { const handleReset = () => {
setTrimValues([0, duration]); setTrimValues([0, duration]);
if (videoRef.current) { if (videoRef.current) videoRef.current.currentTime = 0;
videoRef.current.currentTime = 0;
}
}; };
const handleClear = () => { const handleClear = () => {
if (videoSrc) { if (videoSrc) URL.revokeObjectURL(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 ( return (
<>
<Toaster />
<Card className="w-full shadow-lg rounded-2xl border"> <Card className="w-full shadow-lg rounded-2xl border">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">Video Clip Cutter</CardTitle>
Video Clip Cutter <CardDescription className="text-lg text-muted-foreground pt-2">Upload, trim, and share your video in seconds.</CardDescription>
</CardTitle>
<CardDescription className="text-lg text-muted-foreground pt-2">
Upload, trim, and export your video in seconds.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
{videoSrc ? ( {videoSrc ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="aspect-video w-full overflow-hidden rounded-lg border"> <div className="aspect-video w-full overflow-hidden rounded-lg border">
<video <video ref={videoRef} src={videoSrc} controls className="w-full h-full object-contain bg-black" onLoadedMetadata={handleLoadedMetadata} />
ref={videoRef}
src={videoSrc}
controls
className="w-full h-full object-contain bg-black"
onLoadedMetadata={handleLoadedMetadata}
/>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold mb-4">Trim Video</h3> <h3 className="text-lg font-semibold mb-4">Trim Video</h3>
<div className="space-y-4"> <div className="space-y-4">
<Slider <Slider value={trimValues} onValueChange={handleTrimValueChange} max={duration} step={0.1} aria-label="Video trimmer" disabled={isProcessing} />
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"> <div className="flex justify-between text-sm font-mono text-muted-foreground">
<span>Start: {formatTime(trimValues[0])}</span> <span>Start: {formatTime(trimValues[0])}</span>
<span>End: {formatTime(trimValues[1])}</span> <span>End: {formatTime(trimValues[1])}</span>
</div> </div>
</div> </div>
</div> </div>
{isTrimming && ( {isProcessing && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Trimming in progress...</Label> <Label>Processing your clip...</Label>
<Progress value={progress} /> <Progress value={progress} />
<p className="text-sm text-muted-foreground text-center">{progress}% complete</p> <p className="text-sm text-muted-foreground text-center">{progress}% complete</p>
</div> </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> </div>
) : ( ) : (
<div className="flex items-center justify-center w-full"> <div className="flex items-center justify-center w-full">
<Label <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">
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"> <div className="flex flex-col items-center justify-center pt-5 pb-6">
<UploadCloud className="w-10 h-10 mb-4 text-muted-foreground" /> <UploadCloud className="w-10 h-10 mb-4 text-muted-foreground" />
<p className="mb-2 text-sm 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>
<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> <p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
</div> </div>
<Input <Input id="video-upload" type="file" className="hidden" accept="video/*" onChange={handleFileChange} />
id="video-upload"
type="file"
className="hidden"
accept="video/*"
onChange={handleFileChange}
/>
</Label> </Label>
</div> </div>
)} )}
</CardContent> </CardContent>
{videoSrc && ( {videoSrc && (
<CardFooter className="flex justify-between items-center p-6"> <CardFooter className="flex justify-between items-center p-6">
<Button variant="destructive" size="lg" onClick={handleClear} disabled={isTrimming}> <Button variant="destructive" size="lg" onClick={handleClear} disabled={isProcessing}>
<Trash2 className="mr-2 h-5 w-5" /> <Trash2 className="mr-2 h-5 w-5" /> Clear
Clear
</Button> </Button>
<div className="flex space-x-4"> <div className="flex space-x-4">
<Button variant="ghost" size="lg" onClick={handleReset} disabled={isTrimming}> <Button variant="ghost" size="lg" onClick={handleReset} disabled={isProcessing}>
<RotateCcw className="mr-2 h-5 w-5" /> <RotateCcw className="mr-2 h-5 w-5" /> Reset
Reset
</Button> </Button>
<Button variant="outline" size="lg" onClick={handleCutVideo} disabled={isTrimming}> <Button variant="default" size="lg" onClick={handleCutAndShare} disabled={isProcessing}>
{isTrimming ? ( {isProcessing ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : <Share2 className="mr-2 h-5 w-5" />}
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> {isProcessing ? "Processing..." : "Cut & Share"}
) : (
<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> </Button>
</div> </div>
</CardFooter> </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);