Add player controls and configurable profiles
This commit is contained in:
@@ -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