Replace demo pages with live app data
This commit is contained in:
@@ -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
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user