Initial WatchLink scaffold
This commit is contained in:
69
.codex/project.md
Normal file
69
.codex/project.md
Normal 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
10
.dockerignore
Normal 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
7
.env.example
Normal 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"
|
||||
43
.gitea/workflows/build.yml
Normal file
43
.gitea/workflows/build.yml
Normal 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"
|
||||
27
.gitea/workflows/dependency-check.yml
Normal file
27
.gitea/workflows/dependency-check.yml
Normal 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
|
||||
33
.gitea/workflows/release-dry-run.yml
Normal file
33
.gitea/workflows/release-dry-run.yml
Normal 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
|
||||
21
.gitea/workflows/repo-cleanup.yml
Normal file
21
.gitea/workflows/repo-cleanup.yml
Normal 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
|
||||
32
.gitea/workflows/security-scan.yml
Normal file
32
.gitea/workflows/security-scan.yml
Normal 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
|
||||
29
.gitea/workflows/template-compliance.yml
Normal file
29
.gitea/workflows/template-compliance.yml
Normal 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
14
.gitignore
vendored
Normal 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
52
AGENTS.md
Normal 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
5
CHANGELOG.md
Normal 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
14
CONTRIBUTING.md
Normal 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
26
Dockerfile
Normal 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
83
README.md
Normal 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
17
SECURITY.md
Normal 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
9
blueprint.json
Normal 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
36
blueprint.md
Normal 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
32
docker-compose.yml
Normal 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
11
docs/agent-handoff.md
Normal 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.
|
||||
BIN
docs/design/watchlink-dashboard-concept.png
Normal file
BIN
docs/design/watchlink-dashboard-concept.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
9
docs/release-checklist.md
Normal file
9
docs/release-checklist.md
Normal 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
5
docs/release-notes.md
Normal 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
17
docs/security-review.md
Normal 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
5
next-env.d.ts
vendored
Normal 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
11
next.config.mjs
Normal 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
47
package.json
Normal 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
134
prisma/schema.prisma
Normal 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
70
server.js
Normal 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
65
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/app/dashboard/page.tsx
Normal file
85
src/app/dashboard/page.tsx
Normal 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
37
src/app/friends/page.tsx
Normal 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
402
src/app/globals.css
Normal 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
15
src/app/layout.tsx
Normal 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
31
src/app/login/page.tsx
Normal 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
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
31
src/app/register/page.tsx
Normal file
31
src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/rooms/[slug]/page.tsx
Normal file
24
src/app/rooms/[slug]/page.tsx
Normal 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
123
src/app/setup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/app-shell.tsx
Normal file
35
src/components/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/room-console.tsx
Normal file
131
src/components/room-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/components/status-badge.tsx
Normal file
5
src/components/status-badge.tsx
Normal 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
37
src/lib/access.ts
Normal 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
93
src/lib/media.ts
Normal 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
13
src/lib/prisma.ts
Normal 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
38
src/lib/sample-data.ts
Normal 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
51
src/lib/session.ts
Normal 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
59
src/lib/user-actions.ts
Normal 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
18
tests/access.test.ts
Normal 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
22
tests/media.test.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user