Enable room queue controls
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "MediaSource" ADD COLUMN "thumbnailUrl" TEXT;
|
||||
ALTER TABLE "MediaSource" ADD COLUMN "queuePosition" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT "id", ROW_NUMBER() OVER (PARTITION BY "roomId" ORDER BY "createdAt" ASC, "id" ASC) AS position
|
||||
FROM "MediaSource"
|
||||
)
|
||||
UPDATE "MediaSource"
|
||||
SET "queuePosition" = ordered.position
|
||||
FROM ordered
|
||||
WHERE "MediaSource"."id" = ordered."id";
|
||||
@@ -134,7 +134,9 @@ model MediaSource {
|
||||
provider MediaProvider
|
||||
originalUrl String
|
||||
playbackUrl String
|
||||
thumbnailUrl String?
|
||||
title String?
|
||||
queuePosition Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -302,6 +302,28 @@ textarea {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
border-color 140ms ease,
|
||||
background 140ms ease,
|
||||
color 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled),
|
||||
.icon-button:hover:not(:disabled),
|
||||
.rail-tabs button:hover:not(:disabled),
|
||||
.tab:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled),
|
||||
.icon-button:active:not(:disabled),
|
||||
.rail-tabs button:active:not(:disabled),
|
||||
.tab:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
@@ -352,6 +374,13 @@ textarea {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.inline-check {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -688,6 +717,27 @@ textarea {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.queue-thumbnail {
|
||||
display: grid;
|
||||
width: 72px;
|
||||
aspect-ratio: 16 / 9;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: #05070a;
|
||||
color: var(--accent-2);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.queue-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.queue-row .row-title,
|
||||
.timeline-item .row-title {
|
||||
min-width: 0;
|
||||
|
||||
@@ -3,26 +3,35 @@ import { RoomConsole } from "@/components/room-console";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { PageHeader, StatusDot } from "@/components/ui";
|
||||
import { canEnterRoom } from "@/lib/access";
|
||||
import { normalizeMediaUrl } from "@/lib/media";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getShellContext } from "@/lib/shell";
|
||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||
import { requireInitialSetup } from "@/lib/setup";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RoomPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
export default async function RoomPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ rail?: string; saved?: string; error?: string }>;
|
||||
}) {
|
||||
await requireInitialSetup();
|
||||
const user = await requireCurrentUser();
|
||||
const isAdmin = userIsAdmin(user);
|
||||
const { slug } = await params;
|
||||
const { rail = "Activity", saved, error } = await searchParams;
|
||||
const roomSlug = decodeURIComponent(slug);
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { slug: roomSlug },
|
||||
include: {
|
||||
owner: true,
|
||||
members: { include: { user: true } },
|
||||
mediaSources: { include: { submitter: true }, orderBy: { createdAt: "desc" }, take: 20 }
|
||||
mediaSources: { include: { submitter: true }, orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }], take: 40 }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,8 +85,8 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<button className="button" type="button" disabled title="Invite persistence is not implemented yet.">Invite</button>
|
||||
<button className="button" type="button" disabled title="Room settings require dedicated server actions before editing.">Settings</button>
|
||||
<Link className="button animated-button" href={`/rooms/${encodeURIComponent(room.slug)}?rail=Invite`}>Invite</Link>
|
||||
<Link className="button animated-button" href={`/rooms/${encodeURIComponent(room.slug)}?rail=Settings`}>Settings</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -87,16 +96,23 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
|
||||
roomName={room.name}
|
||||
roomVisibility={room.visibility}
|
||||
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
|
||||
initialRail={rail}
|
||||
savedState={saved}
|
||||
errorState={error}
|
||||
currentUser={user.displayName || user.username}
|
||||
queue={room.mediaSources.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title || item.originalUrl,
|
||||
provider: item.provider,
|
||||
originalUrl: item.originalUrl,
|
||||
playbackUrl: item.playbackUrl,
|
||||
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
|
||||
createdAt: formatDate(item.createdAt)
|
||||
}))}
|
||||
queue={room.mediaSources.map((item) => {
|
||||
const normalized = normalizeMediaUrl(item.originalUrl);
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || item.originalUrl,
|
||||
provider: item.provider,
|
||||
originalUrl: item.originalUrl,
|
||||
playbackUrl: item.playbackUrl,
|
||||
thumbnailUrl: item.thumbnailUrl || normalized.thumbnailUrl,
|
||||
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
|
||||
createdAt: formatDate(item.createdAt)
|
||||
};
|
||||
})}
|
||||
participants={[
|
||||
...(room.owner
|
||||
? [
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2 } from "lucide-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 } from "@/lib/media-actions";
|
||||
import { addMediaToRoom, moveMediaDown, moveMediaUp, removeMediaFromRoom, setCurrentMedia } from "@/lib/media-actions";
|
||||
import { addRoomMember, updateRoomSettings } from "@/lib/room-actions";
|
||||
import { Avatar } from "./avatar";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import { EmptyState, Panel, StatusDot } from "./ui";
|
||||
@@ -45,6 +46,7 @@ type QueueItem = {
|
||||
provider: string;
|
||||
originalUrl: string;
|
||||
playbackUrl: string;
|
||||
thumbnailUrl?: string | null;
|
||||
by: string;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -65,6 +67,9 @@ export function RoomConsole({
|
||||
roomName,
|
||||
roomVisibility,
|
||||
ownerName,
|
||||
initialRail = "Activity",
|
||||
savedState,
|
||||
errorState,
|
||||
currentUser,
|
||||
queue = [],
|
||||
participants = []
|
||||
@@ -74,13 +79,16 @@ export function RoomConsole({
|
||||
roomName: string;
|
||||
roomVisibility: string;
|
||||
ownerName: string;
|
||||
initialRail?: string;
|
||||
savedState?: string;
|
||||
errorState?: string;
|
||||
currentUser: string;
|
||||
queue?: QueueItem[];
|
||||
participants?: Participant[];
|
||||
}) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [source, setSource] = useState("");
|
||||
const [rail, setRail] = useState("Activity");
|
||||
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 videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -150,6 +158,10 @@ export function RoomConsole({
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function submitAndPlay(item: QueueItem) {
|
||||
playQueueItem(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="watch-console">
|
||||
<div className="player-column">
|
||||
@@ -217,23 +229,38 @@ export function RoomConsole({
|
||||
{queue.map((item, index) => (
|
||||
<div className="queue-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>}
|
||||
</div>
|
||||
<div className="row-title">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.provider} by {item.by} - {item.createdAt}</span>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" title="Play now" onClick={() => playQueueItem(item)}>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" title="Queue removal needs a server action." disabled>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -265,7 +292,7 @@ export function RoomConsole({
|
||||
<aside className="right-rail">
|
||||
<Panel title="Room Rail" eyebrow="Activity and settings">
|
||||
<div className="rail-tabs" role="tablist" aria-label="Room rail">
|
||||
{["Activity", "Chat", "Settings"].map((item) => (
|
||||
{["Activity", "Chat", "Invite", "Settings"].map((item) => (
|
||||
<button key={item} className={rail === item ? "active" : ""} type="button" onClick={() => setRail(item)}>
|
||||
{item}
|
||||
</button>
|
||||
@@ -300,13 +327,53 @@ export function RoomConsole({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rail === "Invite" ? (
|
||||
<div className="settings-list" id="room-invite">
|
||||
{savedState ? <p className="form-success">Room member saved.</p> : null}
|
||||
{errorState === "user" ? <p className="form-error">No matching user was found, or the user already owns the room.</p> : null}
|
||||
<form className="form compact-form" action={addRoomMember}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<label>
|
||||
Username
|
||||
<input className="input" name="username" placeholder="username" autoComplete="off" required />
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input name="canManage" type="checkbox" />
|
||||
Can manage room
|
||||
</label>
|
||||
<button className="button primary animated-button" type="submit">
|
||||
<UserPlus size={16} /> Add member
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rail === "Settings" ? (
|
||||
<div className="settings-list">
|
||||
<SettingRow icon={<ShieldCheck size={16} />} label="Visibility" value={formatVisibility(roomVisibility)} />
|
||||
<div className="settings-list" id="room-settings">
|
||||
{savedState ? <p className="form-success">Room settings saved.</p> : null}
|
||||
<form className="form compact-form" action={updateRoomSettings}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<label>
|
||||
Room name
|
||||
<input className="input" name="name" defaultValue={roomName} maxLength={80} required />
|
||||
</label>
|
||||
<label>
|
||||
Visibility
|
||||
<select className="input" name="visibility" defaultValue={roomVisibility}>
|
||||
<option value="FRIENDS">Friends-only</option>
|
||||
<option value="PUBLIC">Public</option>
|
||||
<option value="EXPLICIT">Explicit members</option>
|
||||
<option value="ROLE_RESTRICTED">Role restricted</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="button primary animated-button" type="submit">
|
||||
<Settings2 size={16} /> Save room settings
|
||||
</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" />
|
||||
<p className="disabled-note">Editable settings require dedicated server actions; the current values are read-only.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { normalizeMediaUrl } from "./media";
|
||||
import { prisma } from "./prisma";
|
||||
import { requireCurrentUser } from "./session";
|
||||
import { requireCurrentUser, userIsAdmin } from "./session";
|
||||
import { getAppSettings } from "./settings";
|
||||
|
||||
export async function addMediaToRoom(formData: FormData) {
|
||||
@@ -27,6 +27,8 @@ export async function addMediaToRoom(formData: FormData) {
|
||||
const media = normalizeMediaUrl(sourceUrl);
|
||||
if (!settings.allowedProviders.includes(media.provider)) return;
|
||||
|
||||
const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
|
||||
|
||||
await prisma.mediaSource.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
@@ -34,6 +36,8 @@ export async function addMediaToRoom(formData: FormData) {
|
||||
provider: media.provider,
|
||||
originalUrl: media.originalUrl,
|
||||
playbackUrl: media.playbackUrl,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
queuePosition: nextPosition + 1,
|
||||
title: media.originalUrl
|
||||
}
|
||||
});
|
||||
@@ -54,3 +58,149 @@ export async function addMediaToRoom(formData: FormData) {
|
||||
revalidatePath(`/rooms/${encodeURIComponent(room.slug)}`);
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
export async function removeMediaFromRoom(formData: FormData) {
|
||||
const { room, media } = await requireMediaManager(formData);
|
||||
if (!room || !media) return;
|
||||
|
||||
await prisma.mediaSource.delete({ where: { id: media.id } });
|
||||
await normalizeQueue(room.id);
|
||||
revalidateRoom(room.slug);
|
||||
}
|
||||
|
||||
export async function moveMediaUp(formData: FormData) {
|
||||
await moveMedia(formData, -1);
|
||||
}
|
||||
|
||||
export async function moveMediaDown(formData: FormData) {
|
||||
await moveMedia(formData, 1);
|
||||
}
|
||||
|
||||
export async function setCurrentMedia(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const mediaId = String(formData.get("mediaId") || "");
|
||||
if (!mediaId) return;
|
||||
|
||||
const media = await prisma.mediaSource.findUnique({
|
||||
where: { id: mediaId },
|
||||
include: {
|
||||
room: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
ownerId: true,
|
||||
visibility: true,
|
||||
members: { where: { userId: user.id }, select: { canManage: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!media || !canUseRoom(user.id, media.room, user)) return;
|
||||
|
||||
await prisma.room.update({
|
||||
where: { id: media.roomId },
|
||||
data: {
|
||||
currentState: {
|
||||
provider: media.provider,
|
||||
originalUrl: media.originalUrl,
|
||||
playbackUrl: media.playbackUrl,
|
||||
mediaSourceId: media.id,
|
||||
updatedBy: user.username,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
revalidateRoom(media.room.slug);
|
||||
}
|
||||
|
||||
async function moveMedia(formData: FormData, direction: -1 | 1) {
|
||||
const { room, media } = await requireMediaManager(formData);
|
||||
if (!room || !media) return;
|
||||
|
||||
const queue = await prisma.mediaSource.findMany({
|
||||
where: { roomId: room.id },
|
||||
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }],
|
||||
select: { id: true }
|
||||
});
|
||||
const index = queue.findIndex((item) => item.id === media.id);
|
||||
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 }
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
revalidateRoom(room.slug);
|
||||
}
|
||||
|
||||
async function requireMediaManager(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const mediaId = String(formData.get("mediaId") || "");
|
||||
if (!mediaId) return { room: null, media: null };
|
||||
|
||||
const media = await prisma.mediaSource.findUnique({
|
||||
where: { id: mediaId },
|
||||
include: {
|
||||
room: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
ownerId: true,
|
||||
visibility: true,
|
||||
members: { where: { userId: user.id }, select: { canManage: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!media || !canManageRoom(user.id, media.room, user)) return { room: null, media: null };
|
||||
return { room: media.room, media };
|
||||
}
|
||||
|
||||
function canUseRoom(
|
||||
userId: string,
|
||||
room: { ownerId: string | null; visibility: string; members: Array<{ canManage: boolean }> },
|
||||
user?: Awaited<ReturnType<typeof requireCurrentUser>>
|
||||
) {
|
||||
return room.ownerId === userId || Boolean(user && userIsAdmin(user)) || room.visibility === "PUBLIC" || room.members.length > 0;
|
||||
}
|
||||
|
||||
function canManageRoom(
|
||||
userId: string,
|
||||
room: { ownerId: string | null; members: Array<{ canManage: boolean }> },
|
||||
user?: Awaited<ReturnType<typeof requireCurrentUser>>
|
||||
) {
|
||||
return room.ownerId === userId || Boolean(user && userIsAdmin(user)) || room.members.some((member) => member.canManage);
|
||||
}
|
||||
|
||||
async function normalizeQueue(roomId: string) {
|
||||
const queue = await prisma.mediaSource.findMany({
|
||||
where: { roomId },
|
||||
orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }],
|
||||
select: { id: true }
|
||||
});
|
||||
if (queue.length === 0) return;
|
||||
await prisma.$transaction(
|
||||
queue.map((item, index) =>
|
||||
prisma.mediaSource.update({
|
||||
where: { id: item.id },
|
||||
data: { queuePosition: index + 1 }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function revalidateRoom(slug: string) {
|
||||
revalidatePath(`/rooms/${encodeURIComponent(slug)}`);
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/rooms");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export type NormalizedMedia = {
|
||||
provider: "YOUTUBE" | "TWITCH" | "DIRECT" | "UNKNOWN";
|
||||
originalUrl: string;
|
||||
playbackUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
@@ -34,6 +35,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
||||
return {
|
||||
provider: "YOUTUBE",
|
||||
originalUrl,
|
||||
thumbnailUrl: `https://img.youtube.com/vi/${id}/hqdefault.jpg`,
|
||||
playbackUrl: `https://www.youtube.com/embed/${id}?enablejsapi=1&origin=${encodeURIComponent(
|
||||
process.env.NEXTAUTH_URL || "http://localhost:3000"
|
||||
)}`
|
||||
@@ -48,6 +50,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
||||
return {
|
||||
provider: "TWITCH",
|
||||
originalUrl,
|
||||
thumbnailUrl: "/watchlink-icon-64.png",
|
||||
playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=${parent}`
|
||||
};
|
||||
}
|
||||
@@ -55,6 +58,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
||||
return {
|
||||
provider: "TWITCH",
|
||||
originalUrl,
|
||||
thumbnailUrl: "/watchlink-icon-64.png",
|
||||
playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=${parent}`
|
||||
};
|
||||
}
|
||||
@@ -64,6 +68,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
||||
return {
|
||||
provider: "DIRECT",
|
||||
originalUrl,
|
||||
thumbnailUrl: "/watchlink-icon-64.png",
|
||||
playbackUrl: originalUrl
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RoomVisibility } from "@prisma/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "./prisma";
|
||||
import { requireCurrentUser } from "./session";
|
||||
import { requireCurrentUser, userIsAdmin } from "./session";
|
||||
import { getAppSettings } from "./settings";
|
||||
|
||||
function normalizeSlug(value: string) {
|
||||
@@ -53,3 +53,74 @@ export async function createRoom(formData: FormData) {
|
||||
revalidatePath("/dashboard");
|
||||
redirect(`/rooms/${encodeURIComponent(room.slug)}`);
|
||||
}
|
||||
|
||||
export async function updateRoomSettings(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const roomId = String(formData.get("roomId") || "");
|
||||
const name = String(formData.get("name") || "").trim().slice(0, 80);
|
||||
const visibility = String(formData.get("visibility") || "FRIENDS");
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
|
||||
});
|
||||
|
||||
if (!room || !canManageRoom(user, room) || !name) return;
|
||||
|
||||
const allowedVisibility: RoomVisibility[] = ["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"];
|
||||
const roomVisibility = allowedVisibility.includes(visibility as RoomVisibility)
|
||||
? (visibility as RoomVisibility)
|
||||
: room.visibility;
|
||||
|
||||
await prisma.room.update({
|
||||
where: { id: room.id },
|
||||
data: {
|
||||
name,
|
||||
visibility: roomVisibility
|
||||
}
|
||||
});
|
||||
|
||||
revalidateRoom(room.slug);
|
||||
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Settings&saved=1`);
|
||||
}
|
||||
|
||||
export async function addRoomMember(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const roomId = String(formData.get("roomId") || "");
|
||||
const username = String(formData.get("username") || "").trim().toLowerCase();
|
||||
const canManage = formData.get("canManage") === "on";
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { where: { userId: user.id }, select: { canManage: true } } }
|
||||
});
|
||||
|
||||
if (!room || !canManageRoom(user, room) || !username) return;
|
||||
|
||||
const invited = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
||||
if (!invited || invited.id === room.ownerId) {
|
||||
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&error=user`);
|
||||
}
|
||||
|
||||
await prisma.roomMember.upsert({
|
||||
where: { roomId_userId: { roomId: room.id, userId: invited.id } },
|
||||
update: { canManage },
|
||||
create: { roomId: room.id, userId: invited.id, canManage }
|
||||
});
|
||||
|
||||
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 }> }
|
||||
) {
|
||||
return room.ownerId === user.id || userIsAdmin(user) || room.members.some((member) => member.canManage);
|
||||
}
|
||||
|
||||
function revalidateRoom(slug: string) {
|
||||
revalidatePath(`/rooms/${encodeURIComponent(slug)}`);
|
||||
revalidatePath("/rooms");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user