Add player controls and configurable profiles
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 1m34s
Build / build (push) Successful in 11m47s
Template Compliance / compliance (push) Successful in 5s

This commit is contained in:
MrSphay
2026-05-15 21:36:22 +02:00
parent 9fbd79c7ef
commit 7a5cc2f64b
27 changed files with 592 additions and 56 deletions

View File

@@ -5,3 +5,4 @@ POSTGRES_DB="watchlink"
POSTGRES_USER="watchlink" POSTGRES_USER="watchlink"
POSTGRES_PASSWORD="watchlink" POSTGRES_PASSWORD="watchlink"
HOST_PORT="3000" HOST_PORT="3000"
# Uploaded avatars are stored in the Docker volume `avatar-uploads`.

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ docker-compose.override.yml
Thumbs.db Thumbs.db
.kit/ .kit/
package-registry/ package-registry/
public/uploads/

View File

@@ -26,6 +26,7 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
RUN mkdir -p /app/public/uploads/avatars && chown -R nextjs:nodejs /app/public/uploads
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -11,6 +11,8 @@ WatchLink is a self-hosted shared-watch web app with persistent user rooms, loca
- Every user gets a stable personal room like `/rooms/@username`. - 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. - Friend requests and room access are modeled for friends-only, public, role-restricted, and explicit access.
- Admin surface for rooms, users, roles, and permissions. - Admin surface for rooms, users, roles, and permissions.
- Site-wide instance settings for registration mode, default room visibility, media providers, and avatar upload limits.
- Profile settings with display name and profile picture upload.
- Shared playback state via Socket.IO. - Shared playback state via Socket.IO.
- Media URL normalization for YouTube, Twitch, and direct video URLs. - Media URL normalization for YouTube, Twitch, and direct video URLs.
- Uptime Kuma/Dockge-inspired app shell with system light/dark theme. - Uptime Kuma/Dockge-inspired app shell with system light/dark theme.
@@ -94,10 +96,13 @@ services:
PORT: 3000 PORT: 3000
ports: ports:
- "${HOST_PORT:-3000}:3000" - "${HOST_PORT:-3000}:3000"
volumes:
- avatar-uploads:/app/public/uploads
command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js" command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js"
volumes: volumes:
postgres-data: postgres-data:
avatar-uploads:
``` ```
Start the stack: Start the stack:
@@ -106,7 +111,7 @@ Start the stack:
docker compose up --build docker compose up --build
``` ```
The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` and uses a Postgres volume named `watchlink_postgres-data`. Keep the container `PORT` at `3000`; change `HOST_PORT` when another container already uses port 3000 on the host. The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` and uses named volumes for Postgres data and uploaded avatars. Keep the container `PORT` at `3000`; change `HOST_PORT` when another container already uses port 3000 on the host.
On first start, the web container runs `prisma migrate deploy` before starting Next.js. This creates the required tables in a clean Postgres volume. On first start, the web container runs `prisma migrate deploy` before starting Next.js. This creates the required tables in a clean Postgres volume.

View File

@@ -26,7 +26,10 @@ services:
PORT: 3000 PORT: 3000
ports: ports:
- "${HOST_PORT:-3000}:3000" - "${HOST_PORT:-3000}:3000"
volumes:
- avatar-uploads:/app/public/uploads
command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js" command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js"
volumes: volumes:
postgres-data: postgres-data:
avatar-uploads:

View File

@@ -0,0 +1,9 @@
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT;
CREATE TABLE "AppSetting" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key")
);

View File

