Inital commit
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
videos
|
||||||
|
thumbnails
|
||||||
@@ -39,3 +39,11 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# local media uploads
|
||||||
|
/videos/*
|
||||||
|
!/videos/.gitkeep
|
||||||
|
/thumbnails/*
|
||||||
|
!/thumbnails/.gitkeep
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ARG BASE_IMAGE=mirror.gcr.io/library/node:20-alpine
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG DATABASE_URL="postgresql://postgres:postgres@localhost:5432/metube?schema=public"
|
||||||
|
RUN DATABASE_URL="$DATABASE_URL" SESSION_SECRET="metube-build-session-secret-change-me-12345" npm run build
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -1,36 +1 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# MeTube
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BASE_IMAGE: ${BASE_IMAGE:-mirror.gcr.io/library/node:20-alpine}
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@db:5432/metube?schema=public
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@db:5432/metube?schema=public
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./videos:/app/videos
|
||||||
|
- ./thumbnails:/app/thumbnails
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: ${POSTGRES_IMAGE:-mirror.gcr.io/library/postgres:16-alpine}
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: metube
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d metube"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./prisma/migrations/0001_init/migration.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
||||||
|
- ./prisma/migrations/0002_channel_age_gate/migration.sql:/docker-entrypoint-initdb.d/002_channel_age_gate.sql:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -6,12 +6,22 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
"@prisma/client": "^7.7.0",
|
||||||
|
"argon2": "^0.44.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -20,6 +30,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
|
"prisma": "^7.7.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- CreateSchema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "public";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Channel" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Channel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Video" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"videoFilename" TEXT NOT NULL,
|
||||||
|
"videoMimeType" TEXT NOT NULL,
|
||||||
|
"videoSizeBytes" INTEGER NOT NULL,
|
||||||
|
"thumbnailFilename" TEXT,
|
||||||
|
"thumbnailMimeType" TEXT,
|
||||||
|
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Video_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Video_publishedAt_idx" ON "Video"("publishedAt" DESC);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Video" ADD CONSTRAINT "Video_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Channel"
|
||||||
|
ADD COLUMN "isAdult" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Channel {
|
||||||
|
id String @id
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
isAdult Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
videos Video[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Video {
|
||||||
|
id String @id
|
||||||
|
channelId String
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
videoFilename String
|
||||||
|
videoMimeType String
|
||||||
|
videoSizeBytes Int
|
||||||
|
thumbnailFilename String?
|
||||||
|
thumbnailMimeType String?
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([publishedAt(sort: Desc)])
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 172 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSessionToken,
|
||||||
|
getSessionCookieOptions,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
verifyPassword,
|
||||||
|
} from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { isSetupComplete } from "@/lib/setup";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().trim().min(3).max(32),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
function redirectWithError(request: Request, error: string) {
|
||||||
|
const url = new URL("/login", request.url);
|
||||||
|
url.searchParams.set("error", error);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const parsed = loginSchema.safeParse({
|
||||||
|
username: formData.get("username"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return redirectWithError(request, "Invalid credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: {
|
||||||
|
username: parsed.data.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return redirectWithError(request, "Invalid credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return redirectWithError(request, "Invalid credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = await createSessionToken(user.id, user.username);
|
||||||
|
const response = NextResponse.redirect(new URL("/dashboard", request.url), 303);
|
||||||
|
|
||||||
|
response.cookies.set(
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
sessionToken,
|
||||||
|
getSessionCookieOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
import { isSetupComplete } from "@/lib/setup";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const setupComplete = await isSetupComplete();
|
||||||
|
const response = NextResponse.redirect(
|
||||||
|
new URL(setupComplete ? "/login" : "/setup", request.url),
|
||||||
|
303,
|
||||||
|
);
|
||||||
|
|
||||||
|
response.cookies.delete(SESSION_COOKIE_NAME);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ADULT_GATE_COOKIE_NAME,
|
||||||
|
getAdultGateCookieOptions,
|
||||||
|
} from "@/lib/channel";
|
||||||
|
import { getChannel, isSetupComplete } from "@/lib/setup";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const ageGateSchema = z.object({
|
||||||
|
decision: z.enum(["yes", "no"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await getChannel();
|
||||||
|
if (!channel || !channel.isAdult) {
|
||||||
|
return NextResponse.redirect(new URL("/", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const parsed = ageGateSchema.safeParse({
|
||||||
|
decision: formData.get("decision"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.redirect(new URL("/", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.decision === "yes") {
|
||||||
|
const response = NextResponse.redirect(new URL("/", request.url), 303);
|
||||||
|
|
||||||
|
response.cookies.set(
|
||||||
|
ADULT_GATE_COOKIE_NAME,
|
||||||
|
channel.id,
|
||||||
|
getAdultGateCookieOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL("https://google.com"), 303);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getChannel, isSetupComplete } from "@/lib/setup";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const channelSettingsSchema = z.object({
|
||||||
|
channelName: z.string().trim().min(2).max(80),
|
||||||
|
channelDescription: z.string().trim().max(500).optional(),
|
||||||
|
channelIsAdult: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function redirectWithError(request: Request, error: string) {
|
||||||
|
const url = new URL("/dashboard/settings", request.url);
|
||||||
|
url.searchParams.set("error", error);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithSuccess(request: Request, success: string) {
|
||||||
|
const url = new URL("/dashboard/settings", request.url);
|
||||||
|
url.searchParams.set("success", success);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await getChannel();
|
||||||
|
if (!channel) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const parsed = channelSettingsSchema.safeParse({
|
||||||
|
channelName: formData.get("channelName"),
|
||||||
|
channelDescription: formData.get("channelDescription") || undefined,
|
||||||
|
channelIsAdult: formData.get("channelIsAdult") === "on",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return redirectWithError(request, "Please provide a valid channel name.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.channel.update({
|
||||||
|
where: {
|
||||||
|
id: channel.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: parsed.data.channelName,
|
||||||
|
description: parsed.data.channelDescription || null,
|
||||||
|
isAdult: parsed.data.channelIsAdult,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirectWithSuccess(request, "Channel settings saved.");
|
||||||
|
} catch {
|
||||||
|
return redirectWithError(request, "Failed to save channel settings.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getThumbnailPath } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
context: RouteContext<"/api/media/thumbnails/[videoId]">,
|
||||||
|
) {
|
||||||
|
const { videoId } = await context.params;
|
||||||
|
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
thumbnailFilename: true,
|
||||||
|
thumbnailMimeType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video?.thumbnailFilename) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = await readFile(getThumbnailPath(video.thumbnailFilename));
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": video.thumbnailMimeType ?? "image/jpeg",
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getVideoPath } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function parseRangeHeader(rangeHeader: string, size: number) {
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = match[1] ? Number(match[1]) : 0;
|
||||||
|
const end = match[2] ? Number(match[2]) : size - 1;
|
||||||
|
|
||||||
|
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start < 0 || end < start || end >= size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseHeaders(mimeType: string, size: number): HeadersInit {
|
||||||
|
return {
|
||||||
|
"Content-Type": mimeType,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": String(size),
|
||||||
|
"Cache-Control": "public, max-age=0, must-revalidate",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function HEAD(
|
||||||
|
_request: Request,
|
||||||
|
context: RouteContext<"/api/media/videos/[videoId]">,
|
||||||
|
) {
|
||||||
|
const { videoId } = await context.params;
|
||||||
|
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
videoFilename: true,
|
||||||
|
videoMimeType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStats = await stat(getVideoPath(video.videoFilename));
|
||||||
|
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: baseHeaders(video.videoMimeType, fileStats.size),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
context: RouteContext<"/api/media/videos/[videoId]">,
|
||||||
|
) {
|
||||||
|
const { videoId } = await context.params;
|
||||||
|
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
videoFilename: true,
|
||||||
|
videoMimeType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = getVideoPath(video.videoFilename);
|
||||||
|
|
||||||
|
let fileStats;
|
||||||
|
try {
|
||||||
|
fileStats = await stat(filePath);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeHeader = request.headers.get("range");
|
||||||
|
if (!rangeHeader) {
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: baseHeaders(video.videoMimeType, fileStats.size),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = parseRangeHeader(rangeHeader, fileStats.size);
|
||||||
|
if (!range) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: {
|
||||||
|
"Content-Range": `bytes */${fileStats.size}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath, {
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
});
|
||||||
|
const chunkSize = range.end - range.start + 1;
|
||||||
|
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": video.videoMimeType,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": String(chunkSize),
|
||||||
|
"Content-Range": `bytes ${range.start}-${range.end}/${fileStats.size}`,
|
||||||
|
"Cache-Control": "public, max-age=0, must-revalidate",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSessionToken,
|
||||||
|
getSessionCookieOptions,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
hashPassword,
|
||||||
|
} from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { PRIMARY_CHANNEL_ID, isSetupComplete } from "@/lib/setup";
|
||||||
|
import { ensureStorageDirectories } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const setupSchema = z.object({
|
||||||
|
channelName: z.string().trim().min(2).max(80),
|
||||||
|
channelDescription: z.string().trim().max(500).optional(),
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3)
|
||||||
|
.max(32)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
function redirectWithError(request: Request, error: string) {
|
||||||
|
const url = new URL("/setup", request.url);
|
||||||
|
url.searchParams.set("error", error);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (await isSetupComplete()) {
|
||||||
|
return NextResponse.redirect(new URL("/", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const parsed = setupSchema.safeParse({
|
||||||
|
channelName: formData.get("channelName"),
|
||||||
|
channelDescription: formData.get("channelDescription") || undefined,
|
||||||
|
username: formData.get("username"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return redirectWithError(request, "Please fill all required fields correctly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(parsed.data.password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureStorageDirectories();
|
||||||
|
|
||||||
|
const user = await db.$transaction(async (tx) => {
|
||||||
|
await tx.channel.create({
|
||||||
|
data: {
|
||||||
|
id: PRIMARY_CHANNEL_ID,
|
||||||
|
name: parsed.data.channelName,
|
||||||
|
description: parsed.data.channelDescription || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.user.create({
|
||||||
|
data: {
|
||||||
|
username: parsed.data.username,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionToken = await createSessionToken(user.id, user.username);
|
||||||
|
const response = NextResponse.redirect(new URL("/dashboard", request.url), 303);
|
||||||
|
|
||||||
|
response.cookies.set(
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
sessionToken,
|
||||||
|
getSessionCookieOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return redirectWithError(
|
||||||
|
request,
|
||||||
|
"Setup failed. If this instance was already initialized, sign in instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { unlink, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getChannel, isSetupComplete } from "@/lib/setup";
|
||||||
|
import {
|
||||||
|
ensureStorageDirectories,
|
||||||
|
getThumbnailExtension,
|
||||||
|
getThumbnailPath,
|
||||||
|
getVideoPath,
|
||||||
|
} from "@/lib/storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const videoMetadataSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(140),
|
||||||
|
description: z.string().trim().max(5000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxVideoBytes = Number(process.env.MAX_VIDEO_UPLOAD_BYTES ?? 1_073_741_824);
|
||||||
|
const maxThumbnailBytes = Number(process.env.MAX_THUMBNAIL_UPLOAD_BYTES ?? 5_242_880);
|
||||||
|
|
||||||
|
function redirectWithError(request: Request, error: string) {
|
||||||
|
const url = new URL("/dashboard", request.url);
|
||||||
|
url.searchParams.set("error", error);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithSuccess(request: Request, success: string) {
|
||||||
|
const url = new URL("/dashboard", request.url);
|
||||||
|
url.searchParams.set("success", success);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoRouteContext = {
|
||||||
|
params: Promise<{ videoId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: VideoRouteContext) {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await getChannel();
|
||||||
|
if (!channel) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
const formData = await request.formData();
|
||||||
|
const shouldDelete = formData.get("intent") === "delete";
|
||||||
|
|
||||||
|
const existingVideo = await db.video.findUnique({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
channelId: true,
|
||||||
|
videoFilename: true,
|
||||||
|
videoMimeType: true,
|
||||||
|
videoSizeBytes: true,
|
||||||
|
thumbnailFilename: true,
|
||||||
|
thumbnailMimeType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingVideo || existingVideo.channelId !== channel.id) {
|
||||||
|
return redirectWithError(request, "Video not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDelete) {
|
||||||
|
try {
|
||||||
|
await db.video.delete({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
unlink(getVideoPath(existingVideo.videoFilename)).catch(() => undefined),
|
||||||
|
existingVideo.thumbnailFilename
|
||||||
|
? unlink(getThumbnailPath(existingVideo.thumbnailFilename)).catch(() => undefined)
|
||||||
|
: undefined,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirectWithSuccess(request, "Video deleted.");
|
||||||
|
} catch {
|
||||||
|
return redirectWithError(request, "Failed to delete video.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = videoMetadataSchema.safeParse({
|
||||||
|
title: formData.get("title"),
|
||||||
|
description: formData.get("description") || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return redirectWithError(request, "Video title is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoInput = formData.get("video");
|
||||||
|
const replacementVideo =
|
||||||
|
videoInput instanceof File && videoInput.size > 0 ? videoInput : null;
|
||||||
|
|
||||||
|
if (replacementVideo) {
|
||||||
|
if (replacementVideo.size > maxVideoBytes) {
|
||||||
|
return redirectWithError(request, "Video exceeds the configured upload size limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerVideoName = replacementVideo.name.toLowerCase();
|
||||||
|
const isMp4 =
|
||||||
|
replacementVideo.type === "video/mp4" || lowerVideoName.endsWith(".mp4");
|
||||||
|
if (!isMp4) {
|
||||||
|
return redirectWithError(request, "Only MP4 uploads are supported right now.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailInput = formData.get("thumbnail");
|
||||||
|
const replacementThumbnail =
|
||||||
|
thumbnailInput instanceof File && thumbnailInput.size > 0 ? thumbnailInput : null;
|
||||||
|
|
||||||
|
let replacementThumbnailExtension: string | null = null;
|
||||||
|
|
||||||
|
if (replacementThumbnail) {
|
||||||
|
if (replacementThumbnail.size > maxThumbnailBytes) {
|
||||||
|
return redirectWithError(
|
||||||
|
request,
|
||||||
|
"Thumbnail exceeds the configured upload size limit.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
replacementThumbnailExtension = getThumbnailExtension(replacementThumbnail);
|
||||||
|
if (!replacementThumbnailExtension) {
|
||||||
|
return redirectWithError(request, "Thumbnail must be JPG, PNG, or WEBP.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextVideoFilename = existingVideo.videoFilename;
|
||||||
|
let nextVideoMimeType = existingVideo.videoMimeType;
|
||||||
|
let nextVideoSizeBytes = existingVideo.videoSizeBytes;
|
||||||
|
let nextThumbnailFilename = existingVideo.thumbnailFilename;
|
||||||
|
let nextThumbnailMimeType = existingVideo.thumbnailMimeType;
|
||||||
|
|
||||||
|
let newVideoPath: string | null = null;
|
||||||
|
let newThumbnailPath: string | null = null;
|
||||||
|
|
||||||
|
if (replacementVideo) {
|
||||||
|
const version = randomUUID().replaceAll("-", "");
|
||||||
|
nextVideoFilename = `${videoId}-${version}.mp4`;
|
||||||
|
nextVideoMimeType = replacementVideo.type || "video/mp4";
|
||||||
|
nextVideoSizeBytes = replacementVideo.size;
|
||||||
|
newVideoPath = getVideoPath(nextVideoFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacementThumbnail && replacementThumbnailExtension) {
|
||||||
|
const version = randomUUID().replaceAll("-", "");
|
||||||
|
nextThumbnailFilename = `${videoId}-${version}${replacementThumbnailExtension}`;
|
||||||
|
nextThumbnailMimeType =
|
||||||
|
replacementThumbnail.type ||
|
||||||
|
(replacementThumbnailExtension === ".png"
|
||||||
|
? "image/png"
|
||||||
|
: replacementThumbnailExtension === ".webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "image/jpeg");
|
||||||
|
newThumbnailPath = getThumbnailPath(nextThumbnailFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (replacementVideo || replacementThumbnail) {
|
||||||
|
await ensureStorageDirectories();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacementVideo && newVideoPath) {
|
||||||
|
await writeFile(newVideoPath, Buffer.from(await replacementVideo.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacementThumbnail && newThumbnailPath) {
|
||||||
|
await writeFile(
|
||||||
|
newThumbnailPath,
|
||||||
|
Buffer.from(await replacementThumbnail.arrayBuffer()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.video.update({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: parsed.data.title,
|
||||||
|
description: parsed.data.description || null,
|
||||||
|
videoFilename: nextVideoFilename,
|
||||||
|
videoMimeType: nextVideoMimeType,
|
||||||
|
videoSizeBytes: nextVideoSizeBytes,
|
||||||
|
thumbnailFilename: nextThumbnailFilename,
|
||||||
|
thumbnailMimeType: nextThumbnailMimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
replacementVideo
|
||||||
|
? unlink(getVideoPath(existingVideo.videoFilename)).catch(() => undefined)
|
||||||
|
: undefined,
|
||||||
|
replacementThumbnail && existingVideo.thumbnailFilename
|
||||||
|
? unlink(getThumbnailPath(existingVideo.thumbnailFilename)).catch(() => undefined)
|
||||||
|
: undefined,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirectWithSuccess(request, "Video updated.");
|
||||||
|
} catch {
|
||||||
|
await Promise.all([
|
||||||
|
newVideoPath ? unlink(newVideoPath).catch(() => undefined) : undefined,
|
||||||
|
newThumbnailPath ? unlink(newThumbnailPath).catch(() => undefined) : undefined,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirectWithError(request, "Failed to update video.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { unlink, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getChannel, isSetupComplete } from "@/lib/setup";
|
||||||
|
import {
|
||||||
|
ensureStorageDirectories,
|
||||||
|
getThumbnailExtension,
|
||||||
|
getThumbnailPath,
|
||||||
|
getVideoPath,
|
||||||
|
} from "@/lib/storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const metadataSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(140),
|
||||||
|
description: z.string().trim().max(5000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxVideoBytes = Number(process.env.MAX_VIDEO_UPLOAD_BYTES ?? 1_073_741_824);
|
||||||
|
const maxThumbnailBytes = Number(process.env.MAX_THUMBNAIL_UPLOAD_BYTES ?? 5_242_880);
|
||||||
|
|
||||||
|
function redirectWithError(request: Request, error: string) {
|
||||||
|
const url = new URL("/dashboard", request.url);
|
||||||
|
url.searchParams.set("error", error);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithSuccess(request: Request, success: string) {
|
||||||
|
const url = new URL("/dashboard", request.url);
|
||||||
|
url.searchParams.set("success", success);
|
||||||
|
return NextResponse.redirect(url, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await getChannel();
|
||||||
|
if (!channel) {
|
||||||
|
return NextResponse.redirect(new URL("/setup", request.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const metadata = metadataSchema.safeParse({
|
||||||
|
title: formData.get("title"),
|
||||||
|
description: formData.get("description") || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!metadata.success) {
|
||||||
|
return redirectWithError(request, "Video title is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoInput = formData.get("video");
|
||||||
|
if (!(videoInput instanceof File)) {
|
||||||
|
return redirectWithError(request, "Please select a video file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoInput.size <= 0) {
|
||||||
|
return redirectWithError(request, "Video file cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoInput.size > maxVideoBytes) {
|
||||||
|
return redirectWithError(request, "Video exceeds the configured upload size limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerVideoName = videoInput.name.toLowerCase();
|
||||||
|
const isMp4 = videoInput.type === "video/mp4" || lowerVideoName.endsWith(".mp4");
|
||||||
|
if (!isMp4) {
|
||||||
|
return redirectWithError(request, "Only MP4 uploads are supported right now.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailInput = formData.get("thumbnail");
|
||||||
|
const thumbnailFile =
|
||||||
|
thumbnailInput instanceof File && thumbnailInput.size > 0
|
||||||
|
? thumbnailInput
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let thumbnailExtension: string | null = null;
|
||||||
|
if (thumbnailFile) {
|
||||||
|
if (thumbnailFile.size > maxThumbnailBytes) {
|
||||||
|
return redirectWithError(
|
||||||
|
request,
|
||||||
|
"Thumbnail exceeds the configured upload size limit.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailExtension = getThumbnailExtension(thumbnailFile);
|
||||||
|
if (!thumbnailExtension) {
|
||||||
|
return redirectWithError(
|
||||||
|
request,
|
||||||
|
"Thumbnail must be JPG, PNG, or WEBP.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = randomUUID().replaceAll("-", "");
|
||||||
|
const videoFilename = `${videoId}.mp4`;
|
||||||
|
const videoPath = getVideoPath(videoFilename);
|
||||||
|
|
||||||
|
let thumbnailFilename: string | null = null;
|
||||||
|
let thumbnailMimeType: string | null = null;
|
||||||
|
let thumbnailPath: string | null = null;
|
||||||
|
|
||||||
|
if (thumbnailFile && thumbnailExtension) {
|
||||||
|
thumbnailFilename = `${videoId}${thumbnailExtension}`;
|
||||||
|
thumbnailMimeType =
|
||||||
|
thumbnailFile.type ||
|
||||||
|
(thumbnailExtension === ".png"
|
||||||
|
? "image/png"
|
||||||
|
: thumbnailExtension === ".webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "image/jpeg");
|
||||||
|
thumbnailPath = getThumbnailPath(thumbnailFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureStorageDirectories();
|
||||||
|
|
||||||
|
await writeFile(videoPath, Buffer.from(await videoInput.arrayBuffer()));
|
||||||
|
|
||||||
|
if (thumbnailFile && thumbnailPath) {
|
||||||
|
await writeFile(thumbnailPath, Buffer.from(await thumbnailFile.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.video.create({
|
||||||
|
data: {
|
||||||
|
id: videoId,
|
||||||
|
channelId: channel.id,
|
||||||
|
title: metadata.data.title,
|
||||||
|
description: metadata.data.description || null,
|
||||||
|
videoFilename,
|
||||||
|
videoMimeType: videoInput.type || "video/mp4",
|
||||||
|
videoSizeBytes: videoInput.size,
|
||||||
|
thumbnailFilename,
|
||||||
|
thumbnailMimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirectWithSuccess(request, "Upload complete.");
|
||||||
|
} catch {
|
||||||
|
await Promise.all([
|
||||||
|
unlink(videoPath).catch(() => undefined),
|
||||||
|
thumbnailPath ? unlink(thumbnailPath).catch(() => undefined) : undefined,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirectWithError(request, "Upload failed. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { DashboardTabs } from "@/components/DashboardTabs";
|
||||||
|
import { requireAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { getChannel, requireSetup } from "@/lib/setup";
|
||||||
|
|
||||||
|
type DashboardLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
|
await requireSetup();
|
||||||
|
|
||||||
|
const [user, channel] = await Promise.all([requireAuthenticatedUser(), getChannel()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell dashboard-root">
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<div className="dashboard-header-main">
|
||||||
|
<div className="dashboard-header-titles">
|
||||||
|
<p className="dashboard-eyebrow">MeTube</p>
|
||||||
|
<h1 className="dashboard-title">{channel?.name ?? "Channel"}</h1>
|
||||||
|
<p className="dashboard-subtitle">
|
||||||
|
<span className="text-[color:var(--foreground)]">@{user.username}</span>
|
||||||
|
<span className="mx-2 text-[color:var(--card-border)]">·</span>
|
||||||
|
<span>Manage uploads and channel details</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-header-actions">
|
||||||
|
<Link href="/" className="btn btn-secondary btn-sm">
|
||||||
|
View channel
|
||||||
|
</Link>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit" className="btn btn-ghost btn-sm">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DashboardTabs />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="dashboard-content">{children}</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { VideoEditorModal } from "@/components/VideoEditorModal";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { formatBytes, formatDate } from "@/lib/format";
|
||||||
|
import { singleValue } from "@/lib/query";
|
||||||
|
|
||||||
|
type DashboardPageProps = {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const videos = await db.video.findMany({
|
||||||
|
orderBy: {
|
||||||
|
publishedAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = singleValue(query.success);
|
||||||
|
const error = singleValue(query.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(success || error) && (
|
||||||
|
<div className="dashboard-alerts" role="status">
|
||||||
|
{success ? <div className="alert alert-success">{success}</div> : null}
|
||||||
|
{error ? <div className="alert alert-error">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{videos.length > 0 ? (
|
||||||
|
<div className="dash-toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 className="dash-section-title">Videos</h2>
|
||||||
|
<p className="dash-toolbar-hint">
|
||||||
|
MP4 uploads appear here. You can edit metadata, replace video files, update
|
||||||
|
thumbnails, or delete videos anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="dash-toolbar-aside">
|
||||||
|
<VideoEditorModal mode="upload" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{videos.length === 0 ? (
|
||||||
|
<div className="dash-empty">
|
||||||
|
<h2 className="dash-empty-title">No videos yet</h2>
|
||||||
|
<p className="dash-empty-text">
|
||||||
|
Publish a video now and see it live on your channel. Make sure to give it a nice
|
||||||
|
name, thumbnail and description.
|
||||||
|
</p>
|
||||||
|
<VideoEditorModal mode="upload" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="dash-video-list">
|
||||||
|
{videos.map((video) => (
|
||||||
|
<li key={video.id} className="dash-video-row">
|
||||||
|
<div className="dash-video-thumb">
|
||||||
|
{video.thumbnailFilename ? (
|
||||||
|
<Image
|
||||||
|
src={`/api/media/thumbnails/${video.id}`}
|
||||||
|
alt=""
|
||||||
|
className="dash-video-thumb-img"
|
||||||
|
width={160}
|
||||||
|
height={90}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="dash-video-thumb-placeholder" aria-hidden>
|
||||||
|
<svg
|
||||||
|
className="dash-video-thumb-placeholder-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dash-video-body">
|
||||||
|
<Link href={`/watch/${video.id}`} className="dash-video-title">
|
||||||
|
{video.title}
|
||||||
|
</Link>
|
||||||
|
{video.description ? (
|
||||||
|
<p className="dash-video-desc">{video.description}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="dash-video-meta">
|
||||||
|
<span>{formatDate(video.publishedAt)}</span>
|
||||||
|
<span className="dash-video-meta-sep" aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="mono">{formatBytes(video.videoSizeBytes)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dash-video-actions">
|
||||||
|
<Link href={`/watch/${video.id}`} className="btn btn-ghost btn-sm">
|
||||||
|
Open
|
||||||
|
</Link>
|
||||||
|
<VideoEditorModal
|
||||||
|
mode="edit"
|
||||||
|
videoId={video.id}
|
||||||
|
initialTitle={video.title}
|
||||||
|
initialDescription={video.description}
|
||||||
|
publishedAtLabel={formatDate(video.publishedAt)}
|
||||||
|
hasThumbnail={Boolean(video.thumbnailFilename)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { singleValue } from "@/lib/query";
|
||||||
|
import { getChannel } from "@/lib/setup";
|
||||||
|
|
||||||
|
type DashboardSettingsPageProps = {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DashboardSettingsPage({
|
||||||
|
searchParams,
|
||||||
|
}: DashboardSettingsPageProps) {
|
||||||
|
const [query, channel] = await Promise.all([searchParams, getChannel()]);
|
||||||
|
const success = singleValue(query.success);
|
||||||
|
const error = singleValue(query.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(success || error) && (
|
||||||
|
<div className="dashboard-alerts" role="status">
|
||||||
|
{success ? <div className="alert alert-success">{success}</div> : null}
|
||||||
|
{error ? <div className="alert alert-error">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="dash-settings" aria-labelledby="settings-heading">
|
||||||
|
<header className="dash-settings-head">
|
||||||
|
<h2 id="settings-heading" className="dash-section-title">
|
||||||
|
Channel profile
|
||||||
|
</h2>
|
||||||
|
<p className="dash-toolbar-hint">
|
||||||
|
Edits here update your public channel: the home page header, watch pages, and anywhere the
|
||||||
|
channel name appears.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form action="/api/channel" method="post" className="dash-settings-form">
|
||||||
|
<div className="dash-settings-fields">
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-label-row">
|
||||||
|
<label htmlFor="channelName" className="field-label">
|
||||||
|
Display name
|
||||||
|
</label>
|
||||||
|
<span className="field-label-meta">Max 80</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="channelName"
|
||||||
|
name="channelName"
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
defaultValue={channel?.name ?? ""}
|
||||||
|
className="field-input"
|
||||||
|
autoComplete="organization"
|
||||||
|
aria-describedby="channelName-hint"
|
||||||
|
/>
|
||||||
|
<p id="channelName-hint" className="field-hint">
|
||||||
|
Shown next to your avatar and in navigation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-label-row">
|
||||||
|
<label htmlFor="channelDescription" className="field-label">
|
||||||
|
About
|
||||||
|
</label>
|
||||||
|
<span className="field-label-meta">Optional · max 500</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="channelDescription"
|
||||||
|
name="channelDescription"
|
||||||
|
rows={4}
|
||||||
|
maxLength={500}
|
||||||
|
defaultValue={channel?.description ?? ""}
|
||||||
|
className="field-input field-textarea"
|
||||||
|
placeholder="Appears in About this channel"
|
||||||
|
aria-describedby="channelDescription-hint"
|
||||||
|
/>
|
||||||
|
<p id="channelDescription-hint" className="field-hint">
|
||||||
|
Shown in the About this channel dialog on your public page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">Audience restriction</span>
|
||||||
|
<label htmlFor="channelIsAdult" className="flex items-start gap-3 text-sm text-[color:var(--foreground)]">
|
||||||
|
<input
|
||||||
|
id="channelIsAdult"
|
||||||
|
name="channelIsAdult"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={channel?.isAdult ?? false}
|
||||||
|
className="mt-0.5 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Mark this as an 18+ channel.
|
||||||
|
<span className="mt-1 block text-xs text-[color:var(--muted)]">
|
||||||
|
Visitors must confirm they are 18 or older on first visit to the channel home page.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dash-settings-footer">
|
||||||
|
<p className="dash-settings-footer-note">
|
||||||
|
Save to apply everywhere. There is no draft state.
|
||||||
|
</p>
|
||||||
|
<div className="dash-settings-footer-actions">
|
||||||
|
<Link href="/dashboard" className="btn btn-ghost btn-sm">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,20 +1,26 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { IBM_Plex_Mono, Roboto } from "next/font/google";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const roboto = Roboto({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-roboto",
|
||||||
|
weight: ["400", "500", "700"],
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const ibmPlexMono = IBM_Plex_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-ibm-plex-mono",
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "MeTube",
|
||||||
description: "Generated by create next app",
|
description: "Self-hosted video channel platform",
|
||||||
|
icons: {
|
||||||
|
icon: "/metube.png",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -25,9 +31,11 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${roboto.variable} ${ibmPlexMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col bg-[color:var(--background)] text-[color:var(--foreground)]">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { singleValue } from "@/lib/query";
|
||||||
|
import { isSetupComplete } from "@/lib/setup";
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
redirect("/setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (user) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = await searchParams;
|
||||||
|
const error = singleValue(query.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell flex flex-1 items-center py-12">
|
||||||
|
<section className="surface-card auth-panel rounded-3xl p-6 sm:p-9">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Welcome back</h1>
|
||||||
|
<p className="mt-3 text-sm text-[color:var(--muted)]">
|
||||||
|
After logging in, manage your <b>YOUR</b> channel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-error mt-5">{error}</div> : null}
|
||||||
|
|
||||||
|
<form action="/api/auth/login" method="post" className="mt-7 space-y-5">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="username" className="field-label">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={32}
|
||||||
|
className="field-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="password" className="field-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
maxLength={128}
|
||||||
|
className="field-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-deep btn-block">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-[color:var(--muted)]">
|
||||||
|
<Link href="/" className="font-medium text-[color:var(--brand)] hover:underline">
|
||||||
|
Back to channel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="page-shell flex flex-1 items-center py-12">
|
||||||
|
<section className="surface-card auth-panel rounded-3xl p-8 text-center">
|
||||||
|
<p className="mono mb-2 text-xs tracking-[0.2em] text-[color:var(--muted)]">404</p>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Video not found</h1>
|
||||||
|
<p className="mt-3 text-sm text-[color:var(--muted)] sm:text-base">
|
||||||
|
The video you requested does not exist on this channel.
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn btn-deep mt-8 inline-flex">
|
||||||
|
Return home
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,175 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
export default function Home() {
|
import { AboutChannelModal } from "@/components/AboutChannelModal";
|
||||||
return (
|
import { AccountMenuModal } from "@/components/AccountMenuModal";
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
import { ADULT_GATE_COOKIE_NAME } from "@/lib/channel";
|
||||||
<Image
|
import { formatDate } from "@/lib/format";
|
||||||
className="dark:invert"
|
import { db } from "@/lib/db";
|
||||||
src="/next.svg"
|
import { getChannel, requireSetup } from "@/lib/setup";
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
export const dynamic = "force-dynamic";
|
||||||
height={20}
|
|
||||||
priority
|
export default async function HomePage() {
|
||||||
/>
|
await requireSetup();
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
const [channel, user, cookieStore] = await Promise.all([
|
||||||
To get started, edit the page.tsx file.
|
getChannel(),
|
||||||
</h1>
|
getAuthenticatedUser(),
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
cookies(),
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
]);
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
if (!channel) {
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
channel.isAdult &&
|
||||||
|
cookieStore.get(ADULT_GATE_COOKIE_NAME)?.value !== channel.id
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<main className="page-shell home-page justify-center py-12 sm:py-16">
|
||||||
|
<section
|
||||||
|
className="age-gate home-glass home-hero page-hero mx-auto w-full max-w-lg rounded-3xl"
|
||||||
|
aria-labelledby="age-gate-title"
|
||||||
|
>
|
||||||
|
<div className="page-hero-inner flex flex-col items-center px-8 py-10 text-center sm:px-12 sm:py-14">
|
||||||
|
<p className="home-hero-eyebrow mb-3">Age confirmation</p>
|
||||||
|
<h1
|
||||||
|
id="age-gate-title"
|
||||||
|
className="mb-4 max-w-[22rem] text-balance text-2xl font-semibold tracking-tight sm:max-w-none sm:text-4xl"
|
||||||
>
|
>
|
||||||
Templates
|
This channel is marked 18+
|
||||||
</a>{" "}
|
</h1>
|
||||||
or the{" "}
|
<p className="max-w-md text-pretty text-[0.9375rem] leading-relaxed text-[color:var(--muted)] sm:text-base">
|
||||||
<a
|
You must confirm that you are 18 years or older before viewing this channel.
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</p>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
<div className="mt-9 flex w-full max-w-md flex-col gap-3 sm:mt-10 sm:flex-row sm:gap-4">
|
||||||
>
|
<form action="/api/channel/age-gate" method="post" className="min-w-0 flex-1">
|
||||||
Learning
|
<input type="hidden" name="decision" value="yes" />
|
||||||
</a>{" "}
|
<button type="submit" className="btn btn-primary h-11 w-full px-6">
|
||||||
center.
|
Yes, I am 18+
|
||||||
</p>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<form action="/api/channel/age-gate" method="post" className="min-w-0 flex-1">
|
||||||
<a
|
<input type="hidden" name="decision" value="no" />
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
<button type="submit" className="btn btn-secondary h-11 w-full px-6">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
No
|
||||||
target="_blank"
|
</button>
|
||||||
rel="noopener noreferrer"
|
</form>
|
||||||
>
|
</div>
|
||||||
<Image
|
</div>
|
||||||
className="dark:invert"
|
</section>
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = await db.video.findMany({
|
||||||
|
orderBy: {
|
||||||
|
publishedAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell home-page">
|
||||||
|
<section className="home-glass home-hero page-hero mb-10 overflow-hidden rounded-3xl">
|
||||||
|
<div className="page-hero-inner px-6 py-6 sm:px-10 sm:py-8">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="mb-2 text-3xl font-semibold tracking-tight sm:text-5xl">
|
||||||
|
{channel.name}
|
||||||
|
</h1>
|
||||||
|
{channel.description ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<AboutChannelModal channelName={channel.name} description={channel.description} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[color:var(--muted)]">
|
||||||
|
This channel has not added a description yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
|
{user ? (
|
||||||
|
<AccountMenuModal username={user.username} />
|
||||||
|
) : (
|
||||||
|
<Link href="/login" className="btn btn-deep">
|
||||||
|
Channel login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{videos.length === 0 ? (
|
||||||
|
<section className="home-glass home-empty rounded-2xl px-6 py-10 text-center text-[color:var(--muted)]">
|
||||||
|
<p className="text-base">No videos yet.</p>
|
||||||
|
<p className="mt-2 text-sm">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
Upload your first one from the{" "}
|
||||||
|
<Link href="/dashboard" className="font-medium text-[color:var(--brand)] underline-offset-2 hover:underline">
|
||||||
|
dashboard
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign in to upload from the dashboard."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="home-videos">
|
||||||
|
<h2 className="home-videos-heading">Latest uploads</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{videos.map((video) => (
|
||||||
|
<Link
|
||||||
|
key={video.id}
|
||||||
|
href={`/watch/${video.id}`}
|
||||||
|
className="home-video-card group overflow-hidden rounded-2xl transition-[transform,box-shadow,border-color] duration-200 motion-safe:hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<div className="aspect-video overflow-hidden thumb-placeholder">
|
||||||
|
{video.thumbnailFilename ? (
|
||||||
|
<Image
|
||||||
|
src={`/api/media/thumbnails/${video.id}`}
|
||||||
|
alt={video.title}
|
||||||
|
width={640}
|
||||||
|
height={360}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="thumb-placeholder flex h-full w-full items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="thumb-placeholder-label h-14 w-14 shrink-0 opacity-80"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-video-card-meta px-4 py-4">
|
||||||
|
<h3 className="line-clamp-2 text-base font-semibold leading-6 sm:text-lg">
|
||||||
|
{video.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mono mt-2 text-xs text-[color:var(--muted)]">
|
||||||
|
{formatDate(video.publishedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getChannel, isSetupComplete } from "@/lib/setup";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const RECENT_VIDEO_LIMIT = 20;
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await getChannel();
|
||||||
|
if (!channel) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = await db.video.findMany({
|
||||||
|
take: RECENT_VIDEO_LIMIT,
|
||||||
|
orderBy: {
|
||||||
|
publishedAt: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
publishedAt: true,
|
||||||
|
videoMimeType: true,
|
||||||
|
videoSizeBytes: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
const homeUrl = new URL("/", origin).toString();
|
||||||
|
const feedUrl = new URL("/rss", origin).toString();
|
||||||
|
|
||||||
|
const items = videos
|
||||||
|
.map((video) => {
|
||||||
|
const watchUrl = new URL(`/watch/${video.id}`, origin).toString();
|
||||||
|
const mediaUrl = new URL(`/api/media/videos/${video.id}`, origin).toString();
|
||||||
|
const description =
|
||||||
|
video.description?.trim() || `Watch on ${channel.name}.`;
|
||||||
|
|
||||||
|
return ` <item>
|
||||||
|
<title>${escapeXml(video.title)}</title>
|
||||||
|
<link>${escapeXml(watchUrl)}</link>
|
||||||
|
<guid isPermaLink="false">${escapeXml(video.id)}</guid>
|
||||||
|
<description>${escapeXml(description)}</description>
|
||||||
|
<pubDate>${video.publishedAt.toUTCString()}</pubDate>
|
||||||
|
<enclosure url="${escapeXml(mediaUrl)}" length="${video.videoSizeBytes}" type="${escapeXml(video.videoMimeType)}" />
|
||||||
|
</item>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const feedDescription =
|
||||||
|
channel.description?.trim() || `Recent uploads from ${channel.name}`;
|
||||||
|
|
||||||
|
const rss = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>${escapeXml(channel.name)}</title>
|
||||||
|
<link>${escapeXml(homeUrl)}</link>
|
||||||
|
<description>${escapeXml(feedDescription)}</description>
|
||||||
|
<atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />
|
||||||
|
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||||
|
${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return new Response(rss, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=300, stale-while-revalidate=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { singleValue } from "@/lib/query";
|
||||||
|
import { isSetupComplete } from "@/lib/setup";
|
||||||
|
|
||||||
|
type SetupPageProps = {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SetupPage({ searchParams }: SetupPageProps) {
|
||||||
|
if (await isSetupComplete()) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = await searchParams;
|
||||||
|
const error = singleValue(query.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell flex flex-1 items-center py-12">
|
||||||
|
<section className="surface-card auth-panel auth-panel-wide rounded-3xl p-6 sm:p-9">
|
||||||
|
<p className="mono mb-2 text-xs tracking-[0.2em] text-[color:var(--muted)]">
|
||||||
|
FIRST RUN SETUP
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Create your channel</h1>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[color:var(--muted)] sm:text-base">
|
||||||
|
This setup runs once per instance. You will create the channel identity and an admin
|
||||||
|
account used for uploads.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-error mt-5">{error}</div> : null}
|
||||||
|
|
||||||
|
<form action="/api/setup" method="post" className="mt-7 space-y-5">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="channelName" className="field-label">
|
||||||
|
Channel name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="channelName"
|
||||||
|
name="channelName"
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
className="field-input"
|
||||||
|
placeholder="My Self-Hosted Channel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="channelDescription" className="field-label">
|
||||||
|
Channel description (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="channelDescription"
|
||||||
|
name="channelDescription"
|
||||||
|
rows={4}
|
||||||
|
maxLength={500}
|
||||||
|
className="field-input field-textarea"
|
||||||
|
placeholder="What is this channel about?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="username" className="field-label">
|
||||||
|
Admin username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={32}
|
||||||
|
className="field-input"
|
||||||
|
placeholder="owner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="password" className="field-label">
|
||||||
|
Admin password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
maxLength={128}
|
||||||
|
className="field-input"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block">
|
||||||
|
Create channel and continue
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { getChannel, requireSetup } from "@/lib/setup";
|
||||||
|
|
||||||
|
type WatchPageProps = {
|
||||||
|
params: Promise<{ videoId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function WatchPage({ params }: WatchPageProps) {
|
||||||
|
await requireSetup();
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
const [channel, video, user] = await Promise.all([
|
||||||
|
getChannel(),
|
||||||
|
db.video.findUnique({
|
||||||
|
where: {
|
||||||
|
id: videoId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getAuthenticatedUser(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!channel || !video) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedLabel = `Published ${formatDate(video.publishedAt)}`;
|
||||||
|
const thumbnailSrc = video.thumbnailFilename
|
||||||
|
? `/api/media/thumbnails/${video.id}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="watch-page">
|
||||||
|
{/* Ambient background glow from thumbnail */}
|
||||||
|
{thumbnailSrc && (
|
||||||
|
<div className="watch-ambient" aria-hidden>
|
||||||
|
<Image
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="watch-ambient-img"
|
||||||
|
sizes="100vw"
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top navigation bar */}
|
||||||
|
<nav className="watch-nav">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="watch-nav-back"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span>{channel.name}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="watch-nav-actions">
|
||||||
|
{user ? (
|
||||||
|
<Link href="/dashboard" className="btn btn-secondary btn-sm">
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/login" className="btn btn-deep btn-sm">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Video player — full width, cinematic */}
|
||||||
|
<div className="watch-cinema">
|
||||||
|
<div className="watch-cinema-inner">
|
||||||
|
<video
|
||||||
|
src={`/api/media/videos/${video.id}`}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
poster={thumbnailSrc ?? undefined}
|
||||||
|
className="watch-player"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video info section */}
|
||||||
|
<div className="watch-info">
|
||||||
|
<div className="watch-info-inner">
|
||||||
|
{/* Title and meta */}
|
||||||
|
<div className="watch-info-primary">
|
||||||
|
<h1 className="watch-title">{video.title}</h1>
|
||||||
|
<div className="watch-meta-row">
|
||||||
|
<span className="watch-channel-name">{channel.name}</span>
|
||||||
|
<span className="watch-divider" aria-hidden />
|
||||||
|
<span className="mono watch-date">{publishedLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{video.description ? (
|
||||||
|
<div className="watch-description-card">
|
||||||
|
<p className="watch-description-label">Description</p>
|
||||||
|
<p className="watch-description-body">{video.description}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Modal } from "@/components/Modal";
|
||||||
|
|
||||||
|
type AboutChannelModalProps = {
|
||||||
|
channelName: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AboutChannelModal({ channelName, description }: AboutChannelModalProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setOpen(true)}>
|
||||||
|
About this channel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal open={open} onOpenChange={setOpen} title={channelName} size="md">
|
||||||
|
<p className="about-channel-body">{description}</p>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Modal } from "@/components/Modal";
|
||||||
|
|
||||||
|
type AccountMenuModalProps = {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountMenuModal({ username }: AccountMenuModalProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setOpen(true)}>
|
||||||
|
Account
|
||||||
|
<svg className="btn-chevron" viewBox="0 0 12 12" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M3 4.5 6 7.5 9 4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title="Signed in"
|
||||||
|
description={`You are logged in as @${username}.`}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="modal-stack">
|
||||||
|
<Link href="/dashboard" className="btn btn-primary btn-block" onClick={() => setOpen(false)}>
|
||||||
|
Open dashboard
|
||||||
|
</Link>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit" className="btn btn-danger-ghost btn-block">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
href: "/dashboard",
|
||||||
|
label: "Videos",
|
||||||
|
isActive: (pathname: string) => pathname === "/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/settings",
|
||||||
|
label: "Settings",
|
||||||
|
isActive: (pathname: string) => pathname.startsWith("/dashboard/settings"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardTabs() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="dashboard-tabs" aria-label="Dashboard sections">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = tab.isActive(pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
href={tab.href}
|
||||||
|
className={`dashboard-tab ${active ? "dashboard-tab-active" : ""}`}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef } from "react";
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
titleClassName?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
size?: "md" | "lg" | "xl";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
titleClassName,
|
||||||
|
children,
|
||||||
|
size = "md",
|
||||||
|
}: ModalProps) {
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (open && !el.open) {
|
||||||
|
el.showModal();
|
||||||
|
} else if (!open && el.open) {
|
||||||
|
el.close();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const dialogSizeClass =
|
||||||
|
size === "xl" ? " modal-dialog-xl" : size === "lg" ? " modal-dialog-lg" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={ref}
|
||||||
|
className={`modal-dialog${dialogSizeClass}`}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={description ? descId : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-panel surface-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="modal-header">
|
||||||
|
<div className="min-w-0 flex-1 pr-3">
|
||||||
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
className={titleClassName ? `modal-title ${titleClassName}` : "modal-title"}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description ? (
|
||||||
|
<p id={descId} className="modal-description">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close-btn"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Close dialog"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="modal-close-icon"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Modal } from "@/components/Modal";
|
||||||
|
|
||||||
|
type VideoDetailsModalProps = {
|
||||||
|
channelName: string;
|
||||||
|
videoTitle: string;
|
||||||
|
publishedLabel: string;
|
||||||
|
description: string | null;
|
||||||
|
thumbnailSrc: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VideoDetailsModal({
|
||||||
|
channelName,
|
||||||
|
videoTitle,
|
||||||
|
publishedLabel,
|
||||||
|
description,
|
||||||
|
thumbnailSrc,
|
||||||
|
}: VideoDetailsModalProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const metaLine = `${channelName} · ${publishedLabel}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setOpen(true)}>
|
||||||
|
Description
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title={videoTitle}
|
||||||
|
description={metaLine}
|
||||||
|
titleClassName="modal-title-video-details"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="video-details-panel">
|
||||||
|
{thumbnailSrc ? (
|
||||||
|
<div className="video-details-hero">
|
||||||
|
<Image
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="video-details-thumb"
|
||||||
|
sizes="(max-width: 480px) calc(100vw - 2rem), min(40rem, calc(100vw - 2rem))"
|
||||||
|
/>
|
||||||
|
<div className="video-details-hero-edge" aria-hidden />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="video-details-prose">
|
||||||
|
<p className="video-details-section-label">About this video</p>
|
||||||
|
{description ? (
|
||||||
|
<p className="video-details-body">{description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="video-details-empty">No description was added for this upload.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
|
import { Modal } from "@/components/Modal";
|
||||||
|
import { VideoFormPreview } from "@/components/VideoFormPreview";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
|
||||||
|
type UploadVideoEditorModalProps = {
|
||||||
|
mode: "upload";
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditVideoEditorModalProps = {
|
||||||
|
mode: "edit";
|
||||||
|
videoId: string;
|
||||||
|
initialTitle: string;
|
||||||
|
initialDescription: string | null;
|
||||||
|
publishedAtLabel: string;
|
||||||
|
hasThumbnail: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VideoEditorModalProps = UploadVideoEditorModalProps | EditVideoEditorModalProps;
|
||||||
|
|
||||||
|
export function VideoEditorModal(props: VideoEditorModalProps) {
|
||||||
|
const isEditMode = props.mode === "edit";
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState(isEditMode ? props.initialTitle : "");
|
||||||
|
const [description, setDescription] = useState(
|
||||||
|
isEditMode ? props.initialDescription ?? "" : "",
|
||||||
|
);
|
||||||
|
const [thumbnailObjectUrl, setThumbnailObjectUrl] = useState<string | null>(null);
|
||||||
|
const inputIdPrefix = useId();
|
||||||
|
const formId = `${inputIdPrefix}-video-form`;
|
||||||
|
|
||||||
|
const remoteThumbnailSrc =
|
||||||
|
isEditMode && props.hasThumbnail ? `/api/media/thumbnails/${props.videoId}` : null;
|
||||||
|
const previewThumbnailSrc = thumbnailObjectUrl ?? remoteThumbnailSrc;
|
||||||
|
const previewDate = isEditMode ? props.publishedAtLabel : formatDate(new Date());
|
||||||
|
|
||||||
|
const resetThumbnailPreview = () => {
|
||||||
|
setThumbnailObjectUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (isEditMode && nextOpen) {
|
||||||
|
setTitle(props.initialTitle);
|
||||||
|
setDescription(props.initialDescription ?? "");
|
||||||
|
resetThumbnailPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditMode && !nextOpen) {
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
resetThumbnailPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (thumbnailObjectUrl) {
|
||||||
|
URL.revokeObjectURL(thumbnailObjectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [thumbnailObjectUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isEditMode ? "btn btn-ghost btn-sm" : "btn btn-primary btn-sm"}
|
||||||
|
onClick={() => handleOpenChange(true)}
|
||||||
|
>
|
||||||
|
{isEditMode ? "Edit" : "Upload video"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
title={isEditMode ? "Edit video" : "New video"}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="video-editor-shell">
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
action={isEditMode ? `/api/videos/${props.videoId}` : "/api/videos"}
|
||||||
|
method="post"
|
||||||
|
encType="multipart/form-data"
|
||||||
|
className="modal-form video-editor-form"
|
||||||
|
>
|
||||||
|
{isEditMode ? <input type="hidden" name="intent" value="update" /> : null}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor={`${inputIdPrefix}-title`} className="field-label">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${inputIdPrefix}-title`}
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
maxLength={140}
|
||||||
|
placeholder="Title"
|
||||||
|
className="field-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor={`${inputIdPrefix}-description`} className="field-label">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`${inputIdPrefix}-description`}
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
maxLength={5000}
|
||||||
|
placeholder="Optional"
|
||||||
|
className="field-input field-textarea video-editor-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor={`${inputIdPrefix}-video`} className="field-label">
|
||||||
|
{isEditMode ? "Video (MP4)" : "Video"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${inputIdPrefix}-video`}
|
||||||
|
name="video"
|
||||||
|
type="file"
|
||||||
|
required={!isEditMode}
|
||||||
|
accept="video/mp4,.mp4"
|
||||||
|
className="field-file"
|
||||||
|
/>
|
||||||
|
{isEditMode ? (
|
||||||
|
<p className="field-hint">Leave blank to keep the current file.</p>
|
||||||
|
) : (
|
||||||
|
<p className="field-hint">MP4 only.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor={`${inputIdPrefix}-thumbnail`} className="field-label">
|
||||||
|
Thumbnail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${inputIdPrefix}-thumbnail`}
|
||||||
|
name="thumbnail"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
|
||||||
|
className="field-file"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
setThumbnailObjectUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return file ? URL.createObjectURL(file) : null;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="field-hint">JPG, PNG, or WebP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VideoFormPreview
|
||||||
|
title={title}
|
||||||
|
thumbnailSrc={previewThumbnailSrc}
|
||||||
|
dateLabel={previewDate}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="modal-actions video-editor-actions">
|
||||||
|
{isEditMode ? (
|
||||||
|
<form
|
||||||
|
action={`/api/videos/${props.videoId}`}
|
||||||
|
method="post"
|
||||||
|
className="video-editor-delete"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
if (!window.confirm("Delete this video permanently? This cannot be undone.")) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<button type="submit" className="btn btn-danger-ghost btn-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="video-editor-actions-main">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="submit" form={formId} className="btn btn-primary btn-sm">
|
||||||
|
{isEditMode ? "Save" : "Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
type VideoFormPreviewProps = {
|
||||||
|
title: string;
|
||||||
|
thumbnailSrc: string | null;
|
||||||
|
dateLabel: string;
|
||||||
|
emptyTitleFallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VideoFormPreview({
|
||||||
|
title,
|
||||||
|
thumbnailSrc,
|
||||||
|
dateLabel,
|
||||||
|
emptyTitleFallback = "Untitled",
|
||||||
|
}: VideoFormPreviewProps) {
|
||||||
|
const labelId = useId();
|
||||||
|
const displayTitle = title.trim() || emptyTitleFallback;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="video-form-preview" aria-labelledby={labelId}>
|
||||||
|
<span id={labelId} className="video-form-preview-label">
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
<div className="video-form-preview-body">
|
||||||
|
<div className="video-form-preview-thumb">
|
||||||
|
{thumbnailSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- blob URLs and same-origin API URLs
|
||||||
|
<img src={thumbnailSrc} alt="" className="video-form-preview-img" />
|
||||||
|
) : (
|
||||||
|
<div className="video-form-preview-placeholder" aria-hidden>
|
||||||
|
<svg
|
||||||
|
className="video-form-preview-placeholder-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="video-form-preview-text">
|
||||||
|
<p className="video-form-preview-title">{displayTitle}</p>
|
||||||
|
<p className="video-form-preview-date mono">{dateLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import argon2 from "argon2";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { jwtVerify, SignJWT } from "jose";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export const SESSION_COOKIE_NAME = "metube_session";
|
||||||
|
|
||||||
|
const SESSION_EXPIRATION_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
type SessionPayload = {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSessionSecret(): Uint8Array {
|
||||||
|
const developmentFallback =
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? "metube-dev-session-secret-change-me-12345"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rawSecret = process.env.SESSION_SECRET ?? developmentFallback;
|
||||||
|
|
||||||
|
if (!rawSecret || rawSecret.length < 32) {
|
||||||
|
throw new Error("SESSION_SECRET must be set and at least 32 characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextEncoder().encode(rawSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_EXPIRATION_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return argon2.hash(password, {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
hash: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await argon2.verify(hash, password);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionToken(
|
||||||
|
userId: string,
|
||||||
|
username: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return new SignJWT({ username })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setSubject(userId)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${SESSION_EXPIRATION_SECONDS}s`)
|
||||||
|
.sign(getSessionSecret());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySessionToken(token: string): Promise<SessionPayload | null> {
|
||||||
|
try {
|
||||||
|
const verified = await jwtVerify(token, getSessionSecret());
|
||||||
|
const username = verified.payload.username;
|
||||||
|
|
||||||
|
if (typeof verified.payload.sub !== "string" || typeof username !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub: verified.payload.sub,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionPayload(): Promise<SessionPayload | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifySessionToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthenticatedUser() {
|
||||||
|
const session = await getSessionPayload();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: session.sub,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuthenticatedUser() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const ADULT_GATE_COOKIE_NAME = "metube_age_verified";
|
||||||
|
|
||||||
|
const ADULT_GATE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
export function getAdultGateCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: ADULT_GATE_COOKIE_MAX_AGE_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL must be set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
pool: Pool | undefined;
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = globalForPrisma.pool ?? new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
export const db = globalForPrisma.prisma ?? new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.pool = pool;
|
||||||
|
globalForPrisma.prisma = db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function formatDate(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function singleValue(input: string | string[] | undefined): string | undefined {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { ensureStorageDirectories } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const PRIMARY_CHANNEL_ID = "channel";
|
||||||
|
|
||||||
|
export async function isSetupComplete(): Promise<boolean> {
|
||||||
|
const [channelCount, userCount] = await Promise.all([
|
||||||
|
db.channel.count(),
|
||||||
|
db.user.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return channelCount > 0 && userCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireSetup(): Promise<void> {
|
||||||
|
if (!(await isSetupComplete())) {
|
||||||
|
redirect("/setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureStorageDirectories();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannel() {
|
||||||
|
return db.channel.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
|
||||||
|
export const VIDEOS_DIRECTORY = path.join(process.cwd(), "videos");
|
||||||
|
export const THUMBNAILS_DIRECTORY = path.join(process.cwd(), "thumbnails");
|
||||||
|
|
||||||
|
export async function ensureStorageDirectories(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(VIDEOS_DIRECTORY, { recursive: true }),
|
||||||
|
mkdir(THUMBNAILS_DIRECTORY, { recursive: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoPath(filename: string): string {
|
||||||
|
return path.join(VIDEOS_DIRECTORY, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThumbnailPath(filename: string): string {
|
||||||
|
return path.join(THUMBNAILS_DIRECTORY, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThumbnailExtension(file: File): string | null {
|
||||||
|
const normalizedType = file.type.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedType === "image/jpeg") {
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === "image/png") {
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === "image/webp") {
|
||||||
|
return ".webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = file.name.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedName.endsWith(".jpg") || normalizedName.endsWith(".jpeg")) {
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.endsWith(".png")) {
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.endsWith(".webp")) {
|
||||||
|
return ".webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||