Complete WatchLink V1 realtime features
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
17
README.md
17
README.md
@@ -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
|
||||
|
||||
59
prisma/migrations/20260515224500_v1_completion/migration.sql
Normal file
59
prisma/migrations/20260515224500_v1_completion/migration.sql
Normal 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;
|
||||
@@ -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
501
server.js
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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.">
|
||||
<UserMinus size={14} /> Remove
|
||||
</button>
|
||||
{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,11 +274,22 @@ function Directory({
|
||||
<td><StatusBadge tone={accepted ? "good" : relationship ? "warn" : undefined}>{relationship?.status.toLowerCase() || "none"}</StatusBadge></td>
|
||||
<td>
|
||||
{!relationship ? (
|
||||
<form action={sendFriendRequest}>
|
||||
<input type="hidden" name="receiverId" value={user.id} />
|
||||
<button className="button primary compact-button" type="submit">
|
||||
<UserPlus size={14} /> Add
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -88,6 +88,7 @@ async function createFirstAdmin(formData: FormData) {
|
||||
slug: `@${username}`,
|
||||
name: `${username}'s room`,
|
||||
ownerId: createdUser.id,
|
||||
isPersonal: true,
|
||||
visibility: "FRIENDS"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
setConnected(true);
|
||||
}
|
||||
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), []);
|
||||
|
||||
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
|
||||
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);
|
||||
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 ? (
|
||||
<MediaPreview
|
||||
provider={currentMedia.provider}
|
||||
playbackUrl={currentMedia.playbackUrl}
|
||||
title={currentMedia.title || currentMedia.originalUrl}
|
||||
iframeRef={iframeRef}
|
||||
videoRef={videoRef}
|
||||
twitchPlayerRef={twitchPlayerRef}
|
||||
/>
|
||||
{activeMedia ? (
|
||||
<>
|
||||
<MediaPreview
|
||||
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">
|
||||
<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}>
|
||||
<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}>
|
||||
<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">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</form>
|
||||
<button className="icon-button compact animated-button" type="button" title="Play now" onClick={() => emit("queue:play", { mediaSourceId: item.id })}>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<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>
|
||||
<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>
|
||||
<button className="icon-button compact danger animated-button" type="button" title="Remove" onClick={() => emit("queue:remove", { mediaSourceId: item.id })}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</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
97
src/lib/admin-actions.ts
Normal 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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 }> }
|
||||
|
||||
@@ -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>>) {
|
||||
|
||||
@@ -16,34 +16,64 @@ 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({
|
||||
data: {
|
||||
username,
|
||||
displayName: username,
|
||||
passwordHash,
|
||||
ownedRooms: {
|
||||
create: {
|
||||
slug: `@${username}`,
|
||||
name: `${username}'s room`,
|
||||
visibility: "FRIENDS"
|
||||
user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: username,
|
||||
passwordHash,
|
||||
ownedRooms: {
|
||||
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") {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user