Inital commit

This commit is contained in:
Owen Qwen
2026-04-14 14:39:13 -05:00
parent f7a9dbf9c9
commit b91c8822ac
56 changed files with 5837 additions and 141 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
node_modules
.next
npm-debug.log*
.env
.env.*
videos
thumbnails
+8
View File
@@ -39,3 +39,11 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
# local media uploads
/videos/*
!/videos/.gitkeep
/thumbnails/*
!/thumbnails/.gitkeep
+20
View File
@@ -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
View File
@@ -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).
## 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.
# MeTube
+39
View File
@@ -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:
+3 -1
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+1386 -17
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -6,12 +6,22 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio"
},
"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",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -20,6 +30,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"prisma": "^7.7.0",
"tailwindcss": "^4",
"typescript": "^5"
}
+14
View File
@@ -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"],
},
});
+52
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
provider = "postgresql"
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

-1
View File
@@ -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
View File
@@ -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
View File
@@ -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

+67
View File
@@ -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;
}
+18
View File
@@ -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;
}
+48
View File
@@ -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);
}
+70
View File
@@ -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 });
}
}
+138
View File
@@ -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",
},
});
}
+89
View File
@@ -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.",
);
}
}
+227
View File
@@ -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.");
}
}
+159
View File
@@ -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.");
}
}
+48
View File
@@ -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>
);
}
+122
View File
@@ -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>
)}
</>
);
}
+122
View File
@@ -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>
</>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+1676 -12
View File
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -1,20 +1,26 @@
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";
const geistSans = Geist({
variable: "--font-geist-sans",
const roboto = Roboto({
variable: "--font-roboto",
weight: ["400", "500", "700"],
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const ibmPlexMono = IBM_Plex_Mono({
variable: "--font-ibm-plex-mono",
weight: ["400", "500", "600"],
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "MeTube",
description: "Self-hosted video channel platform",
icons: {
icon: "/metube.png",
},
};
export default function RootLayout({
@@ -25,9 +31,11 @@ export default function RootLayout({
return (
<html
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>
);
}
+80
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+165 -55
View File
@@ -1,65 +1,175 @@
import Link from "next/link";
import Image from "next/image";
import { cookies } from "next/headers";
export default function Home() {
import { AboutChannelModal } from "@/components/AboutChannelModal";
import { AccountMenuModal } from "@/components/AccountMenuModal";
import { getAuthenticatedUser } from "@/lib/auth";
import { ADULT_GATE_COOKIE_NAME } from "@/lib/channel";
import { formatDate } from "@/lib/format";
import { db } from "@/lib/db";
import { getChannel, requireSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
export default async function HomePage() {
await requireSetup();
const [channel, user, cookieStore] = await Promise.all([
getChannel(),
getAuthenticatedUser(),
cookies(),
]);
if (!channel) {
return null;
}
if (
channel.isAdult &&
cookieStore.get(ADULT_GATE_COOKIE_NAME)?.value !== channel.id
) {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<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">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<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">
To get started, edit the page.tsx file.
<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"
>
This channel is marked 18+
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
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"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="max-w-md text-pretty text-[0.9375rem] leading-relaxed text-[color:var(--muted)] sm:text-base">
You must confirm that you are 18 years or older before viewing this channel.
</p>
<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">
<input type="hidden" name="decision" value="yes" />
<button type="submit" className="btn btn-primary h-11 w-full px-6">
Yes, I am 18+
</button>
</form>
<form action="/api/channel/age-gate" method="post" className="min-w-0 flex-1">
<input type="hidden" name="decision" value="no" />
<button type="submit" className="btn btn-secondary h-11 w-full px-6">
No
</button>
</form>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
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]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
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>
</section>
</main>
);
}
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>
</div>
);
}
+86
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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",
},
});
}
+103
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+26
View File
@@ -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>
</>
);
}
+51
View File
@@ -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>
</>
);
}
+40
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+67
View File
@@ -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>
</>
);
}
+213
View File
@@ -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>
</>
);
}
+50
View File
@@ -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>
);
}
+126
View File
@@ -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;
}
+13
View File
@@ -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,
};
}
+24
View File
@@ -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;
}
+22
View File
@@ -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]}`;
}
+7
View File
@@ -0,0 +1,7 @@
export function singleValue(input: string | string[] | undefined): string | undefined {
if (Array.isArray(input)) {
return input[0];
}
return input;
}
+31
View File
@@ -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",
},
});
}
+52
View File
@@ -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;
}
View File
View File