Initial WatchLink scaffold
Some checks failed
Build / build (push) Failing after 1m29s
Release Dry Run / release-dry-run (push) Successful in 1m24s
Template Compliance / compliance (push) Failing after 5s

This commit is contained in:
MrSphay
2026-05-15 03:11:41 +02:00
commit d3e84feedd
51 changed files with 2215 additions and 0 deletions

69
.codex/project.md Normal file
View File

@@ -0,0 +1,69 @@
# Codex Project Notes
## Project
`WatchLink` is a Dockerized Next.js + Postgres web app for persistent shared watch rooms with accounts, friends, roles, permissions, admin setup, and realtime playback sync.
Repository:
```text
MrSphay/WatchLink
```
## Commands
```text
Install: npm install
Dev: npm run dev
Lint: npm run lint
Typecheck: npm run typecheck
Test: npm run test
Build: npm run build
Audit: npm run audit
Release check: npm run release:check
Docker: docker compose up --build
```
## Stack
```text
Next.js App Router, React, TypeScript, Prisma, Postgres, Socket.IO, Docker
```
Package manager:
```text
npm
```
## Build Artifacts
Next.js standalone build output:
```text
.next/standalone
```
Docker image:
```text
git.wilkensxl.de/MrSphay/watchlink:latest
```
## Security Rules
- Do not commit secrets, `.env` files, tokens, private keys, or certificates.
- Use `.env.example` for documentation only.
- Review `docs/security-review.md` before release work.
- Keep package publishing secrets in Gitea secrets as `REGISTRY_TOKEN`.
## Release Rules
Before a release:
1. run `npm run release:check`,
2. verify Docker build,
3. verify Gitea Actions are green,
4. verify the pushed container image can be pulled,
5. update README and changelog,
6. create tags/releases only when explicitly requested.

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.next
.git
.kit
.env
.env.*
!.env.example
coverage
*.log
docs/agent-handoff.md

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
DATABASE_URL="postgresql://watchlink:watchlink@localhost:5432/watchlink?schema=public"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="change-me-with-openssl-rand-base64-32"
POSTGRES_DB="watchlink"
POSTGRES_USER="watchlink"
POSTGRES_PASSWORD="watchlink"
PORT="3000"

View File

@@ -0,0 +1,43 @@
name: Build
on:
push:
branches:
- main
- master
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE_NAME: git.wilkensxl.de/MrSphay/watchlink:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm install
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test
- name: Build
run: npm run build
- name: Build Docker image
run: docker build -t "$IMAGE_NAME" .
- name: Publish Docker image
if: ${{ secrets.REGISTRY_TOKEN != '' }}
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.wilkensxl.de -u "${{ gitea.actor }}" --password-stdin
docker push "$IMAGE_NAME"

View File

@@ -0,0 +1,27 @@
name: Dependency Check
on:
schedule:
- cron: "43 5 * * 3"
workflow_dispatch:
jobs:
dependencies:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm install
- name: Audit
run: npm audit --omit=dev --audit-level=high
- name: Outdated report
run: npm outdated || true

View File

@@ -0,0 +1,33 @@
name: Release Dry Run
on:
push:
branches:
- main
- master
workflow_dispatch:
jobs:
release-dry-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check release docs
run: test -f docs/release-checklist.md && test -f docs/security-review.md && test -f CHANGELOG.md
- name: Check unresolved placeholders
run: |
! grep -RInE "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND" -- . --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.next
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm install
- name: Release check
run: npm run release:check

View File

@@ -0,0 +1,21 @@
name: Repository Cleanup Report
on:
schedule:
- cron: "29 4 * * 2"
workflow_dispatch:
jobs:
report:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Report generated files
run: |
find . -maxdepth 3 \( -path "./node_modules" -o -path "./.next" -o -path "./coverage" -o -path "./dist" \) -print
- name: Report large files
run: |
find . -type f -size +10M -not -path "./.git/*" -print

View File

@@ -0,0 +1,32 @@
name: Security Scan
on:
schedule:
- cron: "17 3 * * 1"
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm install
- name: Audit dependencies
run: npm audit --omit=dev --audit-level=high
- name: Scan secret-prone files
run: |
! find . -type f \( -name ".env" -o -name "*.pem" -o -name "*.key" \) -not -path "./node_modules/*" | grep .
- name: Scan instruction-injection markers
run: |
! grep -RInE "ignore previous instructions|system prompt|developer message" -- . --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.next

View File

@@ -0,0 +1,29 @@
name: Template Compliance
on:
push:
pull_request:
workflow_dispatch:
jobs:
compliance:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Required files
run: |
test -f AGENTS.md
test -f .codex/project.md
test -f README.md
test -f SECURITY.md
test -f CHANGELOG.md
test -f .gitignore
- name: Placeholder scan
run: |
! grep -RInE "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|PACKAGE_NAME|ARTIFACT_NAME|ARTIFACT_OUTPUT_DIRECTORY" -- AGENTS.md .codex README.md docs .gitea || exit 1
- name: README divider
run: grep -q "rainbow.png" README.md

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
node_modules/
.next/
dist/
build/
coverage/
*.log
.env
.env.*
!.env.example
docker-compose.override.yml
.DS_Store
Thumbs.db
.kit/
package-registry/

52
AGENTS.md Normal file
View File

