Compare commits
10 Commits
355df6695e
...
07116d7909
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07116d7909 | ||
|
|
a872525b1e | ||
|
|
19a237df5d | ||
|
|
53a46c89c9 | ||
|
|
3a3b1e5358 | ||
|
|
957c9e3aaf | ||
|
|
c1a9630879 | ||
|
|
b3b98070d7 | ||
|
|
f131a681ab | ||
|
|
aaecd3e203 |
100
package.json
100
package.json
@@ -11,61 +11,63 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.0",
|
||||||
"@radix-ui/react-context-menu": "^2.2.15",
|
"@radix-ui/react-context-menu": "^2.2.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-hover-card": "^1.1.14",
|
"@radix-ui/react-hover-card": "^1.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-menubar": "^1.1.15",
|
"@radix-ui/react-menubar": "^1.1.1",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.0",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.2.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
"@radix-ui/react-slider": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
"@radix-ui/react-toggle": "^1.1.9",
|
"@radix-ui/react-toggle": "^1.1.0",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"@supabase/auth-ui-react": "^0.4.7",
|
||||||
|
"@supabase/auth-ui-shared": "^0.1.8",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.1.6",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.2.5",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.408.0",
|
||||||
"next": "15.3.8",
|
"next": "15.3.8-canary.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^19.2.1",
|
"react": "^19.0.0-rc.0",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.0.0-rc.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.52.1",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^2.0.20",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.12.7",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^0.9.1",
|
||||||
"zod": "^3.25.28"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dyad-sh/nextjs-webpack-component-tagger": "^0.8.0",
|
"@dyad-sh/nextjs-webpack-component-tagger": "^0.8.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19.0.0-rc.0",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19.0.0-rc.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5"
|
"typescript": "^5.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
154
pnpm-lock.yaml
generated
154
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
83
src/app/account/page.tsx
Normal file
83
src/app/account/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { Loader2, Video } from 'lucide-react';
|
||||||
|
import { UserClips } from '@/components/user-clips';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export type Clip = {
|
||||||
|
id: string;
|
||||||
|
short_id: string;
|
||||||
|
title: string;
|
||||||
|
storage_path: string;
|
||||||
|
thumbnail_storage_path: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [clips, setClips] = useState<Clip[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserAndClips = async () => {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('clips')
|
||||||
|
.select('id, short_id, title, storage_path, thumbnail_storage_path, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching clips:', error);
|
||||||
|
} else {
|
||||||
|
setClips(data as Clip[]);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserAndClips();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const onClipDeleted = (clipId: string) => {
|
||||||
|
setClips(prevClips => prevClips.filter(clip => clip.id !== clipId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClipUpdated = (updatedClip: Clip) => {
|
||||||
|
setClips(prevClips => prevClips.map(clip => clip.id === updatedClip.id ? updatedClip : clip));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl py-24 px-4">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-8">My Clips</h1>
|
||||||
|
{clips.length > 0 ? (
|
||||||
|
<UserClips clips={clips} onClipDeleted={onClipDeleted} onClipUpdated={onClipUpdated} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6 border-2 border-dashed rounded-lg">
|
||||||
|
<Video className="w-16 h-16 mb-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-semibold mb-2">No clips yet</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">You haven't created any clips. Go ahead and make your first one!</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Create a Clip</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/clips/[id]/page.tsx
Normal file
63
src/app/clips/[id]/page.tsx
Normal 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, title')
|
||||||
|
.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.title || 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,9 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
metadataBase: new URL("https://clips.linxweiler.xyz"),
|
||||||
description: "Generated by create next app",
|
title: "Video Clip Cutter",
|
||||||
|
description: "Trim and share video clips",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -27,6 +29,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>
|
||||||
|
|||||||
62
src/app/login/page.tsx
Normal file
62
src/app/login/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"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";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
if (session) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleOidcSignIn = async () => {
|
||||||
|
await supabase.auth.signInWithOAuth({
|
||||||
|
provider: 'pocket_id',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
view="sign_in"
|
||||||
|
showLinks={false}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-background px-2 text-muted-foreground">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full" onClick={handleOidcSignIn}>
|
||||||
|
<KeyRound className="mr-2 h-4 w-4" />
|
||||||
|
Sign in with Pocket ID
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
76
src/components/header.tsx
Normal file
76
src/components/header.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { User as SupabaseUser } from '@supabase/supabase-js';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { LogOut, User } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const [user, setUser] = useState<SupabaseUser | 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">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">My Account</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground truncate">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/account">
|
||||||
|
My Clips
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleSignOut}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>Log out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/components/user-clips.tsx
Normal file
214
src/components/user-clips.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Clip } from '@/app/account/page';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogClose } from '@/components/ui/dialog';
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { MoreVertical, Edit, Trash2, Loader2, Copy, Check } from 'lucide-react';
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
type UserClipsProps = {
|
||||||
|
clips: Clip[];
|
||||||
|
onClipDeleted: (clipId: string) => void;
|
||||||
|
onClipUpdated: (clip: Clip) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserClips({ clips, onClipDeleted, onClipUpdated }: UserClipsProps) {
|
||||||
|
const [clipToEdit, setClipToEdit] = useState<Clip | null>(null);
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [hasCopied, setHasCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleEditClick = (clip: Clip) => {
|
||||||
|
setClipToEdit(clip);
|
||||||
|
setNewTitle(clip.title);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChanges = async () => {
|
||||||
|
if (!clipToEdit || !newTitle.trim()) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('clips')
|
||||||
|
.update({ title: newTitle.trim() })
|
||||||
|
.eq('id', clipToEdit.id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
onClipUpdated(data as Clip);
|
||||||
|
toast.success('Title updated successfully!');
|
||||||
|
setClipToEdit(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update title.');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClip = async (clip: Clip) => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const pathsToRemove = [clip.storage_path];
|
||||||
|
if (clip.thumbnail_storage_path) {
|
||||||
|
pathsToRemove.push(clip.thumbnail_storage_path);
|
||||||
|
}
|
||||||
|
const { error: storageError } = await supabase.storage.from('clips').remove(pathsToRemove);
|
||||||
|
if (storageError) throw storageError;
|
||||||
|
|
||||||
|
const { error: dbError } = await supabase.from('clips').delete().eq('id', clip.id);
|
||||||
|
if (dbError) throw dbError;
|
||||||
|
|
||||||
|
onClipDeleted(clip.id);
|
||||||
|
toast.success('Clip deleted successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete clip.');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (shortId: string) => {
|
||||||
|
const link = `${window.location.origin}/clips/${shortId}`;
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
setHasCopied(shortId);
|
||||||
|
setTimeout(() => setHasCopied(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getThumbnailUrl = (path: string) => {
|
||||||
|
const { data } = supabase.storage.from('clips').getPublicUrl(path);
|
||||||
|
return data.publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{clips.map((clip) => (
|
||||||
|
<Card key={clip.id} className="flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="truncate flex justify-between items-start">
|
||||||
|
<Link href={`/clips/${clip.short_id}`} className="hover:underline flex-1 pr-2">
|
||||||
|
{clip.title || 'Untitled Clip'}
|
||||||
|
</Link>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="flex-shrink-0">
|
||||||
|
<MoreVertical className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DialogTrigger asChild onClick={() => handleEditClick(clip)}>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
<span>Edit Title</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-grow">
|
||||||
|
<Link href={`/clips/${clip.short_id}`}>
|
||||||
|
<div className="aspect-video w-full bg-muted rounded-md overflow-hidden relative">
|
||||||
|
{clip.thumbnail_storage_path ? (
|
||||||
|
<Image
|
||||||
|
src={getThumbnailUrl(clip.thumbnail_storage_path)}
|
||||||
|
alt={clip.title || 'Clip thumbnail'}
|
||||||
|
layout="fill"
|
||||||
|
objectFit="cover"
|
||||||
|
className="transition-transform duration-300 hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||||
|
No thumbnail
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-between items-center text-sm text-muted-foreground">
|
||||||
|
<span>{formatDistanceToNow(new Date(clip.created_at), { addSuffix: true })}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => copyToClipboard(clip.short_id)}>
|
||||||
|
{hasCopied === clip.short_id ? <Check className="mr-2 h-4 w-4 text-green-500" /> : <Copy className="mr-2 h-4 w-4" />}
|
||||||
|
{hasCopied === clip.short_id ? 'Copied!' : 'Copy Link'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete your clip and its thumbnail from our servers.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => handleDeleteClip(clip)} disabled={isDeleting} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Title Dialog */}
|
||||||
|
<Dialog open={!!clipToEdit} onOpenChange={(isOpen) => !isOpen && setClipToEdit(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Clip Title</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="title" className="text-right">
|
||||||
|
Title
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" onClick={handleSaveChanges} disabled={isSaving}>
|
||||||
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,22 +7,26 @@ 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 } 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 [videoTitle, setVideoTitle] = useState("");
|
||||||
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 +35,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];
|
||||||
}
|
}
|
||||||
@@ -59,9 +60,10 @@ export function VideoEditor() {
|
|||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
setVideoFile(file);
|
setVideoFile(file);
|
||||||
|
setVideoTitle(file.name.replace(/\.[^/.]+$/, ""));
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
setVideoSrc(url);
|
setVideoSrc(url);
|
||||||
setTrimmedVideoUrl(null);
|
setShareableLink(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -74,25 +76,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,136 +95,170 @@ 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);
|
// Generate thumbnail
|
||||||
setIsTrimming(false);
|
await ffmpeg.exec(['-i', 'output.mp4', '-ss', '00:00:00.001', '-vframes', '1', 'thumbnail.jpg']);
|
||||||
|
const thumbnailData = await ffmpeg.readFile('thumbnail.jpg');
|
||||||
|
const thumbnailBlob = new Blob([(thumbnailData as any).buffer], { type: 'image/jpeg' });
|
||||||
|
|
||||||
|
const shortId = Math.random().toString(36).substring(2, 8);
|
||||||
|
const storagePath = `${user.id}/${shortId}-${videoFile.name}`;
|
||||||
|
const thumbnailStoragePath = `${user.id}/${shortId}-thumbnail.jpg`;
|
||||||
|
|
||||||
|
const { error: uploadError } = await supabase.storage.from('clips').upload(storagePath, trimmedBlob);
|
||||||
|
if (uploadError) throw new Error(`Storage Error: ${uploadError.message}`);
|
||||||
|
|
||||||
|
const { error: thumbnailUploadError } = await supabase.storage.from('clips').upload(thumbnailStoragePath, thumbnailBlob);
|
||||||
|
if (thumbnailUploadError) throw new Error(`Thumbnail Upload Error: ${thumbnailUploadError.message}`);
|
||||||
|
|
||||||
|
const { error: dbError } = await supabase.from('clips').insert({
|
||||||
|
user_id: user.id,
|
||||||
|
storage_path: storagePath,
|
||||||
|
thumbnail_storage_path: thumbnailStoragePath,
|
||||||
|
short_id: shortId,
|
||||||
|
original_file_name: videoFile.name,
|
||||||
|
title: videoTitle,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
if (videoSrc) URL.revokeObjectURL(videoSrc);
|
||||||
|
setVideoSrc(null); setVideoFile(null); setDuration(0);
|
||||||
|
setTrimValues([0, 0]); setIsProcessing(false);
|
||||||
|
setShareableLink(null); setProgress(0);
|
||||||
|
setVideoTitle("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (shareableLink) {
|
||||||
|
navigator.clipboard.writeText(shareableLink);
|
||||||
|
setHasCopied(true);
|
||||||
|
setTimeout(() => setHasCopied(false), 2000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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}
|
</div>
|
||||||
src={videoSrc}
|
<div className="space-y-2">
|
||||||
controls
|
<Label htmlFor="video-title">Title</Label>
|
||||||
className="w-full h-full object-contain bg-black"
|
<Input
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
id="video-title"
|
||||||
|
value={videoTitle}
|
||||||
|
onChange={(e) => setVideoTitle(e.target.value)}
|
||||||
|
placeholder="Enter a title for your clip"
|
||||||
|
disabled={isProcessing}
|
||||||
/>
|
/>
|
||||||
</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-end space-x-4 p-6">
|
<CardFooter className="flex justify-between items-center p-6">
|
||||||
<Button variant="outline" size="lg" onClick={handleCutVideo} disabled={isTrimming}>
|
<Button variant="destructive" size="lg" onClick={handleClear} disabled={isProcessing}>
|
||||||
{isTrimming ? (
|
<Trash2 className="mr-2 h-5 w-5" /> Clear
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Scissors className="mr-2 h-5 w-5" />
|
|
||||||
)}
|
|
||||||
{isTrimming ? "Trimming..." : "Cut Video"}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="default" size="lg" disabled={!trimmedVideoUrl || isTrimming}>
|
<div className="flex space-x-4">
|
||||||
<a href={trimmedVideoUrl!} download={`trimmed-${videoFile?.name || 'video.mp4'}`}>
|
<Button variant="ghost" size="lg" onClick={handleReset} disabled={isProcessing}>
|
||||||
<Download className="mr-2 h-5 w-5" />
|
<RotateCcw className="mr-2 h-5 w-5" /> Reset
|
||||||
Export
|
|
||||||
</a>
|
|
||||||
</Button>
|
</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>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
6
src/integrations/supabase/client.ts
Normal file
6
src/integrations/supabase/client.ts
Normal 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);
|
||||||
Reference in New Issue
Block a user