Replace demo pages with live app data
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 1m35s
Template Compliance / compliance (push) Successful in 5s
Build / build (push) Successful in 11m35s

This commit is contained in:
MrSphay
2026-05-15 17:47:42 +02:00
parent 035a255125
commit d6c2227a54
11 changed files with 741 additions and 143 deletions

View File

@@ -1,9 +1,11 @@
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { StatusBadge } from "@/components/status-badge";
import { SYSTEM_PERMISSIONS } from "@/lib/access";
import { prisma } from "@/lib/prisma";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import { rooms } from "@/lib/sample-data";
import Link from "next/link";
import { redirect } from "next/navigation";
export default async function AdminPage() {
@@ -14,8 +16,29 @@ export default async function AdminPage() {
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 (
<AppShell active="Admin" isAdmin>
<AppShell
active="Admin"
isAdmin
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
userName={user.displayName || user.username}
>
<header className="topbar">
<div className="title-block">
<h1>Admin</h1>
@@ -27,7 +50,7 @@ export default async function AdminPage() {
<section className="panel">
<div className="panel-header">
<h2>Rooms</h2>
<button className="button primary">Create room</button>
<StatusBadge>{rooms.length} total</StatusBadge>
</div>
<div className="panel-body">
<table className="table">
@@ -41,21 +64,69 @@ export default async function AdminPage() {
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.name}>
<td>{room.name}</td>
<td>{room.owner}</td>
<tr key={room.id}>
<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.status}</td>
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
</tr>
))}
</tbody>
</table>
</div>
</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">
<div className="panel-header">
<h2>Permissions</h2>
<StatusBadge>Roles</StatusBadge>
<StatusBadge>System</StatusBadge>
</div>
<div className="panel-body">
{SYSTEM_PERMISSIONS.map((permission) => (

View File

@@ -1,20 +1,63 @@
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
import { prisma } from "@/lib/prisma";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import { dashboardStats, friends, rooms } from "@/lib/sample-data";
import Link from "next/link";
export default async function DashboardPage() {
await requireInitialSetup();
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 (
<AppShell active="Dashboard" isAdmin={userIsAdmin(user)}>
<AppShell active="Dashboard" isAdmin={isAdmin} roomHref={roomHref} userName={user.displayName || user.username}>
<header className="topbar">
<div className="title-block">
<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 className="status-row">
<StatusBadge tone="good">Online</StatusBadge>
@@ -23,7 +66,7 @@ export default async function DashboardPage() {
</header>
<section className="stats-grid" aria-label="System overview">
{dashboardStats.map((stat) => (
{stats.map((stat) => (
<div className="stat" key={stat.label}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
@@ -31,7 +74,42 @@ export default async function DashboardPage() {
))}
</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="panel">
@@ -52,36 +130,50 @@ export default async function DashboardPage() {
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.name}>
<td>{room.name}</td>
<td>{room.owner}</td>
<tr key={room.id}>
<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.status}</td>
<td>{room.source}</td>
<td>{room._count.members + 1} users</td>
<td>{room._count.mediaSources} queued</td>
</tr>
))}
</tbody>
</table>
{rooms.length === 0 ? <div className="empty-state">No accessible rooms found.</div> : null}
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2>Friends</h2>
<StatusBadge tone="good">3 linked</StatusBadge>
<StatusBadge tone={acceptedFriends > 0 ? "good" : undefined}>{acceptedFriends} linked</StatusBadge>
</div>
<div className="panel-body">
{friends.map((friend) => (
<div className="row" key={friend.name}>
{friendships.map((friendship) => {
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">
<strong>{friend.name}</strong>
<span>{friend.room}</span>
<strong>{friend.displayName || friend.username}</strong>
<span>@{friend.username}</span>
</div>
<StatusBadge tone={friend.state === "Online" ? "good" : undefined}>{friend.state}</StatusBadge>
</div>
))}
<StatusBadge tone="good">Friend</StatusBadge>
</div>
);
})}
{friendships.length === 0 ? <div className="empty-state">No friends yet. Add users from Friends.</div> : null}
</div>
</section>
</section>
</AppShell>
);
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
}

View File

@@ -1,42 +1,125 @@
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
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 { requireInitialSetup } from "@/lib/setup";
import { friends } from "@/lib/sample-data";
import Link from "next/link";
export default async function FriendsPage() {
await requireInitialSetup();
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 (
<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">
<div className="title-block">
<h1>Friends</h1>
<p>Add users, accept requests, and enter persistent rooms.</p>
</div>
<button className="button primary">Add friend</button>
<StatusBadge>{users.length} users</StatusBadge>
</header>
<section className="panel">
{incoming.length > 0 ? (
<section className="panel" style={{ marginBottom: 18 }}>
<div className="panel-header">
<h2>Friend graph</h2>
<StatusBadge>Username search</StatusBadge>
<h2>Incoming requests</h2>
<StatusBadge tone="warn">{incoming.length} pending</StatusBadge>
</div>
<div className="panel-body">
{friends.map((friend) => (
<div className="row" key={friend.name}>
{incoming.map((request) => (
<div className="row" key={request.id}>
<div className="row-main">
<Avatar name={request.requester.displayName || request.requester.username} />
<div className="row-title">
<strong>{friend.name}</strong>
<span>{friend.room}</span>
<strong>{request.requester.displayName || request.requester.username}</strong>
<span>@{request.requester.username}</span>
</div>
</div>
<div className="status-row">
<StatusBadge tone={friend.state === "Online" ? "good" : undefined}>{friend.state}</StatusBadge>
<button className="button">Enter room</button>
<form action={acceptFriendRequest}>
<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>
</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>
);
}

View File

@@ -106,6 +106,15 @@ select {
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 {
min-width: 0;
padding: 18px;
@@ -240,6 +249,15 @@ select {
color: #e5edf7;
}
.media-embed {
display: block;
width: 100%;
height: 100%;
min-height: 430px;
border: 0;
background: #05070a;
}
.video-state h2 {
margin: 0 0 10px;
font-size: 24px;
@@ -276,6 +294,11 @@ select {
color: var(--accent);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.input {
min-width: 0;
border: 1px solid var(--border);
@@ -308,6 +331,13 @@ select {
gap: 3px;
}
.row-main {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.row-title strong {
font-size: 14px;
}
@@ -317,6 +347,27 @@ select {
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 {
width: 100%;
border-collapse: collapse;
@@ -386,6 +437,10 @@ select {
padding: 10px;
}
.sidebar-user {
display: none;
}
.nav-list {
display: flex;
overflow-x: auto;

View File

@@ -1,28 +1,116 @@
import { AppShell } from "@/components/app-shell";
import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
import { canEnterRoom } from "@/lib/access";
import { prisma } from "@/lib/prisma";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import { redirect } from "next/navigation";
export default async function RoomPage({ params }: { params: Promise<{ slug: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
const isAdmin = userIsAdmin(user);
const { slug } = await params;
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 (
<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">
<div className="title-block">
<h1>{roomSlug}</h1>
<p>Stable room address with shared playback for authorized users.</p>
<h1>{room.name}</h1>
<p>{room.owner ? `Owned by ${room.owner.displayName || room.owner.username}` : "Shared watch room"}</p>
</div>
<div className="status-row">
<StatusBadge tone="good">Online</StatusBadge>
<StatusBadge>All participants may control</StatusBadge>
<StatusBadge>{room.visibility}</StatusBadge>
</div>
</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>
);
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
}

View File

@@ -1,22 +1,28 @@
import Link from "next/link";
import { Gauge, MonitorPlay, Shield, UsersRound } from "lucide-react";
const nav = [
{ href: "/dashboard", label: "Dashboard", icon: Gauge },
{ href: "/rooms/@admin", label: "Rooms", icon: MonitorPlay },
{ href: "/friends", label: "Friends", icon: UsersRound }
];
import { Avatar } from "./avatar";
export function AppShell({
children,
active = "Dashboard",
isAdmin = false
isAdmin = false,
roomHref = "/dashboard",
userName
}: {
children: React.ReactNode;
active?: string;
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 (
<div className="app-shell">
@@ -36,6 +42,15 @@ export function AppShell({
);
})}
</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>
<main className="main">{children}</main>
</div>

30
src/components/avatar.tsx Normal file
View 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);
}

View File

@@ -4,33 +4,65 @@ import { useMemo, useState } from "react";
import { Pause, Play, Radio, SkipForward } from "lucide-react";
import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media";
import { activity, participants, queue } from "@/lib/sample-data";
import { StatusBadge } from "./status-badge";
import { Avatar } from "./avatar";
import { addMediaToRoom } from "@/lib/media-actions";
const socket = io({
path: "/api/socket",
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 [source, setSource] = useState("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
const media = useMemo(() => normalizeMediaUrl(source), [source]);
const [source, setSource] = useState("");
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
const currentMedia = previewMedia || queue[0] || null;
function connect() {
if (!socket.connected) {
socket.connect();
socket.emit("room:join", { roomSlug, user: "Admin" });
socket.emit("room:join", { roomSlug, user: currentUser });
}
setConnected(true);
}
function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek") {
connect();
const media = previewMedia || queue[0];
socket.emit(event, {
provider: media.provider,
originalUrl: media.originalUrl,
playbackUrl: media.playbackUrl,
provider: media?.provider,
originalUrl: media?.originalUrl,
playbackUrl: media?.playbackUrl,
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>
</div>
<div className="video-frame">
{currentMedia ? (
<MediaPreview
provider={currentMedia.provider}
playbackUrl={currentMedia.playbackUrl}
title={currentMedia.title || currentMedia.originalUrl}
/>
) : (
<div className="video-state">
<StatusBadge tone="good">{media.provider}</StatusBadge>
<h2>Shared playback state</h2>
<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>
<StatusBadge>Idle</StatusBadge>
<h2>No media queued</h2>
<p>Add a YouTube, Twitch, or direct video URL to start this room.</p>
</div>
)}
</div>
<div className="controls">
<button className="button primary" onClick={() => emit("playback:play")}>
<form className="controls" action={addMediaToRoom}>
<input type="hidden" name="roomId" value={roomId} />
<button className="button primary" type="button" onClick={() => emit("playback:play")}>
<Play size={16} /> Play
</button>
<button className="button" onClick={() => emit("playback:pause")}>
<button className="button" type="button" onClick={() => emit("playback:pause")}>
<Pause size={16} /> Pause
</button>
<input
className="input"
aria-label="Source URL"
name="sourceUrl"
value={source}
onChange={(event) => setSource(event.target.value)}
placeholder="Source URL"
/>
<button className="button" onClick={() => emit("media:set")}>
<Radio size={16} /> Source URL
<button className="button" type="submit" onClick={() => emit("media:set")} disabled={!source}>
<Radio size={16} /> Add
</button>
<button className="button" onClick={() => emit("playback:seek")}>
<button className="button" type="button" onClick={() => emit("playback:seek")}>
<SkipForward size={16} /> Seek
</button>
</div>
</form>
</section>
<aside className="stack">
<Panel title="Queue">
{queue.map((item) => (
<div className="row" key={item.title}>
{queue.length === 0 ? (
<div className="empty-state">No media queued yet.</div>
) : (
queue.map((item) => (
<div className="row" key={item.id}>
<div className="row-title">
<strong>{item.title}</strong>
<span>
{item.provider} by {item.by}
</span>
</div>
<StatusBadge>{item.duration}</StatusBadge>
<StatusBadge>{item.createdAt}</StatusBadge>
</div>
))}
))
)}
</Panel>
<Panel title="Participants">
{participants.map((item) => (
<div className="row" key={item.name}>
{participants.length === 0 ? (
<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">
<strong>{item.name}</strong>
<span>{item.role}</span>
</div>
</div>
<StatusBadge tone={item.status === "Buffering" ? "warn" : "good"}>{item.status}</StatusBadge>
</div>
))}
))
)}
</Panel>
<Panel title="Activity">
{activity.map((item) => (
<div className="row" key={item}>
{queue.length === 0 ? (
<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">
<strong>{item}</strong>
<span>just now</span>
<strong>{item.by} added {item.provider.toLowerCase()} media</strong>
<span>{item.createdAt}</span>
</div>
</div>
))}
))
)}
</Panel>
</aside>
</div>
@@ -129,3 +182,30 @@ function Panel({ title, children }: { title: string; children: React.ReactNode }
</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
View 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
View 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");
}

View File

@@ -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"
];