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,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">
<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>
</div>
{currentMedia ? (
<MediaPreview
provider={currentMedia.provider}
playbackUrl={currentMedia.playbackUrl}
title={currentMedia.title || currentMedia.originalUrl}
/>
) : (
<div className="video-state">
<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}>
<div className="row-title">
<strong>{item.title}</strong>
<span>
{item.provider} by {item.by}
</span>
{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.createdAt}</StatusBadge>
</div>
<StatusBadge>{item.duration}</StatusBadge>
</div>
))}
))
)}
</Panel>
<Panel title="Participants">
{participants.map((item) => (
<div className="row" key={item.name}>
<div className="row-title">
<strong>{item.name}</strong>
<span>{item.role}</span>
{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>
<StatusBadge tone={item.status === "Buffering" ? "warn" : "good"}>{item.status}</StatusBadge>
</div>
))}
))
)}
</Panel>
<Panel title="Activity">
{activity.map((item) => (
<div className="row" key={item}>
<div className="row-title">
<strong>{item}</strong>
<span>just now</span>
{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.by} added {item.provider.toLowerCase()} media</strong>
<span>{item.createdAt}</span>
</div>
</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>
);
}