From d6c2227a54cc450aaff86f1900bdb601ca8af91f Mon Sep 17 00:00:00 2001 From: MrSphay Date: Fri, 15 May 2026 17:47:42 +0200 Subject: [PATCH] Replace demo pages with live app data --- src/app/admin/page.tsx | 87 ++++++++++++++-- src/app/dashboard/page.tsx | 130 +++++++++++++++++++---- src/app/friends/page.tsx | 115 ++++++++++++++++++--- src/app/globals.css | 55 ++++++++++ src/app/rooms/[slug]/page.tsx | 98 +++++++++++++++++- src/components/app-shell.tsx | 31 ++++-- src/components/avatar.tsx | 30 ++++++ src/components/room-console.tsx | 178 +++++++++++++++++++++++--------- src/lib/friend-actions.ts | 70 +++++++++++++ src/lib/media-actions.ts | 52 ++++++++++ src/lib/sample-data.ts | 38 ------- 11 files changed, 741 insertions(+), 143 deletions(-) create mode 100644 src/components/avatar.tsx create mode 100644 src/lib/friend-actions.ts create mode 100644 src/lib/media-actions.ts delete mode 100644 src/lib/sample-data.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b8a8fff..575fe53 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 ( - +

Admin

@@ -27,7 +50,7 @@ export default async function AdminPage() {

Rooms

- + {rooms.length} total
@@ -41,21 +64,69 @@ export default async function AdminPage() { {rooms.map((room) => ( - - - + + + - + ))}
{room.name}{room.owner}
+ {room.name} + {room.owner?.displayName || room.owner?.username || "Unassigned"} {room.visibility}{room.status}{room._count.members + 1} users / {room._count.mediaSources} media
+
+
+

Users

+ {users.length} accounts +
+
+ {users.map((account) => ( +
+
+ +
+ {account.displayName || account.username} + @{account.username} ยท {account._count.ownedRooms + account._count.roomMembers} rooms +
+
+
+ {account.roles.map((userRole) => ( + + {userRole.role.name} + + ))} + {account.roles.length === 0 ? user : null} +
+
+ ))} +
+
+ +
+
+
+

Roles

+ {roles.length} defined +
+
+ {roles.map((role) => ( +
+
+ {role.name} + {role.description || `${role.scope.toLowerCase()} role`} +
+ {role._count.users} users / {role._count.permissions} permissions +
+ ))} +
+

Permissions

- Roles + System
{SYSTEM_PERMISSIONS.map((permission) => ( diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index d7e29b8..f453e85 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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 ( - +

Dashboard

-

{user ? `Signed in as ${user.username}` : "Persistent rooms, friends, and shared playback state."}

+

{`Signed in as ${user.displayName || user.username}`}

Online @@ -23,7 +66,7 @@ export default async function DashboardPage() {
- {dashboardStats.map((stat) => ( + {stats.map((stat) => (
{stat.label} {stat.value} @@ -31,7 +74,42 @@ export default async function DashboardPage() { ))}
- + {personalRoom ? ( + ({ + 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" + })) + ]} + /> + ) : ( +
+
+
No personal room exists for this account yet.
+
+
+ )}
@@ -52,36 +130,50 @@ export default async function DashboardPage() { {rooms.map((room) => ( - - {room.name} - {room.owner} + + + {room.name} + + {room.owner?.displayName || room.owner?.username || "Unassigned"} {room.visibility} - {room.status} - {room.source} + {room._count.members + 1} users + {room._count.mediaSources} queued ))} + {rooms.length === 0 ?
No accessible rooms found.
: null}

Friends

- 3 linked + 0 ? "good" : undefined}>{acceptedFriends} linked
- {friends.map((friend) => ( -
-
- {friend.name} - {friend.room} + {friendships.map((friendship) => { + const friend = friendship.requesterId === user.id ? friendship.receiver : friendship.requester; + return ( +
+
+ +
+ {friend.displayName || friend.username} + @{friend.username} +
+
+ Friend
- {friend.state} -
- ))} + ); + })} + {friendships.length === 0 ?
No friends yet. Add users from Friends.
: null}
); } + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date); +} diff --git a/src/app/friends/page.tsx b/src/app/friends/page.tsx index 769123d..4633e8d 100644 --- a/src/app/friends/page.tsx +++ b/src/app/friends/page.tsx @@ -1,40 +1,123 @@ 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(); + 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 ( - +

Friends

Add users, accept requests, and enter persistent rooms.

- + {users.length} users
+ + {incoming.length > 0 ? ( +
+
+

Incoming requests

+ {incoming.length} pending +
+
+ {incoming.map((request) => ( +
+
+ +
+ {request.requester.displayName || request.requester.username} + @{request.requester.username} +
+
+
+
+ + +
+
+ + +
+
+
+ ))} +
+
+ ) : null} +
-

Friend graph

- Username search +

Users

+ Account directory
- {friends.map((friend) => ( -
-
- {friend.name} - {friend.room} + {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 ( +
+
+ +
+ {listedUser.displayName || listedUser.username} + @{listedUser.username} +
+
+
+ {isFriend ? Friend : null} + {isOutgoing ? Requested : null} + {isIncoming ? Waiting : null} + {roomSlug && isFriend ? ( + Enter room + ) : null} + {!relationship ? ( +
+ + +
+ ) : null} +
-
- {friend.state} - -
-
- ))} + ); + })} + {users.length === 0 ?
No other users have registered yet.
: null}
diff --git a/src/app/globals.css b/src/app/globals.css index d03e12a..8edf11c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/rooms/[slug]/page.tsx b/src/app/rooms/[slug]/page.tsx index b491013..2c8c906 100644 --- a/src/app/rooms/[slug]/page.tsx +++ b/src/app/rooms/[slug]/page.tsx @@ -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 ( - +
-

{roomSlug}

-

Stable room address with shared playback for authorized users.

+

{room.name}

+

{room.owner ? `Owned by ${room.owner.displayName || room.owner.username}` : "Shared watch room"}

Online - All participants may control + {room.visibility}
- + ({ + 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" + })) + ]} + />
); } + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date); +} diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 4dda999..809b393 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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 (
@@ -36,6 +42,15 @@ export function AppShell({ ); })} + {userName ? ( +
+ +
+ {userName} + {isAdmin ? "Administrator" : "Member"} +
+
+ ) : null}
{children}
diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx new file mode 100644 index 0000000..1b525d7 --- /dev/null +++ b/src/components/avatar.tsx @@ -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 ( + + ); +} + +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); +} diff --git a/src/components/room-console.tsx b/src/components/room-console.tsx index 9f9f596..79a9857 100644 --- a/src/components/room-console.tsx +++ b/src/components/room-console.tsx @@ -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 }) { {connected ? "Online" : "Local preview"}
-
- {media.provider} -

Shared playback state

-

- Participants in this room can set the source, play, pause, and seek. Late joiners receive the latest room - state from the realtime server. -

-

{media.playbackUrl}

-
+ {currentMedia ? ( + + ) : ( +
+ Idle +

No media queued

+

Add a YouTube, Twitch, or direct video URL to start this room.

+
+ )}
-
- - setSource(event.target.value)} placeholder="Source URL" /> - - -
+ @@ -129,3 +182,30 @@ function Panel({ title, children }: { title: string; children: React.ReactNode } ); } + +function MediaPreview({ provider, playbackUrl, title }: { provider: string; playbackUrl: string; title: string }) { + if (provider === "YOUTUBE" || provider === "TWITCH") { + return ( +