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