Initial WatchLink scaffold
This commit is contained in:
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");
|
||||
}
|
||||
Reference in New Issue
Block a user