Add player controls and configurable profiles
This commit is contained in:
@@ -41,7 +41,7 @@ export default async function ProfilePage() {
|
||||
<section className="split-grid">
|
||||
<Panel title="Account" eyebrow="Identity">
|
||||
<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">
|
||||
<strong>{user.displayName || user.username}</strong>
|
||||
<span>@{user.username}</span>
|
||||
|
||||
@@ -1,40 +1,59 @@
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui";
|
||||
import { updateProfile } from "@/lib/profile-actions";
|
||||
import { getShellContext } from "@/lib/shell";
|
||||
import { requireCurrentUser } from "@/lib/session";
|
||||
import { getAppSettings } from "@/lib/settings";
|
||||
import { requireInitialSetup } from "@/lib/setup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
export default async function AccountSettingsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
||||
await requireInitialSetup();
|
||||
const user = await requireCurrentUser();
|
||||
const shell = await getShellContext(user);
|
||||
const settings = await getAppSettings();
|
||||
const { error, saved } = await searchParams;
|
||||
|
||||
return (
|
||||
<AppShell active="Overview" {...shell}>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Account preferences and session options. Controls stay disabled until matching server actions exist."
|
||||
meta={<StatusDot tone="good" label="account loaded" />}
|
||||
description="Account preferences, display name, avatar upload, and session options."
|
||||
meta={
|
||||
<>
|
||||
<StatusDot tone="good" label="account loaded" />
|
||||
<StatusDot tone="info" label={`${settings.maxAvatarUploadMb} MB avatar limit`} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="split-grid">
|
||||
<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>
|
||||
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>
|
||||
Avatar
|
||||
<input className="input" placeholder="Avatar upload is not wired yet" disabled />
|
||||
Profile picture
|
||||
<input className="input" name="avatar" type="file" accept="image/png,image/jpeg,image/webp,image/gif" />
|
||||
</label>
|
||||
<button className="button primary" type="button" disabled>Save profile</button>
|
||||
<button className="button primary" type="submit">Save profile</button>
|
||||
</form>
|
||||
</Panel>
|
||||
<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>
|
||||
</section>
|
||||
</AppShell>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default async function ActivityPage() {
|
||||
recentMedia.slice(0, 5).map((item) => (
|
||||
<div className="row" key={item.id}>
|
||||
<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">
|
||||
<strong>{item.title || item.originalUrl}</strong>
|
||||
<span>{item.room.name}</span>
|
||||
|
||||
@@ -10,12 +10,14 @@ import { getShellContext } from "@/lib/shell";
|
||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||
import { requireInitialSetup } from "@/lib/setup";
|
||||
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";
|
||||
|
||||
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();
|
||||
const user = await requireCurrentUser();
|
||||
|
||||
@@ -23,12 +25,12 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const { tab = "Users", q = "" } = await searchParams;
|
||||
const { tab = "Users", q = "", saved } = await searchParams;
|
||||
const activeTab = adminTabs.includes(tab) ? tab : "Users";
|
||||
const query = q.trim().toLowerCase();
|
||||
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({
|
||||
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
|
||||
orderBy: { createdAt: "asc" }
|
||||
@@ -41,7 +43,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
include: { _count: { select: { users: true, permissions: true } } },
|
||||
orderBy: { name: "asc" }
|
||||
}),
|
||||
prisma.friendship.count({ where: { status: "PENDING" } })
|
||||
prisma.friendship.count({ where: { status: "PENDING" } }),
|
||||
getAppSettings()
|
||||
]);
|
||||
const filteredUsers = query
|
||||
? users.filter((account) =>
|
||||
@@ -103,8 +106,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
|
||||
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
|
||||
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null}
|
||||
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} /> : null}
|
||||
{activeTab === "Security" ? <SecurityPanel /> : null}
|
||||
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} settings={appSettings} saved={saved} /> : null}
|
||||
{activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} /> : null}
|
||||
</Panel>
|
||||
</AppShell>
|
||||
);
|
||||
@@ -117,6 +120,7 @@ function UsersTable({
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
createdAt: Date;
|
||||
roles: Array<{ roleId: string; role: { name: string } }>;
|
||||
_count: { ownedRooms: number; roomMembers: number };
|
||||
@@ -139,7 +143,7 @@ function UsersTable({
|
||||
<tr key={account.id}>
|
||||
<td>
|
||||
<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">
|
||||
<strong>{account.displayName || account.username}</strong>
|
||||
<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 (
|
||||
<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">
|
||||
<div className="settings-list">
|
||||
<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 (
|
||||
<div className="split-grid">
|
||||
<Panel title="Security Controls" eyebrow="Policy">
|
||||
<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="Open registration" />
|
||||
</div>
|
||||
{saved ? <p className="form-success">Security settings saved.</p> : null}
|
||||
<form className="form compact-form" action={updateSecuritySettings}>
|
||||
<label>
|
||||
Registration mode
|
||||
<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 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." />
|
||||
|
||||
@@ -156,7 +156,7 @@ export default async function DashboardPage() {
|
||||
recentMedia.map((item) => (
|
||||
<div className="row" key={item.id}>
|
||||
<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">
|
||||
<strong>{item.title || item.originalUrl}</strong>
|
||||
<span>{item.room.name} by {item.submitter?.displayName || item.submitter?.username || "Unknown"}</span>
|
||||
|
||||
@@ -49,7 +49,8 @@ a {
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -267,6 +268,12 @@ select {
|
||||
background: #05070a;
|
||||
}
|
||||
|
||||
.twitch-embed iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.video-state h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 24px;
|
||||
@@ -317,6 +324,34 @@ select {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -365,6 +400,13 @@ select {
|
||||
background: color-mix(in srgb, var(--avatar-color) 18%, var(--panel));
|
||||
color: var(--avatar-color);
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -432,6 +474,17 @@ select {
|
||||
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) {
|
||||
.room-layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { loginUser } from "@/lib/user-actions";
|
||||
import { hasAdminUser } from "@/lib/setup";
|
||||
import { getAppSettings } from "@/lib/settings";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -9,6 +10,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
|
||||
if (!(await hasAdminUser())) {
|
||||
redirect("/setup");
|
||||
}
|
||||
const settings = await getAppSettings();
|
||||
const { error } = await searchParams;
|
||||
|
||||
return (
|
||||
@@ -26,7 +28,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
|
||||
<h1>Login</h1>
|
||||
<p>Enter your account to open rooms and manage playback.</p>
|
||||
</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}>
|
||||
<label>
|
||||
Username
|
||||
@@ -39,9 +41,13 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
|
||||
<button className="button primary" type="submit">
|
||||
Login
|
||||
</button>
|
||||
<Link className="button" href="/register">
|
||||
Create account
|
||||
</Link>
|
||||
{settings.registrationMode === "OPEN" ? (
|
||||
<Link className="button" href="/register">
|
||||
Create account
|
||||
</Link>
|
||||
) : (
|
||||
<p className="disabled-note">Registration is {settings.registrationMode.toLowerCase().replace("_", " ")}.</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -18,6 +18,7 @@ type PersonSummary = {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
type FriendshipWithUsers = {
|
||||
@@ -154,7 +155,7 @@ function FriendshipList({
|
||||
return (
|
||||
<div className="row" key={friendship.id}>
|
||||
<div className="row-main">
|
||||
<Avatar name={person.displayName || person.username} />
|
||||
<Avatar name={person.displayName || person.username} src={person.avatarUrl} />
|
||||
<div className="row-title">
|
||||
<strong>{person.displayName || person.username}</strong>
|
||||
<span>@{person.username}</span>
|
||||
@@ -183,7 +184,7 @@ function IncomingList({ requests }: { requests: FriendshipWithUsers[] }) {
|
||||
{requests.map((request) => (
|
||||
<div className="row" key={request.id}>
|
||||
<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">
|
||||
<strong>{request.requester.displayName || request.requester.username}</strong>
|
||||
<span>@{request.requester.username}</span>
|
||||
@@ -236,7 +237,7 @@ function Directory({
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<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">
|
||||
<strong>{user.displayName || user.username}</strong>
|
||||
<span>@{user.username}</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { registerUser } from "@/lib/user-actions";
|
||||
import { hasAdminUser } from "@/lib/setup";
|
||||
import { getAppSettings } from "@/lib/settings";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -9,6 +10,10 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
|
||||
if (!(await hasAdminUser())) {
|
||||
redirect("/setup");
|
||||
}
|
||||
const settings = await getAppSettings();
|
||||
if (settings.registrationMode !== "OPEN") {
|
||||
redirect("/login?error=registration-closed");
|
||||
}
|
||||
const { error } = await searchParams;
|
||||
|
||||
return (
|
||||
@@ -51,5 +56,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
|
||||
|
||||
function registerError(error: string) {
|
||||
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.";
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
|
||||
{
|
||||
id: room.owner.id,
|
||||
name: room.owner.displayName || room.owner.username,
|
||||
avatarUrl: room.owner.avatarUrl,
|
||||
role: "Owner",
|
||||
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) => ({
|
||||
id: member.userId,
|
||||
name: member.user.displayName || member.user.username,
|
||||
avatarUrl: member.user.avatarUrl,
|
||||
role: member.canManage ? "Manager" : "Member",
|
||||
status: member.userId === user.id ? "Online" : "Allowed"
|
||||
}))
|
||||
|
||||
@@ -33,6 +33,8 @@ export function AppShell({
|
||||
active = "Overview",
|
||||
isAdmin = false,
|
||||
userName,
|
||||
userAvatarUrl,
|
||||
instanceName = "WatchLink",
|
||||
rooms = [],
|
||||
pendingRequests = 0,
|
||||
activeRoomCount = 0
|
||||
@@ -41,6 +43,8 @@ export function AppShell({
|
||||
active?: string;
|
||||
isAdmin?: boolean;
|
||||
userName?: string;
|
||||
userAvatarUrl?: string | null;
|
||||
instanceName?: string;
|
||||
rooms?: ShellRoom[];
|
||||
pendingRequests?: number;
|
||||
activeRoomCount?: number;
|
||||
@@ -58,7 +62,7 @@ export function AppShell({
|
||||
<aside className="sidebar">
|
||||
<Link className="brand" href="/dashboard">
|
||||
<span className="brand-mark" aria-hidden="true" />
|
||||
<span>WatchLink</span>
|
||||
<span>{instanceName}</span>
|
||||
</Link>
|
||||
<nav className="nav-list" aria-label="Primary">
|
||||
{visibleNav.map((item) => {
|
||||
@@ -101,7 +105,7 @@ export function AppShell({
|
||||
|
||||
{userName ? (
|
||||
<div className="sidebar-user">
|
||||
<Avatar name={userName} />
|
||||
<Avatar name={userName} src={userAvatarUrl} />
|
||||
<div className="row-title">
|
||||
<strong>{userName}</strong>
|
||||
<span>{isAdmin ? "Administrator" : "Member"}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 color = COLORS[hashString(normalized) % COLORS.length];
|
||||
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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials || "?"}
|
||||
{src ? <img src={src} alt="" /> : initials || "?"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { io } from "socket.io-client";
|
||||
import { normalizeMediaUrl } from "@/lib/media";
|
||||
@@ -14,6 +14,31 @@ const socket = io({
|
||||
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 = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -24,9 +49,12 @@ type QueueItem = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type MediaCommand = Pick<QueueItem, "provider" | "originalUrl" | "playbackUrl"> | null;
|
||||
|
||||
type Participant = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
};
|
||||
@@ -53,8 +81,12 @@ export function RoomConsole({
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [source, setSource] = useState("");
|
||||
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 currentMedia = previewMedia || queue[0] || null;
|
||||
const currentMedia = previewMedia || activeQueueItem || queue[0] || null;
|
||||
|
||||
function connect() {
|
||||
if (!socket.connected) {
|
||||
@@ -64,9 +96,9 @@ export function RoomConsole({
|
||||
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();
|
||||
const media = previewMedia || queue[0];
|
||||
const media = mediaOverride || currentMedia;
|
||||
socket.emit(event, {
|
||||
provider: media?.provider,
|
||||
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 (
|
||||
<section className="watch-console">
|
||||
<div className="player-column">
|
||||
@@ -95,6 +170,9 @@ export function RoomConsole({
|
||||
provider={currentMedia.provider}
|
||||
playbackUrl={currentMedia.playbackUrl}
|
||||
title={currentMedia.title || currentMedia.originalUrl}
|
||||
iframeRef={iframeRef}
|
||||
videoRef={videoRef}
|
||||
twitchPlayerRef={twitchPlayerRef}
|
||||
/>
|
||||
) : (
|
||||
<div className="video-state">
|
||||
@@ -107,13 +185,13 @@ export function RoomConsole({
|
||||
|
||||
<form className="transport-bar" action={addMediaToRoom}>
|
||||
<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} />
|
||||
</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} />
|
||||
</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} />
|
||||
</button>
|
||||
<input
|
||||
@@ -124,7 +202,7 @@ export function RoomConsole({
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
@@ -144,7 +222,7 @@ export function RoomConsole({
|
||||
<span>{item.provider} by {item.by} - {item.createdAt}</span>
|
||||
</div>
|
||||
<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} />
|
||||
</button>
|
||||
<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) => (
|
||||
<div className="row" key={item.id}>
|
||||
<div className="row-main">
|
||||
<Avatar name={item.name} />
|
||||
<Avatar name={item.name} src={item.avatarUrl} />
|
||||
<div className="row-title">
|
||||
<strong>{item.name}</strong>
|
||||
<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 }) {
|
||||
if (provider === "YOUTUBE" || provider === "TWITCH") {
|
||||
function MediaPreview({
|
||||
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 (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="media-embed"
|
||||
src={playbackUrl}
|
||||
src={withBrowserOrigin(playbackUrl)}
|
||||
title={title}
|
||||
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||
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") {
|
||||
return <video className="media-embed" src={playbackUrl} controls playsInline />;
|
||||
return <video ref={videoRef} className="media-embed" src={playbackUrl} controls playsInline />;
|
||||
}
|
||||
|
||||
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) {
|
||||
return value.toLowerCase().replace("_", " ");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { normalizeMediaUrl } from "./media";
|
||||
import { prisma } from "./prisma";
|
||||
import { requireCurrentUser } from "./session";
|
||||
import { getAppSettings } from "./settings";
|
||||
|
||||
export async function addMediaToRoom(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
@@ -22,7 +23,10 @@ export async function addMediaToRoom(formData: FormData) {
|
||||
|
||||
if (!room) return;
|
||||
|
||||
const settings = await getAppSettings();
|
||||
const media = normalizeMediaUrl(sourceUrl);
|
||||
if (!settings.allowedProviders.includes(media.provider)) return;
|
||||
|
||||
await prisma.mediaSource.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
|
||||
49
src/lib/profile-actions.ts
Normal file
49
src/lib/profile-actions.ts
Normal 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");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "./prisma";
|
||||
import { requireCurrentUser } from "./session";
|
||||
import { getAppSettings } from "./settings";
|
||||
|
||||
function normalizeSlug(value: string) {
|
||||
return value
|
||||
@@ -17,8 +18,9 @@ function normalizeSlug(value: string) {
|
||||
|
||||
export async function createRoom(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const settings = await getAppSettings();
|
||||
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);
|
||||
|
||||
if (!name || !baseSlug) {
|
||||
|
||||
69
src/lib/settings-actions.ts
Normal file
69
src/lib/settings-actions.ts
Normal 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
47
src/lib/settings.ts
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { getAppSettings } from "./settings";
|
||||
import { getCurrentUser, userIsAdmin } from "./session";
|
||||
|
||||
type CurrentUser = NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>;
|
||||
|
||||
export async function getShellContext(user: CurrentUser) {
|
||||
const [rooms, pendingRequests, activeRoomCount] = await Promise.all([
|
||||
const [rooms, pendingRequests, activeRoomCount, settings] = await Promise.all([
|
||||
prisma.room.findMany({
|
||||
where: {
|
||||
OR: [{ ownerId: user.id }, { members: { some: { userId: user.id } } }, { visibility: "PUBLIC" }]
|
||||
@@ -14,12 +15,15 @@ export async function getShellContext(user: CurrentUser) {
|
||||
take: 8
|
||||
}),
|
||||
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
|
||||
prisma.room.count({ where: { mediaSources: { some: {} } } })
|
||||
prisma.room.count({ where: { mediaSources: { some: {} } } }),
|
||||
getAppSettings()
|
||||
]);
|
||||
|
||||
return {
|
||||
isAdmin: userIsAdmin(user),
|
||||
userName: user.displayName || user.username,
|
||||
userAvatarUrl: user.avatarUrl,
|
||||
instanceName: settings.instanceName,
|
||||
pendingRequests,
|
||||
activeRoomCount,
|
||||
rooms: rooms.map((room) => ({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { Prisma, type User } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
import { clearSession, setSession } from "./session";
|
||||
import { getAppSettings } from "./settings";
|
||||
|
||||
function normalizeUsername(value: FormDataEntryValue | null) {
|
||||
return String(value || "")
|
||||
@@ -14,6 +15,11 @@ function normalizeUsername(value: FormDataEntryValue | null) {
|
||||
}
|
||||
|
||||
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 password = String(formData.get("password") || "");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user