@@ -0,0 +1,52 @@
# Agent Instructions
## Project
WatchLink is a Dockerized Next.js + Postgres web app for persistent shared watch rooms. It supports local accounts, friend relationships, role and permission management, first-run admin setup, and synchronized playback for YouTube, Twitch, and direct video URLs.
## Repository Rules
- Use `codex-agent-repository-kit` conventions. This repository was initialized from `https://git.wilkensxl.de/MrSphay/codex-agent-repository-kit.git`.
- At the start of every task, check `git status --short --branch`. If an upstream remote exists and the working tree is clean, use a safe fast-forward pull.
- Preserve unrelated user changes. Do not rewrite history or run destructive git commands unless explicitly requested.
- Do not commit secrets, `.env` files, private keys, certificates, or tokens.
- Keep `.codex/project.md` aligned when commands, artifact paths, or release rules change.
- Do not create a release unless explicitly requested.
- Gitea target: `git.wilkensxl.de/MrSphay/WatchLink`.
- Docker image target: `git.wilkensxl.de/MrSphay/watchlink:latest`.
## Commands
```bash
npm install
npm run dev
npm run typecheck
npm run test
npm run build
npm run audit
npm run release:check
docker compose up --build
```
## Architecture Notes
- Next.js App Router lives under `src/app`.
- Shared UI components live under `src/components`.
- Domain helpers live under `src/lib`.
- Prisma schema lives in `prisma/schema.prisma`.
- The custom `server.js` hosts Next.js and Socket.IO at `/api/socket`.
- System theme is handled by CSS variables and `prefers-color-scheme`.
## Security Notes
- `NEXTAUTH_SECRET` must be changed in production.
- `DATABASE_URL` must not be committed outside `.env.example`.
- Passwords are hashed with bcrypt.
- Sessions are signed HTTP-only cookies.
- CI publishing uses `REGISTRY_TOKEN`; never commit it.
## Finish Checklist
- `git diff --check` passes.
- Run the cheapest reliable verification command available in the environment.
- If a pushed Gitea workflow starts, poll it until success or report a concrete blocker.

5
CHANGELOG.md Normal file
View File

@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- Initial WatchLink scaffold with Next.js, Prisma, Socket.IO, Docker, and Gitea repository baseline.

14
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,14 @@
# Contributing
## Local Workflow
1. Install dependencies with `npm install`.
2. Copy `.env.example` to `.env`.
3. Run `npm run db:push` for local development databases.
4. Run `npm run typecheck`, `npm run test`, and `npm run build` before opening a pull request.
## Rules
- Keep changes scoped.
- Do not commit generated dependency folders or local environment files.
- Update README and `.codex/project.md` when commands or deployment behavior changes.

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json ./
RUN npm install
FROM node:22-bookworm-slim AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/server.js ./server.js
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# WatchLink
WatchLink is a self-hosted shared-watch web app with persistent user rooms, local accounts, friends, role-based administration, and realtime playback state.
<p align="center"><img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="-----------------------------------------------------" width="100%"></p>
## Features
- First-run setup creates the first admin user.
- Username/password accounts with bcrypt password hashing.
- Every user gets a stable personal room like `/rooms/@username`.
- Friend requests and room access are modeled for friends-only, public, role-restricted, and explicit access.
- Admin surface for rooms, users, roles, and permissions.
- Shared playback state via Socket.IO.
- Media URL normalization for YouTube, Twitch, and direct video URLs.
- Uptime Kuma/Dockge-inspired app shell with system light/dark theme.
- Docker Compose stack for app and Postgres.
<p align="center"><img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="-----------------------------------------------------" width="100%"></p>
## Development
```bash
npm install
cp .env.example .env
npm run db:push
npm run dev
```
Open `http://localhost:3000/setup` on a clean database to create the first admin.
Useful commands:
```bash
npm run typecheck
npm run test
npm run build
npm run audit
```
<p align="center"><img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="-----------------------------------------------------" width="100%"></p>
## Docker
```bash
docker compose up --build
```
The Compose stack exposes the web app on `http://localhost:3000` and uses a Postgres volume named `watchlink_postgres-data`.
Build and publish the Gitea image:
```bash
docker build -t git.wilkensxl.de/MrSphay/watchlink:latest .
docker login git.wilkensxl.de
docker push git.wilkensxl.de/MrSphay/watchlink:latest
```
<p align="center"><img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="-----------------------------------------------------" width="100%"></p>
## Gitea
Target repository:
```text
git@git.wilkensxl.de:MrSphay/WatchLink.git
```
Target container image:
```text
git.wilkensxl.de/MrSphay/watchlink:latest
```
CI expects `REGISTRY_TOKEN` to be configured as a repository or organization secret when image publishing should run.
<p align="center"><img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="-----------------------------------------------------" width="100%"></p>
## Project Info
- Stack: Next.js, React, TypeScript, Prisma, Postgres, Socket.IO, Docker
- Repository baseline: `codex-agent-repository-kit`
- License: not declared yet

17
SECURITY.md Normal file
View File

@@ -0,0 +1,17 @@
# Security Policy
## Supported Version
WatchLink is pre-release. Security fixes apply to the current `main` branch.
## Reporting
Report vulnerabilities privately to the repository owner. Do not open public issues for secrets, authentication bypasses, or data exposure.
## Baseline Rules
- Do not commit `.env`, tokens, private keys, certificates, or database dumps.
- Change `NEXTAUTH_SECRET` before production use.
- Use a strong Postgres password in production.
- Store Gitea registry credentials in repository or organization secrets.
- Review `docs/security-review.md` before release work.

9
blueprint.json Normal file
View File

@@ -0,0 +1,9 @@
{
"variables": {
"name": "WatchLink",
"description": "Persistent shared watch rooms with accounts, friends, roles, and admin controls."
},
"templates": {
"section-line": "<p align=\"center\"><img src=\"https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png\" alt=\"-----------------------------------------------------\" width=\"100%\"></p>"
}
}

36
blueprint.md Normal file
View File

