Complete WatchLink V1 realtime features
Some checks failed
Template Compliance / compliance (push) Successful in 7s
Release Dry Run / release-dry-run (push) Failing after 1m8s
Build / build (push) Failing after 1m15s

This commit is contained in:
MrSphay
2026-05-15 23:27:18 +02:00
parent 04d75c386f
commit c1ac6e4142
25 changed files with 1775 additions and 253 deletions

View File

@@ -2,7 +2,7 @@
## Project
`WatchLink` is a Dockerized Next.js + Postgres web app for persistent shared watch rooms with accounts, friends, roles, permissions, admin setup, and realtime playback sync.
`WatchLink` is a Dockerized Next.js + Postgres web app for persistent shared watch rooms with accounts, friends, roles, permissions, admin setup, persistent realtime playback sync, invites, room chat, and profile uploads.
Repository:
@@ -37,6 +37,12 @@ Database setup:
Prisma migrations live in prisma/migrations and are applied in Docker with prisma migrate deploy.
```
Runtime uploads:
```text
Avatar uploads are stored in /app/public/uploads and mounted through the avatar-uploads Docker volume.
```
Package manager:
```text

View File

@@ -6,3 +6,4 @@ POSTGRES_USER="watchlink"
POSTGRES_PASSWORD="watchlink"
HOST_PORT="3000"
# Uploaded avatars are stored in the Docker volume `avatar-uploads`.
# YouTube embeds may require viewers to open the video on YouTube when Google shows a sign-in or bot-check challenge.

View File

@@ -9,11 +9,12 @@ WatchLink is a self-hosted shared-watch web app with persistent user rooms, loca
- First-run setup creates the first admin user.
- Username/password accounts with bcrypt password hashing.
- 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.
- Admin surface for rooms, users, roles, and permissions.
- Friend requests and room access for friends-only, public, role-restricted, and explicit access.
- Admin surface for rooms, users, roles, invites, security settings, and audit events.
- 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.
- Profile settings with display name, profile picture upload, password change, and logout.
- Shared playback state via Socket.IO with Postgres-persisted room state.
- Persistent room chat and queue updates through the realtime room channel.
- Media URL normalization for YouTube, Twitch, and direct video URLs.
- Uptime Kuma/Dockge-inspired app shell with system light/dark theme.
- Docker Compose stack for app and Postgres.
@@ -115,6 +116,14 @@ The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` a
On first start, the web container runs `prisma migrate deploy` before starting Next.js. This creates the required tables in a clean Postgres volume.
### Provider notes
WatchLink uses the official YouTube IFrame API. If YouTube shows a sign-in or bot-check challenge inside the embed, WatchLink cannot bypass that provider-side restriction; the room shows a fallback action to open the video on YouTube. Twitch live streams support source and play/pause sync, while seek sync is available only for Twitch VODs and direct video files.
### Uploads and invites
Uploaded avatars are stored below `/app/public/uploads` in the `avatar-uploads` Docker volume. Invite-only registration uses invite codes created in the Admin area; room-scoped invite codes also grant room membership when the account is created.
Build and publish the Gitea image:
```bash

View File