@@ -38,6 +38,7 @@ model User {
username String @unique username String @unique
passwordHash String passwordHash String
displayName String? displayName String?
avatarUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
roles UserRole[] roles UserRole[]
@@ -48,6 +49,12 @@ model User {
submitted MediaSource[] submitted MediaSource[]
} }
model AppSetting {
key String @id
value String
updatedAt DateTime @updatedAt
}
model Role { model Role {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique

View File

@@ -41,7 +41,7 @@ export default async function ProfilePage() {
<section className="split-grid"> <section className="split-grid">
<Panel title="Account" eyebrow="Identity"> <Panel title="Account" eyebrow="Identity">
<div className="profile-block"> <div className="profile-block">
<Avatar name={user.displayName || user.username} size={64} /> <Avatar name={user.displayName || user.username} src={user.avatarUrl} size={64} />
<div className="row-title"> <div className="row-title">
<strong>{user.displayName || user.username}</strong> <strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span> <span>@{user.username}</span>

View File

@@ -1,40 +1,59 @@
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui"; import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui";
import { updateProfile } from "@/lib/profile-actions";
import { getShellContext } from "@/lib/shell"; import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session"; import { requireCurrentUser } from "@/lib/session";
import { getAppSettings } from "@/lib/settings";
import { requireInitialSetup } from "@/lib/setup"; import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AccountSettingsPage() { export default async function AccountSettingsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
await requireInitialSetup(); await requireInitialSetup();
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const shell = await getShellContext(user); const shell = await getShellContext(user);
const settings = await getAppSettings();
const { error, saved } = await searchParams;
return ( return (
<AppShell active="Overview" {...shell}> <AppShell active="Overview" {...shell}>
<PageHeader <PageHeader
title="Settings" title="Settings"
description="Account preferences and session options. Controls stay disabled until matching server actions exist." description="Account preferences, display name, avatar upload, and session options."
meta={<StatusDot tone="good" label="account loaded" />} meta={
<>
<StatusDot tone="good" label="account loaded" />
<StatusDot tone="info" label={`${settings.maxAvatarUploadMb} MB avatar limit`} />
</>
}
/> />
<section className="split-grid"> <section className="split-grid">
<Panel title="Profile Settings" eyebrow="Account"> <Panel title="Profile Settings" eyebrow="Account">
<form className="form compact-form"> {saved ? <p className="form-success">Profile updated.</p> : null}
{error === "avatar" ? <p className="form-error">Upload a PNG, JPG, WEBP, or GIF below {settings.maxAvatarUploadMb} MB.</p> : null}
<form className="form compact-form" action={updateProfile} encType="multipart/form-data">
<div className="profile-block">
<Avatar name={user.displayName || user.username} src={user.avatarUrl} size={72} />
<div className="row-title">
<strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span>
</div>
</div>
<label> <label>
Display name Display name
<input className="input" defaultValue={user.displayName || user.username} disabled /> <input className="input" name="displayName" defaultValue={user.displayName || user.username} maxLength={80} />
</label> </label>
<label> <label>
Avatar Profile picture
<input className="input" placeholder="Avatar upload is not wired yet" disabled /> <input className="input" name="avatar" type="file" accept="image/png,image/jpeg,image/webp,image/gif" />
</label> </label>
<button className="button primary" type="button" disabled>Save profile</button> <button className="button primary" type="submit">Save profile</button>
</form> </form>
</Panel> </Panel>
<Panel title="Sessions" eyebrow="Security"> <Panel title="Sessions" eyebrow="Security">
<EmptyState title="Session list is not persisted yet" description="The active session is managed by a signed HTTP-only cookie." /> <EmptyState title="Only the current session is tracked" description="The active session is managed by a signed HTTP-only cookie; device-level session history still needs a session table." />
</Panel> </Panel>
</section> </section>
</AppShell> </AppShell>

View File

@@ -112,7 +112,7 @@ export default async function ActivityPage() {
recentMedia.slice(0, 5).map((item) => ( recentMedia.slice(0, 5).map((item) => (
<div className="row" key={item.id}> <div className="row" key={item.id}>
<div className="row-main"> <div className="row-main">
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} size={34} /> <Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} src={item.submitter?.avatarUrl} size={34} />
<div className="row-title"> <div className="row-title">
<strong>{item.title || item.originalUrl}</strong> <strong>{item.title || item.originalUrl}</strong>
<span>{item.room.name}</span> <span>{item.room.name}</span>

View File

@@ -10,12 +10,14 @@ import { getShellContext } from "@/lib/shell";
import { requireCurrentUser, userIsAdmin } from "@/lib/session"; import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup"; import { requireInitialSetup } from "@/lib/setup";
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui"; import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { getAppSettings, type AppSettings } from "@/lib/settings";
import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"]; const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"];
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string }> }) { export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string; saved?: string }> }) {
await requireInitialSetup(); await requireInitialSetup();
const user = await requireCurrentUser(); const user = await requireCurrentUser();
@@ -23,12 +25,12 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
redirect("/dashboard"); redirect("/dashboard");
} }
const { tab = "Users", q = "" } = await searchParams; const { tab = "Users", q = "", saved } = await searchParams;
const activeTab = adminTabs.includes(tab) ? tab : "Users"; const activeTab = adminTabs.includes(tab) ? tab : "Users";
const query = q.trim().toLowerCase(); const query = q.trim().toLowerCase();
const shell = await getShellContext(user); const shell = await getShellContext(user);
const [users, rooms, roles, pendingRequests] = await Promise.all([ const [users, rooms, roles, pendingRequests, appSettings] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } }, include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
orderBy: { createdAt: "asc" } orderBy: { createdAt: "asc" }
@@ -41,7 +43,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
include: { _count: { select: { users: true, permissions: true } } }, include: { _count: { select: { users: true, permissions: true } } },
orderBy: { name: "asc" } orderBy: { name: "asc" }
}), }),
prisma.friendship.count({ where: { status: "PENDING" } }) prisma.friendship.count({ where: { status: "PENDING" } }),
getAppSettings()
]); ]);
const filteredUsers = query const filteredUsers = query
? users.filter((account) => ? users.filter((account) =>
@@ -103,8 +106,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null} {activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null} {activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null} {activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null}
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} /> : null} {activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} settings={appSettings} saved={saved} /> : null}
{activeTab === "Security" ? <SecurityPanel /> : null} {activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} /> : null}
</Panel> </Panel>
</AppShell> </AppShell>
); );
@@ -117,6 +120,7 @@ function UsersTable({
id: string; id: string;
username: string; username: string;
displayName: string | null; displayName: string | null;
avatarUrl: string | null;
createdAt: Date; createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>; roles: Array<{ roleId: string; role: { name: string } }>;
_count: { ownedRooms: number; roomMembers: number }; _count: { ownedRooms: number; roomMembers: number };
@@ -139,7 +143,7 @@ function UsersTable({
<tr key={account.id}> <tr key={account.id}>
<td> <td>
<div className="row-main"> <div className="row-main">
<Avatar name={account.displayName || account.username} size={30} /> <Avatar name={account.displayName || account.username} src={account.avatarUrl} size={30} />
<div className="row-title"> <div className="row-title">
<strong>{account.displayName || account.username}</strong> <strong>{account.displayName || account.username}</strong>
<span>@{account.username}</span> <span>@{account.username}</span>
@@ -275,9 +279,45 @@ function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
); );
} }
function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) { function InstancePanel({ userCount, roomCount, settings, saved }: { userCount: number; roomCount: number; settings: AppSettings; saved?: string }) {
return ( return (
<div className="split-grid"> <div className="split-grid">
<Panel title="Instance Settings" eyebrow="Site-wide">
{saved ? <p className="form-success">Instance settings saved.</p> : null}
<form className="form compact-form" action={updateInstanceSettings}>
<label>
Instance name
<input className="input" name="instanceName" defaultValue={settings.instanceName} maxLength={80} />
</label>
<label>
Description
<textarea className="input textarea" name="instanceDescription" defaultValue={settings.instanceDescription} maxLength={240} />
</label>
<label>
Default room visibility
<select className="input" name="defaultRoomVisibility" defaultValue={settings.defaultRoomVisibility}>
<option value="FRIENDS">Friends-only</option>
<option value="PUBLIC">Public</option>
<option value="EXPLICIT">Explicit members</option>
<option value="ROLE_RESTRICTED">Role restricted</option>
</select>
</label>
<label>
Avatar upload limit in MB
<input className="input" name="maxAvatarUploadMb" type="number" min="1" max="10" defaultValue={settings.maxAvatarUploadMb} />
</label>
<fieldset className="checkbox-grid">
<legend>Allowed media providers</legend>
{["YOUTUBE", "TWITCH", "DIRECT"].map((provider) => (
<label key={provider}>
<input name="allowedProviders" type="checkbox" value={provider} defaultChecked={settings.allowedProviders.includes(provider)} />
{provider}
</label>
))}
</fieldset>
<button className="button primary" type="submit">Save instance settings</button>
</form>
</Panel>
<Panel title="Runtime" eyebrow="Container"> <Panel title="Runtime" eyebrow="Container">
<div className="settings-list"> <div className="settings-list">
<Setting icon={<Database size={16} />} label="Database" value="Postgres via DATABASE_URL" /> <Setting icon={<Database size={16} />} label="Database" value="Postgres via DATABASE_URL" />
@@ -297,15 +337,27 @@ function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount:
); );
} }
function SecurityPanel() { function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: string }) {
return ( return (
<div className="split-grid"> <div className="split-grid">
<Panel title="Security Controls" eyebrow="Policy"> <Panel title="Security Controls" eyebrow="Policy">
<div className="settings-list"> {saved ? <p className="form-success">Security settings saved.</p> : null}
<Setting icon={<LockKeyhole size={16} />} label="Password storage" value="bcrypt hash" /> <form className="form compact-form" action={updateSecuritySettings}>
<Setting icon={<Shield size={16} />} label="Admin check" value="Server-side role lookup" /> <label>
<Setting icon={<AlertTriangle size={16} />} label="Registration mode" value="Open registration" /> Registration mode
</div> <select className="input" name="registrationMode" defaultValue={settings.registrationMode}>
<option value="OPEN">Open registration</option>
<option value="INVITE_ONLY">Invite only</option>
<option value="DISABLED">Disabled</option>
</select>
</label>
<div className="settings-list">
<Setting icon={<LockKeyhole size={16} />} label="Password storage" value="bcrypt hash" />
<Setting icon={<Shield size={16} />} label="Admin check" value="Server-side role lookup" />
<Setting icon={<AlertTriangle size={16} />} label="Registration mode" value={settings.registrationMode.toLowerCase().replace("_", " ")} />
</div>
<button className="button primary" type="submit">Save security settings</button>
</form>
</Panel> </Panel>
<Panel title="Danger Zone" eyebrow="Destructive"> <Panel title="Danger Zone" eyebrow="Destructive">
<EmptyState title="Destructive actions are disabled" description="User banning, registration mode changes, and deletion need server actions before controls become active." /> <EmptyState title="Destructive actions are disabled" description="User banning, registration mode changes, and deletion need server actions before controls become active." />

View File

@@ -156,7 +156,7 @@ export default async function DashboardPage() {
recentMedia.map((item) => ( recentMedia.map((item) => (
<div className="row" key={item.id}> <div className="row" key={item.id}>
<div className="row-main"> <div className="row-main">
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} size={34} /> <Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} src={item.submitter?.avatarUrl} size={34} />
<div className="row-title"> <div className="row-title">
<strong>{item.title || item.originalUrl}</strong> <strong>{item.title || item.originalUrl}</strong>
<span>{item.room.name} by {item.submitter?.displayName || item.submitter?.username || "Unknown"}</span> <span>{item.room.name} by {item.submitter?.displayName || item.submitter?.username || "Unknown"}</span>

View File

@@ -49,7 +49,8 @@ a {
button, button,
input, input,
select { select,
textarea {
font: inherit; font: inherit;
} }
@@ -267,6 +268,12 @@ select {
background: #05070a; background: #05070a;
} }
.twitch-embed iframe {
width: 100%;
height: 100%;
border: 0;
}
.video-state h2 { .video-state h2 {
margin: 0 0 10px; margin: 0 0 10px;
font-size: 24px; font-size: 24px;
@@ -317,6 +324,34 @@ select {
padding: 8px 10px; padding: 8px 10px;
} }
.textarea {
min-height: 92px;
resize: vertical;
}
.checkbox-grid {
display: grid;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
margin: 0;
padding: 10px;
}
.checkbox-grid legend {
color: var(--muted);
font-size: 12px;
font-weight: 800;
padding: 0 4px;
}
.checkbox-grid label {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
.stack { .stack {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -365,6 +400,13 @@ select {
background: color-mix(in srgb, var(--avatar-color) 18%, var(--panel)); background: color-mix(in srgb, var(--avatar-color) 18%, var(--panel));
color: var(--avatar-color); color: var(--avatar-color);
font-weight: 800; font-weight: 800;
overflow: hidden;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
} }
.empty-state { .empty-state {
@@ -432,6 +474,17 @@ select {
font-weight: 700; font-weight: 700;
} }
.form-success {
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
color: var(--accent);
margin: 0 0 14px;
padding: 10px 12px;
font-size: 13px;
font-weight: 700;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.room-layout { .room-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { loginUser } from "@/lib/user-actions"; import { loginUser } from "@/lib/user-actions";
import { hasAdminUser } from "@/lib/setup"; import { hasAdminUser } from "@/lib/setup";
import { getAppSettings } from "@/lib/settings";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -9,6 +10,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
if (!(await hasAdminUser())) { if (!(await hasAdminUser())) {
redirect("/setup"); redirect("/setup");
} }
const settings = await getAppSettings();
const { error } = await searchParams; const { error } = await searchParams;
return ( return (
@@ -26,7 +28,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
<h1>Login</h1> <h1>Login</h1>
<p>Enter your account to open rooms and manage playback.</p> <p>Enter your account to open rooms and manage playback.</p>
</div> </div>
{error ? <p className="form-error">Invalid username or password.</p> : null} {error ? <p className="form-error">{error === "registration-closed" ? "Registration is currently disabled by the administrator." : "Invalid username or password."}</p> : null}
<form className="form" action={loginUser}> <form className="form" action={loginUser}>
<label> <label>
Username Username
@@ -39,9 +41,13 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
<button className="button primary" type="submit"> <button className="button primary" type="submit">
Login Login
</button> </button>
<Link className="button" href="/register"> {settings.registrationMode === "OPEN" ? (
Create account <Link className="button" href="/register">
</Link> Create account
</Link>
) : (
<p className="disabled-note">Registration is {settings.registrationMode.toLowerCase().replace("_", " ")}.</p>
)}
</form> </form>
</div> </div>
</section> </section>

View File

@@ -18,6 +18,7 @@ type PersonSummary = {
id: string; id: string;
username: string; username: string;
displayName: string | null; displayName: string | null;
avatarUrl: string | null;
}; };
type FriendshipWithUsers = { type FriendshipWithUsers = {
@@ -154,7 +155,7 @@ function FriendshipList({
return ( return (
<div className="row" key={friendship.id}> <div className="row" key={friendship.id}>
<div className="row-main"> <div className="row-main">
<Avatar name={person.displayName || person.username} /> <Avatar name={person.displayName || person.username} src={person.avatarUrl} />
<div className="row-title"> <div className="row-title">
<strong>{person.displayName || person.username}</strong> <strong>{person.displayName || person.username}</strong>
<span>@{person.username}</span> <span>@{person.username}</span>
@@ -183,7 +184,7 @@ function IncomingList({ requests }: { requests: FriendshipWithUsers[] }) {
{requests.map((request) => ( {requests.map((request) => (
<div className="row" key={request.id}> <div className="row" key={request.id}>
<div className="row-main"> <div className="row-main">
<Avatar name={request.requester.displayName || request.requester.username} /> <Avatar name={request.requester.displayName || request.requester.username} src={request.requester.avatarUrl} />
<div className="row-title"> <div className="row-title">
<strong>{request.requester.displayName || request.requester.username}</strong> <strong>{request.requester.displayName || request.requester.username}</strong>
<span>@{request.requester.username}</span> <span>@{request.requester.username}</span>
@@ -236,7 +237,7 @@ function Directory({
<tr key={user.id}> <tr key={user.id}>
<td> <td>
<div className="row-main"> <div className="row-main">
<Avatar name={user.displayName || user.username} size={30} /> <Avatar name={user.displayName || user.username} src={user.avatarUrl} size={30} />
<div className="row-title"> <div className="row-title">
<strong>{user.displayName || user.username}</strong> <strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span> <span>@{user.username}</span>

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { registerUser } from "@/lib/user-actions"; import { registerUser } from "@/lib/user-actions";
import { hasAdminUser } from "@/lib/setup"; import { hasAdminUser } from "@/lib/setup";
import { getAppSettings } from "@/lib/settings";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -9,6 +10,10 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
if (!(await hasAdminUser())) { if (!(await hasAdminUser())) {
redirect("/setup"); redirect("/setup");
} }
const settings = await getAppSettings();
if (settings.registrationMode !== "OPEN") {
redirect("/login?error=registration-closed");
}
const { error } = await searchParams; const { error } = await searchParams;
return ( return (
@@ -51,5 +56,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
function registerError(error: string) { function registerError(error: string) {
if (error === "username") return "This username is already taken."; if (error === "username") return "This username is already taken.";
if (error === "closed") return "Registration is currently disabled by the administrator.";
return "Use a username and a password with at least 10 characters."; return "Use a username and a password with at least 10 characters.";
} }

View File

@@ -103,6 +103,7 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
{ {
id: room.owner.id, id: room.owner.id,
name: room.owner.displayName || room.owner.username, name: room.owner.displayName || room.owner.username,
avatarUrl: room.owner.avatarUrl,
role: "Owner", role: "Owner",
status: room.owner.id === user.id ? "Online" : "Available" status: room.owner.id === user.id ? "Online" : "Available"
} }
@@ -111,6 +112,7 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
...room.members.map((member) => ({ ...room.members.map((member) => ({
id: member.userId, id: member.userId,
name: member.user.displayName || member.user.username, name: member.user.displayName || member.user.username,
avatarUrl: member.user.avatarUrl,
role: member.canManage ? "Manager" : "Member", role: member.canManage ? "Manager" : "Member",
status: member.userId === user.id ? "Online" : "Allowed" status: member.userId === user.id ? "Online" : "Allowed"
})) }))

View File

@@ -33,6 +33,8 @@ export function AppShell({
active = "Overview", active = "Overview",
isAdmin = false, isAdmin = false,
userName, userName,
userAvatarUrl,
instanceName = "WatchLink",
rooms = [], rooms = [],
pendingRequests = 0, pendingRequests = 0,
activeRoomCount = 0 activeRoomCount = 0
@@ -41,6 +43,8 @@ export function AppShell({
active?: string; active?: string;
isAdmin?: boolean; isAdmin?: boolean;
userName?: string; userName?: string;
userAvatarUrl?: string | null;
instanceName?: string;
rooms?: ShellRoom[]; rooms?: ShellRoom[];
pendingRequests?: number; pendingRequests?: number;
activeRoomCount?: number; activeRoomCount?: number;
@@ -58,7 +62,7 @@ export function AppShell({
<aside className="sidebar"> <aside className="sidebar">
<Link className="brand" href="/dashboard"> <Link className="brand" href="/dashboard">
<span className="brand-mark" aria-hidden="true" /> <span className="brand-mark" aria-hidden="true" />
<span>WatchLink</span> <span>{instanceName}</span>
</Link> </Link>
<nav className="nav-list" aria-label="Primary"> <nav className="nav-list" aria-label="Primary">
{visibleNav.map((item) => { {visibleNav.map((item) => {
@@ -101,7 +105,7 @@ export function AppShell({
{userName ? ( {userName ? (
<div className="sidebar-user"> <div className="sidebar-user">
<Avatar name={userName} /> <Avatar name={userName} src={userAvatarUrl} />
<div className="row-title"> <div className="row-title">
<strong>{userName}</strong> <strong>{userName}</strong>
<span>{isAdmin ? "Administrator" : "Member"}</span> <span>{isAdmin ? "Administrator" : "Member"}</span>

View File

@@ -1,6 +1,6 @@
const COLORS = ["#22c55e", "#06b6d4", "#f59e0b", "#f97316", "#84cc16", "#14b8a6", "#64748b"]; const COLORS = ["#22c55e", "#06b6d4", "#f59e0b", "#f97316", "#84cc16", "#14b8a6", "#64748b"];
export function Avatar({ name, size = 32 }: { name: string; size?: number }) { export function Avatar({ name, size = 32, src }: { name: string; size?: number; src?: string | null }) {
const normalized = name.trim() || "?"; const normalized = name.trim() || "?";
const color = COLORS[hashString(normalized) % COLORS.length]; const color = COLORS[hashString(normalized) % COLORS.length];
const initials = normalized const initials = normalized
@@ -15,7 +15,7 @@ export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
style={{ "--avatar-color": color, width: size, height: size, fontSize: Math.max(11, size * 0.36) } as React.CSSProperties} style={{ "--avatar-color": color, width: size, height: size, fontSize: Math.max(11, size * 0.36) } as React.CSSProperties}
aria-hidden="true" aria-hidden="true"
> >
{initials || "?"} {src ? <img src={src} alt="" /> : initials || "?"}
</span> </span>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2 } from "lucide-react"; import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2 } from "lucide-react";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media"; import { normalizeMediaUrl } from "@/lib/media";
@@ -14,6 +14,31 @@ const socket = io({
autoConnect: false autoConnect: false
}); });
declare global {
interface Window {
Twitch?: {
Player: new (
element: HTMLElement,
options: {
width: string;
height: string;
parent: string[];
autoplay?: boolean;
video?: string;
channel?: string;
}
) => TwitchPlayer;
};
}
}
type TwitchPlayer = {
play: () => void;
pause: () => void;
seek?: (seconds: number) => void;
destroy?: () => void;
};
type QueueItem = { type QueueItem = {
id: string; id: string;
title: string; title: string;
@@ -24,9 +49,12 @@ type QueueItem = {
createdAt: string; createdAt: string;
}; };
type MediaCommand = Pick<QueueItem, "provider" | "originalUrl" | "playbackUrl"> | null;
type Participant = { type Participant = {
id: string; id: string;
name: string; name: string;
avatarUrl?: string | null;
role: string; role: string;
status: string; status: string;
}; };
@@ -53,8 +81,12 @@ export function RoomConsole({
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [source, setSource] = useState(""); const [source, setSource] = useState("");
const [rail, setRail] = useState("Activity"); const [rail, setRail] = useState("Activity");
const [activeQueueItem, setActiveQueueItem] = useState<QueueItem | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const twitchPlayerRef = useRef<TwitchPlayer | null>(null);
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]); const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
const currentMedia = previewMedia || queue[0] || null; const currentMedia = previewMedia || activeQueueItem || queue[0] || null;
function connect() { function connect() {
if (!socket.connected) { if (!socket.connected) {
@@ -64,9 +96,9 @@ export function RoomConsole({
setConnected(true); setConnected(true);
} }
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek") { function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek", mediaOverride?: MediaCommand) {
connect(); connect();
const media = previewMedia || queue[0]; const media = mediaOverride || currentMedia;
socket.emit(event, { socket.emit(event, {
provider: media?.provider, provider: media?.provider,
originalUrl: media?.originalUrl, originalUrl: media?.originalUrl,
@@ -75,6 +107,49 @@ export function RoomConsole({
}); });
} }
function postYoutubeCommand(func: string, args: unknown[] = []) {
iframeRef.current?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args }), "https://www.youtube.com");
}
function controlPlayer(action: "play" | "pause" | "seek") {
const provider = currentMedia?.provider;
if (provider === "YOUTUBE") {
if (action === "play") postYoutubeCommand("playVideo");
if (action === "pause") postYoutubeCommand("pauseVideo");
if (action === "seek") postYoutubeCommand("seekTo", [82, true]);
}
if (provider === "DIRECT" && videoRef.current) {
if (action === "play") void videoRef.current.play();
if (action === "pause") videoRef.current.pause();
if (action === "seek") videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime + 30);
}
if (provider === "TWITCH" && twitchPlayerRef.current) {
if (action === "play") twitchPlayerRef.current.play();
if (action === "pause") twitchPlayerRef.current.pause();
if (action === "seek" && twitchPlayerRef.current.seek) twitchPlayerRef.current.seek(82);
}
}
function handleTransport(event: "playback:play" | "playback:pause" | "playback:seek") {
emit(event);
if (event === "playback:play") controlPlayer("play");
if (event === "playback:pause") controlPlayer("pause");
if (event === "playback:seek") controlPlayer("seek");
}
function playQueueItem(item: QueueItem) {
setActiveQueueItem(item);
setSource("");
emit("media:set", item);
window.setTimeout(() => {
postYoutubeCommand("playVideo");
twitchPlayerRef.current?.play();
void videoRef.current?.play();
}, 800);
}
return ( return (
<section className="watch-console"> <section className="watch-console">
<div className="player-column"> <div className="player-column">
@@ -95,6 +170,9 @@ export function RoomConsole({
provider={currentMedia.provider} provider={currentMedia.provider}
playbackUrl={currentMedia.playbackUrl} playbackUrl={currentMedia.playbackUrl}
title={currentMedia.title || currentMedia.originalUrl} title={currentMedia.title || currentMedia.originalUrl}
iframeRef={iframeRef}
videoRef={videoRef}
twitchPlayerRef={twitchPlayerRef}
/> />
) : ( ) : (
<div className="video-state"> <div className="video-state">
@@ -107,13 +185,13 @@ export function RoomConsole({
<form className="transport-bar" action={addMediaToRoom}> <form className="transport-bar" action={addMediaToRoom}>
<input type="hidden" name="roomId" value={roomId} /> <input type="hidden" name="roomId" value={roomId} />
<button className="icon-button" type="button" onClick={() => emit("playback:play")} title="Play"> <button className="icon-button" type="button" onClick={() => handleTransport("playback:play")} title="Play">
<Play size={16} /> <Play size={16} />
</button> </button>
<button className="icon-button" type="button" onClick={() => emit("playback:pause")} title="Pause"> <button className="icon-button" type="button" onClick={() => handleTransport("playback:pause")} title="Pause">
<Pause size={16} /> <Pause size={16} />
</button> </button>
<button className="icon-button" type="button" onClick={() => emit("playback:seek")} title="Seek"> <button className="icon-button" type="button" onClick={() => handleTransport("playback:seek")} title="Seek">
<SkipForward size={16} /> <SkipForward size={16} />
</button> </button>
<input <input
@@ -124,7 +202,7 @@ export function RoomConsole({
onChange={(event) => setSource(event.target.value)} onChange={(event) => setSource(event.target.value)}
placeholder="Paste YouTube, Twitch, or direct video URL" placeholder="Paste YouTube, Twitch, or direct video URL"
/> />
<button className="button primary" type="submit" onClick={() => emit("media:set")} disabled={!source}> <button className="button primary" type="submit" onClick={() => emit("media:set", previewMedia)} disabled={!source}>
<Radio size={16} /> Add source <Radio size={16} /> Add source
</button> </button>
</form> </form>
@@ -144,7 +222,7 @@ export function RoomConsole({
<span>{item.provider} by {item.by} - {item.createdAt}</span> <span>{item.provider} by {item.by} - {item.createdAt}</span>
</div> </div>
<div className="row-actions"> <div className="row-actions">
<button className="icon-button compact" type="button" title="Play now" onClick={() => emit("media:set")}> <button className="icon-button compact" type="button" title="Play now" onClick={() => playQueueItem(item)}>
<Play size={14} /> <Play size={14} />
</button> </button>
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled> <button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
@@ -170,7 +248,7 @@ export function RoomConsole({
participants.map((item) => ( participants.map((item) => (
<div className="row" key={item.id}> <div className="row" key={item.id}>
<div className="row-main"> <div className="row-main">
<Avatar name={item.name} /> <Avatar name={item.name} src={item.avatarUrl} />
<div className="row-title"> <div className="row-title">
<strong>{item.name}</strong> <strong>{item.name}</strong>
<span>{item.role}</span> <span>{item.role}</span>
@@ -249,12 +327,27 @@ function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: stri
); );
} }
function MediaPreview({ provider, playbackUrl, title }: { provider: string; playbackUrl: string; title: string }) { function MediaPreview({
if (provider === "YOUTUBE" || provider === "TWITCH") { provider,
playbackUrl,
title,
iframeRef,
videoRef,
twitchPlayerRef
}: {
provider: string;
playbackUrl: string;
title: string;
iframeRef: React.RefObject<HTMLIFrameElement | null>;
videoRef: React.RefObject<HTMLVideoElement | null>;
twitchPlayerRef: React.MutableRefObject<TwitchPlayer | null>;
}) {
if (provider === "YOUTUBE") {
return ( return (
<iframe <iframe
ref={iframeRef}
className="media-embed" className="media-embed"
src={playbackUrl} src={withBrowserOrigin(playbackUrl)}
title={title} title={title}
allow="autoplay; encrypted-media; fullscreen; picture-in-picture" allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen allowFullScreen
@@ -262,8 +355,12 @@ function MediaPreview({ provider, playbackUrl, title }: { provider: string; play
); );
} }
if (provider === "TWITCH") {
return <TwitchPreview playbackUrl={playbackUrl} title={title} twitchPlayerRef={twitchPlayerRef} />;
}
if (provider === "DIRECT") { if (provider === "DIRECT") {
return <video className="media-embed" src={playbackUrl} controls playsInline />; return <video ref={videoRef} className="media-embed" src={playbackUrl} controls playsInline />;
} }
return ( return (
@@ -276,6 +373,94 @@ function MediaPreview({ provider, playbackUrl, title }: { provider: string; play
); );
} }
function TwitchPreview({
playbackUrl,
title,
twitchPlayerRef
}: {
playbackUrl: string;
title: string;
twitchPlayerRef: React.MutableRefObject<TwitchPlayer | null>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let disposed = false;
const container = containerRef.current;
if (!container) return;
async function mount() {
await loadTwitchApi();
if (disposed || !container || !window.Twitch) return;
container.replaceChildren();
twitchPlayerRef.current?.destroy?.();
const options = getTwitchOptions(playbackUrl);
twitchPlayerRef.current = new window.Twitch.Player(container, {
width: "100%",
height: "100%",
parent: [window.location.hostname || "localhost"],
autoplay: false,
...options
});
}
void mount();
return () => {
disposed = true;
twitchPlayerRef.current?.destroy?.();
twitchPlayerRef.current = null;
};
}, [playbackUrl, twitchPlayerRef]);
return <div ref={containerRef} className="media-embed twitch-embed" role="region" aria-label={title} />;
}
function loadTwitchApi() {
if (window.Twitch) return Promise.resolve();
const existing = document.querySelector<HTMLScriptElement>('script[src="https://player.twitch.tv/js/embed/v1.js"]');
if (existing) {
return new Promise<void>((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("Failed to load Twitch player API.")), { once: true });
});
}
return new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://player.twitch.tv/js/embed/v1.js";
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Twitch player API."));
document.head.appendChild(script);
});
}
function getTwitchOptions(playbackUrl: string) {
try {
const url = new URL(playbackUrl);
const video = url.searchParams.get("video");
const channel = url.searchParams.get("channel");
return video ? { video } : { channel: channel || "" };
} catch {
return { channel: "" };
}
}
function withBrowserOrigin(playbackUrl: string) {
if (typeof window === "undefined") return playbackUrl;
try {
const url = new URL(playbackUrl);
url.searchParams.set("enablejsapi", "1");
url.searchParams.set("playsinline", "1");
url.searchParams.set("origin", window.location.origin);
return url.toString();
} catch {
return playbackUrl;
}
}
function formatVisibility(value: string) { function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " "); return value.toLowerCase().replace("_", " ");
} }

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { normalizeMediaUrl } from "./media"; import { normalizeMediaUrl } from "./media";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { requireCurrentUser } from "./session"; import { requireCurrentUser } from "./session";
import { getAppSettings } from "./settings";
export async function addMediaToRoom(formData: FormData) { export async function addMediaToRoom(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
@@ -22,7 +23,10 @@ export async function addMediaToRoom(formData: FormData) {
if (!room) return; if (!room) return;
const settings = await getAppSettings();
const media = normalizeMediaUrl(sourceUrl); const media = normalizeMediaUrl(sourceUrl);
if (!settings.allowedProviders.includes(media.provider)) return;
await prisma.mediaSource.create({ await prisma.mediaSource.create({
data: { data: {
roomId: room.id, roomId: room.id,

View File

@@ -0,0 +1,49 @@
"use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser } from "./session";
import { getAppSettings } from "./settings";
const avatarTypes = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/webp", "webp"],
["image/gif", "gif"]
]);
export async function updateProfile(formData: FormData) {
const user = await requireCurrentUser();
const displayName = String(formData.get("displayName") || user.username).trim().slice(0, 80) || user.username;
const avatar = formData.get("avatar");
const data: { displayName: string; avatarUrl?: string } = { displayName };
if (avatar instanceof File && avatar.size > 0) {
const settings = await getAppSettings();
const extension = avatarTypes.get(avatar.type);
const maxBytes = settings.maxAvatarUploadMb * 1024 * 1024;
if (!extension || avatar.size > maxBytes) {
redirect("/account/settings?error=avatar");
}
const uploadsDir = path.join(process.cwd(), "public", "uploads", "avatars");
await mkdir(uploadsDir, { recursive: true });
const fileName = `${user.id}-${Date.now()}.${extension}`;
await writeFile(path.join(uploadsDir, fileName), Buffer.from(await avatar.arrayBuffer()));
data.avatarUrl = `/uploads/avatars/${fileName}`;
}
await prisma.user.update({
where: { id: user.id },
data
});
revalidatePath("/account/profile");
revalidatePath("/account/settings");
revalidatePath("/dashboard");
redirect("/account/settings?saved=1");
}

View File

@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { requireCurrentUser } from "./session"; import { requireCurrentUser } from "./session";
import { getAppSettings } from "./settings";
function normalizeSlug(value: string) { function normalizeSlug(value: string) {
return value return value
@@ -17,8 +18,9 @@ function normalizeSlug(value: string) {
export async function createRoom(formData: FormData) { export async function createRoom(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const settings = await getAppSettings();
const name = String(formData.get("name") || "").trim(); const name = String(formData.get("name") || "").trim();
const visibility = String(formData.get("visibility") || "FRIENDS"); const visibility = String(formData.get("visibility") || settings.defaultRoomVisibility);
const baseSlug = normalizeSlug(name); const baseSlug = normalizeSlug(name);
if (!name || !baseSlug) { if (!name || !baseSlug) {

View File

@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser, userIsAdmin } from "./session";
const allowedRegistrationModes = ["OPEN", "INVITE_ONLY", "DISABLED"];
const allowedVisibility = ["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"];
const allowedProviders = ["YOUTUBE", "TWITCH", "DIRECT"];
async function requireAdmin() {
const user = await requireCurrentUser();
if (!userIsAdmin(user)) {
redirect("/dashboard");
}
return user;
}
export async function updateInstanceSettings(formData: FormData) {
await requireAdmin();
const instanceName = String(formData.get("instanceName") || "WatchLink").trim().slice(0, 80) || "WatchLink";
const instanceDescription = String(formData.get("instanceDescription") || "").trim().slice(0, 240);
const defaultRoomVisibility = normalizeChoice(String(formData.get("defaultRoomVisibility") || "FRIENDS"), allowedVisibility, "FRIENDS");
const selectedProviders = allowedProviders.filter((provider) => formData.getAll("allowedProviders").includes(provider));
const maxAvatarUploadMb = Math.min(10, Math.max(1, Number(formData.get("maxAvatarUploadMb") || 2) || 2));
await writeSettings({
instanceName,
instanceDescription,
defaultRoomVisibility,
allowedProviders: selectedProviders.length > 0 ? selectedProviders.join(",") : "YOUTUBE,TWITCH,DIRECT",
maxAvatarUploadMb: String(maxAvatarUploadMb)
});
revalidateSettingsPaths();
redirect("/admin?tab=Instance&saved=1");
}
export async function updateSecuritySettings(formData: FormData) {
await requireAdmin();
const registrationMode = normalizeChoice(String(formData.get("registrationMode") || "OPEN"), allowedRegistrationModes, "OPEN");
await writeSettings({ registrationMode });
revalidateSettingsPaths();
redirect("/admin?tab=Security&saved=1");
}
async function writeSettings(values: Record<string, string>) {
await prisma.$transaction(
Object.entries(values).map(([key, value]) =>
prisma.appSetting.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
);
}
function normalizeChoice<T extends string>(value: string, allowed: T[], fallback: T) {
return allowed.includes(value as T) ? (value as T) : fallback;
}
function revalidateSettingsPaths() {
revalidatePath("/admin");
revalidatePath("/dashboard");
revalidatePath("/rooms");
revalidatePath("/register");
}

47
src/lib/settings.ts Normal file
View File

@@ -0,0 +1,47 @@
import { prisma } from "./prisma";
export type AppSettings = {
instanceName: string;
instanceDescription: string;
registrationMode: "OPEN" | "INVITE_ONLY" | "DISABLED";
defaultRoomVisibility: "PUBLIC" | "FRIENDS" | "EXPLICIT" | "ROLE_RESTRICTED";
allowedProviders: string[];
maxAvatarUploadMb: number;
};
const defaults: AppSettings = {
instanceName: "WatchLink",
instanceDescription: "Persistent shared watch rooms for friends and teams.",
registrationMode: "OPEN",
defaultRoomVisibility: "FRIENDS",
allowedProviders: ["YOUTUBE", "TWITCH", "DIRECT"],
maxAvatarUploadMb: 2
};
export async function getAppSettings(): Promise<AppSettings> {
const rows = await prisma.appSetting.findMany();
const values = new Map(rows.map((row) => [row.key, row.value]));
const registrationMode = parseChoice(values.get("registrationMode"), ["OPEN", "INVITE_ONLY", "DISABLED"], defaults.registrationMode);
const defaultRoomVisibility = parseChoice(
values.get("defaultRoomVisibility"),
["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"],
defaults.defaultRoomVisibility
);
const maxAvatarUploadMb = Number(values.get("maxAvatarUploadMb") || defaults.maxAvatarUploadMb);
return {
instanceName: values.get("instanceName") || defaults.instanceName,
instanceDescription: values.get("instanceDescription") || defaults.instanceDescription,
registrationMode,
defaultRoomVisibility,
allowedProviders: (values.get("allowedProviders") || defaults.allowedProviders.join(","))
.split(",")
.map((item) => item.trim())
.filter(Boolean),
maxAvatarUploadMb: Number.isFinite(maxAvatarUploadMb) && maxAvatarUploadMb > 0 ? maxAvatarUploadMb : defaults.maxAvatarUploadMb
};
}
function parseChoice<T extends string>(value: string | undefined, allowed: T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}

View File

@@ -1,10 +1,11 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { getAppSettings } from "./settings";
import { getCurrentUser, userIsAdmin } from "./session"; import { getCurrentUser, userIsAdmin } from "./session";
type CurrentUser = NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>; type CurrentUser = NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>;
export async function getShellContext(user: CurrentUser) { export async function getShellContext(user: CurrentUser) {
const [rooms, pendingRequests, activeRoomCount] = await Promise.all([ const [rooms, pendingRequests, activeRoomCount, settings] = await Promise.all([
prisma.room.findMany({ prisma.room.findMany({
where: { where: {
OR: [{ ownerId: user.id }, { members: { some: { userId: user.id } } }, { visibility: "PUBLIC" }] OR: [{ ownerId: user.id }, { members: { some: { userId: user.id } } }, { visibility: "PUBLIC" }]
@@ -14,12 +15,15 @@ export async function getShellContext(user: CurrentUser) {
take: 8 take: 8
}), }),
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }), prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
prisma.room.count({ where: { mediaSources: { some: {} } } }) prisma.room.count({ where: { mediaSources: { some: {} } } }),
getAppSettings()
]); ]);
return { return {
isAdmin: userIsAdmin(user), isAdmin: userIsAdmin(user),
userName: user.displayName || user.username, userName: user.displayName || user.username,
userAvatarUrl: user.avatarUrl,
instanceName: settings.instanceName,
pendingRequests, pendingRequests,
activeRoomCount, activeRoomCount,
rooms: rooms.map((room) => ({ rooms: rooms.map((room) => ({

View File

@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { Prisma, type User } from "@prisma/client"; import { Prisma, type User } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { clearSession, setSession } from "./session"; import { clearSession, setSession } from "./session";
import { getAppSettings } from "./settings";
function normalizeUsername(value: FormDataEntryValue | null) { function normalizeUsername(value: FormDataEntryValue | null) {
return String(value || "") return String(value || "")
@@ -14,6 +15,11 @@ function normalizeUsername(value: FormDataEntryValue | null) {
} }
export async function registerUser(formData: FormData) { export async function registerUser(formData: FormData) {
const settings = await getAppSettings();
if (settings.registrationMode !== "OPEN") {
redirect("/register?error=closed");
}
const username = normalizeUsername(formData.get("username")); const username = normalizeUsername(formData.get("username"));
const password = String(formData.get("password") || ""); const password = String(formData.get("password") || "");