@@ -0,0 +1,36 @@
# WatchLink
WatchLink is a self-hosted shared-watch web app with persistent user rooms, local accounts, friends, role-based administration, and realtime playback state.
{{ template:section-line }}
## Features
- First-run admin setup
- Username/password accounts
- Persistent user rooms
- Friend graph and access modes
- Shared playback state for YouTube, Twitch, and direct video URLs
- Uptime Kuma/Dockge-inspired app shell
- Dockerized Next.js and Postgres stack
{{ template:section-line }}
## Development
```bash
npm install
cp .env.example .env
npm run db:push
npm run dev
```
{{ template:section-line }}
## Docker
```bash
docker compose up --build
```
Target image: `git.wilkensxl.de/MrSphay/watchlink:latest`

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-watchlink}
POSTGRES_USER: ${POSTGRES_USER:-watchlink}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-watchlink}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-watchlink} -d ${POSTGRES_DB:-watchlink}"]
interval: 10s
timeout: 5s
retries: 5
web:
build: .
image: git.wilkensxl.de/MrSphay/watchlink:latest
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-watchlink}:${POSTGRES_PASSWORD:-watchlink}@postgres:5432/${POSTGRES_DB:-watchlink}?schema=public
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-me-with-openssl-rand-base64-32}
PORT: 3000
ports:
- "3000:3000"
command: sh -c "npx prisma migrate deploy && node server.js"
volumes:
postgres-data:

11
docs/agent-handoff.md Normal file
View File

@@ -0,0 +1,11 @@
# Agent Handoff
## Current State
Initial implementation created from `codex-agent-repository-kit` guidance.
## Notes
- The kit was read and applied as a source template, not kept as a project dependency.
- npm and Docker were not available in the initial local shell, so dependency installation and Docker verification may need a machine with those tools in PATH.
- `GITEA_TOKEN` may be available locally, but it must not be printed or committed.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,9 @@
# Release Checklist
- [ ] `npm run release:check` passes.
- [ ] `docker compose up --build` starts app and Postgres.
- [ ] First setup works on a clean database.
- [ ] Gitea Actions are green.
- [ ] Container image `git.wilkensxl.de/MrSphay/watchlink:latest` is pushed and pull-tested.
- [ ] README and CHANGELOG are current.
- [ ] No `.env`, tokens, private keys, or local secrets are tracked.

5
docs/release-notes.md Normal file
View File

@@ -0,0 +1,5 @@
# Release Notes
## 0.1.0 Draft
Initial pre-release of WatchLink with persistent rooms, account setup, dashboard UI, Prisma schema, Socket.IO realtime server, and Docker packaging.

17
docs/security-review.md Normal file
View File

@@ -0,0 +1,17 @@
# Security Review
## Scope
WatchLink handles user accounts, password hashes, friendship data, room access rules, media URLs, and realtime playback events.
## Current Controls
- Passwords are hashed with bcrypt.
- Sessions use HTTP-only signed cookies.
- Prisma models enforce uniqueness for users, friendships, and room slugs.
- `.env` files are ignored except `.env.example`.
- Container publishing expects Gitea `REGISTRY_TOKEN` as a secret.
## Release Review Notes
Fill this section during release readiness work with commands run, CI links, audit results, and any accepted risks.

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// This file is generated by Next.js in normal installs. It is checked in here
// so a fresh clone has correct TypeScript references before the first build.

11
next.config.mjs Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
experimental: {
serverActions: {
allowedOrigins: ["localhost:3000"]
}
}
};
export default nextConfig;

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "watchlink",
"version": "0.1.0",
"private": true,
"description": "Persistent shared watch rooms with accounts, friends, roles, and admin controls.",
"scripts": {
"dev": "node server.js",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "prisma generate && next build",
"start": "NODE_ENV=production node server.js",
"audit": "npm audit --omit=dev --audit-level=high",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:push": "prisma db push",
"release:check": "npm run typecheck && npm run test && npm run build"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.4",
"@prisma/client": "^6.1.0",
"bcryptjs": "^2.4.3",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"prisma": "^6.1.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
},
"engines": {
"node": ">=22.0.0"
}
}