@@ -0,0 +1,59 @@
-- WatchLink V1 completion support: persistent realtime state, messages, audit,
-- invites, disabled users, and protected personal rooms.
CREATE TYPE "InviteStatus" AS ENUM ('ACTIVE', 'REVOKED', 'USED', 'EXPIRED');
ALTER TABLE "User" ADD COLUMN "disabledAt" TIMESTAMP(3);
ALTER TABLE "Room" ADD COLUMN "isPersonal" BOOLEAN NOT NULL DEFAULT false;
UPDATE "Room"
SET "isPersonal" = true
WHERE "slug" LIKE '@%';
CREATE TABLE "RoomMessage" (
"id" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"userId" TEXT,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RoomMessage_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AuditEvent" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"roomId" TEXT,
"action" TEXT NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Invite" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"roomId" TEXT,
"creatorId" TEXT,
"status" "InviteStatus" NOT NULL DEFAULT 'ACTIVE',
"expiresAt" TIMESTAMP(3),
"usedById" TEXT,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
CREATE INDEX "RoomMessage_roomId_createdAt_idx" ON "RoomMessage"("roomId", "createdAt");
CREATE INDEX "AuditEvent_roomId_createdAt_idx" ON "AuditEvent"("roomId", "createdAt");
CREATE INDEX "AuditEvent_actorId_createdAt_idx" ON "AuditEvent"("actorId", "createdAt");
CREATE INDEX "Invite_roomId_status_idx" ON "Invite"("roomId", "status");
ALTER TABLE "RoomMessage" ADD CONSTRAINT "RoomMessage_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RoomMessage" ADD CONSTRAINT "RoomMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -33,12 +33,20 @@ enum MediaProvider {
UNKNOWN
}
enum InviteStatus {
ACTIVE
REVOKED
USED
EXPIRED
}
model User {
id String @id @default(cuid())
username String @unique
passwordHash String
displayName String?
avatarUrl String?
disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
@@ -47,6 +55,9 @@ model User {
gotFriends Friendship[] @relation("FriendReceiver")
roomMembers RoomMember[]
submitted MediaSource[]
messages RoomMessage[]
auditEvents AuditEvent[]
invites Invite[]
}
model AppSetting {
@@ -108,6 +119,7 @@ model Room {
slug String @unique
name String
ownerId String?
isPersonal Boolean @default(false)
visibility RoomVisibility @default(FRIENDS)
currentState Json?
createdAt DateTime @default(now())
@@ -115,6 +127,9 @@ model Room {
owner User? @relation("RoomOwner", fields: [ownerId], references: [id], onDelete: SetNull)
members RoomMember[]
mediaSources MediaSource[]
messages RoomMessage[]
auditEvents AuditEvent[]
invites Invite[]
}
model RoomMember {
@@ -141,3 +156,45 @@ model MediaSource {
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
}
model RoomMessage {
id String @id @default(cuid())
roomId String
userId String?
body String
createdAt DateTime @default(now())
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([roomId, createdAt])
}
model AuditEvent {
id String @id @default(cuid())
actorId String?
roomId String?
action String
metadata Json?
createdAt DateTime @default(now())
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
room Room? @relation(fields: [roomId], references: [id], onDelete: SetNull)
@@index([roomId, createdAt])
@@index([actorId, createdAt])
}
model Invite {
id String @id @default(cuid())
code String @unique
roomId String?
creatorId String?
status InviteStatus @default(ACTIVE)
expiresAt DateTime?
usedById String?
usedAt DateTime?
createdAt DateTime @default(now())
room Room? @relation(fields: [roomId], references: [id], onDelete: Cascade)
creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull)
@@index([roomId, status])
}

501
server.js
View File

@@ -1,70 +1,495 @@
const { createHmac, timingSafeEqual, randomBytes } = require("node:crypto");
const { createServer } = require("node:http");
const { PrismaClient } = require("@prisma/client");
const next = require("next");
const { Server } = require("socket.io");
const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOSTNAME || "0.0.0.0";
const port = Number(process.env.PORT || 3000);
const sessionCookie = "watchlink_session";
const prisma = new PrismaClient();
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
const roomStates = new Map();
const presence = new Map();
app.prepare().then(() => {
const httpServer = createServer((req, res) => handle(req, res));
const io = new Server(httpServer, {
path: "/api/socket",
cors: {
origin: process.env.NEXTAUTH_URL || "http://localhost:3000"
origin: process.env.NEXTAUTH_URL || "http://localhost:3000",
credentials: true
}
});
io.on("connection", (socket) => {
socket.on("room:join", ({ roomSlug, user }) => {
if (!roomSlug) return;
socket.join(roomSlug);
socket.data.roomSlug = roomSlug;
socket.data.user = user || "Guest";
socket.emit("room:state", roomStates.get(roomSlug) || null);
socket.to(roomSlug).emit("presence:join", { user: socket.data.user });
socket.on("room:join", async ({ roomSlug } = {}) => {
await safeSocket(socket, async () => {
const session = await getSocketSession(socket);
if (!session || !roomSlug) return reject(socket, "Sign in to join this room.");
const context = await getRoomContext(roomSlug, session.user.id);
if (!context.allowed) return reject(socket, "You do not have access to this room.");
socket.data.user = session.user;
socket.data.roomSlug = context.room.slug;
socket.data.roomId = context.room.id;
socket.join(context.room.slug);
addPresence(context.room.slug, session.user, socket.id);
socket.emit("room:state", await buildRoomSnapshot(context.room.id));
io.to(context.room.slug).emit("presence:list", getPresence(context.room.slug));
});
});
socket.on("media:set", (payload) => updateRoom(socket, "media:set", payload));
socket.on("playback:play", (payload) => updateRoom(socket, "playback:play", payload));
socket.on("playback:pause", (payload) => updateRoom(socket, "playback:pause", payload));
socket.on("playback:seek", (payload) => updateRoom(socket, "playback:seek", payload));
socket.on("chat:message", (payload) => relayRoom(socket, "chat:message", payload));
socket.on("queue:add", (payload) => safeRoomAction(socket, async ({ room, user }) => {
const sourceUrl = String(payload?.sourceUrl || "").trim();
if (!sourceUrl) return;
const settings = await getAppSettings();
const media = normalizeMediaUrl(sourceUrl);
if (!settings.allowedProviders.includes(media.provider)) return reject(socket, "This media provider is disabled.");
const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
const created = await prisma.mediaSource.create({
data: {
roomId: room.id,
submitterId: user.id,
provider: media.provider,
originalUrl: media.originalUrl,
playbackUrl: media.playbackUrl,
thumbnailUrl: media.thumbnailUrl,
queuePosition: nextPosition + 1,
title: media.title || media.originalUrl
}
});
if (!room.currentState) {
await persistPlaybackState(room.id, user, {
mediaSourceId: created.id,
status: "PAUSED",
position: 0,
rate: 1
});
}
await audit("room.queue.add", user.id, room.id, { mediaSourceId: created.id, provider: media.provider });
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("queue:play", (payload) => safeRoomAction(socket, async ({ room, user }) => {
const mediaSourceId = String(payload?.mediaSourceId || "");
const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } });
if (!media) return;
await persistPlaybackState(room.id, user, { mediaSourceId: media.id, status: "PLAYING", position: 0, rate: 1 });
await audit("room.queue.play", user.id, room.id, { mediaSourceId: media.id });
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("queue:remove", (payload) => safeRoomAction(socket, async ({ room, user }) => {
const mediaSourceId = String(payload?.mediaSourceId || "");
const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } });
if (!media) return;
await prisma.mediaSource.delete({ where: { id: media.id } });
await normalizeQueue(room.id);
const current = parseState(room.currentState);
if (current?.mediaSourceId === media.id) {
const nextMedia = await prisma.mediaSource.findFirst({
where: { roomId: room.id },
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }]
});
await prisma.room.update({
where: { id: room.id },
data: {
currentState: nextMedia
? playbackState(user, { mediaSourceId: nextMedia.id, status: "PAUSED", position: 0, rate: 1 })
: null
}
});
}
await audit("room.queue.remove", user.id, room.id, { mediaSourceId: media.id });
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("queue:move", (payload) => safeRoomAction(socket, async ({ room, user }) => {
const mediaSourceId = String(payload?.mediaSourceId || "");
const direction = payload?.direction === "down" ? 1 : -1;
await moveMedia(room.id, mediaSourceId, direction);
await audit("room.queue.move", user.id, room.id, { mediaSourceId, direction });
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("playback:play", (payload) => safeRoomAction(socket, async ({ room, user }) => {
await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""),
status: "PLAYING",
position: Number(payload?.position || 0),
rate: 1
});
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("playback:pause", (payload) => safeRoomAction(socket, async ({ room, user }) => {
await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""),
status: "PAUSED",
position: Number(payload?.position || 0),
rate: 1
});
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("playback:seek", (payload) => safeRoomAction(socket, async ({ room, user }) => {
const previous = parseState(room.currentState);
await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || previous?.mediaSourceId || ""),
status: previous?.status || "PAUSED",
position: Number(payload?.position || 0),
rate: 1
});
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("chat:message", (payload) => safeRoomAction(socket, async ({ room, user }) => {
const body = String(payload?.body || "").trim().slice(0, 1000);
if (!body) return;
await prisma.roomMessage.create({ data: { roomId: room.id, userId: user.id, body } });
await audit("room.chat.message", user.id, room.id, {});
await broadcastRoom(io, room.slug, room.id);
}));
socket.on("disconnect", () => {
if (socket.data.roomSlug) {
socket.to(socket.data.roomSlug).emit("presence:leave", { user: socket.data.user || "Guest" });
const roomSlug = socket.data.roomSlug;
const user = socket.data.user;
if (roomSlug && user) {
removePresence(roomSlug, socket.id);
io.to(roomSlug).emit("presence:list", getPresence(roomSlug));
}
});
});
function relayRoom(socket, event, payload) {
const roomSlug = socket.data.roomSlug;
if (!roomSlug) return;
io.to(roomSlug).emit(event, { ...payload, user: socket.data.user || "Guest", at: Date.now() });
}
function updateRoom(socket, event, payload) {
const roomSlug = socket.data.roomSlug;
if (!roomSlug) return;
const previous = roomStates.get(roomSlug) || {};
const nextState = {
...previous,
...payload,
lastEvent: event,
updatedBy: socket.data.user || "Guest",
updatedAt: Date.now()
};
roomStates.set(roomSlug, nextState);
io.to(roomSlug).emit(event, nextState);
}
httpServer.listen(port, hostname, () => {
console.log(`WatchLink ready on http://${hostname}:${port}`);
});
});
async function safeSocket(socket, action) {
try {
await action();
} catch (error) {
console.error(error);
reject(socket, "Realtime action failed.");
}
}
async function safeRoomAction(socket, action) {
await safeSocket(socket, async () => {
const user = socket.data.user || (await getSocketSession(socket))?.user;
const roomSlug = socket.data.roomSlug;
if (!user || !roomSlug) return reject(socket, "Join a room before sending actions.");
const context = await getRoomContext(roomSlug, user.id);
if (!context.allowed) return reject(socket, "You do not have access to this room.");
await action({ room: context.room, user });
});
}
function reject(socket, message) {
socket.emit("room:error", { message });
}
async function broadcastRoom(io, roomSlug, roomId) {
io.to(roomSlug).emit("room:state", await buildRoomSnapshot(roomId));
}
async function getSocketSession(socket) {
const cookies = parseCookies(socket.handshake.headers.cookie || "");
const raw = cookies[sessionCookie];
if (!raw) return null;
const [userId, signature] = raw.split(".");
if (!userId || !signature || !verifySignature(userId, signature)) return null;
const user = await prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } }
});
if (!user || user.disabledAt) return null;
return { user };
}
function parseCookies(header) {
return Object.fromEntries(
header
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const index = part.indexOf("=");
return [decodeURIComponent(part.slice(0, index)), decodeURIComponent(part.slice(index + 1))];
})
);
}
function verifySignature(value, signature) {
const expected = createHmac("sha256", process.env.NEXTAUTH_SECRET || "development-only-change-me")
.update(value)
.digest("base64url");
const expectedBuffer = Buffer.from(expected);
const actualBuffer = Buffer.from(signature);
return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
}
async function getRoomContext(slug, userId) {
const room = await prisma.room.findUnique({
where: { slug },
include: {
owner: true,
members: { where: { userId }, select: { userId: true, canManage: true } }
}
});
if (!room) return { allowed: false, room: null };
const user = await prisma.user.findUnique({ where: { id: userId }, include: { roles: { include: { role: true } } } });
const isAdmin = Boolean(user?.roles.some((userRole) => userRole.role.name === "admin"));
const isOwner = room.ownerId === userId;
const explicitMember = room.members.length > 0;
const isFriend = room.ownerId
? Boolean(
await prisma.friendship.findFirst({
where: {
status: "ACCEPTED",
OR: [
{ requesterId: userId, receiverId: room.ownerId },
{ requesterId: room.ownerId, receiverId: userId }
]
},
select: { id: true }
})
)
: false;
const allowed =
isAdmin ||
isOwner ||
room.visibility === "PUBLIC" ||
(room.visibility === "FRIENDS" && (isFriend || explicitMember)) ||
(room.visibility === "EXPLICIT" && explicitMember) ||
(room.visibility === "ROLE_RESTRICTED" && explicitMember);
return { allowed, room };
}
async function buildRoomSnapshot(roomId) {
const room = await prisma.room.findUnique({
where: { id: roomId },
include: {
mediaSources: {
include: { submitter: true },
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }],
take: 60
},
messages: {
include: { user: true },
orderBy: { createdAt: "desc" },
take: 50
}
}
});
if (!room) return null;
const queue = room.mediaSources.map((item) => serializeMedia(item));
const state = hydrateState(parseState(room.currentState), queue);
return {
roomId: room.id,
roomSlug: room.slug,
queue,
playback: state,
messages: room.messages
.slice()
.reverse()
.map((message) => ({
id: message.id,
body: message.body,
createdAt: message.createdAt.toISOString(),
user: message.user?.displayName || message.user?.username || "Deleted user",
avatarUrl: message.user?.avatarUrl || null
}))
};
}
function hydrateState(state, queue) {
const current = queue.find((item) => item.id === state?.mediaSourceId) || queue[0] || null;
if (!current) return null;
const updatedAt = Number(state?.updatedAt || Date.now());
const basePosition = Number(state?.position || 0);
const status = state?.status === "PLAYING" ? "PLAYING" : "PAUSED";
const livePosition = status === "PLAYING" ? basePosition + Math.max(0, Date.now() - updatedAt) / 1000 : basePosition;
return {
...state,
mediaSourceId: current.id,
media: current,
status,
position: livePosition,
rate: Number(state?.rate || 1),
updatedAt
};
}
function serializeMedia(item) {
return {
id: item.id,
title: item.title || item.originalUrl,
provider: item.provider,
originalUrl: item.originalUrl,
playbackUrl: item.playbackUrl,
thumbnailUrl: item.thumbnailUrl,
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
createdAt: new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(item.createdAt)
};
}
async function persistPlaybackState(roomId, user, input) {
if (!input.mediaSourceId) return;
const media = await prisma.mediaSource.findFirst({ where: { id: input.mediaSourceId, roomId } });
if (!media) return;
await prisma.room.update({
where: { id: roomId },
data: {
currentState: playbackState(user, input)
}
});
}
function playbackState(user, input) {
return {
mediaSourceId: input.mediaSourceId,
status: input.status === "PLAYING" ? "PLAYING" : "PAUSED",
position: Math.max(0, Number(input.position || 0)),
rate: Number(input.rate || 1),
updatedBy: user.username,
updatedById: user.id,
updatedAt: Date.now()
};
}
function parseState(value) {
return value && typeof value === "object" ? value : null;
}
async function moveMedia(roomId, mediaSourceId, direction) {
const queue = await prisma.mediaSource.findMany({
where: { roomId },
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: { id: true }
});
const index = queue.findIndex((item) => item.id === mediaSourceId);
const target = index + direction;
if (index < 0 || target < 0 || target >= queue.length) return;
const reordered = [...queue];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
await prisma.$transaction(
reordered.map((item, itemIndex) =>
prisma.mediaSource.update({ where: { id: item.id }, data: { queuePosition: itemIndex + 1 } })
)
);
}
async function normalizeQueue(roomId) {
const queue = await prisma.mediaSource.findMany({
where: { roomId },
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: { id: true }
});
await prisma.$transaction(
queue.map((item, index) =>
prisma.mediaSource.update({ where: { id: item.id }, data: { queuePosition: index + 1 } })
)
);
}
function addPresence(roomSlug, user, socketId) {
const rows = presence.get(roomSlug) || new Map();
const existing = rows.get(user.id);
rows.set(user.id, {
id: user.id,
name: user.displayName || user.username,
avatarUrl: user.avatarUrl || null,
status: "Online",
sockets: new Set([...(existing?.sockets || []), socketId])
});
presence.set(roomSlug, rows);
}
function removePresence(roomSlug, socketId) {
const rows = presence.get(roomSlug);
if (!rows) return;
for (const user of rows.values()) {
user.sockets.delete(socketId);
if (user.sockets.size === 0) rows.delete(user.id);
}
}
function getPresence(roomSlug) {
return [...(presence.get(roomSlug)?.values() || [])].map(({ sockets, ...user }) => user);
}
async function getAppSettings() {
const rows = await prisma.appSetting.findMany();
const values = new Map(rows.map((row) => [row.key, row.value]));
return {
allowedProviders: (values.get("allowedProviders") || "YOUTUBE,TWITCH,DIRECT")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
};
}
async function audit(action, actorId, roomId, metadata) {
await prisma.auditEvent.create({ data: { action, actorId, roomId, metadata } }).catch(() => {});
}
function normalizeMediaUrl(input) {
const originalUrl = input.trim();
try {
const url = new URL(originalUrl);
const host = url.hostname.replace(/^www\./, "");
if (host === "youtu.be") {
return youtubeMedia(originalUrl, url.pathname.slice(1));
}
if (host.endsWith("youtube.com")) {
return youtubeMedia(originalUrl, url.searchParams.get("v") || url.pathname.split("/").filter(Boolean).pop());
}
if (host.endsWith("twitch.tv")) {
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] === "videos" && parts[1]) {
return {
provider: "TWITCH",
originalUrl,
playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=localhost`,
thumbnailUrl: "/icon.svg",
title: originalUrl
};
}
if (parts[0]) {
return {
provider: "TWITCH",
originalUrl,
playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=localhost`,
thumbnailUrl: "/icon.svg",
title: originalUrl
};
}
}
if (/\.(mp4|webm|ogg|mov|m4v)(\?.*)?$/i.test(url.pathname)) {
return { provider: "DIRECT", originalUrl, playbackUrl: originalUrl, thumbnailUrl: "/icon.svg", title: originalUrl };
}
} catch {}
return { provider: "UNKNOWN", originalUrl, playbackUrl: originalUrl, thumbnailUrl: "/icon.svg", title: originalUrl };
}
function youtubeMedia(originalUrl, id) {
const videoId = String(id || "").replace(/[^a-zA-Z0-9_-]/g, "");
return {
provider: videoId ? "YOUTUBE" : "UNKNOWN",
originalUrl,
playbackUrl: videoId ? `https://www.youtube.com/embed/${videoId}?enablejsapi=1&playsinline=1` : originalUrl,
thumbnailUrl: videoId ? `https://img.youtube.com/vi/${videoId}/hqdefault.jpg` : "/icon.svg",
title: originalUrl
};
}

