Replace demo pages with live app data
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Avatar } from "@/components/avatar";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { SYSTEM_PERMISSIONS } from "@/lib/access";
|
import { SYSTEM_PERMISSIONS } from "@/lib/access";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||||
import { requireInitialSetup } from "@/lib/setup";
|
import { requireInitialSetup } from "@/lib/setup";
|
||||||
import { rooms } from "@/lib/sample-data";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
@@ -14,8 +16,29 @@ export default async function AdminPage() {
|
|||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [personalRoom, users, rooms, roles] = await Promise.all([
|
||||||
|
prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } }),
|
||||||
|
prisma.user.findMany({
|
||||||
|
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
|
||||||
|
orderBy: { createdAt: "asc" }
|
||||||
|
}),
|
||||||
|
prisma.room.findMany({
|
||||||
|
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
|
||||||
|
orderBy: { updatedAt: "desc" }
|
||||||
|
}),
|
||||||
|
prisma.role.findMany({
|
||||||
|
include: { _count: { select: { users: true, permissions: true } } },
|
||||||
|
orderBy: { name: "asc" }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell active="Admin" isAdmin>
|
<AppShell
|
||||||
|
active="Admin"
|
||||||
|
isAdmin
|
||||||
|
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
|
||||||
|
userName={user.displayName || user.username}
|
||||||
|
>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="title-block">
|
<div className="title-block">
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
@@ -27,7 +50,7 @@ export default async function AdminPage() {
|
|||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>Rooms</h2>
|
<h2>Rooms</h2>
|
||||||
<button className="button primary">Create room</button>
|
<StatusBadge>{rooms.length} total</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
<table className="table">
|
<table className="table">
|
||||||
@@ -41,21 +64,69 @@ export default async function AdminPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rooms.map((room) => (
|
{rooms.map((room) => (
|
||||||
<tr key={room.name}>
|
<tr key={room.id}>
|
||||||
<td>{room.name}</td>
|
<td>
|
||||||
<td>{room.owner}</td>
|
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||||
<td>{room.visibility}</td>
|
<td>{room.visibility}</td>
|
||||||
<td>{room.status}</td>
|
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<StatusBadge>{users.length} accounts</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
{users.map((account) => (
|
||||||
|
<div className="row" key={account.id}>
|
||||||
|
<div className="row-main">
|
||||||
|
<Avatar name={account.displayName || account.username} />
|
||||||
|
<div className="row-title">
|
||||||
|
<strong>{account.displayName || account.username}</strong>
|
||||||
|
<span>@{account.username} · {account._count.ownedRooms + account._count.roomMembers} rooms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="status-row">
|
||||||
|
{account.roles.map((userRole) => (
|
||||||
|
<StatusBadge key={userRole.roleId} tone={userRole.role.name === "admin" ? "good" : undefined}>
|
||||||
|
{userRole.role.name}
|
||||||
|
</StatusBadge>
|
||||||
|
))}
|
||||||
|
{account.roles.length === 0 ? <StatusBadge>user</StatusBadge> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<section className="room-layout" style={{ marginTop: 18 }}>
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Roles</h2>
|
||||||
|
<StatusBadge>{roles.length} defined</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<div className="row" key={role.id}>
|
||||||
|
<div className="row-title">
|
||||||
|
<strong>{role.name}</strong>
|
||||||
|
<span>{role.description || `${role.scope.toLowerCase()} role`}</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge>{role._count.users} users / {role._count.permissions} permissions</StatusBadge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>Permissions</h2>
|
<h2>Permissions</h2>
|
||||||
<StatusBadge>Roles</StatusBadge>
|
<StatusBadge>System</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
{SYSTEM_PERMISSIONS.map((permission) => (
|
{SYSTEM_PERMISSIONS.map((permission) => (
|
||||||
|
|||||||
@@ -1,20 +1,63 @@
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Avatar } from "@/components/avatar";
|
||||||
import { RoomConsole } from "@/components/room-console";
|
import { RoomConsole } from "@/components/room-console";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||||
import { requireInitialSetup } from "@/lib/setup";
|
import { requireInitialSetup } from "@/lib/setup";
|
||||||
import { dashboardStats, friends, rooms } from "@/lib/sample-data";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
await requireInitialSetup();
|
await requireInitialSetup();
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
|
const isAdmin = userIsAdmin(user);
|
||||||
|
|
||||||
|
const [personalRoom, userCount, roomCount, pendingRequests, friendships, rooms] = await Promise.all([
|
||||||
|
prisma.room.findFirst({
|
||||||
|
where: { ownerId: user.id },
|
||||||
|
include: {
|
||||||
|
owner: true,
|
||||||
|
members: { include: { user: true } },
|
||||||
|
mediaSources: { include: { submitter: true }, orderBy: { createdAt: "desc" }, take: 8 }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.room.count(),
|
||||||
|
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
|
||||||
|
prisma.friendship.findMany({
|
||||||
|
where: {
|
||||||
|
status: "ACCEPTED",
|
||||||
|
OR: [{ requesterId: user.id }, { receiverId: user.id }]
|
||||||
|
},
|
||||||
|
include: { requester: true, receiver: true },
|
||||||
|
take: 6,
|
||||||
|
orderBy: { updatedAt: "desc" }
|
||||||
|
}),
|
||||||
|
prisma.room.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ visibility: "PUBLIC" }, { ownerId: user.id }, { members: { some: { userId: user.id } } }]
|
||||||
|
},
|
||||||
|
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 8
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const acceptedFriends = friendships.length;
|
||||||
|
const roomHref = personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard";
|
||||||
|
const stats = [
|
||||||
|
{ label: "Users", value: userCount },
|
||||||
|
{ label: "Rooms", value: roomCount },
|
||||||
|
{ label: "Friends", value: acceptedFriends },
|
||||||
|
{ label: "Requests", value: pendingRequests }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell active="Dashboard" isAdmin={userIsAdmin(user)}>
|
<AppShell active="Dashboard" isAdmin={isAdmin} roomHref={roomHref} userName={user.displayName || user.username}>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="title-block">
|
<div className="title-block">
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<p>{user ? `Signed in as ${user.username}` : "Persistent rooms, friends, and shared playback state."}</p>
|
<p>{`Signed in as ${user.displayName || user.username}`}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-row">
|
<div className="status-row">
|
||||||
<StatusBadge tone="good">Online</StatusBadge>
|
<StatusBadge tone="good">Online</StatusBadge>
|
||||||
@@ -23,7 +66,7 @@ export default async function DashboardPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="stats-grid" aria-label="System overview">
|
<section className="stats-grid" aria-label="System overview">
|
||||||
{dashboardStats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div className="stat" key={stat.label}>
|
<div className="stat" key={stat.label}>
|
||||||
<span>{stat.label}</span>
|
<span>{stat.label}</span>
|
||||||
<strong>{stat.value}</strong>
|
<strong>{stat.value}</strong>
|
||||||
@@ -31,7 +74,42 @@ export default async function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<RoomConsole roomSlug="@admin" />
|
{personalRoom ? (
|
||||||
|
<RoomConsole
|
||||||
|
roomId={personalRoom.id}
|
||||||
|
roomSlug={personalRoom.slug}
|
||||||
|
currentUser={user.displayName || user.username}
|
||||||
|
queue={personalRoom.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)
|
||||||
|
}))}
|
||||||
|
participants={[
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
name: user.displayName || user.username,
|
||||||
|
role: "Owner",
|
||||||
|
status: "Online"
|
||||||
|
},
|
||||||
|
...personalRoom.members.map((member) => ({
|
||||||
|
id: member.userId,
|
||||||
|
name: member.user.displayName || member.user.username,
|
||||||
|
role: member.canManage ? "Manager" : "Member",
|
||||||
|
status: "Invited"
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-body">
|
||||||
|
<div className="empty-state">No personal room exists for this account yet.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="room-layout" style={{ marginTop: 18 }}>
|
<section className="room-layout" style={{ marginTop: 18 }}>
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
@@ -52,36 +130,50 @@ export default async function DashboardPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rooms.map((room) => (
|
{rooms.map((room) => (
|
||||||
<tr key={room.name}>
|
<tr key={room.id}>
|
||||||
<td>{room.name}</td>
|
<td>
|
||||||
<td>{room.owner}</td>
|
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||||
<td>{room.visibility}</td>
|
<td>{room.visibility}</td>
|
||||||
<td>{room.status}</td>
|
<td>{room._count.members + 1} users</td>
|
||||||
<td>{room.source}</td>
|
<td>{room._count.mediaSources} queued</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{rooms.length === 0 ? <div className="empty-state">No accessible rooms found.</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>Friends</h2>
|
<h2>Friends</h2>
|
||||||
<StatusBadge tone="good">3 linked</StatusBadge>
|
<StatusBadge tone={acceptedFriends > 0 ? "good" : undefined}>{acceptedFriends} linked</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
{friends.map((friend) => (
|
{friendships.map((friendship) => {
|
||||||
<div className="row" key={friend.name}>
|
const friend = friendship.requesterId === user.id ? friendship.receiver : friendship.requester;
|
||||||
|
return (
|
||||||
|
<div className="row" key={friend.id}>
|
||||||
|
<div className="row-main">
|
||||||
|
<Avatar name={friend.displayName || friend.username} />
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{friend.name}</strong>
|
<strong>{friend.displayName || friend.username}</strong>
|
||||||
<span>{friend.room}</span>
|
<span>@{friend.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={friend.state === "Online" ? "good" : undefined}>{friend.state}</StatusBadge>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<StatusBadge tone="good">Friend</StatusBadge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{friendships.length === 0 ? <div className="empty-state">No friends yet. Add users from Friends.</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,42 +1,125 @@
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Avatar } from "@/components/avatar";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
|
import { acceptFriendRequest, declineFriendRequest, sendFriendRequest } from "@/lib/friend-actions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||||
import { requireInitialSetup } from "@/lib/setup";
|
import { requireInitialSetup } from "@/lib/setup";
|
||||||
import { friends } from "@/lib/sample-data";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function FriendsPage() {
|
export default async function FriendsPage() {
|
||||||
await requireInitialSetup();
|
await requireInitialSetup();
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
|
const personalRoom = await prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } });
|
||||||
|
const [users, friendships] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { id: { not: user.id } },
|
||||||
|
include: { ownedRooms: { select: { slug: true }, take: 1 } },
|
||||||
|
orderBy: { username: "asc" }
|
||||||
|
}),
|
||||||
|
prisma.friendship.findMany({
|
||||||
|
where: { OR: [{ requesterId: user.id }, { receiverId: user.id }] },
|
||||||
|
include: { requester: true, receiver: true }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const incoming = friendships.filter((item) => item.receiverId === user.id && item.status === "PENDING");
|
||||||
|
const relationshipByUserId = new Map<string, (typeof friendships)[number]>();
|
||||||
|
for (const friendship of friendships) {
|
||||||
|
if (friendship.status === "DECLINED") continue;
|
||||||
|
const otherId = friendship.requesterId === user.id ? friendship.receiverId : friendship.requesterId;
|
||||||
|
relationshipByUserId.set(otherId, friendship);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell active="Friends" isAdmin={userIsAdmin(user)}>
|
<AppShell
|
||||||
|
active="Friends"
|
||||||
|
isAdmin={userIsAdmin(user)}
|
||||||
|
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
|
||||||
|
userName={user.displayName || user.username}
|
||||||
|
>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="title-block">
|
<div className="title-block">
|
||||||
<h1>Friends</h1>
|
<h1>Friends</h1>
|
||||||
<p>Add users, accept requests, and enter persistent rooms.</p>
|
<p>Add users, accept requests, and enter persistent rooms.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="button primary">Add friend</button>
|
<StatusBadge>{users.length} users</StatusBadge>
|
||||||
</header>
|
</header>
|
||||||
<section className="panel">
|
|
||||||
|
{incoming.length > 0 ? (
|
||||||
|
<section className="panel" style={{ marginBottom: 18 }}>
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>Friend graph</h2>
|
<h2>Incoming requests</h2>
|
||||||
<StatusBadge>Username search</StatusBadge>
|
<StatusBadge tone="warn">{incoming.length} pending</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
{friends.map((friend) => (
|
{incoming.map((request) => (
|
||||||
<div className="row" key={friend.name}>
|
<div className="row" key={request.id}>
|
||||||
|
<div className="row-main">
|
||||||
|
<Avatar name={request.requester.displayName || request.requester.username} />
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{friend.name}</strong>
|
<strong>{request.requester.displayName || request.requester.username}</strong>
|
||||||
<span>{friend.room}</span>
|
<span>@{request.requester.username}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-row">
|
<div className="status-row">
|
||||||
<StatusBadge tone={friend.state === "Online" ? "good" : undefined}>{friend.state}</StatusBadge>
|
<form action={acceptFriendRequest}>
|
||||||
<button className="button">Enter room</button>
|
<input type="hidden" name="friendshipId" value={request.id} />
|
||||||
|
<button className="button primary" type="submit">Accept</button>
|
||||||
|
</form>
|
||||||
|
<form action={declineFriendRequest}>
|
||||||
|
<input type="hidden" name="friendshipId" value={request.id} />
|
||||||
|
<button className="button" type="submit">Decline</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<StatusBadge>Account directory</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
{users.map((listedUser) => {
|
||||||
|
const relationship = relationshipByUserId.get(listedUser.id);
|
||||||
|
const isFriend = relationship?.status === "ACCEPTED";
|
||||||
|
const isOutgoing = relationship?.requesterId === user.id && relationship.status === "PENDING";
|
||||||
|
const isIncoming = relationship?.receiverId === user.id && relationship.status === "PENDING";
|
||||||
|
const roomSlug = listedUser.ownedRooms[0]?.slug;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row" key={listedUser.id}>
|
||||||
|
<div className="row-main">
|
||||||
|
<Avatar name={listedUser.displayName || listedUser.username} />
|
||||||
|
<div className="row-title">
|
||||||
|
<strong>{listedUser.displayName || listedUser.username}</strong>
|
||||||
|
<span>@{listedUser.username}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="status-row">
|
||||||
|
{isFriend ? <StatusBadge tone="good">Friend</StatusBadge> : null}
|
||||||
|
{isOutgoing ? <StatusBadge tone="warn">Requested</StatusBadge> : null}
|
||||||
|
{isIncoming ? <StatusBadge tone="warn">Waiting</StatusBadge> : null}
|
||||||
|
{roomSlug && isFriend ? (
|
||||||
|
<Link className="button" href={`/rooms/${encodeURIComponent(roomSlug)}`}>Enter room</Link>
|
||||||
|
) : null}
|
||||||
|
{!relationship ? (
|
||||||
|
<form action={sendFriendRequest}>
|
||||||
|
<input type="hidden" name="receiverId" value={listedUser.id} />
|
||||||
|
<button className="button primary" type="submit">Add friend</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{users.length === 0 ? <div className="empty-state">No other users have registered yet.</div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ select {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 8px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -240,6 +249,15 @@ select {
|
|||||||
color: #e5edf7;
|
color: #e5edf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-embed {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 430px;
|
||||||
|
border: 0;
|
||||||
|
background: #05070a;
|
||||||
|
}
|
||||||
|
|
||||||
.video-state h2 {
|
.video-state h2 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -276,6 +294,11 @@ select {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -308,6 +331,13 @@ select {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.row-title strong {
|
.row-title strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -317,6 +347,27 @@ select {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--avatar-color) 42%, var(--border));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--avatar-color) 18%, var(--panel));
|
||||||
|
color: var(--avatar-color);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 140px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -386,6 +437,10 @@ select {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
@@ -1,28 +1,116 @@
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { RoomConsole } from "@/components/room-console";
|
import { RoomConsole } from "@/components/room-console";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
|
import { canEnterRoom } from "@/lib/access";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
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";
|
||||||
|
|
||||||
export default async function RoomPage({ params }: { params: Promise<{ slug: string }> }) {
|
export default async function RoomPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
await requireInitialSetup();
|
await requireInitialSetup();
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
|
const isAdmin = userIsAdmin(user);
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const roomSlug = decodeURIComponent(slug);
|
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 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = room.ownerId === user.id;
|
||||||
|
const explicitMember = room.members.some((member) => member.userId === user.id);
|
||||||
|
const isFriend = room.ownerId
|
||||||
|
? Boolean(
|
||||||
|
await prisma.friendship.findFirst({
|
||||||
|
where: {
|
||||||
|
status: "ACCEPTED",
|
||||||
|
OR: [
|
||||||
|
{ requesterId: user.id, receiverId: room.ownerId },
|
||||||
|
{ requesterId: room.ownerId, receiverId: user.id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!canEnterRoom({
|
||||||
|
visibility: room.visibility,
|
||||||
|
isOwner,
|
||||||
|
isAdmin,
|
||||||
|
isFriend,
|
||||||
|
explicitMember,
|
||||||
|
hasRoomRole: explicitMember
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const personalRoom = await prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell active="Rooms" isAdmin={userIsAdmin(user)}>
|
<AppShell
|
||||||
|
active="Rooms"
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
|
||||||
|
userName={user.displayName || user.username}
|
||||||
|
>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="title-block">
|
<div className="title-block">
|
||||||
<h1>{roomSlug}</h1>
|
<h1>{room.name}</h1>
|
||||||
<p>Stable room address with shared playback for authorized users.</p>
|
<p>{room.owner ? `Owned by ${room.owner.displayName || room.owner.username}` : "Shared watch room"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-row">
|
<div className="status-row">
|
||||||
<StatusBadge tone="good">Online</StatusBadge>
|
<StatusBadge tone="good">Online</StatusBadge>
|
||||||
<StatusBadge>All participants may control</StatusBadge>
|
<StatusBadge>{room.visibility}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<RoomConsole roomSlug={roomSlug} />
|
<RoomConsole
|
||||||
|
roomId={room.id}
|
||||||
|
roomSlug={room.slug}
|
||||||
|
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)
|
||||||
|
}))}
|
||||||
|
participants={[
|
||||||
|
...(room.owner
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: room.owner.id,
|
||||||
|
name: room.owner.displayName || room.owner.username,
|
||||||
|
role: "Owner",
|
||||||
|
status: room.owner.id === user.id ? "Online" : "Available"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...room.members.map((member) => ({
|
||||||
|
id: member.userId,
|
||||||
|
name: member.user.displayName || member.user.username,
|
||||||
|
role: member.canManage ? "Manager" : "Member",
|
||||||
|
status: member.userId === user.id ? "Online" : "Allowed"
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Gauge, MonitorPlay, Shield, UsersRound } from "lucide-react";
|
import { Gauge, MonitorPlay, Shield, UsersRound } from "lucide-react";
|
||||||
|
import { Avatar } from "./avatar";
|
||||||
const nav = [
|
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: Gauge },
|
|
||||||
{ href: "/rooms/@admin", label: "Rooms", icon: MonitorPlay },
|
|
||||||
{ href: "/friends", label: "Friends", icon: UsersRound }
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppShell({
|
export function AppShell({
|
||||||
children,
|
children,
|
||||||
active = "Dashboard",
|
active = "Dashboard",
|
||||||
isAdmin = false
|
isAdmin = false,
|
||||||
|
roomHref = "/dashboard",
|
||||||
|
userName
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
active?: string;
|
active?: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
roomHref?: string;
|
||||||
|
userName?: string;
|
||||||
}) {
|
}) {
|
||||||
const visibleNav = isAdmin ? [...nav, { href: "/admin", label: "Admin", icon: Shield }] : nav;
|
const nav = [
|
||||||
|
{ href: "/dashboard", label: "Dashboard", icon: Gauge },
|
||||||
|
{ href: roomHref, label: "Rooms", icon: MonitorPlay },
|
||||||
|
{ href: "/friends", label: "Friends", icon: UsersRound }
|
||||||
|
];
|
||||||
|
const visibleNav = isAdmin
|
||||||
|
? [nav[0], nav[1], nav[2], { href: "/admin", label: "Admin", icon: Shield }]
|
||||||
|
: nav;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
@@ -36,6 +42,15 @@ export function AppShell({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
{userName ? (
|
||||||
|
<div className="sidebar-user">
|
||||||
|
<Avatar name={userName} />
|
||||||
|
<div className="row-title">
|
||||||
|
<strong>{userName}</strong>
|
||||||
|
<span>{isAdmin ? "Administrator" : "Member"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main">{children}</main>
|
<main className="main">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
src/components/avatar.tsx
Normal file
30
src/components/avatar.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const COLORS = ["#22c55e", "#06b6d4", "#f59e0b", "#f97316", "#84cc16", "#14b8a6", "#64748b"];
|
||||||
|
|
||||||
|
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
||||||
|
const normalized = name.trim() || "?";
|
||||||
|
const color = COLORS[hashString(normalized) % COLORS.length];
|
||||||
|
const initials = normalized
|
||||||
|
.split(/[\s_-]+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="avatar"
|
||||||
|
style={{ "--avatar-color": color, width: size, height: size, fontSize: Math.max(11, size * 0.36) } as React.CSSProperties}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{initials || "?"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashString(value: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
@@ -4,33 +4,65 @@ import { useMemo, useState } from "react";
|
|||||||
import { Pause, Play, Radio, SkipForward } from "lucide-react";
|
import { Pause, Play, Radio, SkipForward } 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 { activity, participants, queue } from "@/lib/sample-data";
|
|
||||||
import { StatusBadge } from "./status-badge";
|
import { StatusBadge } from "./status-badge";
|
||||||
|
import { Avatar } from "./avatar";
|
||||||
|
import { addMediaToRoom } from "@/lib/media-actions";
|
||||||
|
|
||||||
const socket = io({
|
const socket = io({
|
||||||
path: "/api/socket",
|
path: "/api/socket",
|
||||||
autoConnect: false
|
autoConnect: false
|
||||||
});
|
});
|
||||||
|
|
||||||
export function RoomConsole({ roomSlug = "@admin" }: { roomSlug?: string }) {
|
type QueueItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
provider: string;
|
||||||
|
originalUrl: string;
|
||||||
|
playbackUrl: string;
|
||||||
|
by: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Participant = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomConsole({
|
||||||
|
roomId,
|
||||||
|
roomSlug,
|
||||||
|
currentUser,
|
||||||
|
queue = [],
|
||||||
|
participants = []
|
||||||
|
}: {
|
||||||
|
roomId: string;
|
||||||
|
roomSlug: string;
|
||||||
|
currentUser: string;
|
||||||
|
queue?: QueueItem[];
|
||||||
|
participants?: Participant[];
|
||||||
|
}) {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [source, setSource] = useState("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
|
const [source, setSource] = useState("");
|
||||||
const media = useMemo(() => normalizeMediaUrl(source), [source]);
|
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
|
||||||
|
const currentMedia = previewMedia || queue[0] || null;
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
socket.emit("room:join", { roomSlug, user: "Admin" });
|
socket.emit("room:join", { roomSlug, user: currentUser });
|
||||||
}
|
}
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek") {
|
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek") {
|
||||||
connect();
|
connect();
|
||||||
|
const media = previewMedia || queue[0];
|
||||||
socket.emit(event, {
|
socket.emit(event, {
|
||||||
provider: media.provider,
|
provider: media?.provider,
|
||||||
originalUrl: media.originalUrl,
|
originalUrl: media?.originalUrl,
|
||||||
playbackUrl: media.playbackUrl,
|
playbackUrl: media?.playbackUrl,
|
||||||
position: event === "playback:seek" ? 82 : undefined
|
position: event === "playback:seek" ? 82 : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,73 +78,94 @@ export function RoomConsole({ roomSlug = "@admin" }: { roomSlug?: string }) {
|
|||||||
<StatusBadge tone={connected ? "good" : "warn"}>{connected ? "Online" : "Local preview"}</StatusBadge>
|
<StatusBadge tone={connected ? "good" : "warn"}>{connected ? "Online" : "Local preview"}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="video-frame">
|
<div className="video-frame">
|
||||||
|
{currentMedia ? (
|
||||||
|
<MediaPreview
|
||||||
|
provider={currentMedia.provider}
|
||||||
|
playbackUrl={currentMedia.playbackUrl}
|
||||||
|
title={currentMedia.title || currentMedia.originalUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="video-state">
|
<div className="video-state">
|
||||||
<StatusBadge tone="good">{media.provider}</StatusBadge>
|
<StatusBadge>Idle</StatusBadge>
|
||||||
<h2>Shared playback state</h2>
|
<h2>No media queued</h2>
|
||||||
<p>
|
<p>Add a YouTube, Twitch, or direct video URL to start this room.</p>
|
||||||
Participants in this room can set the source, play, pause, and seek. Late joiners receive the latest room
|
|
||||||
state from the realtime server.
|
|
||||||
</p>
|
|
||||||
<p className="eyebrow">{media.playbackUrl}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="controls">
|
<form className="controls" action={addMediaToRoom}>
|
||||||
<button className="button primary" onClick={() => emit("playback:play")}>
|
<input type="hidden" name="roomId" value={roomId} />
|
||||||
|
<button className="button primary" type="button" onClick={() => emit("playback:play")}>
|
||||||
<Play size={16} /> Play
|
<Play size={16} /> Play
|
||||||
</button>
|
</button>
|
||||||
<button className="button" onClick={() => emit("playback:pause")}>
|
<button className="button" type="button" onClick={() => emit("playback:pause")}>
|
||||||
<Pause size={16} /> Pause
|
<Pause size={16} /> Pause
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
aria-label="Source URL"
|
aria-label="Source URL"
|
||||||
|
name="sourceUrl"
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(event) => setSource(event.target.value)}
|
onChange={(event) => setSource(event.target.value)}
|
||||||
placeholder="Source URL"
|
placeholder="Source URL"
|
||||||
/>
|
/>
|
||||||
<button className="button" onClick={() => emit("media:set")}>
|
<button className="button" type="submit" onClick={() => emit("media:set")} disabled={!source}>
|
||||||
<Radio size={16} /> Source URL
|
<Radio size={16} /> Add
|
||||||
</button>
|
</button>
|
||||||
<button className="button" onClick={() => emit("playback:seek")}>
|
<button className="button" type="button" onClick={() => emit("playback:seek")}>
|
||||||
<SkipForward size={16} /> Seek
|
<SkipForward size={16} /> Seek
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="stack">
|
<aside className="stack">
|
||||||
<Panel title="Queue">
|
<Panel title="Queue">
|
||||||
{queue.map((item) => (
|
{queue.length === 0 ? (
|
||||||
<div className="row" key={item.title}>
|
<div className="empty-state">No media queued yet.</div>
|
||||||
|
) : (
|
||||||
|
queue.map((item) => (
|
||||||
|
<div className="row" key={item.id}>
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
<span>
|
<span>
|
||||||
{item.provider} by {item.by}
|
{item.provider} by {item.by}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge>{item.duration}</StatusBadge>
|
<StatusBadge>{item.createdAt}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title="Participants">
|
<Panel title="Participants">
|
||||||
{participants.map((item) => (
|
{participants.length === 0 ? (
|
||||||
<div className="row" key={item.name}>
|
<div className="empty-state">No participants listed yet.</div>
|
||||||
|
) : (
|
||||||
|
participants.map((item) => (
|
||||||
|
<div className="row" key={item.id}>
|
||||||
|
<div className="row-main">
|
||||||
|
<Avatar name={item.name} />
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{item.name}</strong>
|
<strong>{item.name}</strong>
|
||||||
<span>{item.role}</span>
|
<span>{item.role}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<StatusBadge tone={item.status === "Buffering" ? "warn" : "good"}>{item.status}</StatusBadge>
|
<StatusBadge tone={item.status === "Buffering" ? "warn" : "good"}>{item.status}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title="Activity">
|
<Panel title="Activity">
|
||||||
{activity.map((item) => (
|
{queue.length === 0 ? (
|
||||||
<div className="row" key={item}>
|
<div className="empty-state">Room activity will appear after users add media.</div>
|
||||||
|
) : (
|
||||||
|
queue.slice(0, 5).map((item) => (
|
||||||
|
<div className="row" key={`activity-${item.id}`}>
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{item}</strong>
|
<strong>{item.by} added {item.provider.toLowerCase()} media</strong>
|
||||||
<span>just now</span>
|
<span>{item.createdAt}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,3 +182,30 @@ function Panel({ title, children }: { title: string; children: React.ReactNode }
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaPreview({ provider, playbackUrl, title }: { provider: string; playbackUrl: string; title: string }) {
|
||||||
|
if (provider === "YOUTUBE" || provider === "TWITCH") {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
className="media-embed"
|
||||||
|
src={playbackUrl}
|
||||||
|
title={title}
|
||||||
|
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "DIRECT") {
|
||||||
|
return <video className="media-embed" src={playbackUrl} controls playsInline />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="video-state">
|
||||||
|
<StatusBadge tone="warn">Unsupported</StatusBadge>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>This URL is stored in the queue, but it cannot be embedded directly.</p>
|
||||||
|
<p className="eyebrow">{playbackUrl}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
70
src/lib/friend-actions.ts
Normal file
70
src/lib/friend-actions.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import { requireCurrentUser } from "./session";
|
||||||
|
|
||||||
|
export async function sendFriendRequest(formData: FormData) {
|
||||||
|
const user = await requireCurrentUser();
|
||||||
|
const receiverId = String(formData.get("receiverId") || "");
|
||||||
|
|
||||||
|
if (!receiverId || receiverId === user.id) return;
|
||||||
|
|
||||||
|
const existing = await prisma.friendship.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ requesterId: user.id, receiverId },
|
||||||
|
{ requesterId: receiverId, receiverId: user.id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true, status: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.friendship.create({
|
||||||
|
data: {
|
||||||
|
requesterId: user.id,
|
||||||
|
receiverId,
|
||||||
|
status: "PENDING"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (existing.status === "DECLINED") {
|
||||||
|
await prisma.friendship.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
requesterId: user.id,
|
||||||
|
receiverId,
|
||||||
|
status: "PENDING"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/friends");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptFriendRequest(formData: FormData) {
|
||||||
|
await updateIncomingRequest(formData, "ACCEPTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function declineFriendRequest(formData: FormData) {
|
||||||
|
await updateIncomingRequest(formData, "DECLINED");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "DECLINED") {
|
||||||
|
const user = await requireCurrentUser();
|
||||||
|
const friendshipId = String(formData.get("friendshipId") || "");
|
||||||
|
if (!friendshipId) return;
|
||||||
|
|
||||||
|
await prisma.friendship.updateMany({
|
||||||
|
where: {
|
||||||
|
id: friendshipId,
|
||||||
|
receiverId: user.id,
|
||||||
|
status: "PENDING"
|
||||||
|
},
|
||||||
|
data: { status }
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/friends");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
52
src/lib/media-actions.ts
Normal file
52
src/lib/media-actions.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { normalizeMediaUrl } from "./media";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import { requireCurrentUser } from "./session";
|
||||||
|
|
||||||
|
export async function addMediaToRoom(formData: FormData) {
|
||||||
|
const user = await requireCurrentUser();
|
||||||
|
const roomId = String(formData.get("roomId") || "");
|
||||||
|
const sourceUrl = String(formData.get("sourceUrl") || "").trim();
|
||||||
|
|
||||||
|
if (!roomId || !sourceUrl) return;
|
||||||
|
|
||||||
|
const room = await prisma.room.findFirst({
|
||||||
|
where: {
|
||||||
|
id: roomId,
|
||||||
|
OR: [{ ownerId: user.id }, { members: { some: { userId: user.id } } }, { visibility: "PUBLIC" }]
|
||||||
|
},
|
||||||
|
select: { id: true, slug: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const media = normalizeMediaUrl(sourceUrl);
|
||||||
|
await prisma.mediaSource.create({
|
||||||
|
data: {
|
||||||
|
roomId: room.id,
|
||||||
|
submitterId: user.id,
|
||||||
|
provider: media.provider,
|
||||||
|
originalUrl: media.originalUrl,
|
||||||
|
playbackUrl: media.playbackUrl,
|
||||||
|
title: media.originalUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.room.update({
|
||||||
|
where: { id: room.id },
|
||||||
|
data: {
|
||||||
|
currentState: {
|
||||||
|
provider: media.provider,
|
||||||
|
originalUrl: media.originalUrl,
|
||||||
|
playbackUrl: media.playbackUrl,
|
||||||
|
updatedBy: user.username,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/rooms/${encodeURIComponent(room.slug)}`);
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export const dashboardStats = [
|
|
||||||
{ label: "Online", value: "12", tone: "good" },
|
|
||||||
{ label: "Active rooms", value: "5", tone: "info" },
|
|
||||||
{ label: "Pending friends", value: "3", tone: "warn" },
|
|
||||||
{ label: "Queue items", value: "18", tone: "neutral" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const rooms = [
|
|
||||||
{ name: "@maria", owner: "Maria", visibility: "Friends", status: "Live", source: "YouTube" },
|
|
||||||
{ name: "@admin", owner: "Admin", visibility: "Role", status: "Idle", source: "Twitch" },
|
|
||||||
{ name: "Friday Ops", owner: "Ops", visibility: "Public", status: "Live", source: "Direct" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const friends = [
|
|
||||||
{ name: "Maria", state: "Online", room: "@maria" },
|
|
||||||
{ name: "Jens", state: "Away", room: "@jens" },
|
|
||||||
{ name: "Aylin", state: "Offline", room: "@aylin" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const queue = [
|
|
||||||
{ title: "Build stream recap", provider: "YouTube", by: "Maria", duration: "12:40" },
|
|
||||||
{ title: "Dockge deployment notes", provider: "Twitch", by: "Admin", duration: "Live" },
|
|
||||||
{ title: "Local media sample", provider: "Direct", by: "Jens", duration: "03:20" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const participants = [
|
|
||||||
{ name: "Admin", role: "Admin", status: "Host" },
|
|
||||||
{ name: "Maria", role: "Member", status: "Synced" },
|
|
||||||
{ name: "Jens", role: "Member", status: "Synced" },
|
|
||||||
{ name: "Aylin", role: "Guest", status: "Buffering" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const activity = [
|
|
||||||
"Maria set a YouTube source",
|
|
||||||
"Admin seeked to 01:22",
|
|
||||||
"Jens joined @admin",
|
|
||||||
"Aylin requested friendship"
|
|
||||||
];
|
|
||||||
Reference in New Issue
Block a user