134
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,134 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum RoleScope {
SYSTEM
ROOM
}
enum FriendStatus {
PENDING
ACCEPTED
DECLINED
BLOCKED
}
enum RoomVisibility {
PUBLIC
FRIENDS
ROLE_RESTRICTED
EXPLICIT
}
enum MediaProvider {
YOUTUBE
TWITCH
DIRECT
UNKNOWN
}
model User {
id String @id @default(cuid())
username String @unique
passwordHash String
displayName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
ownedRooms Room[] @relation("RoomOwner")
sentFriends Friendship[] @relation("FriendRequester")
gotFriends Friendship[] @relation("FriendReceiver")
roomMembers RoomMember[]
submitted MediaSource[]
}
model Role {
id String @id @default(cuid())
name String @unique
description String?
scope RoleScope @default(SYSTEM)
createdAt DateTime @default(now())
users UserRole[]
permissions RolePermission[]
}
model Permission {
id String @id @default(cuid())
key String @unique
description String?
roles RolePermission[]
}
model UserRole {
userId String
roleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
}
model RolePermission {
roleId String
permissionId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
}
model Friendship {
id String @id @default(cuid())
requesterId String
receiverId String
status FriendStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requester User @relation("FriendRequester", fields: [requesterId], references: [id], onDelete: Cascade)
receiver User @relation("FriendReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@unique([requesterId, receiverId])
}
model Room {
id String @id @default(cuid())
slug String @unique
name String
ownerId String?
visibility RoomVisibility @default(FRIENDS)
currentState Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User? @relation("RoomOwner", fields: [ownerId], references: [id], onDelete: SetNull)
members RoomMember[]
mediaSources MediaSource[]
}
model RoomMember {
roomId String
userId String
canManage Boolean @default(false)
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([roomId, userId])
}
model MediaSource {
id String @id @default(cuid())
roomId String
submitterId String?
provider MediaProvider
originalUrl String
playbackUrl String
title String?
createdAt DateTime @default(now())
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
}

70
server.js Normal file
View File

@@ -0,0 +1,70 @@
const { createServer } = require("node:http");
const next = require("next");
const { Server } = require("socket.io");
const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOSTNAME || "0.0.0.0";
const port = Number(process.env.PORT || 3000);
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
const roomStates = new Map();
app.prepare().then(() => {
const httpServer = createServer((req, res) => handle(req, res));
const io = new Server(httpServer, {
path: "/api/socket",
cors: {
origin: process.env.NEXTAUTH_URL || "http://localhost:3000"
}
});
io.on("connection", (socket) => {
socket.on("room:join", ({ roomSlug, user }) => {
if (!roomSlug) return;
socket.join(roomSlug);
socket.data.roomSlug = roomSlug;
socket.data.user = user || "Guest";
socket.emit("room:state", roomStates.get(roomSlug) || null);
socket.to(roomSlug).emit("presence:join", { user: socket.data.user });
});
socket.on("media:set", (payload) => updateRoom(socket, "media:set", payload));
socket.on("playback:play", (payload) => updateRoom(socket, "playback:play", payload));
socket.on("playback:pause", (payload) => updateRoom(socket, "playback:pause", payload));
socket.on("playback:seek", (payload) => updateRoom(socket, "playback:seek", payload));
socket.on("chat:message", (payload) => relayRoom(socket, "chat:message", payload));
socket.on("disconnect", () => {
if (socket.data.roomSlug) {
socket.to(socket.data.roomSlug).emit("presence:leave", { user: socket.data.user || "Guest" });
}
});
});
function relayRoom(socket, event, payload) {
const roomSlug = socket.data.roomSlug;
if (!roomSlug) return;
io.to(roomSlug).emit(event, { ...payload, user: socket.data.user || "Guest", at: Date.now() });
}
function updateRoom(socket, event, payload) {
const roomSlug = socket.data.roomSlug;
if (!roomSlug) return;
const previous = roomStates.get(roomSlug) || {};
const nextState = {
...previous,
...payload,
lastEvent: event,
updatedBy: socket.data.user || "Guest",
updatedAt: Date.now()
};
roomStates.set(roomSlug, nextState);
io.to(roomSlug).emit(event, nextState);
}
httpServer.listen(port, hostname, () => {
console.log(`WatchLink ready on http://${hostname}:${port}`);
});
});

65
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { AppShell } from "@/components/app-shell";
import { StatusBadge } from "@/components/status-badge";
import { SYSTEM_PERMISSIONS } from "@/lib/access";
import { rooms } from "@/lib/sample-data";
export default function AdminPage() {
return (
<AppShell active="Admin">
<header className="topbar">
<div className="title-block">
<h1>Admin</h1>
<p>Manage roles, rooms, permissions, and users.</p>
</div>
<StatusBadge tone="good">Admin</StatusBadge>
</header>
<section className="room-layout">
<section className="panel">
<div className="panel-header">
<h2>Rooms</h2>
<button className="button primary">Create room</button>
</div>
<div className="panel-body">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Access</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.name}>
<td>{room.name}</td>
<td>{room.owner}</td>
<td>{room.visibility}</td>
<td>{room.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2>Permissions</h2>
<StatusBadge>Roles</StatusBadge>
</div>
<div className="panel-body">
{SYSTEM_PERMISSIONS.map((permission) => (
<div className="row" key={permission}>
<div className="row-title">
<strong>{permission}</strong>
<span>Assignable to roles</span>
</div>
<StatusBadge>Enabled</StatusBadge>
</div>
))}
</div>
</section>
</section>
</AppShell>
);
}

View File

@@ -0,0 +1,85 @@
import { AppShell } from "@/components/app-shell";
import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
import { getCurrentUser } from "@/lib/session";
import { dashboardStats, friends, rooms } from "@/lib/sample-data";
export default async function DashboardPage() {
const user = await getCurrentUser();
return (
<AppShell active="Dashboard">
<header className="topbar">
<div className="title-block">
<h1>Dashboard</h1>
<p>{user ? `Signed in as ${user.username}` : "Persistent rooms, friends, and shared playback state."}</p>
</div>
<div className="status-row">
<StatusBadge tone="good">Online</StatusBadge>
<StatusBadge>System theme</StatusBadge>
</div>
</header>
<section className="stats-grid" aria-label="System overview">
{dashboardStats.map((stat) => (
<div className="stat" key={stat.label}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
</div>
))}
</section>
<RoomConsole roomSlug="@admin" />
<section className="room-layout" style={{ marginTop: 18 }}>
<section className="panel">
<div className="panel-header">
<h2>Rooms</h2>
<StatusBadge>Persistent</StatusBadge>
</div>
<div className="panel-body">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Access</th>
<th>Status</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.name}>
<td>{room.name}</td>
<td>{room.owner}</td>
<td>{room.visibility}</td>
<td>{room.status}</td>
<td>{room.source}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2>Friends</h2>
<StatusBadge tone="good">3 linked</StatusBadge>
</div>
<div className="panel-body">
{friends.map((friend) => (
<div className="row" key={friend.name}>
<div className="row-title">
<strong>{friend.name}</strong>
<span>{friend.room}</span>
</div>
<StatusBadge tone={friend.state === "Online" ? "good" : undefined}>{friend.state}</StatusBadge>
</div>
))}
</div>
</section>
</section>
</AppShell>
);
}

