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

@@ -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,

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 { 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) {

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 { 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) => ({

View File

@@ -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") || "");