Redesign WatchLink application UI
All checks were successful
Template Compliance / compliance (push) Successful in 5s
Build / build (push) Successful in 12m36s
Release Dry Run / release-dry-run (push) Successful in 1m33s

This commit is contained in:
MrSphay
2026-05-15 20:13:29 +02:00
parent cea591b587
commit 9fbd79c7ef
22 changed files with 2370 additions and 533 deletions

View File

@@ -17,3 +17,4 @@ Initial implementation created from `codex-agent-repository-kit` guidance.
- Gitea Actions can run `npm install`, tests, build, and Docker image publishing.
- Local Docker is still unavailable in this Codex environment, but Gitea Actions can build the image.
- Docker publish was fixed by updating `REGISTRY_TOKEN`; Gitea issue #1 is closed.
- UI redesign follow-ups that need real backend work: persisted invite records, editable room settings, queue reorder/remove server actions, chat persistence/moderation, user ban/remove-friend actions, a real audit/event table, and socket acknowledgements for sync health instead of client-local connection assumptions.

View File

@@ -0,0 +1,86 @@
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { StatusBadge } from "@/components/status-badge";
import { DataTable, PageHeader, Panel, StatusDot } from "@/components/ui";
import { logoutUser } from "@/lib/user-actions";
import { prisma } from "@/lib/prisma";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
export default async function ProfilePage() {
await requireInitialSetup();
const user = await requireCurrentUser();
const shell = await getShellContext(user);
const [ownedRooms, friendships] = await Promise.all([
prisma.room.findMany({ where: { ownerId: user.id }, orderBy: { updatedAt: "desc" } }),
prisma.friendship.count({ where: { status: "ACCEPTED", OR: [{ requesterId: user.id }, { receiverId: user.id }] } })
]);
return (
<AppShell active="Overview" {...shell}>
<PageHeader
title="Profile"
description="Account identity, room ownership, roles, and session controls."
meta={
<>
<StatusDot tone="good" label="signed in" />
<StatusDot tone="info" label={`${ownedRooms.length} rooms`} />
<StatusDot tone="good" label={`${friendships} friends`} />
</>
}
actions={
<form action={logoutUser}>
<button className="button" type="submit">Logout</button>
</form>
}
/>
<section className="split-grid">
<Panel title="Account" eyebrow="Identity">
<div className="profile-block">
<Avatar name={user.displayName || user.username} size={64} />
<div className="row-title">
<strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span>
</div>
</div>
<div className="settings-list">
<div className="setting-row"><span><strong>Display name</strong><small>{user.displayName || user.username}</small></span></div>
<div className="setting-row"><span><strong>Created</strong><small>{formatDate(user.createdAt)}</small></span></div>
<div className="setting-row"><span><strong>Roles</strong><small>{user.roles.map((item) => item.role.name).join(", ") || "user"}</small></span></div>
</div>
</Panel>
<Panel title="Owned Rooms" eyebrow="Persistent spaces">
<DataTable>
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Access</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{ownedRooms.map((room) => (
<tr key={room.id}>
<td>{room.name}</td>
<td><StatusBadge>{room.visibility.toLowerCase().replace("_", " ")}</StatusBadge></td>
<td>{formatDate(room.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</DataTable>
</Panel>
</section>
</AppShell>
);
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(date);
}

View File

@@ -0,0 +1,32 @@
import { AppShell } from "@/components/app-shell";
import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui";
import { logoutUser } from "@/lib/user-actions";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
export default async function SessionsPage() {
await requireInitialSetup();
const user = await requireCurrentUser();
const shell = await getShellContext(user);
return (
<AppShell active="Overview" {...shell}>
<PageHeader
title="Sessions"
description="Current login state and session controls."
meta={<StatusDot tone="good" label="current session active" />}
actions={
<form action={logoutUser}>
<button className="button" type="submit">Logout current session</button>
</form>
}
/>
<Panel title="Session Management" eyebrow="HTTP-only cookie">
<EmptyState title="Only the current session is tracked" description="A persisted session table can be added later for device-level revoke controls." />
</Panel>
</AppShell>
);
}

View File

@@ -0,0 +1,42 @@
import { AppShell } from "@/components/app-shell";
import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
export default async function AccountSettingsPage() {
await requireInitialSetup();
const user = await requireCurrentUser();
const shell = await getShellContext(user);
return (
<AppShell active="Overview" {...shell}>
<PageHeader
title="Settings"
description="Account preferences and session options. Controls stay disabled until matching server actions exist."
meta={<StatusDot tone="good" label="account loaded" />}
/>
<section className="split-grid">
<Panel title="Profile Settings" eyebrow="Account">
<form className="form compact-form">
<label>
Display name
<input className="input" defaultValue={user.displayName || user.username} disabled />
</label>
<label>
Avatar
<input className="input" placeholder="Avatar upload is not wired yet" disabled />
</label>
<button className="button primary" type="button" disabled>Save profile</button>
</form>
</Panel>
<Panel title="Sessions" eyebrow="Security">
<EmptyState title="Session list is not persisted yet" description="The active session is managed by a signed HTTP-only cookie." />
</Panel>
</section>
</AppShell>
);
}

152
src/app/activity/page.tsx Normal file
View File

@@ -0,0 +1,152 @@
import Link from "next/link";
import { Clock3, MonitorPlay, Radio, UsersRound } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { StatusBadge } from "@/components/status-badge";
import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui";
import { prisma } from "@/lib/prisma";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
export default async function ActivityPage() {
await requireInitialSetup();
const user = await requireCurrentUser();
const shell = await getShellContext(user);
const [recentMedia, recentRooms, requests] = await Promise.all([
prisma.mediaSource.findMany({
where: {
room: {
OR: [{ visibility: "PUBLIC" }, { ownerId: user.id }, { members: { some: { userId: user.id } } }]
}
},
include: { room: true, submitter: true },
orderBy: { createdAt: "desc" },
take: 12
}),
prisma.room.findMany({
where: { OR: [{ visibility: "PUBLIC" }, { ownerId: user.id }, { members: { some: { userId: user.id } } }] },
include: { owner: true },
orderBy: { updatedAt: "desc" },
take: 8
}),
prisma.friendship.findMany({
where: { OR: [{ requesterId: user.id }, { receiverId: user.id }] },
include: { requester: true, receiver: true },
orderBy: { updatedAt: "desc" },
take: 8
})
]);
const events = [
...recentMedia.map((item) => ({
id: `media-${item.id}`,
icon: <Radio size={15} />,
title: `${item.submitter?.displayName || item.submitter?.username || "Someone"} added ${item.provider.toLowerCase()} media`,
detail: item.room.name,
href: `/rooms/${encodeURIComponent(item.room.slug)}`,
at: item.createdAt
})),
...recentRooms.map((room) => ({
id: `room-${room.id}`,
icon: <MonitorPlay size={15} />,
title: `${room.name} was updated`,
detail: room.owner ? `Owner ${room.owner.displayName || room.owner.username}` : "Unassigned room",
href: `/rooms/${encodeURIComponent(room.slug)}`,
at: room.updatedAt
})),
...requests.map((request) => {
const person = request.requesterId === user.id ? request.receiver : request.requester;
return {
id: `friend-${request.id}`,
icon: <UsersRound size={15} />,
title: `Friend request ${request.status.toLowerCase()}`,
detail: person.displayName || person.username,
href: "/people",
at: request.updatedAt
};
})
].sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, 20);
return (
<AppShell active="Activity" {...shell}>
<PageHeader
title="Activity"
description="Instance event view for room, media, and people changes visible to your account."
meta={
<>
<StatusDot tone="good" label={`${recentMedia.length} media events`} />
<StatusDot tone="info" label={`${recentRooms.length} room updates`} />
<StatusDot tone={requests.length > 0 ? "warn" : "neutral"} label={`${requests.length} relationship events`} />
</>
}
/>
<Panel title="Event Stream" eyebrow="Latest first">
{events.length === 0 ? (
<EmptyState title="No activity yet" description="Create rooms, add media, or connect with people to populate this stream." />
) : (
<div className="timeline-list">
{events.map((event) => (
<Link className="timeline-item interactive" href={event.href} key={event.id}>
{event.icon}
<div className="row-title">
<strong>{event.title}</strong>
<span>{event.detail} - {formatDate(event.at)}</span>
</div>
<StatusBadge>{relativeDate(event.at)}</StatusBadge>
</Link>
))}
</div>
)}
</Panel>
<section className="split-grid">
<Panel title="Most Recent Media" eyebrow="Queue inputs">
{recentMedia.length === 0 ? (
<EmptyState title="No media additions" />
) : (
recentMedia.slice(0, 5).map((item) => (
<div className="row" key={item.id}>
<div className="row-main">
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} size={34} />
<div className="row-title">
<strong>{item.title || item.originalUrl}</strong>
<span>{item.room.name}</span>
</div>
</div>
<StatusBadge>{item.provider}</StatusBadge>
</div>
))
)}
</Panel>
<Panel title="Recent Rooms" eyebrow="Accessible">
{recentRooms.map((room) => (
<div className="row" key={room.id}>
<div className="row-title">
<strong>{room.name}</strong>
<span>{room.owner?.displayName || room.owner?.username || "Unassigned"} - {formatDate(room.updatedAt)}</span>
</div>
<Link className="button compact-button" href={`/rooms/${encodeURIComponent(room.slug)}`}>Open</Link>
</div>
))}
</Panel>
</section>
</AppShell>
);
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
}
function relativeDate(date: Date) {
const minutes = Math.max(1, Math.round((Date.now() - date.getTime()) / 60000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.round(hours / 24)}d`;
}

View File

@@ -1,16 +1,21 @@
import Link from "next/link";
import { AlertTriangle, Database, LockKeyhole, Plus, Search, Shield, UsersRound } from "lucide-react";
import { redirect } from "next/navigation";
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 { getShellContext } from "@/lib/shell";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import Link from "next/link";
import { redirect } from "next/navigation";
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"];
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
@@ -18,8 +23,12 @@ 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 } }),
const { tab = "Users", q = "" } = await searchParams;
const activeTab = adminTabs.includes(tab) ? tab : "Users";
const query = q.trim().toLowerCase();
const shell = await getShellContext(user);
const [users, rooms, roles, pendingRequests] = await Promise.all([
prisma.user.findMany({
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
orderBy: { createdAt: "asc" }
@@ -31,89 +40,197 @@ export default async function AdminPage() {
prisma.role.findMany({
include: { _count: { select: { users: true, permissions: true } } },
orderBy: { name: "asc" }
})
}),
prisma.friendship.count({ where: { status: "PENDING" } })
]);
const filteredUsers = query
? users.filter((account) =>
[account.username, account.displayName || "", ...account.roles.map((role) => role.role.name)].join(" ").toLowerCase().includes(query)
)
: users;
const filteredRooms = query
? rooms.filter((room) =>
[room.name, room.slug, room.visibility, room.owner?.username || "", room.owner?.displayName || ""].join(" ").toLowerCase().includes(query)
)
: rooms;
const filteredRoles = query
? roles.filter((role) => [role.name, role.description || "", role.scope].join(" ").toLowerCase().includes(query))
: roles;
return (
<AppShell
active="Admin"
isAdmin
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
userName={user.displayName || user.username}
<AppShell active="Admin" {...shell}>
<PageHeader
title="Admin"
description="Operational management for accounts, rooms, roles, instance state, and security controls."
meta={
<>
<StatusDot tone="good" label="admin session" />
<StatusDot tone="good" label={`${users.length} users`} />
<StatusDot tone={pendingRequests > 0 ? "warn" : "neutral"} label={`${pendingRequests} pending requests`} />
</>
}
actions={
<>
<button className="button" type="button" disabled title="Invite records are not implemented yet."><Plus size={16} /> Invite</button>
<button className="button" type="button" disabled title="Audit events need a persisted event table first."><Shield size={16} /> Audit</button>
</>
}
/>
<section className="metrics-grid">
<MetricTile label="Users" value={users.length} detail="local accounts" tone="good" />
<MetricTile label="Rooms" value={rooms.length} detail="persistent room records" tone="info" />
<MetricTile label="Roles" value={roles.length} detail="system and room scopes" />
<MetricTile label="Permissions" value={SYSTEM_PERMISSIONS.length} detail="known permission keys" tone="warn" />
</section>
<Panel
title="Admin Workspace"
eyebrow="Management"
actions={
<form className="toolbar" action="/admin">
<input type="hidden" name="tab" value={activeTab} />
<label className="search-field">
<Search size={15} />
<input name="q" placeholder={`Search ${activeTab.toLowerCase()}`} defaultValue={q} />
</label>
</form>
}
>
<header className="topbar">
<div className="title-block">
<h1>Admin</h1>
<p>Manage roles, rooms, permissions, and users.</p>
</div>
<StatusBadge tone="good">Admin</StatusBadge>
</header>
<section className="room-layout">
<section className="panel">
<div className="panel-header">
<h2>Rooms</h2>
<StatusBadge>{rooms.length} total</StatusBadge>
</div>
<div className="panel-body">
<Tabs active={activeTab} items={adminTabs.map((item) => ({ label: item, href: `/admin?tab=${encodeURIComponent(item)}` }))} />
{activeTab === "Users" ? <UsersTable users={filteredUsers} /> : null}
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null}
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} /> : null}
{activeTab === "Security" ? <SecurityPanel /> : null}
</Panel>
</AppShell>
);
}
function UsersTable({
users
}: {
users: Array<{
id: string;
username: string;
displayName: string | null;
createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>;
_count: { ownedRooms: number; roomMembers: number };
}>;
}) {
return (
<DataTable>
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>User</th>
<th>Roles</th>
<th>Rooms</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((account) => (
<tr key={account.id}>
<td>
<div className="row-main">
<Avatar name={account.displayName || account.username} size={30} />
<div className="row-title">
<strong>{account.displayName || account.username}</strong>
<span>@{account.username}</span>
</div>
</div>
</td>
<td>
<div className="status-row">
{account.roles.length === 0 ? <StatusBadge>user</StatusBadge> : null}
{account.roles.map((userRole) => (
<StatusBadge key={userRole.roleId} tone={userRole.role.name === "admin" ? "good" : undefined}>
{userRole.role.name}
</StatusBadge>
))}
</div>
</td>
<td>{account._count.ownedRooms + account._count.roomMembers}</td>
<td>{formatDate(account.createdAt)}</td>
<td><button className="button compact-button" type="button" disabled title="User edit and ban actions are not implemented yet.">Manage</button></td>
</tr>
))}
</tbody>
</table>
</DataTable>
);
}
function RoomsTable({
rooms
}: {
rooms: Array<{
id: string;
slug: string;
name: string;
visibility: string;
updatedAt: Date;
owner: { username: string; displayName: string | null } | null;
_count: { members: number; mediaSources: number };
}>;
}) {
return (
<DataTable>
<table className="table">
<thead>
<tr>
<th>Room</th>
<th>Owner</th>
<th>Access</th>
<th>Status</th>
<th>State</th>
<th>Updated</th>
<th />
</tr>
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.id}>
<td>
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
<div className="row-title">
<strong>{room.name}</strong>
<span>/{room.slug}</span>
</div>
</td>
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
<td>{room.visibility}</td>
<td><StatusBadge>{room.visibility.toLowerCase().replace("_", " ")}</StatusBadge></td>
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
<td>{formatDate(room.updatedAt)}</td>
<td><Link className="button compact-button" href={`/rooms/${encodeURIComponent(room.slug)}`}>Open</Link></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">
</DataTable>
);
}
function RolesTable({
roles
}: {
roles: Array<{
id: string;
name: string;
description: string | null;
scope: string;
_count: { users: number; permissions: number };
}>;
}) {
return roles.length === 0 ? (
<EmptyState title="No roles defined" />
) : (
<div className="split-grid">
<Panel title="Roles" eyebrow="Assignments">
{roles.map((role) => (
<div className="row" key={role.id}>
<div className="row-title">
@@ -123,26 +240,92 @@ export default async function AdminPage() {
<StatusBadge>{role._count.users} users / {role._count.permissions} permissions</StatusBadge>
</div>
))}
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2>Permissions</h2>
<StatusBadge>System</StatusBadge>
</div>
<div className="panel-body">
</Panel>
<Panel title="Permissions" eyebrow="Known keys">
{SYSTEM_PERMISSIONS.map((permission) => (
<div className="row" key={permission}>
<div className="row-title">
<strong>{permission}</strong>
<span>Assignable to roles</span>
<span>Assignable to system roles</span>
</div>
<StatusBadge>Enabled</StatusBadge>
</div>
))}
</Panel>
</div>
</section>
</section>
</AppShell>
);
}
function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
return (
<div className="split-grid">
<Panel title="Room Invites" eyebrow="Pending">
<EmptyState title="Room invite creation is not wired yet" description="The management surface is reserved so invite CRUD can be added without a layout change." />
</Panel>
<Panel title="Friend Requests" eyebrow="Instance">
<div className="setting-row">
<UsersRound size={16} />
<span>
<strong>{pendingRequests} pending friend requests</strong>
<small>Handled by each receiving user in People.</small>
</span>
</div>
</Panel>
</div>
);
}
function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) {
return (
<div className="split-grid">
<Panel title="Runtime" eyebrow="Container">
<div className="settings-list">
<Setting icon={<Database size={16} />} label="Database" value="Postgres via DATABASE_URL" />
<Setting icon={<Shield size={16} />} label="Sessions" value="Signed HTTP-only cookie" />
<Setting icon={<UsersRound size={16} />} label="Accounts" value={`${userCount} users`} />
<Setting icon={<Database size={16} />} label="Rooms" value={`${roomCount} rooms`} />
</div>
</Panel>
<Panel title="Health" eyebrow="Status">
<div className="settings-list">
<StatusDot tone="good" label="Admin page rendered" />
<StatusDot tone="good" label="Database query succeeded" />
<StatusDot tone="info" label="Socket endpoint configured at /api/socket" />
</div>
</Panel>
</div>
);
}
function SecurityPanel() {
return (
<div className="split-grid">
<Panel title="Security Controls" eyebrow="Policy">
<div className="settings-list">
<Setting icon={<LockKeyhole size={16} />} label="Password storage" value="bcrypt hash" />
<Setting icon={<Shield size={16} />} label="Admin check" value="Server-side role lookup" />
<Setting icon={<AlertTriangle size={16} />} label="Registration mode" value="Open registration" />
</div>
</Panel>
<Panel title="Danger Zone" eyebrow="Destructive">
<EmptyState title="Destructive actions are disabled" description="User banning, registration mode changes, and deletion need server actions before controls become active." />
</Panel>
</div>
);
}
function Setting({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="setting-row">
{icon}
<span>
<strong>{label}</strong>
<small>{value}</small>
</span>
</div>
);
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(date);
}

View File

@@ -1,39 +1,30 @@
import Link from "next/link";
import { ArrowRight, Bell, Clock3, MonitorPlay, Plus, UsersRound } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot } from "@/components/ui";
import { prisma } from "@/lib/prisma";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
await requireInitialSetup();
const user = await requireCurrentUser();
const isAdmin = userIsAdmin(user);
const shell = await getShellContext(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 }
}
}),
const [userCount, roomCount, pendingRequests, acceptedFriends, rooms, recentMedia] = await Promise.all([
prisma.user.count(),
prisma.room.count(),
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
prisma.friendship.findMany({
prisma.friendship.count({
where: {
status: "ACCEPTED",
OR: [{ requesterId: user.id }, { receiverId: user.id }]
},
include: { requester: true, receiver: true },
take: 6,
orderBy: { updatedAt: "desc" }
}
}),
prisma.room.findMany({
where: {
@@ -41,141 +32,169 @@ export default async function DashboardPage() {
},
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
orderBy: { updatedAt: "desc" },
take: 8
take: 7
}),
prisma.mediaSource.findMany({
where: {
room: {
OR: [{ visibility: "PUBLIC" }, { ownerId: user.id }, { members: { some: { userId: user.id } } }]
}
},
include: { room: true, submitter: true },
orderBy: { createdAt: "desc" },
take: 6
})
]);
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={isAdmin} roomHref={roomHref} userName={user.displayName || user.username}>
<header className="topbar">
<div className="title-block">
<h1>Dashboard</h1>
<p>{`Signed in as ${user.displayName || user.username}`}</p>
</div>
<div className="status-row">
<StatusBadge tone="good">Online</StatusBadge>
<StatusBadge>System theme</StatusBadge>
</div>
</header>
<section className="stats-grid" aria-label="System overview">
{stats.map((stat) => (
<div className="stat" key={stat.label}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
</div>
))}
</section>
{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"
}))
]}
<AppShell active="Overview" {...shell}>
<PageHeader
title="Overview"
description={`Instance dashboard for ${shell.userName}. Rooms, requests, and recent media are separated from playback.`}
meta={
<>
<StatusDot tone="good" label="App rendered" />
<StatusDot tone="good" label="Database queried" />
<StatusDot tone={shell.activeRoomCount > 0 ? "info" : "neutral"} label={`${shell.activeRoomCount} rooms with queue`} />
</>
}
actions={
<>
<Link className="button" href="/people">
<UsersRound size={16} /> People
</Link>
<Link className="button primary" href="/rooms">
<Plus size={16} /> New room
</Link>
</>
}
/>
) : (
<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">
<div className="panel-header">
<h2>Rooms</h2>
<StatusBadge>Persistent</StatusBadge>
</div>
<div className="panel-body">
<section className="metrics-grid" aria-label="Instance summary">
<MetricTile label="Accounts" value={userCount} detail="registered local users" tone="info" />
<MetricTile label="Rooms" value={roomCount} detail="persistent rooms" tone="good" />
<MetricTile label="Friends" value={acceptedFriends} detail="accepted relationships" />
<MetricTile label="Requests" value={pendingRequests} detail="waiting for your action" tone={pendingRequests > 0 ? "warn" : "neutral"} />
</section>
<section className="overview-grid">
<Panel
title="Accessible Rooms"
eyebrow="Watch surfaces"
actions={<Link className="text-link" href="/rooms">Manage all <ArrowRight size={14} /></Link>}
>
{rooms.length === 0 ? (
<EmptyState title="No rooms are available" description="Create a room or add friends to see shared rooms here." />
) : (
<DataTable>
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Room</th>
<th>Owner</th>
<th>Access</th>
<th>Status</th>
<th>Source</th>
<th>Queue</th>
<th />
</tr>
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.id}>
<td>
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
<div className="row-title">
<strong>{room.name}</strong>
<span>/{room.slug}</span>
</div>
</td>
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
<td>{room.visibility}</td>
<td>{room._count.members + 1} users</td>
<td>{room._count.mediaSources} queued</td>
<td><StatusBadge>{formatVisibility(room.visibility)}</StatusBadge></td>
<td>{room._count.mediaSources} sources</td>
<td>
<Link className="icon-button compact" href={`/rooms/${encodeURIComponent(room.slug)}`} aria-label={`Open ${room.name}`}>
<MonitorPlay size={15} />
</Link>
</td>
</tr>
))}
</tbody>
</table>
{rooms.length === 0 ? <div className="empty-state">No accessible rooms found.</div> : null}
</DataTable>
)}
</Panel>
<div className="stack">
<Panel title="Quick Actions" eyebrow="Common work">
<div className="action-list">
<Link className="action-row" href="/rooms">
<MonitorPlay size={17} />
<span>
<strong>Create or open a room</strong>
<small>Manage persistent rooms and access</small>
</span>
</Link>
<Link className="action-row" href="/people">
<UsersRound size={17} />
<span>
<strong>Review people</strong>
<small>{pendingRequests} incoming request{pendingRequests === 1 ? "" : "s"}</small>
</span>
</Link>
<Link className="action-row" href="/activity">
<Bell size={17} />
<span>
<strong>Audit recent activity</strong>
<small>Media, rooms, and social changes</small>
</span>
</Link>
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2>Friends</h2>
<StatusBadge tone={acceptedFriends > 0 ? "good" : undefined}>{acceptedFriends} linked</StatusBadge>
</div>
<div className="panel-body">
{friendships.map((friendship) => {
const friend = friendship.requesterId === user.id ? friendship.receiver : friendship.requester;
return (
<div className="row" key={friend.id}>
</Panel>
<Panel title="Recent Media" eyebrow="Last additions">
{recentMedia.length === 0 ? (
<EmptyState title="No media activity yet" description="Queued YouTube, Twitch, and direct videos will appear here." />
) : (
recentMedia.map((item) => (
<div className="row" key={item.id}>
<div className="row-main">
<Avatar name={friend.displayName || friend.username} />
<Avatar name={item.submitter?.displayName || item.submitter?.username || "WL"} size={34} />
<div className="row-title">
<strong>{friend.displayName || friend.username}</strong>
<span>@{friend.username}</span>
<strong>{item.title || item.originalUrl}</strong>
<span>{item.room.name} by {item.submitter?.displayName || item.submitter?.username || "Unknown"}</span>
</div>
</div>
<StatusBadge tone="good">Friend</StatusBadge>
<StatusBadge>{item.provider}</StatusBadge>
</div>
);
})}
{friendships.length === 0 ? <div className="empty-state">No friends yet. Add users from Friends.</div> : null}
))
)}
</Panel>
</div>
</section>
</section>
<Panel title="Instance Timeline" eyebrow="Operational events">
{recentMedia.length === 0 && rooms.length === 0 ? (
<EmptyState title="No timeline events" description="Room and media events are shown after the instance is used." />
) : (
<div className="timeline-list">
{recentMedia.map((item) => (
<div className="timeline-item" key={`timeline-${item.id}`}>
<Clock3 size={15} />
<div className="row-title">
<strong>{item.submitter?.displayName || item.submitter?.username || "Someone"} added {item.provider.toLowerCase()} media</strong>
<span>{item.room.name} - {formatDate(item.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</Panel>
</AppShell>
);
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " ");
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
}

View File

@@ -1,127 +1,5 @@
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 Link from "next/link";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
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)}
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>
<StatusBadge>{users.length} users</StatusBadge>
</header>
{incoming.length > 0 ? (
<section className="panel" style={{ marginBottom: 18 }}>
<div className="panel-header">
<h2>Incoming requests</h2>
<StatusBadge tone="warn">{incoming.length} pending</StatusBadge>
</div>
<div className="panel-body">
{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>{request.requester.displayName || request.requester.username}</strong>
<span>@{request.requester.username}</span>
</div>
</div>
<div className="status-row">
<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>
);
export default function FriendsRedirectPage() {
redirect("/people");
}

View File

@@ -207,6 +207,11 @@ select {
color: var(--warn);
}
.badge.info {
border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
color: var(--accent-2);
}
.room-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
@@ -470,3 +475,556 @@ select {
grid-template-columns: 1fr;
}
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.page-header h1 {
margin: 4px 0 0;
font-size: 28px;
line-height: 1.15;
}
.toolbar,
.row-actions,
.account-actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.metric-tile {
display: grid;
gap: 8px;
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 14px;
}
.metric-tile.good {
border-left-color: var(--accent);
}
.metric-tile.warn {
border-left-color: var(--warn);
}
.metric-tile.danger {
border-left-color: var(--danger);
}
.metric-tile.info {
border-left-color: var(--accent-2);
}
.metric-top,
.metric-tile p {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.metric-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.metric-tile strong {
font-size: 28px;
font-weight: 800;
}
.metric-tile p {
margin: 0;
}
.overview-grid,
.rooms-layout,
.watch-console,
.split-grid {
display: grid;
gap: 18px;
}
.overview-grid {
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.55fr);
margin-bottom: 18px;
}
.rooms-layout {
grid-template-columns: 320px minmax(0, 1fr);
}
.watch-console {
grid-template-columns: minmax(0, 1fr) 360px;
align-items: start;
}
.split-grid,
.console-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 18px;
}
.player-column,
.right-rail,
.people-list,
.queue-list,
.timeline-list,
.settings-list,
.action-list {
display: grid;
gap: 10px;
}
.player-panel .panel-body {
padding: 0;
}
.transport-bar {
display: grid;
grid-template-columns: 38px 38px 38px minmax(180px, 1fr) auto;
gap: 8px;
border-top: 1px solid var(--border);
padding: 12px;
}
.queue-row,
.timeline-item,
.action-row,
.setting-row {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
padding: 10px;
}
.timeline-item.interactive:hover,
.action-row:hover {
border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
}
.queue-index {
display: inline-grid;
width: 28px;
height: 28px;
place-items: center;
border-radius: 7px;
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.queue-row .row-title,
.timeline-item .row-title {
min-width: 0;
flex: 1;
}
.queue-row .row-title strong,
.timeline-item .row-title strong,
.action-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-row span,
.setting-row span {
display: grid;
gap: 2px;
}
.action-row small,
.setting-row small {
color: var(--muted);
font-size: 12px;
}
.tabs,
.rail-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.tab,
.rail-tabs button {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
color: var(--muted);
padding: 7px 10px;
font-size: 13px;
font-weight: 750;
}
.tab.active,
.rail-tabs button.active {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
color: var(--accent);
}
.tab-count {
color: var(--muted);
font-size: 11px;
}
.status-dot {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.status-dot::before {
content: "";
width: 8px;
height: 8px;
border-radius: 999px;
background: #94a3b8;
}
.status-dot.good::before {
background: var(--accent);
}
.status-dot.warn::before {
background: var(--warn);
}
.status-dot.danger::before {
background: var(--danger);
}
.status-dot.info::before {
background: var(--accent-2);
}
.table-wrap {
overflow-x: auto;
}
.compact-button,
.icon-button.compact {
min-height: 30px;
padding: 5px 8px;
font-size: 12px;
}
.icon-button {
width: 38px;
padding: 0;
}
.icon-button.danger {
color: var(--danger);
}
.text-link,
.inline-meta {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-2);
font-size: 13px;
font-weight: 750;
}
.search-field {
display: inline-flex;
min-width: 220px;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
color: var(--muted);
padding: 7px 9px;
}
.search-field input,
.chat-input input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
}
.compact-form {
gap: 10px;
}
.sidebar {
display: flex;
min-height: 100vh;
flex-direction: column;
gap: 14px;
}
.sidebar-section {
min-height: 0;
border-top: 1px solid var(--border);
padding: 14px 4px 0;
}
.sidebar-section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 4px 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.room-nav-list {
display: grid;
gap: 6px;
}
.room-nav-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-radius: 8px;
padding: 8px;
}
.room-nav-item:hover {
background: var(--panel-2);
}
.room-nav-item span:first-child,
.room-nav-meta {
display: grid;
gap: 3px;
}
.room-nav-item strong {
font-size: 13px;
}
.room-nav-item small,
.sidebar-empty {
color: var(--muted);
font-size: 12px;
}
.room-nav-meta {
justify-items: end;
}
.sidebar-user {
margin-top: auto;
position: relative;
}
.account-menu {
margin-left: auto;
position: relative;
}
.account-menu summary {
list-style: none;
}
.account-menu summary::-webkit-details-marker {
display: none;
}
.account-menu-popover {
position: absolute;
right: 0;
bottom: 38px;
z-index: 20;
display: grid;
min-width: 170px;
gap: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
padding: 6px;
}
.account-menu-popover a,
.account-menu-popover button {
display: flex;
width: 100%;
align-items: center;
gap: 8px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text);
padding: 8px;
text-align: left;
}
.account-menu-popover a:hover,
.account-menu-popover button:hover {
background: var(--panel-2);
}
.instance-status {
display: grid;
gap: 7px;
border-top: 1px solid var(--border);
padding: 14px 8px 0;
}
.instance-metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 11px;
}
.instance-metrics span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.auth-shell {
display: grid;
width: min(920px, 100%);
grid-template-columns: minmax(0, 1fr) 420px;
gap: 18px;
}
.auth-intro {
display: grid;
align-content: center;
gap: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 24px;
}
.auth-intro .brand-mark {
margin: 0;
}
.setup-checklist {
display: grid;
gap: 10px;
color: var(--muted);
font-size: 14px;
font-weight: 700;
}
.setup-checklist div {
display: flex;
align-items: center;
gap: 8px;
}
.setup-checklist span {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--accent);
}
.profile-block {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
}
.chat-input {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
margin-top: 10px;
padding: 9px;
color: var(--muted);
}
.disabled-note {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
@media (max-width: 1180px) {
.overview-grid,
.rooms-layout,
.watch-console {
grid-template-columns: 1fr;
}
.right-rail {
order: 2;
}
}
@media (max-width: 860px) {
.metrics-grid,
.split-grid,
.console-grid,
.auth-shell {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
}
.transport-bar {
grid-template-columns: repeat(3, 38px) 1fr;
}
.transport-bar .button.primary {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.sidebar {
min-height: auto;
}
.sidebar-section,
.instance-status {
display: none;
}
}

View File

@@ -5,18 +5,28 @@ import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function LoginPage() {
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
if (!(await hasAdminUser())) {
redirect("/setup");
}
const { error } = await searchParams;
return (
<main className="auth-page">
<section className="auth-card">
<section className="auth-shell">
<div className="auth-intro">
<span className="brand-mark" aria-hidden="true" />
<div className="title-block">
<h1>WatchLink</h1>
<p>Persistent watch rooms with local accounts, friends, and synchronized playback.</p>
</div>
</div>
<div className="auth-card">
<div className="title-block" style={{ marginBottom: 18 }}>
<h1>Login</h1>
<p>Enter WatchLink with your username and password.</p>
<p>Enter your account to open rooms and manage playback.</p>
</div>
{error ? <p className="form-error">Invalid username or password.</p> : null}
<form className="form" action={loginUser}>
<label>
Username
@@ -33,6 +43,7 @@ export default async function LoginPage() {
Create account
</Link>
</form>
</div>
</section>
</main>
);

265
src/app/people/page.tsx Normal file
View File

@@ -0,0 +1,265 @@
import Link from "next/link";
import { Search, UserMinus, UserPlus } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
import { StatusBadge } from "@/components/status-badge";
import { DataTable, EmptyState, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { acceptFriendRequest, declineFriendRequest, sendFriendRequest } from "@/lib/friend-actions";
import { prisma } from "@/lib/prisma";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
const tabs = ["Friends", "Incoming", "Outgoing", "Directory", "Blocked"];
type PersonSummary = {
id: string;
username: string;
displayName: string | null;
};
type FriendshipWithUsers = {
id: string;
requesterId: string;
receiverId: string;
status: string;
requester: PersonSummary;
receiver: PersonSummary;
};
type DirectoryUser = PersonSummary & {
ownedRooms: Array<{ slug: string }>;
};
export default async function PeoplePage({ searchParams }: { searchParams: Promise<{ view?: string; q?: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
const shell = await getShellContext(user);
const { view = "Friends", q = "" } = await searchParams;
const activeView = tabs.includes(view) ? view : "Friends";
const query = q.trim().toLowerCase();
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 outgoing = friendships.filter((item) => item.requesterId === user.id && item.status === "PENDING");
const accepted = friendships.filter((item) => item.status === "ACCEPTED");
const blocked = friendships.filter((item) => item.status === "BLOCKED");
const relationshipByUserId = new Map<string, (typeof friendships)[number]>();
for (const friendship of friendships) {
const otherId = friendship.requesterId === user.id ? friendship.receiverId : friendship.requesterId;
relationshipByUserId.set(otherId, friendship);
}
const directory = users
.filter((listedUser) =>
query ? [listedUser.username, listedUser.displayName || ""].join(" ").toLowerCase().includes(query) : true
)
.map((listedUser) => ({ user: listedUser, relationship: relationshipByUserId.get(listedUser.id) }));
const filteredIncoming = filterFriendships(incoming, user.id, query);
const filteredOutgoing = filterFriendships(outgoing, user.id, query);
const filteredAccepted = filterFriendships(accepted, user.id, query);
const filteredBlocked = filterFriendships(blocked, user.id, query);
const tabCounts: Record<string, number> = {
Friends: accepted.length,
Incoming: incoming.length,
Outgoing: outgoing.length,
Directory: users.length,
Blocked: blocked.length
};
return (
<AppShell active="People" {...shell}>
<PageHeader
title="People"
description="Friends, requests, account directory, and access relationships for persistent rooms."
meta={
<>
<StatusDot tone="good" label={`${accepted.length} friends`} />
<StatusDot tone={incoming.length > 0 ? "warn" : "neutral"} label={`${incoming.length} incoming`} />
<StatusDot tone={outgoing.length > 0 ? "warn" : "neutral"} label={`${outgoing.length} outgoing`} />
</>
}
/>
<Panel
title="People Directory"
eyebrow="Social graph"
actions={
<form className="toolbar" action="/people">
<input type="hidden" name="view" value={activeView} />
<label className="search-field">
<Search size={15} />
<input name="q" placeholder="Search users" defaultValue={q} />
</label>
</form>
}
>
<Tabs
active={activeView}
items={tabs.map((tab) => ({
label: tab,
href: `/people?view=${encodeURIComponent(tab)}`,
count: tabCounts[tab]
}))}
/>
{activeView === "Friends" ? <FriendshipList currentUserId={user.id} friendships={filteredAccepted} empty="No accepted friends yet." /> : null}
{activeView === "Incoming" ? <IncomingList requests={filteredIncoming} /> : null}
{activeView === "Outgoing" ? <FriendshipList currentUserId={user.id} friendships={filteredOutgoing} empty="No outgoing friend requests." /> : null}
{activeView === "Blocked" ? <FriendshipList currentUserId={user.id} friendships={filteredBlocked} empty="No blocked users." /> : null}
{activeView === "Directory" ? <Directory rows={directory} /> : null}
</Panel>
</AppShell>
);
}
function filterFriendships(items: FriendshipWithUsers[], currentUserId: string, query: string) {
if (!query) return items;
return items.filter((friendship) => {
const person = friendship.requesterId === currentUserId ? friendship.receiver : friendship.requester;
return [person.username, person.displayName || ""].join(" ").toLowerCase().includes(query);
});
}
function FriendshipList({
currentUserId,
friendships,
empty
}: {
currentUserId: string;
friendships: FriendshipWithUsers[];
empty: string;
}) {
if (friendships.length === 0) {
return <EmptyState title={empty} description="Use the directory tab to discover accounts." />;
}
return (
<div className="people-list">
{friendships.map((friendship) => {
const person = friendship.requesterId === currentUserId ? friendship.receiver : friendship.requester;
return (
<div className="row" key={friendship.id}>
<div className="row-main">
<Avatar name={person.displayName || person.username} />
<div className="row-title">
<strong>{person.displayName || person.username}</strong>
<span>@{person.username}</span>
</div>
</div>
<div className="row-actions">
<StatusBadge tone={friendship.status === "ACCEPTED" ? "good" : "warn"}>{friendship.status.toLowerCase()}</StatusBadge>
<button className="button compact-button" type="button" disabled title="Friend removal needs a dedicated server action.">
<UserMinus size={14} /> Remove
</button>
</div>
</div>
);
})}
</div>
);
}
function IncomingList({ requests }: { requests: FriendshipWithUsers[] }) {
if (requests.length === 0) {
return <EmptyState title="No incoming requests" description="New friend requests will require explicit approval here." />;
}
return (
<div className="people-list">
{requests.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>{request.requester.displayName || request.requester.username}</strong>
<span>@{request.requester.username}</span>
</div>
</div>
<div className="row-actions">
<form action={acceptFriendRequest}>
<input type="hidden" name="friendshipId" value={request.id} />
<button className="button primary compact-button" type="submit">Accept</button>
</form>
<form action={declineFriendRequest}>
<input type="hidden" name="friendshipId" value={request.id} />
<button className="button compact-button" type="submit">Decline</button>
</form>
</div>
</div>
))}
</div>
);
}
function Directory({
rows
}: {
rows: Array<{
user: DirectoryUser;
relationship?: FriendshipWithUsers;
}>;
}) {
if (rows.length === 0) {
return <EmptyState title="No users registered" description="New accounts will appear here after registration." />;
}
return (
<DataTable>
<table className="table">
<thead>
<tr>
<th>User</th>
<th>Room</th>
<th>Relationship</th>
<th />
</tr>
</thead>
<tbody>
{rows.map(({ user, relationship }) => {
const roomSlug = user.ownedRooms[0]?.slug;
const accepted = relationship?.status === "ACCEPTED";
return (
<tr key={user.id}>
<td>
<div className="row-main">
<Avatar name={user.displayName || user.username} size={30} />
<div className="row-title">
<strong>{user.displayName || user.username}</strong>
<span>@{user.username}</span>
</div>
</div>
</td>
<td>{roomSlug && accepted ? <Link className="text-link" href={`/rooms/${encodeURIComponent(roomSlug)}`}>Open personal room</Link> : "Locked"}</td>
<td><StatusBadge tone={accepted ? "good" : relationship ? "warn" : undefined}>{relationship?.status.toLowerCase() || "none"}</StatusBadge></td>
<td>
{!relationship ? (
<form action={sendFriendRequest}>
<input type="hidden" name="receiverId" value={user.id} />
<button className="button primary compact-button" type="submit">
<UserPlus size={14} /> Add
</button>
</form>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</DataTable>
);
}

View File

@@ -5,18 +5,28 @@ import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function RegisterPage() {
export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
if (!(await hasAdminUser())) {
redirect("/setup");
}
const { error } = await searchParams;
return (
<main className="auth-page">
<section className="auth-shell">
<div className="auth-intro">
<span className="brand-mark" aria-hidden="true" />
<div className="title-block">
<h1>Create Account</h1>
<p>Each user receives one persistent personal room that friends can enter without temporary links.</p>
</div>
</div>
<section className="auth-card">
<div className="title-block" style={{ marginBottom: 18 }}>
<h1>Create account</h1>
<p>Register a username and get a persistent room.</p>
</div>
{error ? <p className="form-error">{registerError(error)}</p> : null}
<form className="form" action={registerUser}>
<label>
Username
@@ -34,6 +44,12 @@ export default async function RegisterPage() {
</Link>
</form>
</section>
</section>
</main>
);
}
function registerError(error: string) {
if (error === "username") return "This username is already taken.";
return "Use a username and a password with at least 10 characters.";
}

View File

@@ -1,8 +1,10 @@
import { AppShell } from "@/components/app-shell";
import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
import { PageHeader, StatusDot } from "@/components/ui";
import { canEnterRoom } from "@/lib/access";
import { prisma } from "@/lib/prisma";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import { redirect } from "next/navigation";
@@ -58,28 +60,33 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
redirect("/dashboard");
}
const personalRoom = await prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } });
const shell = await getShellContext(user);
return (
<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>{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>{room.visibility}</StatusBadge>
</div>
</header>
<AppShell active="Rooms" {...shell}>
<PageHeader
title={room.name}
description={room.owner ? `Owned by ${room.owner.displayName || room.owner.username}. Stable route /rooms/${room.slug}.` : "Shared watch room"}
meta={
<>
<StatusDot tone="good" label="Socket ready" />
<StatusDot tone={room.mediaSources.length > 0 ? "good" : "neutral"} label={`${room.mediaSources.length} queued`} />
<StatusBadge>{room.visibility.toLowerCase().replace("_", " ")}</StatusBadge>
</>
}
actions={
<>
<button className="button" type="button" disabled title="Invite persistence is not implemented yet.">Invite</button>
<button className="button" type="button" disabled title="Room settings require dedicated server actions before editing.">Settings</button>
</>
}
/>
<RoomConsole
roomId={room.id}
roomSlug={room.slug}
roomName={room.name}
roomVisibility={room.visibility}
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
currentUser={user.displayName || user.username}
queue={room.mediaSources.map((item) => ({
id: item.id,

194
src/app/rooms/page.tsx Normal file
View File

@@ -0,0 +1,194 @@
import Link from "next/link";
import { Lock, Plus, Search, UsersRound } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { StatusBadge } from "@/components/status-badge";
import { DataTable, EmptyState, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { createRoom } from "@/lib/room-actions";
import { prisma } from "@/lib/prisma";
import { getShellContext } from "@/lib/shell";
import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
export const dynamic = "force-dynamic";
const roomTabs = ["My Rooms", "Shared", "Public", "Recent", "Invites"];
export default async function RoomsPage({ searchParams }: { searchParams: Promise<{ view?: string; error?: string; q?: string }> }) {
await requireInitialSetup();
const user = await requireCurrentUser();
const shell = await getShellContext(user);
const { view = "My Rooms", error, q = "" } = await searchParams;
const activeView = roomTabs.includes(view) ? view : "My Rooms";
const query = q.trim().toLowerCase();
const [owned, shared, publicRooms, recent] = await Promise.all([
prisma.room.findMany({
where: { ownerId: user.id },
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
orderBy: { updatedAt: "desc" }
}),
prisma.room.findMany({
where: { members: { some: { userId: user.id } }, ownerId: { not: user.id } },
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
orderBy: { updatedAt: "desc" }
}),
prisma.room.findMany({
where: { visibility: "PUBLIC" },
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
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: 12
})
]);
const roomGroups = {
"My Rooms": owned,
Shared: shared,
Public: publicRooms,
Recent: recent,
Invites: []
};
const rows = roomGroups[activeView as keyof typeof roomGroups] || owned;
const filteredRows = query
? rows.filter((room) =>
[room.name, room.slug, room.owner?.displayName || "", room.owner?.username || "", room.visibility]
.join(" ")
.toLowerCase()
.includes(query)
)
: rows;
const tabCounts: Record<string, number> = {
"My Rooms": owned.length,
Shared: shared.length,
Public: publicRooms.length,
Recent: recent.length,
Invites: 0
};
return (
<AppShell active="Rooms" {...shell}>
<PageHeader
title="Rooms"
description="Persistent watch rooms with stable routes, clear access state, and operational controls."
meta={
<>
<StatusDot tone="good" label={`${owned.length} owned`} />
<StatusDot tone={shared.length > 0 ? "good" : "neutral"} label={`${shared.length} shared`} />
<StatusDot tone="info" label={`${publicRooms.length} public`} />
</>
}
/>
<section className="rooms-layout">
<Panel title="Create Room" eyebrow="Persistent route">
{error ? <p className="form-error">Use a room name with at least one letter or number.</p> : null}
<form className="form compact-form" action={createRoom}>
<label>
Name
<input className="input" name="name" placeholder="Friday stream room" required />
</label>
<label>
Visibility
<select className="input" name="visibility" defaultValue="FRIENDS">
<option value="FRIENDS">Friends-only</option>
<option value="PUBLIC">Public</option>
<option value="EXPLICIT">Explicit members</option>
<option value="ROLE_RESTRICTED">Role restricted</option>
</select>
</label>
<button className="button primary" type="submit">
<Plus size={16} /> Create room
</button>
</form>
</Panel>
<Panel
title="Room Directory"
eyebrow="Index"
actions={
<form className="toolbar" action="/rooms">
<input type="hidden" name="view" value={activeView} />
<label className="search-field">
<Search size={15} />
<input name="q" placeholder="Filter rooms" defaultValue={q} />
</label>
</form>
}
>
<Tabs
active={activeView}
items={roomTabs.map((tab) => ({
label: tab,
href: `/rooms?view=${encodeURIComponent(tab)}`,
count: tabCounts[tab]
}))}
/>
{filteredRows.length === 0 ? (
<EmptyState
title={`No ${activeView.toLowerCase()} rooms`}
description={
activeView === "Invites"
? "Room invite persistence is not implemented yet; shared rooms appear in the Shared tab after access is granted."
: "Rooms appear here when ownership, membership, or visibility matches this tab."
}
/>
) : (
<DataTable>
<table className="table">
<thead>
<tr>
<th>Room</th>
<th>Owner</th>
<th>Access</th>
<th>State</th>
<th>Updated</th>
<th />
</tr>
</thead>
<tbody>
{filteredRows.map((room) => (
<tr key={room.id}>
<td>
<div className="row-title">
<strong>{room.name}</strong>
<span>/{room.slug}</span>
</div>
</td>
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
<td><StatusBadge>{formatVisibility(room.visibility)}</StatusBadge></td>
<td>
<span className="inline-meta">
<UsersRound size={14} /> {room._count.members + 1}
<Lock size={14} /> {room._count.mediaSources}
</span>
</td>
<td>{formatDate(room.updatedAt)}</td>
<td>
<Link className="button compact-button" href={`/rooms/${encodeURIComponent(room.slug)}`}>
Open
</Link>
</td>
</tr>
))}
</tbody>
</table>
</DataTable>
)}
</Panel>
</section>
</AppShell>
);
}
function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " ");
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
}

View File

@@ -107,10 +107,24 @@ export default async function SetupPage({ searchParams }: { searchParams: Promis
return (
<main className="auth-page">
<section className="auth-shell setup-shell">
<div className="auth-intro">
<span className="brand-mark" aria-hidden="true" />
<div className="title-block">
<h1>First Setup</h1>
<p>Create the first administrator, seed permissions, and create the initial persistent room.</p>
</div>
<div className="setup-checklist">
<div><span /> Admin role and permissions</div>
<div><span /> First user account</div>
<div><span /> Personal room route</div>
<div><span /> Setup lockout after completion</div>
</div>
</div>
<section className="auth-card">
<div className="title-block" style={{ marginBottom: 18 }}>
<h1>WatchLink first setup</h1>
<p>Create the first admin account. This screen locks after setup.</p>
<h1>Administrator</h1>
<p>This screen is only available while no admin user exists.</p>
</div>
{error ? <p className="form-error">{setupErrorMessage(error)}</p> : null}
<form className="form" action={createFirstAdmin}>
@@ -127,6 +141,7 @@ export default async function SetupPage({ searchParams }: { searchParams: Promis
</button>
</form>
</section>
</section>
</main>
);
}

View File

@@ -1,28 +1,57 @@
import Link from "next/link";
import { Gauge, MonitorPlay, Shield, UsersRound } from "lucide-react";
import {
Activity,
Database,
Gauge,
LogOut,
MonitorPlay,
Plus,
Settings,
Shield,
User,
UsersRound,
Wifi
} from "lucide-react";
import { logoutUser } from "@/lib/user-actions";
import { Avatar } from "./avatar";
import { StatusBadge } from "./status-badge";
import { StatusDot } from "./ui";
type ShellRoom = {
id: string;
name: string;
slug: string;
href: string;
visibility: string;
participantCount: number;
queueCount: number;
isPersonal?: boolean;
};
export function AppShell({
children,
active = "Dashboard",
active = "Overview",
isAdmin = false,
roomHref = "/dashboard",
userName
userName,
rooms = [],
pendingRequests = 0,
activeRoomCount = 0
}: {
children: React.ReactNode;
active?: string;
isAdmin?: boolean;
roomHref?: string;
userName?: string;
rooms?: ShellRoom[];
pendingRequests?: number;
activeRoomCount?: number;
}) {
const nav = [
{ href: "/dashboard", label: "Dashboard", icon: Gauge },
{ href: roomHref, label: "Rooms", icon: MonitorPlay },
{ href: "/friends", label: "Friends", icon: UsersRound }
{ href: "/dashboard", label: "Overview", icon: Gauge },
{ href: "/rooms", label: "Rooms", icon: MonitorPlay },
{ href: "/people", label: "People", icon: UsersRound },
{ href: "/activity", label: "Activity", icon: Activity }
];
const visibleNav = isAdmin
? [nav[0], nav[1], nav[2], { href: "/admin", label: "Admin", icon: Shield }]
: nav;
const visibleNav = isAdmin ? [...nav, { href: "/admin", label: "Admin", icon: Shield }] : nav;
return (
<div className="app-shell">
@@ -42,6 +71,34 @@ export function AppShell({
);
})}
</nav>
<section className="sidebar-section">
<div className="sidebar-section-title">
<span>Your Rooms</span>
<Link href="/rooms" aria-label="Create or manage rooms">
<Plus size={15} />
</Link>
</div>
<div className="room-nav-list">
{rooms.length === 0 ? (
<div className="sidebar-empty">No rooms yet</div>
) : (
rooms.slice(0, 6).map((room) => (
<Link className="room-nav-item" href={room.href} key={room.id}>
<span>
<strong>{room.name}</strong>
<small>{room.visibility.toLowerCase().replace("_", " ")}</small>
</span>
<span className="room-nav-meta">
{room.queueCount > 0 ? <StatusBadge tone="info">Queued</StatusBadge> : <StatusBadge>Empty</StatusBadge>}
<small>{room.participantCount}</small>
</span>
</Link>
))
)}
</div>
</section>
{userName ? (
<div className="sidebar-user">
<Avatar name={userName} />
@@ -49,8 +106,36 @@ export function AppShell({
<strong>{userName}</strong>
<span>{isAdmin ? "Administrator" : "Member"}</span>
</div>
<details className="account-menu">
<summary className="icon-button compact" aria-label="Account menu">
<Settings size={15} />
</summary>
<div className="account-menu-popover">
<Link href="/account/profile"><User size={15} /> Profile</Link>
<Link href="/account/settings"><Settings size={15} /> Settings</Link>
<Link href="/account/sessions"><Shield size={15} /> Sessions</Link>
<form action={logoutUser}>
<button type="submit"><LogOut size={15} /> Logout</button>
</form>
</div>
</details>
</div>
) : null}
<div className="instance-status">
<StatusDot tone="good" label="App rendered" />
<StatusDot tone="good" label="DB queried" />
<StatusDot tone={activeRoomCount > 0 ? "info" : "neutral"} label={`${activeRoomCount} rooms with queue`} />
{pendingRequests > 0 ? <StatusDot tone="warn" label={`${pendingRequests} requests`} /> : null}
<div className="instance-metrics">
<span>
<Wifi size={13} /> Socket endpoint
</span>
<span>
<Database size={13} /> v0.1.0
</span>
</div>
</div>
</aside>
<main className="main">{children}</main>
</div>

View File

@@ -1,12 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import { Pause, Play, Radio, SkipForward } from "lucide-react";
import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2 } from "lucide-react";
import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media";
import { StatusBadge } from "./status-badge";
import { Avatar } from "./avatar";
import { addMediaToRoom } from "@/lib/media-actions";
import { Avatar } from "./avatar";
import { StatusBadge } from "./status-badge";
import { EmptyState, Panel, StatusDot } from "./ui";
const socket = io({
path: "/api/socket",
@@ -33,18 +34,25 @@ type Participant = {
export function RoomConsole({
roomId,
roomSlug,
roomName,
roomVisibility,
ownerName,
currentUser,
queue = [],
participants = []
}: {
roomId: string;
roomSlug: string;
roomName: string;
roomVisibility: string;
ownerName: string;
currentUser: string;
queue?: QueueItem[];
participants?: Participant[];
}) {
const [connected, setConnected] = useState(false);
const [source, setSource] = useState("");
const [rail, setRail] = useState("Activity");
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
const currentMedia = previewMedia || queue[0] || null;
@@ -68,15 +76,19 @@ export function RoomConsole({
}
return (
<div className="room-layout">
<section className="panel">
<div className="panel-header">
<div>
<h2>{roomSlug}</h2>
<span className="eyebrow">Persistent room</span>
</div>
<StatusBadge tone={connected ? "good" : "warn"}>{connected ? "Online" : "Local preview"}</StatusBadge>
<section className="watch-console">
<div className="player-column">
<Panel
className="player-panel"
title={roomName}
eyebrow={`/${roomSlug}`}
actions={
<div className="status-row">
<StatusDot tone={connected ? "good" : "warn"} label={connected ? "connected" : "local preview"} />
<StatusBadge>{formatVisibility(roomVisibility)}</StatusBadge>
</div>
}
>
<div className="video-frame">
{currentMedia ? (
<MediaPreview
@@ -92,13 +104,17 @@ export function RoomConsole({
</div>
)}
</div>
<form className="controls" action={addMediaToRoom}>
<form className="transport-bar" action={addMediaToRoom}>
<input type="hidden" name="roomId" value={roomId} />
<button className="button primary" type="button" onClick={() => emit("playback:play")}>
<Play size={16} /> Play
<button className="icon-button" type="button" onClick={() => emit("playback:play")} title="Play">
<Play size={16} />
</button>
<button className="button" type="button" onClick={() => emit("playback:pause")}>
<Pause size={16} /> Pause
<button className="icon-button" type="button" onClick={() => emit("playback:pause")} title="Pause">
<Pause size={16} />
</button>
<button className="icon-button" type="button" onClick={() => emit("playback:seek")} title="Seek">
<SkipForward size={16} />
</button>
<input
className="input"
@@ -106,38 +122,50 @@ export function RoomConsole({
name="sourceUrl"
value={source}
onChange={(event) => setSource(event.target.value)}
placeholder="Source URL"
placeholder="Paste YouTube, Twitch, or direct video URL"
/>
<button className="button" type="submit" onClick={() => emit("media:set")} disabled={!source}>
<Radio size={16} /> Add
</button>
<button className="button" type="button" onClick={() => emit("playback:seek")}>
<SkipForward size={16} /> Seek
<button className="button primary" type="submit" onClick={() => emit("media:set")} disabled={!source}>
<Radio size={16} /> Add source
</button>
</form>
</section>
</Panel>
<aside className="stack">
<Panel title="Queue">
<div className="console-grid">
<Panel title="Queue" eyebrow={`${queue.length} sources`}>
{queue.length === 0 ? (
<div className="empty-state">No media queued yet.</div>
<EmptyState title="Queue is empty" description="Sources added by room participants will appear here with moderation actions." />
) : (
queue.map((item) => (
<div className="row" key={item.id}>
<div className="queue-list">
{queue.map((item, index) => (
<div className="queue-row" key={item.id}>
<span className="queue-index">{index + 1}</span>
<div className="row-title">
<strong>{item.title}</strong>
<span>
{item.provider} by {item.by}
</span>
<span>{item.provider} by {item.by} - {item.createdAt}</span>
</div>
<StatusBadge>{item.createdAt}</StatusBadge>
<div className="row-actions">
<button className="icon-button compact" type="button" title="Play now" onClick={() => emit("media:set")}>
<Play size={14} />
</button>
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
<ChevronUp size={14} />
</button>
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
<ChevronDown size={14} />
</button>
<button className="icon-button compact danger" type="button" title="Queue removal needs a server action." disabled>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
))
)}
</Panel>
<Panel title="Participants">
<Panel title="Participants" eyebrow={`${participants.length} present or allowed`}>
{participants.length === 0 ? (
<div className="empty-state">No participants listed yet.</div>
<EmptyState title="No participants listed" description="Room presence appears when users open the room." />
) : (
participants.map((item) => (
<div className="row" key={item.id}>
@@ -153,12 +181,27 @@ export function RoomConsole({
))
)}
</Panel>
<Panel title="Activity">
</div>
</div>
<aside className="right-rail">
<Panel title="Room Rail" eyebrow="Activity and settings">
<div className="rail-tabs" role="tablist" aria-label="Room rail">
{["Activity", "Chat", "Settings"].map((item) => (
<button key={item} className={rail === item ? "active" : ""} type="button" onClick={() => setRail(item)}>
{item}
</button>
))}
</div>
{rail === "Activity" ? (
<div className="timeline-list">
{queue.length === 0 ? (
<div className="empty-state">Room activity will appear after users add media.</div>
<EmptyState title="No activity yet" description="Playback and queue events will stream here." />
) : (
queue.slice(0, 5).map((item) => (
<div className="row" key={`activity-${item.id}`}>
queue.slice(0, 8).map((item) => (
<div className="timeline-item" key={`activity-${item.id}`}>
<Radio size={15} />
<div className="row-title">
<strong>{item.by} added {item.provider.toLowerCase()} media</strong>
<span>{item.createdAt}</span>
@@ -166,20 +209,43 @@ export function RoomConsole({
</div>
))
)}
</div>
) : null}
{rail === "Chat" ? (
<div className="chat-panel">
<EmptyState title="Chat is not enabled yet" description="Socket-backed messages need persistence and moderation before this input is enabled." />
<label className="chat-input">
<MessageSquareText size={15} />
<input placeholder="Message disabled until chat persistence is added" disabled />
</label>
</div>
) : null}
{rail === "Settings" ? (
<div className="settings-list">
<SettingRow icon={<ShieldCheck size={16} />} label="Visibility" value={formatVisibility(roomVisibility)} />
<SettingRow icon={<Play size={16} />} label="Playback control" value="All authorized participants" />
<SettingRow icon={<Settings2 size={16} />} label="Owner" value={ownerName} />
<SettingRow icon={<Radio size={16} />} label="Sync mode" value="Socket.IO room channel" />
<p className="disabled-note">Editable settings require dedicated server actions; the current values are read-only.</p>
</div>
) : null}
</Panel>
</aside>
</div>
</section>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<section className="panel">
<div className="panel-header">
<h3>{title}</h3>
<div className="setting-row">
{icon}
<span>
<strong>{label}</strong>
<small>{value}</small>
</span>
</div>
<div className="panel-body">{children}</div>
</section>
);
}
@@ -209,3 +275,7 @@ function MediaPreview({ provider, playbackUrl, title }: { provider: string; play
</div>
);
}
function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " ");
}

117
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { clsx } from "clsx";
export function PageHeader({
title,
description,
actions,
meta
}: {
title: string;
description?: string;
actions?: React.ReactNode;
meta?: React.ReactNode;
}) {
return (
<header className="page-header">
<div className="title-block">
{meta ? <div className="status-row">{meta}</div> : null}
<h1>{title}</h1>
{description ? <p>{description}</p> : null}
</div>
{actions ? <div className="toolbar">{actions}</div> : null}
</header>
);
}
export function Panel({
title,
eyebrow,
actions,
children,
className
}: {
title?: string;
eyebrow?: string;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<section className={clsx("panel", className)}>
{title || actions || eyebrow ? (
<div className="panel-header">
<div>
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
{title ? <h2>{title}</h2> : null}
</div>
{actions ? <div className="toolbar compact">{actions}</div> : null}
</div>
) : null}
<div className="panel-body">{children}</div>
</section>
);
}
export function MetricTile({
label,
value,
detail,
tone = "neutral",
icon
}: {
label: string;
value: React.ReactNode;
detail?: string;
tone?: "neutral" | "good" | "warn" | "danger" | "info";
icon?: React.ReactNode;
}) {
return (
<div className={clsx("metric-tile", tone)}>
<div className="metric-top">
<span>{label}</span>
{icon ? <span className="metric-icon">{icon}</span> : null}
</div>
<strong>{value}</strong>
{detail ? <p>{detail}</p> : null}
</div>
);
}
export function Tabs({ items, active }: { items: Array<{ label: string; href: string; count?: number }>; active: string }) {
return (
<nav className="tabs" aria-label="View tabs">
{items.map((item) => (
<a className={clsx("tab", active === item.label && "active")} href={item.href} key={item.label}>
{item.label}
{typeof item.count === "number" ? <span className="tab-count">{item.count}</span> : null}
</a>
))}
</nav>
);
}
export function EmptyState({
title,
description,
action
}: {
title: string;
description?: string;
action?: React.ReactNode;
}) {
return (
<div className="empty-state">
<strong>{title}</strong>
{description ? <span>{description}</span> : null}
{action ? <div className="toolbar compact">{action}</div> : null}
</div>
);
}
export function StatusDot({ tone = "neutral", label }: { tone?: string; label: string }) {
return <span className={clsx("status-dot", tone)}>{label}</span>;
}
export function DataTable({ children }: { children: React.ReactNode }) {
return <div className="table-wrap">{children}</div>;
}

View File

@@ -40,6 +40,7 @@ export async function sendFriendRequest(formData: FormData) {
}
revalidatePath("/friends");
revalidatePath("/people");
revalidatePath("/dashboard");
}
@@ -66,5 +67,6 @@ async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "D
});
revalidatePath("/friends");
revalidatePath("/people");
revalidatePath("/dashboard");
}

53
src/lib/room-actions.ts Normal file
View File

@@ -0,0 +1,53 @@
"use server";
import { RoomVisibility } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { requireCurrentUser } from "./session";
function normalizeSlug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
export async function createRoom(formData: FormData) {
const user = await requireCurrentUser();
const name = String(formData.get("name") || "").trim();
const visibility = String(formData.get("visibility") || "FRIENDS");
const baseSlug = normalizeSlug(name);
if (!name || !baseSlug) {
redirect("/rooms?error=invalid-room");
}
let slug = baseSlug;
let suffix = 2;
while (await prisma.room.findUnique({ where: { slug }, select: { id: true } })) {
slug = `${baseSlug}-${suffix}`;
suffix += 1;
}
const allowedVisibility: RoomVisibility[] = ["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"];
const roomVisibility = allowedVisibility.includes(visibility as RoomVisibility)
? (visibility as RoomVisibility)
: "FRIENDS";
const room = await prisma.room.create({
data: {
name,
slug,
ownerId: user.id,
visibility: roomVisibility
},
select: { slug: true }
});
revalidatePath("/rooms");
revalidatePath("/dashboard");
redirect(`/rooms/${encodeURIComponent(room.slug)}`);
}

36
src/lib/shell.ts Normal file
View File

@@ -0,0 +1,36 @@
import { prisma } from "./prisma";
import { getCurrentUser, userIsAdmin } from "./session";
type CurrentUser = NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>;
export async function getShellContext(user: CurrentUser) {
const [rooms, pendingRequests, activeRoomCount] = await Promise.all([
prisma.room.findMany({
where: {
OR: [{ ownerId: user.id }, { members: { some: { userId: user.id } } }, { visibility: "PUBLIC" }]
},
include: { _count: { select: { members: true, mediaSources: true } } },
orderBy: [{ ownerId: "desc" }, { updatedAt: "desc" }],
take: 8
}),
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
prisma.room.count({ where: { mediaSources: { some: {} } } })
]);
return {
isAdmin: userIsAdmin(user),
userName: user.displayName || user.username,
pendingRequests,
activeRoomCount,
rooms: rooms.map((room) => ({
id: room.id,
name: room.name,
slug: room.slug,
href: `/rooms/${encodeURIComponent(room.slug)}`,
visibility: room.visibility,
participantCount: room._count.members + 1,
queueCount: room._count.mediaSources,
isPersonal: room.ownerId === user.id
}))
};
}

View File

@@ -2,8 +2,9 @@
import { compare, hash } from "bcryptjs";
import { redirect } from "next/navigation";
import { Prisma, type User } from "@prisma/client";
import { prisma } from "./prisma";
import { setSession } from "./session";
import { clearSession, setSession } from "./session";
function normalizeUsername(value: FormDataEntryValue | null) {
return String(value || "")
@@ -17,11 +18,14 @@ export async function registerUser(formData: FormData) {
const password = String(formData.get("password") || "");
if (!username || password.length < 10) {
throw new Error("Username is required and password must be at least 10 characters.");
redirect("/register?error=invalid");
}
const passwordHash = await hash(password, 12);
const user = await prisma.user.create({
let user: User;
try {
user = await prisma.user.create({
data: {
username,
displayName: username,
@@ -35,6 +39,12 @@ export async function registerUser(formData: FormData) {
}
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
redirect("/register?error=username");
}
throw error;
}
await setSession(user.id);
redirect("/dashboard");
@@ -46,14 +56,19 @@ export async function loginUser(formData: FormData) {
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
throw new Error("Invalid username or password.");
redirect("/login?error=credentials");
}
const ok = await compare(password, user.passwordHash);
if (!ok) {
throw new Error("Invalid username or password.");
redirect("/login?error=credentials");
}
await setSession(user.id);
redirect("/dashboard");
}
export async function logoutUser() {
await clearSession();
redirect("/login");
}