Redesign WatchLink application UI
This commit is contained in:
@@ -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.
|
||||
|
||||
86
src/app/account/profile/page.tsx
Normal file
86
src/app/account/profile/page.tsx
Normal 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);
|
||||
}
|
||||
32
src/app/account/sessions/page.tsx
Normal file
32
src/app/account/sessions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/app/account/settings/page.tsx
Normal file
42
src/app/account/settings/page.tsx
Normal 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
152
src/app/activity/page.tsx
Normal 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`;
|
||||
}
|
||||
@@ -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,118 +40,292 @@ 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}
|
||||
>
|
||||
<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">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Access</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((room) => (
|
||||
<tr key={room.id}>
|
||||
<td>
|
||||
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
||||
</td>
|
||||
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||
<td>{room.visibility}</td>
|
||||
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Users</h2>
|
||||
<StatusBadge>{users.length} accounts</StatusBadge>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{users.map((account) => (
|
||||
<div className="row" key={account.id}>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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>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} />
|
||||
<Avatar name={account.displayName || account.username} size={30} />
|
||||
<div className="row-title">
|
||||
<strong>{account.displayName || account.username}</strong>
|
||||
<span>@{account.username} · {account._count.ownedRooms + account._count.roomMembers} rooms</span>
|
||||
<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>
|
||||
))}
|
||||
{account.roles.length === 0 ? <StatusBadge>user</StatusBadge> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<section className="room-layout" style={{ marginTop: 18 }}>
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Roles</h2>
|
||||
<StatusBadge>{roles.length} defined</StatusBadge>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{roles.map((role) => (
|
||||
<div className="row" key={role.id}>
|
||||
<div className="row-title">
|
||||
<strong>{role.name}</strong>
|
||||
<span>{role.description || `${role.scope.toLowerCase()} role`}</span>
|
||||
</div>
|
||||
<StatusBadge>{role._count.users} users / {role._count.permissions} permissions</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Permissions</h2>
|
||||
<StatusBadge>System</StatusBadge>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{SYSTEM_PERMISSIONS.map((permission) => (
|
||||
<div className="row" key={permission}>
|
||||
<div className="row-title">
|
||||
<strong>{permission}</strong>
|
||||
<span>Assignable to roles</span>
|
||||
</div>
|
||||
<StatusBadge>Enabled</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</AppShell>
|
||||
</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>State</th>
|
||||
<th>Updated</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.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>{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>
|
||||
</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">
|
||||
<strong>{role.name}</strong>
|
||||
<span>{role.description || `${role.scope.toLowerCase()} role`}</span>
|
||||
</div>
|
||||
<StatusBadge>{role._count.users} users / {role._count.permissions} permissions</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</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 system roles</span>
|
||||
</div>
|
||||
<StatusBadge>Enabled</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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="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 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>
|
||||
|
||||
{personalRoom ? (
|
||||
<RoomConsole
|
||||
roomId={personalRoom.id}
|
||||
roomSlug={personalRoom.slug}
|
||||
currentUser={user.displayName || user.username}
|
||||
queue={personalRoom.mediaSources.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title || item.originalUrl,
|
||||
provider: item.provider,
|
||||
originalUrl: item.originalUrl,
|
||||
playbackUrl: item.playbackUrl,
|
||||
by: item.submitter?.displayName || item.submitter?.username || "Unknown",
|
||||
createdAt: formatDate(item.createdAt)
|
||||
}))}
|
||||
participants={[
|
||||
{
|
||||
id: user.id,
|
||||
name: user.displayName || user.username,
|
||||
role: "Owner",
|
||||
status: "Online"
|
||||
},
|
||||
...personalRoom.members.map((member) => ({
|
||||
id: member.userId,
|
||||
name: member.user.displayName || member.user.username,
|
||||
role: member.canManage ? "Manager" : "Member",
|
||||
status: "Invited"
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<section className="panel">
|
||||
<div className="panel-body">
|
||||
<div className="empty-state">No personal room exists for this account yet.</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="room-layout" style={{ marginTop: 18 }}>
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Rooms</h2>
|
||||
<StatusBadge>Persistent</StatusBadge>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Access</th>
|
||||
<th>Status</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((room) => (
|
||||
<tr key={room.id}>
|
||||
<td>
|
||||
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
||||
</td>
|
||||
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||
<td>{room.visibility}</td>
|
||||
<td>{room._count.members + 1} users</td>
|
||||
<td>{room._count.mediaSources} queued</td>
|
||||
<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>Room</th>
|
||||
<th>Owner</th>
|
||||
<th>Access</th>
|
||||
<th>Queue</th>
|
||||
<th />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rooms.length === 0 ? <div className="empty-state">No accessible rooms found.</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Friends</h2>
|
||||
<StatusBadge tone={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}>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.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>{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>
|
||||
</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>
|
||||
</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}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
265
src/app/people/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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
194
src/app/rooms/page.tsx
Normal 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);
|
||||
}
|
||||
@@ -107,25 +107,40 @@ export default async function SetupPage({ searchParams }: { searchParams: Promis
|
||||
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<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>
|
||||
<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>
|
||||
{error ? <p className="form-error">{setupErrorMessage(error)}</p> : null}
|
||||
<form className="form" action={createFirstAdmin}>
|
||||
<label>
|
||||
Username
|
||||
<input className="input" name="username" autoComplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input className="input" name="password" type="password" autoComplete="new-password" minLength={10} required />
|
||||
</label>
|
||||
<button className="button primary" type="submit">
|
||||
Create admin
|
||||
</button>
|
||||
</form>
|
||||
<section className="auth-card">
|
||||
<div className="title-block" style={{ marginBottom: 18 }}>
|
||||
<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}>
|
||||
<label>
|
||||
Username
|
||||
<input className="input" name="username" autoComplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input className="input" name="password" type="password" autoComplete="new-password" minLength={10} required />
|
||||
</label>
|
||||
<button className="button primary" type="submit">
|
||||
Create admin
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,118 +76,176 @@ 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>
|
||||
</div>
|
||||
<div className="video-frame">
|
||||
{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>
|
||||
<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>
|
||||
<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" 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" 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>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<div className="video-frame">
|
||||
{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>
|
||||
|
||||
<aside className="stack">
|
||||
<Panel title="Queue">
|
||||
{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>
|
||||
))
|
||||
)}
|
||||
<form className="transport-bar" action={addMediaToRoom}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<button className="icon-button" type="button" onClick={() => emit("playback:play")} title="Play">
|
||||
<Play size={16} />
|
||||
</button>
|
||||
<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"
|
||||
aria-label="Source URL"
|
||||
name="sourceUrl"
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
placeholder="Paste YouTube, Twitch, or direct video URL"
|
||||
/>
|
||||
<button className="button primary" type="submit" onClick={() => emit("media:set")} disabled={!source}>
|
||||
<Radio size={16} /> Add source
|
||||
</button>
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel title="Participants">
|
||||
{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 className="console-grid">
|
||||
<Panel title="Queue" eyebrow={`${queue.length} sources`}>
|
||||
{queue.length === 0 ? (
|
||||
<EmptyState title="Queue is empty" description="Sources added by room participants will appear here with moderation actions." />
|
||||
) : (
|
||||
<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} - {item.createdAt}</span>
|
||||
</div>
|
||||
<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>
|
||||
<StatusBadge tone={item.status === "Buffering" ? "warn" : "good"}>{item.status}</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Activity">
|
||||
{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>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Participants" eyebrow={`${participants.length} present or allowed`}>
|
||||
{participants.length === 0 ? (
|
||||
<EmptyState title="No participants listed" description="Room presence appears when users open the room." />
|
||||
) : (
|
||||
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>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
</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 ? (
|
||||
<EmptyState title="No activity yet" description="Playback and queue events will stream here." />
|
||||
) : (
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
<div className="panel-body">{children}</div>
|
||||
</section>
|
||||
<div className="setting-row">
|
||||
{icon}
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<small>{value}</small>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
117
src/components/ui.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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
53
src/lib/room-actions.ts
Normal 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
36
src/lib/shell.ts
Normal 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
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -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,24 +18,33 @@ 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({
|
||||
data: {
|
||||
username,
|
||||
displayName: username,
|
||||
passwordHash,
|
||||
ownedRooms: {
|
||||
create: {
|
||||
slug: `@${username}`,
|
||||
name: `${username}'s room`,
|
||||
visibility: "FRIENDS"
|
||||
let user: User;
|
||||
|
||||
try {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: username,
|
||||
passwordHash,
|
||||
ownedRooms: {
|
||||
create: {
|
||||
slug: `@${username}`,
|
||||
name: `${username}'s room`,
|
||||
visibility: "FRIENDS"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user