37
src/app/friends/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { AppShell } from "@/components/app-shell";
import { StatusBadge } from "@/components/status-badge";
import { friends } from "@/lib/sample-data";
export default function FriendsPage() {
return (
<AppShell active="Friends">
<header className="topbar">
<div className="title-block">
<h1>Friends</h1>
<p>Add users, accept requests, and enter persistent rooms.</p>
</div>
<button className="button primary">Add friend</button>
</header>
<section className="panel">
<div className="panel-header">
<h2>Friend graph</h2>
<StatusBadge>Username search</StatusBadge>
</div>
<div className="panel-body">
{friends.map((friend) => (
<div className="row" key={friend.name}>
<div className="row-title">
<strong>{friend.name}</strong>
<span>{friend.room}</span>
</div>
<div className="status-row">
<StatusBadge tone={friend.state === "Online" ? "good" : undefined}>{friend.state}</StatusBadge>
<button className="button">Enter room</button>
</div>
</div>
))}
</div>
</section>
</AppShell>
);
}

402
src/app/globals.css Normal file
View File

@@ -0,0 +1,402 @@
:root {
color-scheme: light dark;
--bg: #f5f7fb;
--panel: #ffffff;
--panel-2: #f9fafb;
--text: #18212f;
--muted: #687487;
--border: #d7dde7;
--accent: #16a34a;
--accent-2: #0891b2;
--warn: #d97706;
--danger: #dc2626;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f141b;
--panel: #151b23;
--panel-2: #10161d;
--text: #e5edf7;
--muted: #8b97a8;
--border: #263241;
--accent: #22c55e;
--accent-2: #06b6d4;
--warn: #f59e0b;
--danger: #f87171;
--shadow: 0 18px 45px rgba(0, 0, 0, 0.24);
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
}
.app-shell {
display: grid;
min-height: 100vh;
grid-template-columns: 248px 1fr;
}
.sidebar {
border-right: 1px solid var(--border);
background: var(--panel);
padding: 20px 14px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 8px 24px;
font-weight: 750;
font-size: 20px;
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.nav-list {
display: grid;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
border-radius: 8px;
padding: 10px 12px;
color: var(--muted);
font-size: 14px;
font-weight: 650;
}
.nav-item.active,
.nav-item:hover {
background: var(--panel-2);
color: var(--text);
}
.main {
min-width: 0;
padding: 18px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title-block h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
.title-block p {
margin: 4px 0 0;
color: var(--muted);
font-size: 14px;
}
.status-row,
.stats-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 18px;
}
.stat,
.panel,
.auth-card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.stat {
padding: 14px;
}
.stat span,
.eyebrow {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.stat strong {
display: block;
margin-top: 6px;
font-size: 26px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 5px 9px;
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.badge.good {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
color: var(--accent);
}
.badge.warn {
border-color: color-mix(in srgb, var(--warn) 45%, var(--border));
color: var(--warn);
}
.room-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 18px;
}
.panel {
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
padding: 12px 14px;
}
.panel-header h2,
.panel-header h3 {
margin: 0;
font-size: 15px;
}
.panel-body {
padding: 14px;
}
.video-frame {
display: grid;
min-height: 430px;
place-items: center;
background:
linear-gradient(135deg, rgba(34, 197, 94, 0.08), transparent 36%),
#05070a;
}
.video-state {
width: min(520px, 86%);
border: 1px solid #1f2937;
border-radius: 8px;
padding: 22px;
background: rgba(15, 23, 42, 0.82);
color: #e5edf7;
}
.video-state h2 {
margin: 0 0 10px;
font-size: 24px;
}
.controls {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 10px;
border-top: 1px solid var(--border);
padding: 12px;
}
.button,
.icon-button {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
color: var(--text);
padding: 8px 12px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.button.primary {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 18%, var(--panel));
color: var(--accent);
}
.input {
min-width: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
padding: 8px 10px;
}
.stack {
display: grid;
gap: 12px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
padding: 10px 0;
}
.row:last-child {
border-bottom: 0;
}
.row-title {
display: grid;
gap: 3px;
}
.row-title strong {
font-size: 14px;
}
.row-title span {
color: var(--muted);
font-size: 12px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th,
.table td {
border-bottom: 1px solid var(--border);
padding: 10px;
text-align: left;
}
.table th {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
}
.auth-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
}
.auth-card {
width: min(440px, 100%);
padding: 22px;
}
.form {
display: grid;
gap: 12px;
}
.form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
@media (max-width: 1100px) {
.room-layout {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
}
.nav-list {
display: flex;
overflow-x: auto;
}
.nav-item {
white-space: nowrap;
}
.stats-grid,
.controls {
grid-template-columns: 1fr;
}
}

15
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "WatchLink",
description: "Persistent shared watch rooms for friends and teams."
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

31
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,31 @@
import Link from "next/link";
import { loginUser } from "@/lib/user-actions";
export default function LoginPage() {
return (
<main className="auth-page">
<section className="auth-card">
<div className="title-block" style={{ marginBottom: 18 }}>
<h1>Login</h1>
<p>Enter WatchLink with your username and password.</p>
</div>
<form className="form" action={loginUser}>
<label>
Username
<input className="input" name="username" autoComplete="username" required />
</label>
<label>
Password
<input className="input" name="password" type="password" autoComplete="current-password" required />
</label>
<button className="button primary" type="submit">
Login
</button>
<Link className="button" href="/register">
Create account
</Link>
</form>
</section>
</main>
);
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/dashboard");
}

31
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,31 @@
import Link from "next/link";
import { registerUser } from "@/lib/user-actions";
export default function RegisterPage() {
return (
<main className="auth-page">
<section className="auth-card">
<div className="title-block" style={{ marginBottom: 18 }}>
<h1>Create account</h1>
<p>Register a username and get a persistent room.</p>
</div>
<form className="form" action={registerUser}>
<label>
Username
<input className="input" name="username" autoComplete="username" required />
</label>
<label>
Password
<input className="input" name="password" type="password" autoComplete="new-password" minLength={10} required />
</label>
<button className="button primary" type="submit">
Create account
</button>
<Link className="button" href="/login">
Login instead
</Link>
</form>
</section>
</main>
);
}

View File

@@ -0,0 +1,24 @@
import { AppShell } from "@/components/app-shell";
import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
export default async function RoomPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const roomSlug = decodeURIComponent(slug);
return (
<AppShell active="Rooms">
<header className="topbar">
<div className="title-block">
<h1>{roomSlug}</h1>
<p>Stable room address with shared playback for authorized users.</p>
</div>
<div className="status-row">
<StatusBadge tone="good">Online</StatusBadge>
<StatusBadge>All participants may control</StatusBadge>
</div>
</header>
<RoomConsole roomSlug={roomSlug} />
</AppShell>
);
}

123
src/app/setup/page.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { redirect } from "next/navigation";
import { hash } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { SYSTEM_PERMISSIONS } from "@/lib/access";
import { setSession } from "@/lib/session";
async function hasAdmin() {
try {
const admin = await prisma.userRole.findFirst({
where: { role: { name: "admin" } },
select: { userId: true }
});
return Boolean(admin);
} catch {
return false;
}
}
async function createFirstAdmin(formData: FormData) {
"use server";
const username = String(formData.get("username") || "").trim().toLowerCase();
const password = String(formData.get("password") || "");
if (!username || password.length < 10) {
throw new Error("Username is required and password must be at least 10 characters.");
}
const existingAdmin = await prisma.userRole.findFirst({
where: { role: { name: "admin" } },
select: { userId: true }
});
if (existingAdmin) {
redirect("/login");
}
const passwordHash = await hash(password, 12);
const user = await prisma.$transaction(async (tx) => {
const permissions = await Promise.all(
SYSTEM_PERMISSIONS.map((key) =>
tx.permission.upsert({
where: { key },
update: {},
create: { key, description: key }
})
)
);
const adminRole = await tx.role.upsert({
where: { name: "admin" },
update: {},
create: {
name: "admin",
description: "Full system administrator"
}
});
await Promise.all(
permissions.map((permission) =>
tx.rolePermission.upsert({
where: { roleId_permissionId: { roleId: adminRole.id, permissionId: permission.id } },
update: {},
create: { roleId: adminRole.id, permissionId: permission.id }
})
)
);
const createdUser = await tx.user.create({
data: {
username,
displayName: username,
passwordHash
}
});
await tx.userRole.create({ data: { userId: createdUser.id, roleId: adminRole.id } });
await tx.room.create({
data: {
slug: `@${username}`,
name: `${username}'s room`,
ownerId: createdUser.id,
visibility: "FRIENDS"
}
});
return createdUser;
});
await setSession(user.id);
redirect("/dashboard");
}
export default async function SetupPage() {
if (await hasAdmin()) {
redirect("/login");
}
return (
<main className="auth-page">
<section className="auth-card">
<div className="title-block" style={{ marginBottom: 18 }}>
<h1>WatchLink first setup</h1>
<p>Create the first admin account. This screen locks after setup.</p>
</div>
<form className="form" action={createFirstAdmin}>
<label>
Username
<input className="input" name="username" autoComplete="username" required />
</label>
<label>
Password
<input className="input" name="password" type="password" autoComplete="new-password" minLength={10} required />
</label>
<button className="button primary" type="submit">
Create admin
</button>
</form>
</section>
</main>
);
}

View File

@@ -0,0 +1,35 @@
import Link from "next/link";
import { Gauge, MonitorPlay, Shield, UserRoundPlus, UsersRound } from "lucide-react";
const nav = [
{ href: "/dashboard", label: "Dashboard", icon: Gauge },
{ href: "/rooms/@admin", label: "Rooms", icon: MonitorPlay },
{ href: "/friends", label: "Friends", icon: UsersRound },
{ href: "/admin", label: "Admin", icon: Shield },
{ href: "/setup", label: "Setup", icon: UserRoundPlus }
];
export function AppShell({ children, active = "Dashboard" }: { children: React.ReactNode; active?: string }) {
return (
<div className="app-shell">
<aside className="sidebar">
<Link className="brand" href="/dashboard">
<span className="brand-mark">WL</span>
<span>WatchLink</span>
</Link>
<nav className="nav-list" aria-label="Primary">
{nav.map((item) => {
const Icon = item.icon;
return (
<Link key={item.href} href={item.href} className={`nav-item ${active === item.label ? "active" : ""}`}>
<Icon size={17} />
{item.label}
</Link>
);
})}
</nav>
</aside>
<main className="main">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useMemo, useState } from "react";
import { Pause, Play, Radio, SkipForward } from "lucide-react";
import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media";
import { activity, participants, queue } from "@/lib/sample-data";
import { StatusBadge } from "./status-badge";
const socket = io({
path: "/api/socket",
autoConnect: false
});
export function RoomConsole({ roomSlug = "@admin" }: { roomSlug?: string }) {
const [connected, setConnected] = useState(false);
const [source, setSource] = useState("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
const media = useMemo(() => normalizeMediaUrl(source), [source]);
function connect() {
if (!socket.connected) {
socket.connect();
socket.emit("room:join", { roomSlug, user: "Admin" });
}
setConnected(true);
}
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek") {
connect();
socket.emit(event, {
provider: media.provider,
originalUrl: media.originalUrl,
playbackUrl: media.playbackUrl,
position: event === "playback:seek" ? 82 : undefined
});
}
return (
<div className="room-layout">
<section className="panel">
<div className="panel-header">
<div>
<h2>{roomSlug}</h2>
<span className="eyebrow">Persistent room</span>
</div>
<StatusBadge tone={connected ? "good" : "warn"}>{connected ? "Online" : "Local preview"}</StatusBadge>
</div>
<div className="video-frame">
<div className="video-state">
<StatusBadge tone="good">{media.provider}</StatusBadge>
<h2>Shared playback state</h2>
<p>
Participants in this room can set the source, play, pause, and seek. Late joiners receive the latest room
state from the realtime server.
</p>
<p className="eyebrow">{media.playbackUrl}</p>
</div>
</div>
<div className="controls">
<button className="button primary" onClick={() => emit("playback:play")}>
<Play size={16} /> Play
</button>
<button className="button" onClick={() => emit("playback:pause")}>
<Pause size={16} /> Pause
</button>
<input
className="input"
aria-label="Source URL"
value={source}
onChange={(event) => setSource(event.target.value)}
placeholder="Source URL"
/>
<button className="button" onClick={() => emit("media:set")}>
<Radio size={16} /> Source URL
</button>
<button className="button" onClick={() => emit("playback:seek")}>
<SkipForward size={16} /> Seek
</button>
</div>
</section>
<aside className="stack">
<Panel title="Queue">
{queue.map((item) => (
<div className="row" key={item.title}>
<div className="row-title">
<strong>{item.title}</strong>
<span>
{item.provider} by {item.by}
</span>
</div>
<StatusBadge>{item.duration}</StatusBadge>
</div>
))}
</Panel>
<Panel title="Participants">
{participants.map((item) => (
<div className="row" key={item.name}>
<div className="row-title">
<strong>{item.name}</strong>
<span>{item.role}</span>
</div>
<StatusBadge tone={item.status === "Buffering" ? "warn" : "good"}>{item.status}</StatusBadge>
</div>
))}
</Panel>
<Panel title="Activity">
{activity.map((item) => (
<div className="row" key={item}>
<div className="row-title">
<strong>{item}</strong>
<span>just now</span>
</div>
</div>
))}
</Panel>
</aside>
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="panel">
<div className="panel-header">
<h3>{title}</h3>
</div>
<div className="panel-body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,5 @@
import { clsx } from "clsx";
export function StatusBadge({ children, tone = "neutral" }: { children: React.ReactNode; tone?: string }) {
return <span className={clsx("badge", tone)}>{children}</span>;
}

37
src/lib/access.ts Normal file
View File

@@ -0,0 +1,37 @@
export type RoomVisibility = "PUBLIC" | "FRIENDS" | "ROLE_RESTRICTED" | "EXPLICIT";
type AccessInput = {
visibility: RoomVisibility;
isOwner?: boolean;
isAdmin?: boolean;
isFriend?: boolean;
hasRoomRole?: boolean;
explicitMember?: boolean;
};
export function canEnterRoom(input: AccessInput) {
if (input.isAdmin || input.isOwner) return true;
switch (input.visibility) {
case "PUBLIC":
return true;
case "FRIENDS":
return Boolean(input.isFriend || input.explicitMember);
case "ROLE_RESTRICTED":
return Boolean(input.hasRoomRole || input.explicitMember);
case "EXPLICIT":
return Boolean(input.explicitMember);
default:
return false;
}
}
export const SYSTEM_PERMISSIONS = [
"admin.users.manage",
"admin.roles.manage",
"admin.rooms.manage",
"rooms.create",
"rooms.manage.own",
"rooms.media.control",
"friends.manage"
] as const;

93
src/lib/media.ts Normal file
View File

@@ -0,0 +1,93 @@
import { z } from "zod";
export type NormalizedMedia = {
provider: "YOUTUBE" | "TWITCH" | "DIRECT" | "UNKNOWN";
originalUrl: string;
playbackUrl: string;
title?: string;
};
const urlSchema = z.string().url();
const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"]);
const TWITCH_HOSTS = new Set(["twitch.tv", "www.twitch.tv", "m.twitch.tv"]);
const DIRECT_VIDEO_EXTENSIONS = [".mp4", ".webm", ".ogg", ".mov", ".m4v"];
export function normalizeMediaUrl(input: string): NormalizedMedia {
const originalUrl = input.trim();
const parsedInput = urlSchema.safeParse(originalUrl);
if (!parsedInput.success) {
return {
provider: "UNKNOWN",
originalUrl,
playbackUrl: originalUrl
};
}
const url = new URL(parsedInput.data);
const host = url.hostname.toLowerCase();
if (YOUTUBE_HOSTS.has(host)) {
const id = getYoutubeId(url);
if (id) {
return {
provider: "YOUTUBE",
originalUrl,
playbackUrl: `https://www.youtube.com/embed/${id}?enablejsapi=1&origin=${encodeURIComponent(
process.env.NEXTAUTH_URL || "http://localhost:3000"
)}`
};
}
}
if (TWITCH_HOSTS.has(host)) {
const parts = url.pathname.split("/").filter(Boolean);
const parent = new URL(process.env.NEXTAUTH_URL || "http://localhost:3000").hostname;
if (parts[0] === "videos" && parts[1]) {
return {
provider: "TWITCH",
originalUrl,
playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=${parent}`
};
}
if (parts[0]) {
return {
provider: "TWITCH",
originalUrl,
playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=${parent}`
};
}
}
if (DIRECT_VIDEO_EXTENSIONS.some((extension) => url.pathname.toLowerCase().endsWith(extension))) {
return {
provider: "DIRECT",
originalUrl,
playbackUrl: originalUrl
};
}
return {
provider: "UNKNOWN",
originalUrl,
playbackUrl: originalUrl
};
}
function getYoutubeId(url: URL) {
if (url.hostname === "youtu.be") {
return url.pathname.split("/").filter(Boolean)[0];
}
if (url.pathname === "/watch") {
return url.searchParams.get("v");
}
const parts = url.pathname.split("/").filter(Boolean);
if (["embed", "shorts", "live"].includes(parts[0])) {
return parts[1];
}
return null;
}

13
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"]
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

38
src/lib/sample-data.ts Normal file
View File

@@ -0,0 +1,38 @@
export const dashboardStats = [
{ label: "Online", value: "12", tone: "good" },
{ label: "Active rooms", value: "5", tone: "info" },
{ label: "Pending friends", value: "3", tone: "warn" },
{ label: "Queue items", value: "18", tone: "neutral" }
];
export const rooms = [
{ name: "@maria", owner: "Maria", visibility: "Friends", status: "Live", source: "YouTube" },
{ name: "@admin", owner: "Admin", visibility: "Role", status: "Idle", source: "Twitch" },
{ name: "Friday Ops", owner: "Ops", visibility: "Public", status: "Live", source: "Direct" }
];
export const friends = [
{ name: "Maria", state: "Online", room: "@maria" },
{ name: "Jens", state: "Away", room: "@jens" },
{ name: "Aylin", state: "Offline", room: "@aylin" }
];
export const queue = [
{ title: "Build stream recap", provider: "YouTube", by: "Maria", duration: "12:40" },
{ title: "Dockge deployment notes", provider: "Twitch", by: "Admin", duration: "Live" },
{ title: "Local media sample", provider: "Direct", by: "Jens", duration: "03:20" }
];
export const participants = [
{ name: "Admin", role: "Admin", status: "Host" },
{ name: "Maria", role: "Member", status: "Synced" },
{ name: "Jens", role: "Member", status: "Synced" },
{ name: "Aylin", role: "Guest", status: "Buffering" }
];
export const activity = [
"Maria set a YouTube source",
"Admin seeked to 01:22",
"Jens joined @admin",
"Aylin requested friendship"
];

51
src/lib/session.ts Normal file
View File

@@ -0,0 +1,51 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "./prisma";
const COOKIE_NAME = "watchlink_session";
function secret() {
return process.env.NEXTAUTH_SECRET || "development-only-change-me";
}
function sign(value: string) {
return createHmac("sha256", secret()).update(value).digest("base64url");
}
export async function setSession(userId: string) {
const cookieStore = await cookies();
const value = `${userId}.${sign(userId)}`;
cookieStore.set(COOKIE_NAME, value, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 30
});
}
export async function clearSession() {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
}
export async function getCurrentUser() {
const cookieStore = await cookies();
const raw = cookieStore.get(COOKIE_NAME)?.value;
if (!raw) return null;
const [userId, signature] = raw.split(".");
if (!userId || !signature) return null;
const expected = sign(userId);
const expectedBuffer = Buffer.from(expected);
const actualBuffer = Buffer.from(signature);
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
return null;
}
return prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } }
});
}