View File

@@ -1,7 +1,7 @@
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 { changePassword, updateProfile } from "@/lib/profile-actions";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { getAppSettings } from "@/lib/settings";
@@ -33,6 +33,7 @@ export default async function AccountSettingsPage({ searchParams }: { searchPara
<Panel title="Profile Settings" eyebrow="Account">
{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}
{error === "password" ? <p className="form-error">Check your current password and use a matching new password with at least 10 characters.</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} />
@@ -55,6 +56,24 @@ export default async function AccountSettingsPage({ searchParams }: { searchPara
<Panel title="Sessions" eyebrow="Security">
<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 title="Password" eyebrow="Security">
{saved === "password" ? <p className="form-success">Password changed.</p> : null}
<form className="form compact-form" action={changePassword}>
<label>
Current password
<input className="input" name="currentPassword" type="password" autoComplete="current-password" required />
</label>
<label>
New password
<input className="input" name="newPassword" type="password" minLength={10} autoComplete="new-password" required />
</label>
<label>
Confirm new password
<input className="input" name="confirmPassword" type="password" minLength={10} autoComplete="new-password" required />
</label>
<button className="button primary" type="submit">Change password</button>
</form>
</Panel>
</section>
</AppShell>
);

View File

@@ -12,6 +12,8 @@ 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";
import { createInstanceInvite, disableUser, enableUser, grantAdminRole, revokeAdminRole, revokeInvite } from "@/lib/admin-actions";
import { deleteRoom } from "@/lib/room-actions";
export const dynamic = "force-dynamic";
@@ -30,7 +32,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
const query = q.trim().toLowerCase();
const shell = await getShellContext(user);
const [users, rooms, roles, pendingRequests, appSettings] = await Promise.all([
const [users, rooms, roles, pendingRequests, appSettings, invites, auditEvents] = await Promise.all([
prisma.user.findMany({
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
orderBy: { createdAt: "asc" }
@@ -44,7 +46,9 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
orderBy: { name: "asc" }
}),
prisma.friendship.count({ where: { status: "PENDING" } }),
getAppSettings()
getAppSettings(),
prisma.invite.findMany({ include: { room: true, creator: true }, orderBy: { createdAt: "desc" }, take: 50 }),
prisma.auditEvent.findMany({ include: { actor: true, room: true }, orderBy: { createdAt: "desc" }, take: 20 })
]);
const filteredUsers = query
? users.filter((account) =>
@@ -74,8 +78,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
}
actions={
<>
<button className="button" type="button" disabled title="Invite records are not implemented yet."><Plus size={16} /> Invite</button>
<button className="button" type="button" disabled title="Audit events need a persisted event table first."><Shield size={16} /> Audit</button>
<Link className="button" href="/admin?tab=Invites"><Plus size={16} /> Invite</Link>
<Link className="button" href="/admin?tab=Security"><Shield size={16} /> Audit</Link>
</>
}
/>
@@ -102,29 +106,32 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
>
<Tabs active={activeTab} items={adminTabs.map((item) => ({ label: item, href: `/admin?tab=${encodeURIComponent(item)}` }))} />
{activeTab === "Users" ? <UsersTable users={filteredUsers} /> : null}
{activeTab === "Users" ? <UsersTable users={filteredUsers} currentUserId={user.id} /> : null}
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null}
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} invites={invites} rooms={rooms} /> : null}
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} settings={appSettings} saved={saved} /> : null}
{activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} /> : null}
{activeTab === "Security" ? <SecurityPanel settings={appSettings} saved={saved} auditEvents={auditEvents} /> : null}
</Panel>
</AppShell>
);
}
function UsersTable({
users
users,
currentUserId
}: {
users: Array<{
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
disabledAt: Date | null;
createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>;
_count: { ownedRooms: number; roomMembers: number };
}>;
currentUserId: string;
}) {
return (
<DataTable>
@@ -161,8 +168,33 @@ function UsersTable({
</div>
</td>
<td>{account._count.ownedRooms + account._count.roomMembers}</td>
<td>{formatDate(account.createdAt)}</td>
<td><button className="button compact-button" type="button" disabled title="User edit and ban actions are not implemented yet.">Manage</button></td>
<td>{account.disabledAt ? <StatusBadge tone="danger">disabled</StatusBadge> : formatDate(account.createdAt)}</td>
<td>
<div className="row-actions">
{account.roles.some((role) => role.role.name === "admin") ? (
<form action={revokeAdminRole}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button" type="submit" disabled={account.id === currentUserId}>Revoke admin</button>
</form>
) : (
<form action={grantAdminRole}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button" type="submit">Make admin</button>
</form>
)}
{account.disabledAt ? (
<form action={enableUser}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button" type="submit">Enable</button>
</form>
) : (
<form action={disableUser}>
<input type="hidden" name="userId" value={account.id} />
<button className="button compact-button danger" type="submit" disabled={account.id === currentUserId}>Disable</button>
</form>
)}
</div>
</td>
</tr>
))}
</tbody>
@@ -179,6 +211,7 @@ function RoomsTable({
slug: string;
name: string;
visibility: string;
isPersonal: boolean;
updatedAt: Date;
owner: { username: string; displayName: string | null } | null;
_count: { members: number; mediaSources: number };
@@ -210,7 +243,19 @@ function RoomsTable({
<td><StatusBadge>{room.visibility.toLowerCase().replace("_", " ")}</StatusBadge></td>
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
<td>{formatDate(room.updatedAt)}</td>
<td><Link className="button compact-button" href={`/rooms/${encodeURIComponent(room.slug)}`}>Open</Link></td>
<td>
<div className="row-actions">
<Link className="button compact-button" href={`/rooms/${encodeURIComponent(room.slug)}`}>Open</Link>
{!room.isPersonal ? (
<form action={deleteRoom}>
<input type="hidden" name="roomId" value={room.id} />
<button className="button compact-button danger" type="submit">Delete</button>
</form>
) : (
<StatusBadge>personal</StatusBadge>
)}
</div>
</td>
</tr>
))}
</tbody>
@@ -260,11 +305,65 @@ function RolesTable({
);
}
function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
function InvitesPanel({
pendingRequests,
invites,
rooms
}: {
pendingRequests: number;
invites: Array<{
id: string;
code: string;
status: string;
expiresAt: Date | null;
createdAt: Date;
room: { id: string; name: string; slug: string } | null;
creator: { username: string; displayName: string | null } | null;
}>;
rooms: Array<{ id: string; name: string; slug: string }>;
}) {
return (
<div className="split-grid">
<Panel title="Room Invites" eyebrow="Pending">
<EmptyState title="Room invite creation is not wired yet" description="The management surface is reserved so invite CRUD can be added without a layout change." />
<Panel title="Create Invite" eyebrow="Access">
<form className="form compact-form" action={createInstanceInvite}>
<label>
Room access
<select className="input" name="roomId" defaultValue="">
<option value="">Instance registration only</option>
{rooms.map((room) => (
<option value={room.id} key={room.id}>{room.name} /{room.slug}</option>
))}
</select>
</label>
<label>
Expires after days
<input className="input" name="expiresDays" type="number" min="0" max="365" defaultValue="7" />
</label>
<button className="button primary" type="submit">Create invite</button>
</form>
</Panel>
<Panel title="Invites" eyebrow={`${invites.length} recent`}>
{invites.length === 0 ? (
<EmptyState title="No invites yet" description="Create an invite to allow registration or room access workflows." />
) : (
<div className="settings-list">
{invites.map((invite) => (
<div className="setting-row" key={invite.id}>
<span>
<strong>{invite.code}</strong>
<small>{invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - {formatDate(invite.createdAt)}</small>
</span>
<StatusBadge tone={invite.status === "ACTIVE" ? "good" : "warn"}>{invite.status.toLowerCase()}</StatusBadge>
{invite.status === "ACTIVE" ? (
<form action={revokeInvite}>
<input type="hidden" name="inviteId" value={invite.id} />
<button className="button compact-button danger" type="submit">Revoke</button>
</form>
) : null}
</div>
))}
</div>
)}
</Panel>
<Panel title="Friend Requests" eyebrow="Instance">
<div className="setting-row">
@@ -337,7 +436,21 @@ function InstancePanel({ userCount, roomCount, settings, saved }: { userCount: n
);
}
function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: string }) {
function SecurityPanel({
settings,
saved,
auditEvents
}: {
settings: AppSettings;
saved?: string;
auditEvents: Array<{
id: string;
action: string;
createdAt: Date;
actor: { username: string; displayName: string | null } | null;
room: { slug: string; name: string } | null;
}>;
}) {
return (
<div className="split-grid">
<Panel title="Security Controls" eyebrow="Policy">
@@ -360,7 +473,27 @@ function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: str
</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." />
<div className="settings-list">
<StatusDot tone="good" label="User disable actions enabled" />
<StatusDot tone="good" label="Non-personal room deletion enabled" />
<StatusDot tone="good" label="Registration mode controls enabled" />
</div>
</Panel>
<Panel title="Audit Events" eyebrow="Recent">
{auditEvents.length === 0 ? (
<EmptyState title="No audit events yet" description="Admin, room, queue, and chat actions will appear here." />
) : (
<div className="settings-list">
{auditEvents.map((event) => (
<div className="setting-row" key={event.id}>
<span>
<strong>{event.action}</strong>
<small>{event.actor?.displayName || event.actor?.username || "System"} - {event.room?.slug || "instance"} - {formatDate(event.createdAt)}</small>
</span>
</div>
))}
</div>
)}
</Panel>
</div>
);

View File

@@ -213,6 +213,11 @@ textarea {
color: var(--accent-2);
}
.badge.danger {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
color: var(--danger);
}
.room-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
@@ -242,6 +247,7 @@ textarea {
}
.video-frame {
position: relative;
display: grid;
min-height: 430px;
place-items: center;
@@ -274,6 +280,40 @@ textarea {
border: 0;
}
.youtube-embed iframe {
width: 100%;
height: 100%;
border: 0;
}
.player-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-content: center;
gap: 12px;
padding: 24px;
background: rgba(5, 7, 10, 0.78);
color: #e5edf7;
text-align: center;
}
.player-overlay h2,
.player-overlay p {
margin: 0;
}
.compact-overlay {
inset: auto 16px 16px 16px;
place-content: start;
justify-items: start;
border: 1px solid #263449;
border-radius: 8px;
background: rgba(15, 23, 42, 0.94);
text-align: left;
}
.video-state h2 {
margin: 0 0 10px;
font-size: 24px;
@@ -687,6 +727,25 @@ textarea {
padding: 12px;
}
.provider-note {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-top: 1px solid var(--border);
padding: 8px 12px;
color: var(--muted);
font-size: 12px;
}
.provider-note a {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--accent-2);
font-weight: 800;
}
.queue-row,
.timeline-item,
.action-row,
@@ -700,6 +759,18 @@ textarea {
padding: 10px;
}
.queue-row.active-row {
border-color: color-mix(in srgb, var(--accent-2) 70%, var(--border));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-2) 28%, transparent);
}
.chat-messages {
display: grid;
max-height: 420px;
gap: 10px;
overflow: auto;
}
.timeline-item.interactive:hover,
.action-row:hover {
border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
@@ -850,6 +921,11 @@ textarea {
color: var(--danger);
}
.button.danger {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
color: var(--danger);
}
.text-link,
.inline-meta {
display: inline-flex;

View File

@@ -28,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">{error === "registration-closed" ? "Registration is currently disabled by the administrator." : "Invalid username or password."}</p> : null}
{error ? <p className="form-error">{loginError(error)}</p> : null}
<form className="form" action={loginUser}>
<label>
Username
@@ -54,3 +54,9 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
</main>
);
}
function loginError(error: string) {
if (error === "registration-closed") return "Registration is currently disabled by the administrator.";
if (error === "disabled") return "This account has been disabled by an administrator.";
return "Invalid username or password.";
}

View File

@@ -4,7 +4,7 @@ import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { StatusBadge } from "@/components/status-badge";
import { DataTable, EmptyState, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { acceptFriendRequest, declineFriendRequest, sendFriendRequest } from "@/lib/friend-actions";
import { acceptFriendRequest, blockUser, cancelFriendRequest, declineFriendRequest, removeFriendship, sendFriendRequest, unblockUser } from "@/lib/friend-actions";
import { prisma } from "@/lib/prisma";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
@@ -121,7 +121,7 @@ export default async function PeoplePage({ searchParams }: { searchParams: Promi
{activeView === "Incoming" ? <IncomingList requests={filteredIncoming} /> : null}
{activeView === "Outgoing" ? <FriendshipList currentUserId={user.id} friendships={filteredOutgoing} empty="No outgoing friend requests." /> : null}
{activeView === "Blocked" ? <FriendshipList currentUserId={user.id} friendships={filteredBlocked} empty="No blocked users." /> : null}
{activeView === "Directory" ? <Directory rows={directory} /> : null}
{activeView === "Directory" ? <Directory rows={directory} currentUserId={user.id} /> : null}
</Panel>
</AppShell>
);
@@ -163,9 +163,32 @@ function FriendshipList({
</div>
<div className="row-actions">
<StatusBadge tone={friendship.status === "ACCEPTED" ? "good" : "warn"}>{friendship.status.toLowerCase()}</StatusBadge>
<button className="button compact-button" type="button" disabled title="Friend removal needs a dedicated server action.">
{friendship.status === "PENDING" && friendship.requesterId === currentUserId ? (
<form action={cancelFriendRequest}>
<input type="hidden" name="friendshipId" value={friendship.id} />
<button className="button compact-button" type="submit">Cancel</button>
</form>
) : null}
{friendship.status === "BLOCKED" ? (
<form action={unblockUser}>
<input type="hidden" name="friendshipId" value={friendship.id} />
<button className="button compact-button" type="submit">Unblock</button>
</form>
) : null}
{friendship.status === "ACCEPTED" ? (
<>
<form action={removeFriendship}>
<input type="hidden" name="friendshipId" value={friendship.id} />
<button className="button compact-button" type="submit">
<UserMinus size={14} /> Remove
</button>
</form>
<form action={blockUser}>
<input type="hidden" name="targetId" value={person.id} />
<button className="button compact-button danger" type="submit">Block</button>
</form>
</>
) : null}
</div>
</div>
);
@@ -207,12 +230,14 @@ function IncomingList({ requests }: { requests: FriendshipWithUsers[] }) {
}
function Directory({
rows
rows,
currentUserId
}: {
rows: Array<{
user: DirectoryUser;
relationship?: FriendshipWithUsers;
}>;
currentUserId: string;
}) {
if (rows.length === 0) {
return <EmptyState title="No users registered" description="New accounts will appear here after registration." />;
@@ -233,6 +258,7 @@ function Directory({
{rows.map(({ user, relationship }) => {
const roomSlug = user.ownedRooms[0]?.slug;
const accepted = relationship?.status === "ACCEPTED";
const blocked = relationship?.status === "BLOCKED";
return (
<tr key={user.id}>
<td>
@@ -248,12 +274,23 @@ function Directory({
<td><StatusBadge tone={accepted ? "good" : relationship ? "warn" : undefined}>{relationship?.status.toLowerCase() || "none"}</StatusBadge></td>
<td>
{!relationship ? (
<div className="row-actions">
<form action={sendFriendRequest}>
<input type="hidden" name="receiverId" value={user.id} />
<button className="button primary compact-button" type="submit">
<UserPlus size={14} /> Add
</button>
</form>
<form action={blockUser}>
<input type="hidden" name="targetId" value={user.id} />
<button className="button compact-button danger" type="submit">Block</button>
</form>
</div>
) : blocked && relationship.requesterId === currentUserId ? (
<form action={unblockUser}>
<input type="hidden" name="friendshipId" value={relationship.id} />
<button className="button compact-button" type="submit">Unblock</button>
</form>
) : null}
</td>
</tr>

View File

@@ -11,7 +11,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
redirect("/setup");
}
const settings = await getAppSettings();
if (settings.registrationMode !== "OPEN") {
if (settings.registrationMode === "DISABLED") {
redirect("/login?error=registration-closed");
}
const { error } = await searchParams;
@@ -41,6 +41,12 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro
Password
<input className="input" name="password" type="password" autoComplete="new-password" minLength={10} required />
</label>
{settings.registrationMode === "INVITE_ONLY" ? (
<label>
Invite code
<input className="input" name="inviteCode" autoComplete="off" required />
</label>
) : null}
<button className="button primary" type="submit">
Create account
</button>
@@ -57,5 +63,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.";
if (error === "invite") return "Use a valid active invite code.";
return "Use a username and a password with at least 10 characters.";
}

View File

@@ -95,6 +95,7 @@ export default async function RoomPage({
roomSlug={room.slug}
roomName={room.name}
roomVisibility={room.visibility}
isPersonal={room.isPersonal}
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
initialRail={rail}
savedState={saved}

View File

@@ -133,7 +133,7 @@ export default async function RoomsPage({ searchParams }: { searchParams: Promis
title={`No ${activeView.toLowerCase()} rooms`}
description={
activeView === "Invites"
? "Room invite persistence is not implemented yet; shared rooms appear in the Shared tab after access is granted."
? "Invite codes grant access during registration; accepted shared rooms appear in the Shared tab."
: "Rooms appear here when ownership, membership, or visibility matches this tab."
}
/>

View File

@@ -88,6 +88,7 @@ async function createFirstAdmin(formData: FormData) {
slug: `@${username}`,
name: `${username}'s room`,
ownerId: createdUser.id,
isPersonal: true,
visibility: "FRIENDS"
}
});

View File

@@ -1,22 +1,46 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2, UserPlus } from "lucide-react";
import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media";
import { addMediaToRoom, moveMediaDown, moveMediaUp, removeMediaFromRoom, setCurrentMedia } from "@/lib/media-actions";
import { addRoomMember, updateRoomSettings } from "@/lib/room-actions";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ChevronDown,
ChevronUp,
ExternalLink,
MessageSquareText,
Pause,
Play,
Radio,
RotateCcw,
Settings2,
ShieldCheck,
SkipForward,
Trash2,
UserPlus
} from "lucide-react";
import { io, type Socket } from "socket.io-client";
import { addRoomMember, deleteRoom, removeRoomMember, resetPersonalRoom, updateRoomSettings } from "@/lib/room-actions";
import { Avatar } from "./avatar";
import { StatusBadge } from "./status-badge";
import { EmptyState, Panel, StatusDot } from "./ui";
const socket = io({
path: "/api/socket",
autoConnect: false
});
declare global {
interface Window {
YT?: {
Player: new (
element: HTMLElement,
options: {
videoId: string;
width: string;
height: string;
playerVars: Record<string, string | number>;
events: {
onReady?: () => void;
onError?: () => void;
};
}
) => YouTubePlayer;
};
onYouTubeIframeAPIReady?: () => void;
Twitch?: {
Player: new (
element: HTMLElement,
@@ -33,10 +57,19 @@ declare global {
}
}
type YouTubePlayer = {
playVideo: () => void;
pauseVideo: () => void;
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
getCurrentTime: () => number;
destroy: () => void;
};
type TwitchPlayer = {
play: () => void;
pause: () => void;
seek?: (seconds: number) => void;
getCurrentTime?: () => number;
destroy?: () => void;
};
@@ -51,7 +84,29 @@ type QueueItem = {
createdAt: string;
};
type MediaCommand = Pick<QueueItem, "provider" | "originalUrl" | "playbackUrl"> | null;
type RoomMessage = {
id: string;
body: string;
createdAt: string;
user: string;
avatarUrl?: string | null;
};
type PlaybackState = {
mediaSourceId: string;
media: QueueItem;
status: "PLAYING" | "PAUSED";
position: number;
rate: number;
updatedAt: number;
updatedBy?: string;
};
type RoomSnapshot = {
queue: QueueItem[];
playback: PlaybackState | null;
messages: RoomMessage[];
};
type Participant = {
id: string;
@@ -66,6 +121,7 @@ export function RoomConsole({
roomSlug,
roomName,
roomVisibility,
isPersonal,
ownerName,
initialRail = "Activity",
savedState,
@@ -78,6 +134,7 @@ export function RoomConsole({
roomSlug: string;
roomName: string;
roomVisibility: string;
isPersonal: boolean;
ownerName: string;
initialRail?: string;
savedState?: string;
@@ -89,77 +146,151 @@ export function RoomConsole({
const [connected, setConnected] = useState(false);
const [source, setSource] = useState("");
const [rail, setRail] = useState(["Activity", "Chat", "Invite", "Settings"].includes(initialRail) ? initialRail : "Activity");
const [activeQueueItem, setActiveQueueItem] = useState<QueueItem | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [queueState, setQueueState] = useState(queue);
const [messages, setMessages] = useState<RoomMessage[]>([]);
const [presence, setPresence] = useState<Participant[]>(participants);
const [playback, setPlayback] = useState<PlaybackState | null>(queue[0] ? initialPlayback(queue[0]) : null);
const [socketError, setSocketError] = useState("");
const [syncBlocked, setSyncBlocked] = useState(false);
const [playerReady, setPlayerReady] = useState(false);
const [providerIssue, setProviderIssue] = useState("");
const socketRef = useRef<Socket | null>(null);
const youtubePlayerRef = useRef<YouTubePlayer | 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 || activeQueueItem || queue[0] || null;
const userActivatedRef = useRef(false);
const lastAppliedRef = useRef("");
function connect() {
if (!socket.connected) {
socket.connect();
socket.emit("room:join", { roomSlug, user: currentUser });
const activeMedia = playback?.media || queueState[0] || null;
const participantRows = useMemo(() => mergePresence(participants, presence), [participants, presence]);
const handlePlayerReady = useCallback(() => setPlayerReady(true), []);
const handleProviderIssue = useCallback((message: string) => setProviderIssue(message), []);
const emit = useCallback((event: string, payload: Record<string, unknown> = {}) => {
setSocketError("");
socketRef.current?.emit(event, payload);
}, []);
const currentPosition = useCallback(() => {
if (activeMedia?.provider === "YOUTUBE") return youtubePlayerRef.current?.getCurrentTime?.() || playback?.position || 0;
if (activeMedia?.provider === "TWITCH") return twitchPlayerRef.current?.getCurrentTime?.() || playback?.position || 0;
if (activeMedia?.provider === "DIRECT") return videoRef.current?.currentTime || playback?.position || 0;
return playback?.position || 0;
}, [activeMedia?.provider, playback?.position]);
const applyPlayback = useCallback(
async (state: PlaybackState | null, force = false) => {
if (!state?.media || !playerReady) return;
const key = `${state.mediaSourceId}:${state.status}:${Math.floor(state.position)}:${state.updatedAt}`;
if (!force && lastAppliedRef.current === key) return;
lastAppliedRef.current = key;
const target = Math.max(0, state.position);
setProviderIssue("");
if (state.status === "PLAYING" && !userActivatedRef.current) {
setSyncBlocked(true);
return;
}
try {
if (state.media.provider === "YOUTUBE" && youtubePlayerRef.current) {
youtubePlayerRef.current.seekTo(target, true);
if (state.status === "PLAYING") youtubePlayerRef.current.playVideo();
if (state.status === "PAUSED") youtubePlayerRef.current.pauseVideo();
}
if (state.media.provider === "TWITCH" && twitchPlayerRef.current) {
if (state.status === "PLAYING") twitchPlayerRef.current.play();
if (state.status === "PAUSED") twitchPlayerRef.current.pause();
if (isTwitchVod(state.media.playbackUrl) && twitchPlayerRef.current.seek) {
twitchPlayerRef.current.seek(target);
}
}
if (state.media.provider === "DIRECT" && videoRef.current) {
if (Math.abs(videoRef.current.currentTime - target) > 1.5) videoRef.current.currentTime = target;
if (state.status === "PLAYING") await videoRef.current.play();
if (state.status === "PAUSED") videoRef.current.pause();
}
setSyncBlocked(false);
} catch {
setSyncBlocked(state.status === "PLAYING");
}
},
[playerReady]
);
useEffect(() => {
const socket = io({ path: "/api/socket", autoConnect: false, withCredentials: true });
socketRef.current = socket;
socket.on("connect", () => {
setConnected(true);
}
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek", mediaOverride?: MediaCommand) {
connect();
const media = mediaOverride || currentMedia;
socket.emit(event, {
provider: media?.provider,
originalUrl: media?.originalUrl,
playbackUrl: media?.playbackUrl,
position: event === "playback:seek" ? 82 : undefined
socket.emit("room:join", { roomSlug });
});
}
socket.on("disconnect", () => setConnected(false));
socket.on("room:error", ({ message }: { message: string }) => setSocketError(message));
socket.on("presence:list", (rows: Participant[]) => setPresence(rows));
socket.on("room:state", (snapshot: RoomSnapshot | null) => {
if (!snapshot) return;
setQueueState(snapshot.queue || []);
setMessages(snapshot.messages || []);
setPlayback(snapshot.playback);
});
socket.connect();
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [roomSlug]);
function postYoutubeCommand(func: string, args: unknown[] = []) {
iframeRef.current?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args }), "https://www.youtube.com");
}
useEffect(() => {
setPlayerReady(false);
setProviderIssue("");
lastAppliedRef.current = "";
}, [activeMedia?.id]);
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]);
}
useEffect(() => {
void applyPlayback(playback);
}, [applyPlayback, playback]);
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);
}
useEffect(() => {
const timer = window.setInterval(() => {
if (!playback || playback.status !== "PLAYING" || !activeMedia) return;
const drift = Math.abs(currentPosition() - playback.position);
if (drift > 3) void applyPlayback(playback, true);
}, 5000);
return () => window.clearInterval(timer);
}, [activeMedia, applyPlayback, currentPosition, playback]);
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 activateSync() {
userActivatedRef.current = true;
setSyncBlocked(false);
void applyPlayback(playback, true);
}
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");
userActivatedRef.current = true;
if (!activeMedia) return;
const position = event === "playback:seek" ? currentPosition() + 30 : currentPosition();
emit(event, { mediaSourceId: activeMedia.id, position });
}
function playQueueItem(item: QueueItem) {
setActiveQueueItem(item);
function addSource(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
userActivatedRef.current = true;
const sourceUrl = source.trim();
if (!sourceUrl) return;
emit("queue:add", { sourceUrl });
setSource("");
emit("media:set", item);
window.setTimeout(() => {
postYoutubeCommand("playVideo");
twitchPlayerRef.current?.play();
void videoRef.current?.play();
}, 800);
}
function submitAndPlay(item: QueueItem) {
playQueueItem(item);
function sendMessage(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const input = form.elements.namedItem("body") as HTMLInputElement | null;
const body = input?.value.trim() || "";
if (!body) return;
emit("chat:message", { body });
form.reset();
}
return (
@@ -171,21 +302,48 @@ export function RoomConsole({
eyebrow={`/${roomSlug}`}
actions={
<div className="status-row">
<StatusDot tone={connected ? "good" : "warn"} label={connected ? "connected" : "local preview"} />
<StatusDot tone={connected ? "good" : "warn"} label={connected ? "connected" : "connecting"} />
<StatusBadge>{formatVisibility(roomVisibility)}</StatusBadge>
</div>
}
>
{socketError ? <p className="form-error">{socketError}</p> : null}
<div className="video-frame">
{currentMedia ? (
{activeMedia ? (
<>
<MediaPreview
provider={currentMedia.provider}
playbackUrl={currentMedia.playbackUrl}
title={currentMedia.title || currentMedia.originalUrl}
iframeRef={iframeRef}
provider={activeMedia.provider}
playbackUrl={activeMedia.playbackUrl}
originalUrl={activeMedia.originalUrl}
title={activeMedia.title || activeMedia.originalUrl}
youtubePlayerRef={youtubePlayerRef}
videoRef={videoRef}
twitchPlayerRef={twitchPlayerRef}
onReady={handlePlayerReady}
onIssue={handleProviderIssue}
/>
{syncBlocked ? (
<div className="player-overlay">
<StatusBadge tone="warn">Action needed</StatusBadge>
<h2>Enable sync in this browser</h2>
<p>Browsers can block remote autoplay until you interact with the page.</p>
<button className="button primary animated-button" type="button" onClick={activateSync}>
<Play size={16} /> Enable sync
</button>
</div>
) : null}
{providerIssue ? (
<div className="player-overlay compact-overlay">
<StatusBadge tone="warn">Provider</StatusBadge>
<p>{providerIssue}</p>
{activeMedia.provider === "YOUTUBE" ? (
<a className="button compact-button" href={activeMedia.originalUrl} target="_blank" rel="noreferrer">
<ExternalLink size={14} /> Open on YouTube
</a>
) : null}
</div>
) : null}
</>
) : (
<div className="video-state">
<StatusBadge>Idle</StatusBadge>
@@ -195,15 +353,20 @@ export function RoomConsole({
)}
</div>
<form className="transport-bar" action={addMediaToRoom}>
<input type="hidden" name="roomId" value={roomId} />
<button className="icon-button" type="button" onClick={() => handleTransport("playback:play")} title="Play">
<form className="transport-bar" onSubmit={addSource}>
<button className="icon-button animated-button" type="button" onClick={() => handleTransport("playback:play")} title="Play" disabled={!activeMedia}>
<Play size={16} />
</button>
<button className="icon-button" type="button" onClick={() => handleTransport("playback:pause")} title="Pause">
<button className="icon-button animated-button" type="button" onClick={() => handleTransport("playback:pause")} title="Pause" disabled={!activeMedia}>
<Pause size={16} />
</button>
<button className="icon-button" type="button" onClick={() => handleTransport("playback:seek")} title="Seek">
<button
className="icon-button animated-button"
type="button"
onClick={() => handleTransport("playback:seek")}
title="Seek 30 seconds"
disabled={!activeMedia || (activeMedia.provider === "TWITCH" && !isTwitchVod(activeMedia.playbackUrl))}
>
<SkipForward size={16} />
</button>
<input
@@ -214,20 +377,28 @@ 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", previewMedia)} disabled={!source}>
<button className="button primary animated-button" type="submit" disabled={!source.trim()}>
<Radio size={16} /> Add source
</button>
</form>
{activeMedia?.provider === "YOUTUBE" ? (
<div className="provider-note">
<span>YouTube may require sign-in in embeds.</span>
<a href={activeMedia.originalUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Open on YouTube
</a>
</div>
) : null}
</Panel>
<div className="console-grid">
<Panel title="Queue" eyebrow={`${queue.length} sources`}>
{queue.length === 0 ? (
<Panel title="Queue" eyebrow={`${queueState.length} sources`}>
{queueState.length === 0 ? (
<EmptyState title="Queue is empty" description="Sources added by room participants will appear here with moderation actions." />
) : (
<div className="queue-list">
{queue.map((item, index) => (
<div className="queue-row" key={item.id}>
{queueState.map((item, index) => (
<div className={`queue-row ${playback?.mediaSourceId === item.id ? "active-row" : ""}`} key={item.id}>
<span className="queue-index">{index + 1}</span>
<div className="queue-thumbnail">
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt="" /> : <span>{item.provider.slice(0, 2)}</span>}
@@ -237,30 +408,30 @@ export function RoomConsole({
<span>{item.provider} by {item.by} - {item.createdAt}</span>
</div>
<div className="row-actions">
<form action={setCurrentMedia} onSubmit={() => submitAndPlay(item)}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact animated-button" type="submit" title="Play now">
<button className="icon-button compact animated-button" type="button" title="Play now" onClick={() => emit("queue:play", { mediaSourceId: item.id })}>
<Play size={14} />
</button>
</form>
<form action={moveMediaUp}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact animated-button" type="submit" title="Move up" disabled={index === 0}>
<button
className="icon-button compact animated-button"
type="button"
title="Move up"
disabled={index === 0}
onClick={() => emit("queue:move", { mediaSourceId: item.id, direction: "up" })}
>
<ChevronUp size={14} />
</button>
</form>
<form action={moveMediaDown}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact animated-button" type="submit" title="Move down" disabled={index === queue.length - 1}>
<button
className="icon-button compact animated-button"
type="button"
title="Move down"
disabled={index === queueState.length - 1}
onClick={() => emit("queue:move", { mediaSourceId: item.id, direction: "down" })}
>
<ChevronDown size={14} />
</button>
</form>
<form action={removeMediaFromRoom}>
<input type="hidden" name="mediaId" value={item.id} />
<button className="icon-button compact danger animated-button" type="submit" title="Remove">
<button className="icon-button compact danger animated-button" type="button" title="Remove" onClick={() => emit("queue:remove", { mediaSourceId: item.id })}>
<Trash2 size={14} />
</button>
</form>
</div>
</div>
))}
@@ -268,11 +439,11 @@ export function RoomConsole({
)}
</Panel>
<Panel title="Participants" eyebrow={`${participants.length} present or allowed`}>
{participants.length === 0 ? (
<Panel title="Participants" eyebrow={`${participantRows.length} present or allowed`}>
{participantRows.length === 0 ? (
<EmptyState title="No participants listed" description="Room presence appears when users open the room." />
) : (
participants.map((item) => (
participantRows.map((item) => (
<div className="row" key={item.id}>
<div className="row-main">
<Avatar name={item.name} src={item.avatarUrl} />
@@ -281,7 +452,7 @@ export function RoomConsole({
<span>{item.role}</span>
</div>
</div>
<StatusBadge tone={item.status === "Buffering" ? "warn" : "good"}>{item.status}</StatusBadge>
<StatusBadge tone={item.status === "Online" ? "good" : undefined}>{item.status}</StatusBadge>
</div>
))
)}
@@ -301,10 +472,10 @@ export function RoomConsole({
{rail === "Activity" ? (
<div className="timeline-list">
{queue.length === 0 ? (
{queueState.length === 0 ? (
<EmptyState title="No activity yet" description="Playback and queue events will stream here." />
) : (
queue.slice(0, 8).map((item) => (
queueState.slice(0, 8).map((item) => (
<div className="timeline-item" key={`activity-${item.id}`}>
<Radio size={15} />
<div className="row-title">
@@ -319,11 +490,25 @@ export function RoomConsole({
{rail === "Chat" ? (
<div className="chat-panel">
<EmptyState title="Chat is not enabled yet" description="Socket-backed messages need persistence and moderation before this input is enabled." />
<label className="chat-input">
<div className="chat-messages">
{messages.length === 0 ? (
<EmptyState title="No chat messages" description="Messages in this room will persist here." />
) : (
messages.map((message) => (
<div className="timeline-item" key={message.id}>
<Avatar name={message.user} src={message.avatarUrl} size={26} />
<div className="row-title">
<strong>{message.user}</strong>
<span>{message.body}</span>
</div>
</div>
))
)}
</div>
<form className="chat-input" onSubmit={sendMessage}>
<MessageSquareText size={15} />
<input placeholder="Message disabled until chat persistence is added" disabled />
</label>
<input name="body" placeholder="Message this room" maxLength={1000} />
</form>
</div>
) : null}
@@ -345,6 +530,28 @@ export function RoomConsole({
<UserPlus size={16} /> Add member
</button>
</form>
{participants.filter((participant) => participant.role !== "Owner").length > 0 ? (
<div className="settings-list">
{participants
.filter((participant) => participant.role !== "Owner")
.map((participant) => (
<div className="setting-row" key={`member-${participant.id}`}>
<Avatar name={participant.name} src={participant.avatarUrl} size={26} />
<span>
<strong>{participant.name}</strong>
<small>{participant.role}</small>
</span>
<form action={removeRoomMember}>
<input type="hidden" name="roomId" value={roomId} />
<input type="hidden" name="userId" value={participant.id} />
<button className="icon-button compact danger animated-button" type="submit" title="Remove member">
<Trash2 size={14} />
</button>
</form>
</div>
))}
</div>
) : null}
</div>
) : null}
@@ -370,10 +577,28 @@ export function RoomConsole({
<Settings2 size={16} /> Save room settings
</button>
</form>
<button className="button animated-button" type="button" onClick={() => emit("queue:remove", { mediaSourceId: playback?.mediaSourceId })} disabled={!playback?.mediaSourceId}>
<RotateCcw size={16} /> Remove current source
</button>
{isPersonal ? (
<form action={resetPersonalRoom}>
<input type="hidden" name="roomId" value={roomId} />
<button className="button danger animated-button" type="submit">
<RotateCcw size={16} /> Reset personal room
</button>
</form>
) : (
<form action={deleteRoom}>
<input type="hidden" name="roomId" value={roomId} />
<button className="button danger animated-button" type="submit">
<Trash2 size={16} /> Delete room
</button>
</form>
)}
<SettingRow icon={<ShieldCheck size={16} />} label="Current visibility" value={formatVisibility(roomVisibility)} />
<SettingRow icon={<Play size={16} />} label="Playback control" value="All authorized participants" />
<SettingRow icon={<Settings2 size={16} />} label="Owner" value={ownerName} />
<SettingRow icon={<Radio size={16} />} label="Sync mode" value="Socket.IO room channel" />
<SettingRow icon={<Radio size={16} />} label="Sync mode" value="Persistent Socket.IO state" />
</div>
) : null}
</Panel>
@@ -382,54 +607,36 @@ export function RoomConsole({
);
}
function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="setting-row">
{icon}
<span>
<strong>{label}</strong>
<small>{value}</small>
</span>
</div>
);
}
function MediaPreview({
provider,
playbackUrl,
originalUrl,
title,
iframeRef,
youtubePlayerRef,
videoRef,
twitchPlayerRef
twitchPlayerRef,
onReady,
onIssue
}: {
provider: string;
playbackUrl: string;
originalUrl: string;
title: string;
iframeRef: React.RefObject<HTMLIFrameElement | null>;
youtubePlayerRef: React.MutableRefObject<YouTubePlayer | null>;
videoRef: React.RefObject<HTMLVideoElement | null>;
twitchPlayerRef: React.MutableRefObject<TwitchPlayer | null>;
onReady: () => void;
onIssue: (message: string) => void;
}) {
if (provider === "YOUTUBE") {
return (
<iframe
ref={iframeRef}
className="media-embed"
src={withBrowserOrigin(playbackUrl)}
title={title}
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen
/>
);
return <YouTubePreview playbackUrl={playbackUrl} originalUrl={originalUrl} title={title} playerRef={youtubePlayerRef} onReady={onReady} onIssue={onIssue} />;
}
if (provider === "TWITCH") {
return <TwitchPreview playbackUrl={playbackUrl} title={title} twitchPlayerRef={twitchPlayerRef} />;
return <TwitchPreview playbackUrl={playbackUrl} title={title} twitchPlayerRef={twitchPlayerRef} onReady={onReady} onIssue={onIssue} />;
}
if (provider === "DIRECT") {
return <video ref={videoRef} className="media-embed" src={playbackUrl} controls playsInline />;
return <video ref={videoRef} className="media-embed" src={playbackUrl} controls playsInline onCanPlay={onReady} />;
}
return (
<div className="video-state">
<StatusBadge tone="warn">Unsupported</StatusBadge>
@@ -440,14 +647,84 @@ function MediaPreview({
);
}
function YouTubePreview({
playbackUrl,
originalUrl,
title,
playerRef,
onReady,
onIssue
}: {
playbackUrl: string;
originalUrl: string;
title: string;
playerRef: React.MutableRefObject<YouTubePlayer | null>;
onReady: () => void;
onIssue: (message: string) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let disposed = false;
const timeout = window.setTimeout(() => {
if (!disposed && !playerRef.current) {
onIssue("YouTube did not expose a controllable player. If YouTube asks for sign-in, open the video directly.");
}
}, 6000);
async function mount() {
await loadYouTubeApi();
if (disposed || !containerRef.current || !window.YT) return;
playerRef.current?.destroy();
const videoId = getYouTubeId(playbackUrl) || getYouTubeId(originalUrl);
if (!videoId) {
onIssue("This YouTube URL does not contain a usable video id.");
return;
}
playerRef.current = new window.YT.Player(containerRef.current, {
videoId,
width: "100%",
height: "100%",
playerVars: {
origin: window.location.origin,
playsinline: 1,
rel: 0,
modestbranding: 1
},
events: {
onReady: () => {
window.clearTimeout(timeout);
onReady();
},
onError: () => onIssue("YouTube refused this embed. Open the video on YouTube or choose another source.")
}
});
}
void mount();
return () => {
disposed = true;
window.clearTimeout(timeout);
playerRef.current?.destroy();
playerRef.current = null;
};
}, [originalUrl, playbackUrl, playerRef, onIssue, onReady]);
return <div ref={containerRef} className="media-embed youtube-embed" role="region" aria-label={title} />;
}
function TwitchPreview({
playbackUrl,
title,
twitchPlayerRef
twitchPlayerRef,
onReady,
onIssue
}: {
playbackUrl: string;
title: string;
twitchPlayerRef: React.MutableRefObject<TwitchPlayer | null>;
onReady: () => void;
onIssue: (message: string) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -455,13 +732,11 @@ function TwitchPreview({
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%",
@@ -470,20 +745,45 @@ function TwitchPreview({
autoplay: false,
...options
});
onReady();
if (!isTwitchVod(playbackUrl)) onIssue("Twitch live streams support source and play/pause sync; seeking is unavailable.");
}
void mount();
return () => {
disposed = true;
twitchPlayerRef.current?.destroy?.();
twitchPlayerRef.current = null;
};
}, [playbackUrl, twitchPlayerRef]);
}, [playbackUrl, twitchPlayerRef, onIssue, onReady]);
return <div ref={containerRef} className="media-embed twitch-embed" role="region" aria-label={title} />;
}
function loadYouTubeApi() {
if (window.YT?.Player) return Promise.resolve();
const existing = document.querySelector<HTMLScriptElement>('script[src="https://www.youtube.com/iframe_api"]');
if (existing) {
return new Promise<void>((resolve) => {
const previous = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
previous?.();
resolve();
};
});
}
return new Promise<void>((resolve) => {
const previous = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
previous?.();
resolve();
};
const script = document.createElement("script");
script.src = "https://www.youtube.com/iframe_api";
script.async = true;
document.head.appendChild(script);
});
}
function loadTwitchApi() {
if (window.Twitch) return Promise.resolve();
const existing = document.querySelector<HTMLScriptElement>('script[src="https://player.twitch.tv/js/embed/v1.js"]');
@@ -493,7 +793,6 @@ function loadTwitchApi() {
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";
@@ -515,19 +814,55 @@ function getTwitchOptions(playbackUrl: string) {
}
}
function withBrowserOrigin(playbackUrl: string) {
if (typeof window === "undefined") return playbackUrl;
function getYouTubeId(value: string) {
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();
const url = new URL(value);
if (url.hostname.includes("youtu.be")) return url.pathname.slice(1);
if (url.searchParams.get("v")) return url.searchParams.get("v");
const embed = url.pathname.match(/\/embed\/([^/?]+)/);
return embed?.[1] || null;
} catch {
return playbackUrl;
return null;
}
}
function isTwitchVod(playbackUrl: string) {
try {
return new URL(playbackUrl).searchParams.has("video");
} catch {
return false;
}
}
function initialPlayback(item: QueueItem): PlaybackState {
return {
mediaSourceId: item.id,
media: item,
status: "PAUSED",
position: 0,
rate: 1,
updatedAt: Date.now()
};
}
function mergePresence(allowed: Participant[], online: Participant[]) {
const rows = new Map(allowed.map((item) => [item.id, item]));
for (const item of online) rows.set(item.id, { ...rows.get(item.id), ...item, role: rows.get(item.id)?.role || "Participant" });
return [...rows.values()];
}
function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="setting-row">
{icon}
<span>
<strong>{label}</strong>
<small>{value}</small>
</span>
</div>
);
}
function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " ");
}

97
src/lib/admin-actions.ts Normal file
View File

@@ -0,0 +1,97 @@
"use server";
import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser, userIsAdmin } from "./session";
async function requireAdmin() {
const user = await requireCurrentUser();
if (!userIsAdmin(user)) redirect("/dashboard");
return user;
}
export async function disableUser(formData: FormData) {
const admin = await requireAdmin();
const userId = String(formData.get("userId") || "");
if (!userId || userId === admin.id) return;
await prisma.user.update({ where: { id: userId }, data: { disabledAt: new Date() } });
await audit(admin.id, "admin.user.disable", { userId });
revalidateAdmin();
}
export async function enableUser(formData: FormData) {
const admin = await requireAdmin();
const userId = String(formData.get("userId") || "");
if (!userId) return;
await prisma.user.update({ where: { id: userId }, data: { disabledAt: null } });
await audit(admin.id, "admin.user.enable", { userId });
revalidateAdmin();
}
export async function grantAdminRole(formData: FormData) {
const admin = await requireAdmin();
const userId = String(formData.get("userId") || "");
if (!userId) return;
const role = await prisma.role.upsert({
where: { name: "admin" },
update: {},
create: { name: "admin", description: "Full system administrator" }
});
await prisma.userRole.upsert({
where: { userId_roleId: { userId, roleId: role.id } },
update: {},
create: { userId, roleId: role.id }
});
await audit(admin.id, "admin.user.role.grant", { userId, role: "admin" });
revalidateAdmin();
}
export async function revokeAdminRole(formData: FormData) {
const admin = await requireAdmin();
const userId = String(formData.get("userId") || "");
if (!userId || userId === admin.id) return;
const role = await prisma.role.findUnique({ where: { name: "admin" } });
if (!role) return;
await prisma.userRole.deleteMany({ where: { userId, roleId: role.id } });
await audit(admin.id, "admin.user.role.revoke", { userId, role: "admin" });
revalidateAdmin();
}
export async function createInstanceInvite(formData: FormData) {
const admin = await requireAdmin();
const roomId = String(formData.get("roomId") || "") || null;
const expiresDays = Number(formData.get("expiresDays") || 0);
const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null;
const invite = await prisma.invite.create({
data: {
code: randomBytes(12).toString("base64url"),
creatorId: admin.id,
roomId,
expiresAt
}
});
await audit(admin.id, "admin.invite.create", { inviteId: invite.id, roomId });
revalidateAdmin();
redirect("/admin?tab=Invites&saved=1");
}
export async function revokeInvite(formData: FormData) {
const admin = await requireAdmin();
const inviteId = String(formData.get("inviteId") || "");
if (!inviteId) return;
await prisma.invite.update({ where: { id: inviteId }, data: { status: "REVOKED" } });
await audit(admin.id, "admin.invite.revoke", { inviteId });
revalidateAdmin();
}
async function audit(actorId: string, action: string, metadata: Record<string, unknown>) {
await prisma.auditEvent.create({ data: { actorId, action, metadata } });
}
function revalidateAdmin() {
revalidatePath("/admin");
revalidatePath("/dashboard");
revalidatePath("/rooms");
}

View File

@@ -52,6 +52,82 @@ export async function declineFriendRequest(formData: FormData) {
await updateIncomingRequest(formData, "DECLINED");
}
export async function removeFriendship(formData: FormData) {
const user = await requireCurrentUser();
const friendshipId = String(formData.get("friendshipId") || "");
if (!friendshipId) return;
await prisma.friendship.deleteMany({
where: {
id: friendshipId,
OR: [{ requesterId: user.id }, { receiverId: user.id }]
}
});
revalidatePeople();
}
export async function cancelFriendRequest(formData: FormData) {
const user = await requireCurrentUser();
const friendshipId = String(formData.get("friendshipId") || "");
if (!friendshipId) return;
await prisma.friendship.deleteMany({
where: {
id: friendshipId,
requesterId: user.id,
status: "PENDING"
}
});
revalidatePeople();
}
export async function blockUser(formData: FormData) {
const user = await requireCurrentUser();
const targetId = String(formData.get("targetId") || "");
if (!targetId || targetId === user.id) return;
const existing = await prisma.friendship.findFirst({
where: {
OR: [
{ requesterId: user.id, receiverId: targetId },
{ requesterId: targetId, receiverId: user.id }
]
},
select: { id: true }
});
if (existing) {
await prisma.friendship.update({
where: { id: existing.id },
data: { requesterId: user.id, receiverId: targetId, status: "BLOCKED" }
});
} else {
await prisma.friendship.create({
data: { requesterId: user.id, receiverId: targetId, status: "BLOCKED" }
});
}
revalidatePeople();
}
export async function unblockUser(formData: FormData) {
const user = await requireCurrentUser();
const friendshipId = String(formData.get("friendshipId") || "");
if (!friendshipId) return;
await prisma.friendship.deleteMany({
where: {
id: friendshipId,
requesterId: user.id,
status: "BLOCKED"
}
});
revalidatePeople();
}
async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "DECLINED") {
const user = await requireCurrentUser();
const friendshipId = String(formData.get("friendshipId") || "");
@@ -70,3 +146,9 @@ async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "D
revalidatePath("/people");
revalidatePath("/dashboard");
}
function revalidatePeople() {
revalidatePath("/friends");
revalidatePath("/people");
revalidatePath("/dashboard");
}

View File

@@ -29,7 +29,7 @@ export async function addMediaToRoom(formData: FormData) {
const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
await prisma.mediaSource.create({
const created = await prisma.mediaSource.create({
data: {
roomId: room.id,
submitterId: user.id,
@@ -49,6 +49,10 @@ export async function addMediaToRoom(formData: FormData) {
provider: media.provider,
originalUrl: media.originalUrl,
playbackUrl: media.playbackUrl,
mediaSourceId: created.id,
status: "PAUSED",
position: 0,
rate: 1,
updatedBy: user.username,
updatedAt: Date.now()
}
@@ -106,6 +110,9 @@ export async function setCurrentMedia(formData: FormData) {
originalUrl: media.originalUrl,
playbackUrl: media.playbackUrl,
mediaSourceId: media.id,
status: "PLAYING",
position: 0,
rate: 1,
updatedBy: user.username,
updatedAt: Date.now()
}

View File

@@ -2,6 +2,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { compare, hash } from "bcryptjs";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
@@ -47,3 +48,27 @@ export async function updateProfile(formData: FormData) {
revalidatePath("/dashboard");
redirect("/account/settings?saved=1");
}
export async function changePassword(formData: FormData) {
const user = await requireCurrentUser();
const currentPassword = String(formData.get("currentPassword") || "");
const newPassword = String(formData.get("newPassword") || "");
const confirmPassword = String(formData.get("confirmPassword") || "");
if (newPassword.length < 10 || newPassword !== confirmPassword) {
redirect("/account/settings?error=password");
}
const account = await prisma.user.findUnique({ where: { id: user.id }, select: { passwordHash: true } });
if (!account || !(await compare(currentPassword, account.passwordHash))) {
redirect("/account/settings?error=password");
}
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: await hash(newPassword, 12) }
});
revalidatePath("/account/settings");
redirect("/account/settings?saved=password");
}

View File

@@ -54,6 +54,65 @@ export async function createRoom(formData: FormData) {
redirect(`/rooms/${encodeURIComponent(room.slug)}`);
}
export async function deleteRoom(formData: FormData) {
const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || "");
if (!roomId) return;
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
});
if (!room || room.isPersonal || !canManageRoom(user, room)) return;
await prisma.$transaction([
prisma.auditEvent.create({
data: {
actorId: user.id,
roomId: room.id,
action: "room.delete",
metadata: { slug: room.slug, name: room.name }
}
}),
prisma.room.delete({ where: { id: room.id } })
]);
revalidatePath("/rooms");
revalidatePath("/dashboard");
redirect("/rooms?deleted=1");
}
export async function resetPersonalRoom(formData: FormData) {
const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || "");
if (!roomId) return;
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
});
if (!room || !room.isPersonal || !canManageRoom(user, room)) return;
await prisma.$transaction([
prisma.mediaSource.deleteMany({ where: { roomId: room.id } }),
prisma.roomMessage.deleteMany({ where: { roomId: room.id } }),
prisma.room.update({ where: { id: room.id }, data: { currentState: null } }),
prisma.auditEvent.create({
data: {
actorId: user.id,
roomId: room.id,
action: "room.reset",
metadata: { slug: room.slug }
}
})
]);
revalidateRoom(room.slug);
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Settings&saved=1`);
}
export async function updateRoomSettings(formData: FormData) {
const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || "");
@@ -112,6 +171,33 @@ export async function addRoomMember(formData: FormData) {
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
}
export async function removeRoomMember(formData: FormData) {
const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || "");
const userId = String(formData.get("userId") || "");
if (!roomId || !userId) return;
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
});
if (!room || !canManageRoom(user, room) || userId === room.ownerId) return;
await prisma.roomMember.deleteMany({ where: { roomId: room.id, userId } });
await prisma.auditEvent.create({
data: {
actorId: user.id,
roomId: room.id,
action: "room.member.remove",
metadata: { userId }
}
});
revalidateRoom(room.slug);
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
}
function canManageRoom(
user: Awaited<ReturnType<typeof requireCurrentUser>>,
room: { ownerId: string | null; members: Array<{ canManage: boolean }> }

View File

@@ -45,10 +45,11 @@ export async function getCurrentUser() {
return null;
}
return prisma.user.findUnique({
const user = await prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } }
});
return user?.disabledAt ? null : user;
}
export function userIsAdmin(user: Awaited<ReturnType<typeof getCurrentUser>>) {

View File

@@ -16,22 +16,36 @@ function normalizeUsername(value: FormDataEntryValue | null) {
export async function registerUser(formData: FormData) {
const settings = await getAppSettings();
if (settings.registrationMode !== "OPEN") {
if (settings.registrationMode === "DISABLED") {
redirect("/register?error=closed");
}
const username = normalizeUsername(formData.get("username"));
const password = String(formData.get("password") || "");
const inviteCode = String(formData.get("inviteCode") || "").trim();
if (!username || password.length < 10) {
redirect("/register?error=invalid");
}
const invite =
settings.registrationMode === "INVITE_ONLY"
? await prisma.invite.findUnique({ where: { code: inviteCode } })
: null;
if (
settings.registrationMode === "INVITE_ONLY" &&
(!invite || invite.status !== "ACTIVE" || (invite.expiresAt && invite.expiresAt.getTime() < Date.now()))
) {
redirect("/register?error=invite");
}
const passwordHash = await hash(password, 12);
let user: User;
try {
user = await prisma.user.create({
user = await prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username,
displayName: username,
@@ -40,11 +54,27 @@ export async function registerUser(formData: FormData) {
create: {
slug: `@${username}`,
name: `${username}'s room`,
isPersonal: true,
visibility: "FRIENDS"
}
}
}
});
if (invite) {
await tx.invite.update({
where: { id: invite.id },
data: { status: "USED", usedById: created.id, usedAt: new Date() }
});
if (invite.roomId) {
await tx.roomMember.upsert({
where: { roomId_userId: { roomId: invite.roomId, userId: created.id } },
update: {},
create: { roomId: invite.roomId, userId: created.id }
});
}
}
return created;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
redirect("/register?error=username");
@@ -64,6 +94,9 @@ export async function loginUser(formData: FormData) {
if (!user) {
redirect("/login?error=credentials");
}
if (user.disabledAt) {
redirect("/login?error=disabled");
}
const ok = await compare(password, user.passwordHash);
if (!ok) {

View File

@@ -14,5 +14,11 @@ describe("canEnterRoom", () => {
it("requires accepted friend or explicit membership for friends-only rooms", () => {
expect(canEnterRoom({ visibility: "FRIENDS" })).toBe(false);
expect(canEnterRoom({ visibility: "FRIENDS", isFriend: true })).toBe(true);
expect(canEnterRoom({ visibility: "FRIENDS", explicitMember: true })).toBe(true);
});
it("requires explicit membership for explicit rooms", () => {
expect(canEnterRoom({ visibility: "EXPLICIT", isFriend: true })).toBe(false);
expect(canEnterRoom({ visibility: "EXPLICIT", explicitMember: true })).toBe(true);
});
});

View File

@@ -6,6 +6,13 @@ describe("normalizeMediaUrl", () => {
const media = normalizeMediaUrl("https://www.youtube.com/watch?v=abc123");
expect(media.provider).toBe("YOUTUBE");
expect(media.playbackUrl).toContain("/embed/abc123");
expect(media.thumbnailUrl).toContain("img.youtube.com/vi/abc123");
});
it("normalizes YouTube shorts urls", () => {
const media = normalizeMediaUrl("https://www.youtube.com/shorts/short123");
expect(media.provider).toBe("YOUTUBE");
expect(media.playbackUrl).toContain("/embed/short123");
});
it("normalizes Twitch channels", () => {
@@ -14,6 +21,12 @@ describe("normalizeMediaUrl", () => {
expect(media.playbackUrl).toContain("channel=example");
});
it("normalizes Twitch VODs", () => {
const media = normalizeMediaUrl("https://www.twitch.tv/videos/12345");
expect(media.provider).toBe("TWITCH");
expect(media.playbackUrl).toContain("video=12345");
});
it("detects direct video sources", () => {
const media = normalizeMediaUrl("https://cdn.example.com/video.mp4");
expect(media.provider).toBe("DIRECT");