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
|
provider MediaProvider
|
||||||
originalUrl String
|
originalUrl String
|
||||||
playbackUrl String
|
playbackUrl String
|
||||||
|
thumbnailUrl String?
|
||||||
title String?
|
title String?
|
||||||
|
queuePosition Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
|
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
|
||||||
|
|||||||
@@ -302,6 +302,28 @@ textarea {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
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 {
|
.button.primary {
|
||||||
@@ -352,6 +374,13 @@ textarea {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-check {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -688,6 +717,27 @@ textarea {
|
|||||||
font-weight: 800;
|
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,
|
.queue-row .row-title,
|
||||||
.timeline-item .row-title {
|
.timeline-item .row-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -3,26 +3,35 @@ import { RoomConsole } from "@/components/room-console";
|
|||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { PageHeader, StatusDot } from "@/components/ui";
|
import { PageHeader, StatusDot } from "@/components/ui";
|
||||||
import { canEnterRoom } from "@/lib/access";
|
import { canEnterRoom } from "@/lib/access";
|
||||||
|
import { normalizeMediaUrl } from "@/lib/media";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getShellContext } from "@/lib/shell";
|
import { getShellContext } from "@/lib/shell";
|
||||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||||
import { requireInitialSetup } from "@/lib/setup";
|
import { requireInitialSetup } from "@/lib/setup";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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();
|
await requireInitialSetup();
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
const isAdmin = userIsAdmin(user);
|
const isAdmin = userIsAdmin(user);
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const { rail = "Activity", saved, error } = await searchParams;
|
||||||
const roomSlug = decodeURIComponent(slug);
|
const roomSlug = decodeURIComponent(slug);
|
||||||
const room = await prisma.room.findUnique({
|
const room = await prisma.room.findUnique({
|
||||||
where: { slug: roomSlug },
|
where: { slug: roomSlug },
|
||||||
include: {
|
include: {
|
||||||
owner: true,
|
owner: true,
|
||||||
members: { include: { user: 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={
|
actions={
|
||||||
<>
|
<>
|
||||||
<button className="button" type="button" disabled title="Invite persistence is not implemented yet.">Invite</button>
|
<Link className="button animated-button" href={`/rooms/${encodeURIComponent(room.slug)}?rail=Invite`}>Invite</Link>
|
||||||
<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=Settings`}>Settings</Link>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -87,16 +96,23 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
|
|||||||
roomName={room.name}
|
roomName={room.name}
|
||||||
roomVisibility={room.visibility}
|
roomVisibility={room.visibility}
|
||||||
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
|
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
|
||||||
|
initialRail={rail}
|
||||||
|
savedState={saved}
|
||||||
|
errorState={error}
|
||||||
currentUser={user.displayName || user.username}
|
currentUser={user.displayName || user.username}
|
||||||
queue={room.mediaSources.map((item) => ({
|
queue={room.mediaSources.map((item) => {
|
||||||
id: item.id,
|
const normalized = normalizeMediaUrl(item.originalUrl);
|
||||||
title: item.title || item.originalUrl,
|
return {
|
||||||
provider: item.provider,
|
id: item.id,
|
||||||
originalUrl: item.originalUrl,
|
title: item.title || item.originalUrl,
|
||||||
playbackUrl: item.playbackUrl,
|
provider: item.provider,
|
||||||
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
|
originalUrl: item.originalUrl,
|
||||||
createdAt: formatDate(item.createdAt)
|
playbackUrl: item.playbackUrl,
|
||||||
}))}
|
thumbnailUrl: item.thumbnailUrl || normalized.thumbnailUrl,
|
||||||
|
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
|
||||||
|
createdAt: formatDate(item.createdAt)
|
||||||
|
};
|
||||||
|
})}
|
||||||
participants={[
|
participants={[
|
||||||
...(room.owner
|
...(room.owner
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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 { io } from "socket.io-client";
|
||||||
import { normalizeMediaUrl } from "@/lib/media";
|
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 { Avatar } from "./avatar";
|
||||||
import { StatusBadge } from "./status-badge";
|
import { StatusBadge } from "./status-badge";
|
||||||
import { EmptyState, Panel, StatusDot } from "./ui";
|
import { EmptyState, Panel, StatusDot } from "./ui";
|
||||||
@@ -45,6 +46,7 @@ type QueueItem = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
originalUrl: string;
|
originalUrl: string;
|
||||||
playbackUrl: string;
|
playbackUrl: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
by: string;
|
by: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
@@ -65,6 +67,9 @@ export function RoomConsole({
|
|||||||
roomName,
|
roomName,
|
||||||
roomVisibility,
|
roomVisibility,
|
||||||
ownerName,
|
ownerName,
|
||||||
|
initialRail = "Activity",
|
||||||
|
savedState,
|
||||||
|
errorState,
|
||||||
currentUser,
|
currentUser,
|
||||||
queue = [],
|
queue = [],
|
||||||
participants = []
|
participants = []
|
||||||
@@ -74,13 +79,16 @@ export function RoomConsole({
|
|||||||
roomName: string;
|
roomName: string;
|
||||||
roomVisibility: string;
|
roomVisibility: string;
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
|
initialRail?: string;
|
||||||
|
savedState?: string;
|
||||||
|
errorState?: string;
|
||||||
currentUser: string;
|
currentUser: string;
|
||||||
queue?: QueueItem[];
|
queue?: QueueItem[];
|
||||||
participants?: Participant[];
|
participants?: Participant[];
|
||||||
}) {
|
}) {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [source, setSource] = useState("");
|
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 [activeQueueItem, setActiveQueueItem] = useState<QueueItem | null>(null);
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
@@ -150,6 +158,10 @@ export function RoomConsole({
|
|||||||
}, 800);
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitAndPlay(item: QueueItem) {
|
||||||
|
playQueueItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="watch-console">
|
<section className="watch-console">
|
||||||
<div className="player-column">
|
<div className="player-column">
|
||||||
@@ -217,23 +229,38 @@ export function RoomConsole({
|
|||||||
{queue.map((item, index) => (
|
{queue.map((item, index) => (
|
||||||
<div className="queue-row" key={item.id}>
|
<div className="queue-row" key={item.id}>
|
||||||
<span className="queue-index">{index + 1}</span>
|
<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">
|
<div className="row-title">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
<span>{item.provider} by {item.by} - {item.createdAt}</span>
|
<span>{item.provider} by {item.by} - {item.createdAt}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
<button className="icon-button compact" type="button" title="Play now" onClick={() => playQueueItem(item)}>
|
<form action={setCurrentMedia} onSubmit={() => submitAndPlay(item)}>
|
||||||
<Play size={14} />
|
<input type="hidden" name="mediaId" value={item.id} />
|
||||||
</button>
|
<button className="icon-button compact animated-button" type="submit" title="Play now">
|
||||||
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
|
<Play size={14} />
|
||||||
<ChevronUp size={14} />
|
</button>
|
||||||
</button>
|
</form>
|
||||||
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
|
<form action={moveMediaUp}>
|
||||||
<ChevronDown size={14} />
|
<input type="hidden" name="mediaId" value={item.id} />
|
||||||
</button>
|
<button className="icon-button compact animated-button" type="submit" title="Move up" disabled={index === 0}>
|
||||||
<button className="icon-button compact danger" type="button" title="Queue removal needs a server action." disabled>
|
<ChevronUp size={14} />
|
||||||
<Trash2 size={14} />
|
</button>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -265,7 +292,7 @@ export function RoomConsole({
|
|||||||
<aside className="right-rail">
|
<aside className="right-rail">
|
||||||
<Panel title="Room Rail" eyebrow="Activity and settings">
|
<Panel title="Room Rail" eyebrow="Activity and settings">
|
||||||
<div className="rail-tabs" role="tablist" aria-label="Room rail">
|
<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)}>
|
<button key={item} className={rail === item ? "active" : ""} type="button" onClick={() => setRail(item)}>
|
||||||
{item}
|
{item}
|
||||||
</button>
|
</button>
|
||||||
@@ -300,13 +327,53 @@ export function RoomConsole({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" ? (
|
{rail === "Settings" ? (
|
||||||
<div className="settings-list">
|
<div className="settings-list" id="room-settings">
|
||||||
<SettingRow icon={<ShieldCheck size={16} />} label="Visibility" value={formatVisibility(roomVisibility)} />
|
{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={<Play size={16} />} label="Playback control" value="All authorized participants" />
|
||||||
<SettingRow icon={<Settings2 size={16} />} label="Owner" value={ownerName} />
|
<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="Socket.IO room channel" />
|
||||||
<p className="disabled-note">Editable settings require dedicated server actions; the current values are read-only.</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { normalizeMediaUrl } from "./media";
|
import { normalizeMediaUrl } from "./media";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { requireCurrentUser } from "./session";
|
import { requireCurrentUser, userIsAdmin } from "./session";
|
||||||
import { getAppSettings } from "./settings";
|
import { getAppSettings } from "./settings";
|
||||||
|
|
||||||
export async function addMediaToRoom(formData: FormData) {
|
export async function addMediaToRoom(formData: FormData) {
|
||||||
@@ -27,6 +27,8 @@ export async function addMediaToRoom(formData: FormData) {
|
|||||||
const media = normalizeMediaUrl(sourceUrl);
|
const media = normalizeMediaUrl(sourceUrl);
|
||||||
if (!settings.allowedProviders.includes(media.provider)) return;
|
if (!settings.allowedProviders.includes(media.provider)) return;
|
||||||
|
|
||||||
|
const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
|
||||||
|
|
||||||
await prisma.mediaSource.create({
|
await prisma.mediaSource.create({
|
||||||
data: {
|
data: {
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
@@ -34,6 +36,8 @@ export async function addMediaToRoom(formData: FormData) {
|
|||||||
provider: media.provider,
|
provider: media.provider,
|
||||||
originalUrl: media.originalUrl,
|
originalUrl: media.originalUrl,
|
||||||
playbackUrl: media.playbackUrl,
|
playbackUrl: media.playbackUrl,
|
||||||
|
thumbnailUrl: media.thumbnailUrl,
|
||||||
|
queuePosition: nextPosition + 1,
|
||||||
title: media.originalUrl
|
title: media.originalUrl
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -54,3 +58,149 @@ export async function addMediaToRoom(formData: FormData) {
|
|||||||
revalidatePath(`/rooms/${encodeURIComponent(room.slug)}`);
|
revalidatePath(`/rooms/${encodeURIComponent(room.slug)}`);
|
||||||
revalidatePath("/dashboard");
|
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";
|
provider: "YOUTUBE" | "TWITCH" | "DIRECT" | "UNKNOWN";
|
||||||
originalUrl: string;
|
originalUrl: string;
|
||||||
playbackUrl: string;
|
playbackUrl: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
|||||||
return {
|
return {
|
||||||
provider: "YOUTUBE",
|
provider: "YOUTUBE",
|
||||||
originalUrl,
|
originalUrl,
|
||||||
|
thumbnailUrl: `https://img.youtube.com/vi/${id}/hqdefault.jpg`,
|
||||||
playbackUrl: `https://www.youtube.com/embed/${id}?enablejsapi=1&origin=${encodeURIComponent(
|
playbackUrl: `https://www.youtube.com/embed/${id}?enablejsapi=1&origin=${encodeURIComponent(
|
||||||
process.env.NEXTAUTH_URL || "http://localhost:3000"
|
process.env.NEXTAUTH_URL || "http://localhost:3000"
|
||||||
)}`
|
)}`
|
||||||
@@ -48,6 +50,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
|||||||
return {
|
return {
|
||||||
provider: "TWITCH",
|
provider: "TWITCH",
|
||||||
originalUrl,
|
originalUrl,
|
||||||
|
thumbnailUrl: "/watchlink-icon-64.png",
|
||||||
playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=${parent}`
|
playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=${parent}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
|||||||
return {
|
return {
|
||||||
provider: "TWITCH",
|
provider: "TWITCH",
|
||||||
originalUrl,
|
originalUrl,
|
||||||
|
thumbnailUrl: "/watchlink-icon-64.png",
|
||||||
playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=${parent}`
|
playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=${parent}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,6 +68,7 @@ export function normalizeMediaUrl(input: string): NormalizedMedia {
|
|||||||
return {
|
return {
|
||||||
provider: "DIRECT",
|
provider: "DIRECT",
|
||||||
originalUrl,
|
originalUrl,
|
||||||
|
thumbnailUrl: "/watchlink-icon-64.png",
|
||||||
playbackUrl: originalUrl
|
playbackUrl: originalUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { RoomVisibility } from "@prisma/client";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { requireCurrentUser } from "./session";
|
import { requireCurrentUser, userIsAdmin } from "./session";
|
||||||
import { getAppSettings } from "./settings";
|
import { getAppSettings } from "./settings";
|
||||||
|
|
||||||
function normalizeSlug(value: string) {
|
function normalizeSlug(value: string) {
|
||||||
@@ -53,3 +53,74 @@ export async function createRoom(formData: FormData) {
|
|||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
redirect(`/rooms/${encodeURIComponent(room.slug)}`);
|
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