59
src/lib/user-actions.ts Normal file
View File

@@ -0,0 +1,59 @@
"use server";
import { compare, hash } from "bcryptjs";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { setSession } from "./session";
function normalizeUsername(value: FormDataEntryValue | null) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "");
}
export async function registerUser(formData: FormData) {
const username = normalizeUsername(formData.get("username"));
const password = String(formData.get("password") || "");
if (!username || password.length < 10) {
throw new Error("Username is required and password must be at least 10 characters.");
}
const passwordHash = await hash(password, 12);
const user = await prisma.user.create({
data: {
username,
displayName: username,
passwordHash,
ownedRooms: {
create: {
slug: `@${username}`,
name: `${username}'s room`,
visibility: "FRIENDS"
}
}
}
});
await setSession(user.id);
redirect("/dashboard");
}
export async function loginUser(formData: FormData) {
const username = normalizeUsername(formData.get("username"));
const password = String(formData.get("password") || "");
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
throw new Error("Invalid username or password.");
}
const ok = await compare(password, user.passwordHash);
if (!ok) {
throw new Error("Invalid username or password.");
}
await setSession(user.id);
redirect("/dashboard");
}

18
tests/access.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { canEnterRoom } from "../src/lib/access";
describe("canEnterRoom", () => {
it("allows public rooms", () => {
expect(canEnterRoom({ visibility: "PUBLIC" })).toBe(true);
});
it("allows owners and admins regardless of room visibility", () => {
expect(canEnterRoom({ visibility: "EXPLICIT", isOwner: true })).toBe(true);
expect(canEnterRoom({ visibility: "ROLE_RESTRICTED", isAdmin: true })).toBe(true);
});
it("requires accepted friend or explicit membership for friends-only rooms", () => {
expect(canEnterRoom({ visibility: "FRIENDS" })).toBe(false);
expect(canEnterRoom({ visibility: "FRIENDS", isFriend: true })).toBe(true);
});
});

22
tests/media.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { normalizeMediaUrl } from "../src/lib/media";
describe("normalizeMediaUrl", () => {
it("normalizes YouTube watch urls", () => {
const media = normalizeMediaUrl("https://www.youtube.com/watch?v=abc123");
expect(media.provider).toBe("YOUTUBE");
expect(media.playbackUrl).toContain("/embed/abc123");
});
it("normalizes Twitch channels", () => {
const media = normalizeMediaUrl("https://www.twitch.tv/example");
expect(media.provider).toBe("TWITCH");
expect(media.playbackUrl).toContain("channel=example");
});
it("detects direct video sources", () => {
const media = normalizeMediaUrl("https://cdn.example.com/video.mp4");
expect(media.provider).toBe("DIRECT");
expect(media.playbackUrl).toBe("https://cdn.example.com/video.mp4");
});
});

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "es2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}