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.
|
- 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.
|
- 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.
|
- 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 { AppShell } from "@/components/app-shell";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { SYSTEM_PERMISSIONS } from "@/lib/access";
|
import { SYSTEM_PERMISSIONS } from "@/lib/access";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getShellContext } from "@/lib/shell";
|
||||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||||
import { requireInitialSetup } from "@/lib/setup";
|
import { requireInitialSetup } from "@/lib/setup";
|
||||||
import Link from "next/link";
|
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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();
|
await requireInitialSetup();
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
|
|
||||||
@@ -18,8 +23,12 @@ export default async function AdminPage() {
|
|||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [personalRoom, users, rooms, roles] = await Promise.all([
|
const { tab = "Users", q = "" } = await searchParams;
|
||||||
prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } }),
|
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({
|
prisma.user.findMany({
|
||||||
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
|
include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
|
||||||
orderBy: { createdAt: "asc" }
|
orderBy: { createdAt: "asc" }
|
||||||
@@ -31,89 +40,197 @@ export default async function AdminPage() {
|
|||||||
prisma.role.findMany({
|
prisma.role.findMany({
|
||||||
include: { _count: { select: { users: true, permissions: true } } },
|
include: { _count: { select: { users: true, permissions: true } } },
|
||||||
orderBy: { name: "asc" }
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell active="Admin" {...shell}>
|
||||||
active="Admin"
|
<PageHeader
|
||||||
isAdmin
|
title="Admin"
|
||||||
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
|
description="Operational management for accounts, rooms, roles, instance state, and security controls."
|
||||||
userName={user.displayName || user.username}
|
meta={
|
||||||
|
<>
|
||||||
|
<StatusDot tone="good" label="admin session" />
|
||||||
|
<StatusDot tone="good" label={`${users.length} users`} />
|
||||||
|
<StatusDot tone={pendingRequests > 0 ? "warn" : "neutral"} label={`${pendingRequests} pending requests`} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button className="button" type="button" disabled title="Invite records are not implemented yet."><Plus size={16} /> Invite</button>
|
||||||
|
<button className="button" type="button" disabled title="Audit events need a persisted event table first."><Shield size={16} /> Audit</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="metrics-grid">
|
||||||
|
<MetricTile label="Users" value={users.length} detail="local accounts" tone="good" />
|
||||||
|
<MetricTile label="Rooms" value={rooms.length} detail="persistent room records" tone="info" />
|
||||||
|
<MetricTile label="Roles" value={roles.length} detail="system and room scopes" />
|
||||||
|
<MetricTile label="Permissions" value={SYSTEM_PERMISSIONS.length} detail="known permission keys" tone="warn" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title="Admin Workspace"
|
||||||
|
eyebrow="Management"
|
||||||
|
actions={
|
||||||
|
<form className="toolbar" action="/admin">
|
||||||
|
<input type="hidden" name="tab" value={activeTab} />
|
||||||
|
<label className="search-field">
|
||||||
|
<Search size={15} />
|
||||||
|
<input name="q" placeholder={`Search ${activeTab.toLowerCase()}`} defaultValue={q} />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<header className="topbar">
|
<Tabs active={activeTab} items={adminTabs.map((item) => ({ label: item, href: `/admin?tab=${encodeURIComponent(item)}` }))} />
|
||||||
<div className="title-block">
|
|
||||||
<h1>Admin</h1>
|
{activeTab === "Users" ? <UsersTable users={filteredUsers} /> : null}
|
||||||
<p>Manage roles, rooms, permissions, and users.</p>
|
{activeTab === "Rooms" ? <RoomsTable rooms={filteredRooms} /> : null}
|
||||||
</div>
|
{activeTab === "Roles" ? <RolesTable roles={filteredRoles} /> : null}
|
||||||
<StatusBadge tone="good">Admin</StatusBadge>
|
{activeTab === "Invites" ? <InvitesPanel pendingRequests={pendingRequests} /> : null}
|
||||||
</header>
|
{activeTab === "Instance" ? <InstancePanel userCount={users.length} roomCount={rooms.length} /> : null}
|
||||||
<section className="room-layout">
|
{activeTab === "Security" ? <SecurityPanel /> : null}
|
||||||
<section className="panel">
|
</Panel>
|
||||||
<div className="panel-header">
|
</AppShell>
|
||||||
<h2>Rooms</h2>
|
);
|
||||||
<StatusBadge>{rooms.length} total</StatusBadge>
|
}
|
||||||
</div>
|
|
||||||
<div className="panel-body">
|
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">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>User</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Rooms</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((account) => (
|
||||||
|
<tr key={account.id}>
|
||||||
|
<td>
|
||||||
|
<div className="row-main">
|
||||||
|
<Avatar name={account.displayName || account.username} size={30} />
|
||||||
|
<div className="row-title">
|
||||||
|
<strong>{account.displayName || account.username}</strong>
|
||||||
|
<span>@{account.username}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="status-row">
|
||||||
|
{account.roles.length === 0 ? <StatusBadge>user</StatusBadge> : null}
|
||||||
|
{account.roles.map((userRole) => (
|
||||||
|
<StatusBadge key={userRole.roleId} tone={userRole.role.name === "admin" ? "good" : undefined}>
|
||||||
|
{userRole.role.name}
|
||||||
|
</StatusBadge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{account._count.ownedRooms + account._count.roomMembers}</td>
|
||||||
|
<td>{formatDate(account.createdAt)}</td>
|
||||||
|
<td><button className="button compact-button" type="button" disabled title="User edit and ban actions are not implemented yet.">Manage</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomsTable({
|
||||||
|
rooms
|
||||||
|
}: {
|
||||||
|
rooms: Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
visibility: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
owner: { username: string; displayName: string | null } | null;
|
||||||
|
_count: { members: number; mediaSources: number };
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DataTable>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Room</th>
|
||||||
<th>Owner</th>
|
<th>Owner</th>
|
||||||
<th>Access</th>
|
<th>Access</th>
|
||||||
<th>Status</th>
|
<th>State</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rooms.map((room) => (
|
{rooms.map((room) => (
|
||||||
<tr key={room.id}>
|
<tr key={room.id}>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
<div className="row-title">
|
||||||
|
<strong>{room.name}</strong>
|
||||||
|
<span>/{room.slug}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||||
<td>{room.visibility}</td>
|
<td><StatusBadge>{room.visibility.toLowerCase().replace("_", " ")}</StatusBadge></td>
|
||||||
<td>{room._count.members + 1} users / {room._count.mediaSources} media</td>
|
<td>{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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</DataTable>
|
||||||
</section>
|
);
|
||||||
<section className="panel">
|
}
|
||||||
<div className="panel-header">
|
|
||||||
<h2>Users</h2>
|
function RolesTable({
|
||||||
<StatusBadge>{users.length} accounts</StatusBadge>
|
roles
|
||||||
</div>
|
}: {
|
||||||
<div className="panel-body">
|
roles: Array<{
|
||||||
{users.map((account) => (
|
id: string;
|
||||||
<div className="row" key={account.id}>
|
name: string;
|
||||||
<div className="row-main">
|
description: string | null;
|
||||||
<Avatar name={account.displayName || account.username} />
|
scope: string;
|
||||||
<div className="row-title">
|
_count: { users: number; permissions: number };
|
||||||
<strong>{account.displayName || account.username}</strong>
|
}>;
|
||||||
<span>@{account.username} · {account._count.ownedRooms + account._count.roomMembers} rooms</span>
|
}) {
|
||||||
</div>
|
return roles.length === 0 ? (
|
||||||
</div>
|
<EmptyState title="No roles defined" />
|
||||||
<div className="status-row">
|
) : (
|
||||||
{account.roles.map((userRole) => (
|
<div className="split-grid">
|
||||||
<StatusBadge key={userRole.roleId} tone={userRole.role.name === "admin" ? "good" : undefined}>
|
<Panel title="Roles" eyebrow="Assignments">
|
||||||
{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) => (
|
{roles.map((role) => (
|
||||||
<div className="row" key={role.id}>
|
<div className="row" key={role.id}>
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
@@ -123,26 +240,92 @@ export default async function AdminPage() {
|
|||||||
<StatusBadge>{role._count.users} users / {role._count.permissions} permissions</StatusBadge>
|
<StatusBadge>{role._count.users} users / {role._count.permissions} permissions</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Panel>
|
||||||
</section>
|
<Panel title="Permissions" eyebrow="Known keys">
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-header">
|
|
||||||
<h2>Permissions</h2>
|
|
||||||
<StatusBadge>System</StatusBadge>
|
|
||||||
</div>
|
|
||||||
<div className="panel-body">
|
|
||||||
{SYSTEM_PERMISSIONS.map((permission) => (
|
{SYSTEM_PERMISSIONS.map((permission) => (
|
||||||
<div className="row" key={permission}>
|
<div className="row" key={permission}>
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{permission}</strong>
|
<strong>{permission}</strong>
|
||||||
<span>Assignable to roles</span>
|
<span>Assignable to system roles</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge>Enabled</StatusBadge>
|
<StatusBadge>Enabled</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</AppShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
|
||||||
|
return (
|
||||||
|
<div className="split-grid">
|
||||||
|
<Panel title="Room Invites" eyebrow="Pending">
|
||||||
|
<EmptyState title="Room invite creation is not wired yet" description="The management surface is reserved so invite CRUD can be added without a layout change." />
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Friend Requests" eyebrow="Instance">
|
||||||
|
<div className="setting-row">
|
||||||
|
<UsersRound size={16} />
|
||||||
|
<span>
|
||||||
|
<strong>{pendingRequests} pending friend requests</strong>
|
||||||
|
<small>Handled by each receiving user in People.</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) {
|
||||||
|
return (
|
||||||
|
<div className="split-grid">
|
||||||
|
<Panel title="Runtime" eyebrow="Container">
|
||||||
|
<div className="settings-list">
|
||||||
|
<Setting icon={<Database size={16} />} label="Database" value="Postgres via DATABASE_URL" />
|
||||||
|
<Setting icon={<Shield size={16} />} label="Sessions" value="Signed HTTP-only cookie" />
|
||||||
|
<Setting icon={<UsersRound size={16} />} label="Accounts" value={`${userCount} users`} />
|
||||||
|
<Setting icon={<Database size={16} />} label="Rooms" value={`${roomCount} rooms`} />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Health" eyebrow="Status">
|
||||||
|
<div className="settings-list">
|
||||||
|
<StatusDot tone="good" label="Admin page rendered" />
|
||||||
|
<StatusDot tone="good" label="Database query succeeded" />
|
||||||
|
<StatusDot tone="info" label="Socket endpoint configured at /api/socket" />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityPanel() {
|
||||||
|
return (
|
||||||
|
<div className="split-grid">
|
||||||
|
<Panel title="Security Controls" eyebrow="Policy">
|
||||||
|
<div className="settings-list">
|
||||||
|
<Setting icon={<LockKeyhole size={16} />} label="Password storage" value="bcrypt hash" />
|
||||||
|
<Setting icon={<Shield size={16} />} label="Admin check" value="Server-side role lookup" />
|
||||||
|
<Setting icon={<AlertTriangle size={16} />} label="Registration mode" value="Open registration" />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<Panel title="Danger Zone" eyebrow="Destructive">
|
||||||
|
<EmptyState title="Destructive actions are disabled" description="User banning, registration mode changes, and deletion need server actions before controls become active." />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Setting({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="setting-row">
|
||||||
|
{icon}
|
||||||
|
<span>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<small>{value}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(date);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { AppShell } from "@/components/app-shell";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { RoomConsole } from "@/components/room-console";
|
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
|
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot } from "@/components/ui";
|
||||||
import { prisma } from "@/lib/prisma";
|
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 { requireInitialSetup } from "@/lib/setup";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
await requireInitialSetup();
|
await requireInitialSetup();
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
const isAdmin = userIsAdmin(user);
|
const shell = await getShellContext(user);
|
||||||
|
|
||||||
const [personalRoom, userCount, roomCount, pendingRequests, friendships, rooms] = await Promise.all([
|
const [userCount, roomCount, pendingRequests, acceptedFriends, rooms, recentMedia] = await Promise.all([
|
||||||
prisma.room.findFirst({
|
|
||||||
where: { ownerId: user.id },
|
|
||||||
include: {
|
|
||||||
owner: true,
|
|
||||||
members: { include: { user: true } },
|
|
||||||
mediaSources: { include: { submitter: true }, orderBy: { createdAt: "desc" }, take: 8 }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
prisma.user.count(),
|
prisma.user.count(),
|
||||||
prisma.room.count(),
|
prisma.room.count(),
|
||||||
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
|
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
|
||||||
prisma.friendship.findMany({
|
prisma.friendship.count({
|
||||||
where: {
|
where: {
|
||||||
status: "ACCEPTED",
|
status: "ACCEPTED",
|
||||||
OR: [{ requesterId: user.id }, { receiverId: user.id }]
|
OR: [{ requesterId: user.id }, { receiverId: user.id }]
|
||||||
},
|
}
|
||||||
include: { requester: true, receiver: true },
|
|
||||||
take: 6,
|
|
||||||
orderBy: { updatedAt: "desc" }
|
|
||||||
}),
|
}),
|
||||||
prisma.room.findMany({
|
prisma.room.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -41,141 +32,169 @@ export default async function DashboardPage() {
|
|||||||
},
|
},
|
||||||
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
|
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
|
||||||
orderBy: { updatedAt: "desc" },
|
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 (
|
return (
|
||||||
<AppShell active="Dashboard" isAdmin={isAdmin} roomHref={roomHref} userName={user.displayName || user.username}>
|
<AppShell active="Overview" {...shell}>
|
||||||
<header className="topbar">
|
<PageHeader
|
||||||
<div className="title-block">
|
title="Overview"
|
||||||
<h1>Dashboard</h1>
|
description={`Instance dashboard for ${shell.userName}. Rooms, requests, and recent media are separated from playback.`}
|
||||||
<p>{`Signed in as ${user.displayName || user.username}`}</p>
|
meta={
|
||||||
</div>
|
<>
|
||||||
<div className="status-row">
|
<StatusDot tone="good" label="App rendered" />
|
||||||
<StatusBadge tone="good">Online</StatusBadge>
|
<StatusDot tone="good" label="Database queried" />
|
||||||
<StatusBadge>System theme</StatusBadge>
|
<StatusDot tone={shell.activeRoomCount > 0 ? "info" : "neutral"} label={`${shell.activeRoomCount} rooms with queue`} />
|
||||||
</div>
|
</>
|
||||||
</header>
|
}
|
||||||
|
actions={
|
||||||
<section className="stats-grid" aria-label="System overview">
|
<>
|
||||||
{stats.map((stat) => (
|
<Link className="button" href="/people">
|
||||||
<div className="stat" key={stat.label}>
|
<UsersRound size={16} /> People
|
||||||
<span>{stat.label}</span>
|
</Link>
|
||||||
<strong>{stat.value}</strong>
|
<Link className="button primary" href="/rooms">
|
||||||
</div>
|
<Plus size={16} /> New room
|
||||||
))}
|
</Link>
|
||||||
</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="metrics-grid" aria-label="Instance summary">
|
||||||
<section className="panel">
|
<MetricTile label="Accounts" value={userCount} detail="registered local users" tone="info" />
|
||||||
<div className="panel-header">
|
<MetricTile label="Rooms" value={roomCount} detail="persistent rooms" tone="good" />
|
||||||
<h2>Rooms</h2>
|
<MetricTile label="Friends" value={acceptedFriends} detail="accepted relationships" />
|
||||||
<StatusBadge>Persistent</StatusBadge>
|
<MetricTile label="Requests" value={pendingRequests} detail="waiting for your action" tone={pendingRequests > 0 ? "warn" : "neutral"} />
|
||||||
</div>
|
</section>
|
||||||
<div className="panel-body">
|
|
||||||
|
<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">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Room</th>
|
||||||
<th>Owner</th>
|
<th>Owner</th>
|
||||||
<th>Access</th>
|
<th>Access</th>
|
||||||
<th>Status</th>
|
<th>Queue</th>
|
||||||
<th>Source</th>
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rooms.map((room) => (
|
{rooms.map((room) => (
|
||||||
<tr key={room.id}>
|
<tr key={room.id}>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/rooms/${encodeURIComponent(room.slug)}`}>{room.name}</Link>
|
<div className="row-title">
|
||||||
|
<strong>{room.name}</strong>
|
||||||
|
<span>/{room.slug}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
<td>{room.owner?.displayName || room.owner?.username || "Unassigned"}</td>
|
||||||
<td>{room.visibility}</td>
|
<td><StatusBadge>{formatVisibility(room.visibility)}</StatusBadge></td>
|
||||||
<td>{room._count.members + 1} users</td>
|
<td>{room._count.mediaSources} sources</td>
|
||||||
<td>{room._count.mediaSources} queued</td>
|
<td>
|
||||||
|
<Link className="icon-button compact" href={`/rooms/${encodeURIComponent(room.slug)}`} aria-label={`Open ${room.name}`}>
|
||||||
|
<MonitorPlay size={15} />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{rooms.length === 0 ? <div className="empty-state">No accessible rooms found.</div> : null}
|
</DataTable>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="stack">
|
||||||
|
<Panel title="Quick Actions" eyebrow="Common work">
|
||||||
|
<div className="action-list">
|
||||||
|
<Link className="action-row" href="/rooms">
|
||||||
|
<MonitorPlay size={17} />
|
||||||
|
<span>
|
||||||
|
<strong>Create or open a room</strong>
|
||||||
|
<small>Manage persistent rooms and access</small>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link className="action-row" href="/people">
|
||||||
|
<UsersRound size={17} />
|
||||||
|
<span>
|
||||||
|
<strong>Review people</strong>
|
||||||
|
<small>{pendingRequests} incoming request{pendingRequests === 1 ? "" : "s"}</small>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link className="action-row" href="/activity">
|
||||||
|
<Bell size={17} />
|
||||||
|
<span>
|
||||||
|
<strong>Audit recent activity</strong>
|
||||||
|
<small>Media, rooms, and social changes</small>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Panel>
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-header">
|
<Panel title="Recent Media" eyebrow="Last additions">
|
||||||
<h2>Friends</h2>
|
{recentMedia.length === 0 ? (
|
||||||
<StatusBadge tone={acceptedFriends > 0 ? "good" : undefined}>{acceptedFriends} linked</StatusBadge>
|
<EmptyState title="No media activity yet" description="Queued YouTube, Twitch, and direct videos will appear here." />
|
||||||
</div>
|
) : (
|
||||||
<div className="panel-body">
|
recentMedia.map((item) => (
|
||||||
{friendships.map((friendship) => {
|
<div className="row" key={item.id}>
|
||||||
const friend = friendship.requesterId === user.id ? friendship.receiver : friendship.requester;
|
|
||||||
return (
|
|
||||||
<div className="row" key={friend.id}>
|
|
||||||
<div className="row-main">
|
<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">
|
<div className="row-title">
|
||||||
<strong>{friend.displayName || friend.username}</strong>
|
<strong>{item.title || item.originalUrl}</strong>
|
||||||
<span>@{friend.username}</span>
|
<span>{item.room.name} by {item.submitter?.displayName || item.submitter?.username || "Unknown"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone="good">Friend</StatusBadge>
|
<StatusBadge>{item.provider}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
);
|
))
|
||||||
})}
|
)}
|
||||||
{friendships.length === 0 ? <div className="empty-state">No friends yet. Add users from Friends.</div> : null}
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
<Panel title="Instance Timeline" eyebrow="Operational events">
|
||||||
|
{recentMedia.length === 0 && rooms.length === 0 ? (
|
||||||
|
<EmptyState title="No timeline events" description="Room and media events are shown after the instance is used." />
|
||||||
|
) : (
|
||||||
|
<div className="timeline-list">
|
||||||
|
{recentMedia.map((item) => (
|
||||||
|
<div className="timeline-item" key={`timeline-${item.id}`}>
|
||||||
|
<Clock3 size={15} />
|
||||||
|
<div className="row-title">
|
||||||
|
<strong>{item.submitter?.displayName || item.submitter?.username || "Someone"} added {item.provider.toLowerCase()} media</strong>
|
||||||
|
<span>{item.room.name} - {formatDate(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatVisibility(value: string) {
|
||||||
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
|
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 { redirect } from "next/navigation";
|
||||||
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";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export default function FriendsRedirectPage() {
|
||||||
|
redirect("/people");
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,11 @@ select {
|
|||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.info {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
|
||||||
|
color: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
.room-layout {
|
.room-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 340px;
|
grid-template-columns: minmax(0, 1fr) 340px;
|
||||||
@@ -470,3 +475,556 @@ select {
|
|||||||
grid-template-columns: 1fr;
|
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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||||
if (!(await hasAdminUser())) {
|
if (!(await hasAdminUser())) {
|
||||||
redirect("/setup");
|
redirect("/setup");
|
||||||
}
|
}
|
||||||
|
const { error } = await searchParams;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<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 }}>
|
<div className="title-block" style={{ marginBottom: 18 }}>
|
||||||
<h1>Login</h1>
|
<h1>Login</h1>
|
||||||
<p>Enter WatchLink with your username and password.</p>
|
<p>Enter your account to open rooms and manage playback.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <p className="form-error">Invalid username or password.</p> : null}
|
||||||
<form className="form" action={loginUser}>
|
<form className="form" action={loginUser}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
@@ -33,6 +43,7 @@ export default async function LoginPage() {
|
|||||||
Create account
|
Create account
|
||||||
</Link>
|
</Link>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||||
if (!(await hasAdminUser())) {
|
if (!(await hasAdminUser())) {
|
||||||
redirect("/setup");
|
redirect("/setup");
|
||||||
}
|
}
|
||||||
|
const { error } = await searchParams;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<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">
|
<section className="auth-card">
|
||||||
<div className="title-block" style={{ marginBottom: 18 }}>
|
<div className="title-block" style={{ marginBottom: 18 }}>
|
||||||
<h1>Create account</h1>
|
<h1>Create account</h1>
|
||||||
<p>Register a username and get a persistent room.</p>
|
<p>Register a username and get a persistent room.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <p className="form-error">{registerError(error)}</p> : null}
|
||||||
<form className="form" action={registerUser}>
|
<form className="form" action={registerUser}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
@@ -34,6 +44,12 @@ export default async function RegisterPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
</main>
|
</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 { AppShell } from "@/components/app-shell";
|
||||||
import { RoomConsole } from "@/components/room-console";
|
import { RoomConsole } from "@/components/room-console";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
|
import { PageHeader, StatusDot } from "@/components/ui";
|
||||||
import { canEnterRoom } from "@/lib/access";
|
import { canEnterRoom } from "@/lib/access";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getShellContext } from "@/lib/shell";
|
||||||
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
|
||||||
import { requireInitialSetup } from "@/lib/setup";
|
import { requireInitialSetup } from "@/lib/setup";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -58,28 +60,33 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
|
|||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const personalRoom = await prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } });
|
const shell = await getShellContext(user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell active="Rooms" {...shell}>
|
||||||
active="Rooms"
|
<PageHeader
|
||||||
isAdmin={isAdmin}
|
title={room.name}
|
||||||
roomHref={personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard"}
|
description={room.owner ? `Owned by ${room.owner.displayName || room.owner.username}. Stable route /rooms/${room.slug}.` : "Shared watch room"}
|
||||||
userName={user.displayName || user.username}
|
meta={
|
||||||
>
|
<>
|
||||||
<header className="topbar">
|
<StatusDot tone="good" label="Socket ready" />
|
||||||
<div className="title-block">
|
<StatusDot tone={room.mediaSources.length > 0 ? "good" : "neutral"} label={`${room.mediaSources.length} queued`} />
|
||||||
<h1>{room.name}</h1>
|
<StatusBadge>{room.visibility.toLowerCase().replace("_", " ")}</StatusBadge>
|
||||||
<p>{room.owner ? `Owned by ${room.owner.displayName || room.owner.username}` : "Shared watch room"}</p>
|
</>
|
||||||
</div>
|
}
|
||||||
<div className="status-row">
|
actions={
|
||||||
<StatusBadge tone="good">Online</StatusBadge>
|
<>
|
||||||
<StatusBadge>{room.visibility}</StatusBadge>
|
<button className="button" type="button" disabled title="Invite persistence is not implemented yet.">Invite</button>
|
||||||
</div>
|
<button className="button" type="button" disabled title="Room settings require dedicated server actions before editing.">Settings</button>
|
||||||
</header>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<RoomConsole
|
<RoomConsole
|
||||||
roomId={room.id}
|
roomId={room.id}
|
||||||
roomSlug={room.slug}
|
roomSlug={room.slug}
|
||||||
|
roomName={room.name}
|
||||||
|
roomVisibility={room.visibility}
|
||||||
|
ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"}
|
||||||
currentUser={user.displayName || user.username}
|
currentUser={user.displayName || user.username}
|
||||||
queue={room.mediaSources.map((item) => ({
|
queue={room.mediaSources.map((item) => ({
|
||||||
id: item.id,
|
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,10 +107,24 @@ export default async function SetupPage({ searchParams }: { searchParams: Promis
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
|
<section className="auth-shell setup-shell">
|
||||||
|
<div className="auth-intro">
|
||||||
|
<span className="brand-mark" aria-hidden="true" />
|
||||||
|
<div className="title-block">
|
||||||
|
<h1>First Setup</h1>
|
||||||
|
<p>Create the first administrator, seed permissions, and create the initial persistent room.</p>
|
||||||
|
</div>
|
||||||
|
<div className="setup-checklist">
|
||||||
|
<div><span /> Admin role and permissions</div>
|
||||||
|
<div><span /> First user account</div>
|
||||||
|
<div><span /> Personal room route</div>
|
||||||
|
<div><span /> Setup lockout after completion</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<section className="auth-card">
|
<section className="auth-card">
|
||||||
<div className="title-block" style={{ marginBottom: 18 }}>
|
<div className="title-block" style={{ marginBottom: 18 }}>
|
||||||
<h1>WatchLink first setup</h1>
|
<h1>Administrator</h1>
|
||||||
<p>Create the first admin account. This screen locks after setup.</p>
|
<p>This screen is only available while no admin user exists.</p>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{setupErrorMessage(error)}</p> : null}
|
{error ? <p className="form-error">{setupErrorMessage(error)}</p> : null}
|
||||||
<form className="form" action={createFirstAdmin}>
|
<form className="form" action={createFirstAdmin}>
|
||||||
@@ -127,6 +141,7 @@ export default async function SetupPage({ searchParams }: { searchParams: Promis
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,57 @@
|
|||||||
import Link from "next/link";
|
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 { 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({
|
export function AppShell({
|
||||||
children,
|
children,
|
||||||
active = "Dashboard",
|
active = "Overview",
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
roomHref = "/dashboard",
|
userName,
|
||||||
userName
|
rooms = [],
|
||||||
|
pendingRequests = 0,
|
||||||
|
activeRoomCount = 0
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
active?: string;
|
active?: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
roomHref?: string;
|
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
rooms?: ShellRoom[];
|
||||||
|
pendingRequests?: number;
|
||||||
|
activeRoomCount?: number;
|
||||||
}) {
|
}) {
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: Gauge },
|
{ href: "/dashboard", label: "Overview", icon: Gauge },
|
||||||
{ href: roomHref, label: "Rooms", icon: MonitorPlay },
|
{ href: "/rooms", label: "Rooms", icon: MonitorPlay },
|
||||||
{ href: "/friends", label: "Friends", icon: UsersRound }
|
{ href: "/people", label: "People", icon: UsersRound },
|
||||||
|
{ href: "/activity", label: "Activity", icon: Activity }
|
||||||
];
|
];
|
||||||
const visibleNav = isAdmin
|
const visibleNav = isAdmin ? [...nav, { href: "/admin", label: "Admin", icon: Shield }] : nav;
|
||||||
? [nav[0], nav[1], nav[2], { href: "/admin", label: "Admin", icon: Shield }]
|
|
||||||
: nav;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
@@ -42,6 +71,34 @@ export function AppShell({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</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 ? (
|
{userName ? (
|
||||||
<div className="sidebar-user">
|
<div className="sidebar-user">
|
||||||
<Avatar name={userName} />
|
<Avatar name={userName} />
|
||||||
@@ -49,8 +106,36 @@ export function AppShell({
|
|||||||
<strong>{userName}</strong>
|
<strong>{userName}</strong>
|
||||||
<span>{isAdmin ? "Administrator" : "Member"}</span>
|
<span>{isAdmin ? "Administrator" : "Member"}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</aside>
|
||||||
<main className="main">{children}</main>
|
<main className="main">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
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 { io } from "socket.io-client";
|
||||||
import { normalizeMediaUrl } from "@/lib/media";
|
import { normalizeMediaUrl } from "@/lib/media";
|
||||||
import { StatusBadge } from "./status-badge";
|
|
||||||
import { Avatar } from "./avatar";
|
|
||||||
import { addMediaToRoom } from "@/lib/media-actions";
|
import { addMediaToRoom } from "@/lib/media-actions";
|
||||||
|
import { Avatar } from "./avatar";
|
||||||
|
import { StatusBadge } from "./status-badge";
|
||||||
|
import { EmptyState, Panel, StatusDot } from "./ui";
|
||||||
|
|
||||||
const socket = io({
|
const socket = io({
|
||||||
path: "/api/socket",
|
path: "/api/socket",
|
||||||
@@ -33,18 +34,25 @@ type Participant = {
|
|||||||
export function RoomConsole({
|
export function RoomConsole({
|
||||||
roomId,
|
roomId,
|
||||||
roomSlug,
|
roomSlug,
|
||||||
|
roomName,
|
||||||
|
roomVisibility,
|
||||||
|
ownerName,
|
||||||
currentUser,
|
currentUser,
|
||||||
queue = [],
|
queue = [],
|
||||||
participants = []
|
participants = []
|
||||||
}: {
|
}: {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
roomSlug: string;
|
roomSlug: string;
|
||||||
|
roomName: string;
|
||||||
|
roomVisibility: string;
|
||||||
|
ownerName: string;
|
||||||
currentUser: string;
|
currentUser: string;
|
||||||
queue?: QueueItem[];
|
queue?: QueueItem[];
|
||||||
participants?: Participant[];
|
participants?: Participant[];
|
||||||
}) {
|
}) {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
|
const [rail, setRail] = useState("Activity");
|
||||||
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
|
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
|
||||||
const currentMedia = previewMedia || queue[0] || null;
|
const currentMedia = previewMedia || queue[0] || null;
|
||||||
|
|
||||||
@@ -68,15 +76,19 @@ export function RoomConsole({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="room-layout">
|
<section className="watch-console">
|
||||||
<section className="panel">
|
<div className="player-column">
|
||||||
<div className="panel-header">
|
<Panel
|
||||||
<div>
|
className="player-panel"
|
||||||
<h2>{roomSlug}</h2>
|
title={roomName}
|
||||||
<span className="eyebrow">Persistent room</span>
|
eyebrow={`/${roomSlug}`}
|
||||||
</div>
|
actions={
|
||||||
<StatusBadge tone={connected ? "good" : "warn"}>{connected ? "Online" : "Local preview"}</StatusBadge>
|
<div className="status-row">
|
||||||
|
<StatusDot tone={connected ? "good" : "warn"} label={connected ? "connected" : "local preview"} />
|
||||||
|
<StatusBadge>{formatVisibility(roomVisibility)}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="video-frame">
|
<div className="video-frame">
|
||||||
{currentMedia ? (
|
{currentMedia ? (
|
||||||
<MediaPreview
|
<MediaPreview
|
||||||
@@ -92,13 +104,17 @@ export function RoomConsole({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<form className="controls" action={addMediaToRoom}>
|
|
||||||
|
<form className="transport-bar" action={addMediaToRoom}>
|
||||||
<input type="hidden" name="roomId" value={roomId} />
|
<input type="hidden" name="roomId" value={roomId} />
|
||||||
<button className="button primary" type="button" onClick={() => emit("playback:play")}>
|
<button className="icon-button" type="button" onClick={() => emit("playback:play")} title="Play">
|
||||||
<Play size={16} /> Play
|
<Play size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button className="button" type="button" onClick={() => emit("playback:pause")}>
|
<button className="icon-button" type="button" onClick={() => emit("playback:pause")} title="Pause">
|
||||||
<Pause size={16} /> Pause
|
<Pause size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" onClick={() => emit("playback:seek")} title="Seek">
|
||||||
|
<SkipForward size={16} />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -106,38 +122,50 @@ export function RoomConsole({
|
|||||||
name="sourceUrl"
|
name="sourceUrl"
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(event) => setSource(event.target.value)}
|
onChange={(event) => setSource(event.target.value)}
|
||||||
placeholder="Source URL"
|
placeholder="Paste YouTube, Twitch, or direct video URL"
|
||||||
/>
|
/>
|
||||||
<button className="button" type="submit" onClick={() => emit("media:set")} disabled={!source}>
|
<button className="button primary" type="submit" onClick={() => emit("media:set")} disabled={!source}>
|
||||||
<Radio size={16} /> Add
|
<Radio size={16} /> Add source
|
||||||
</button>
|
|
||||||
<button className="button" type="button" onClick={() => emit("playback:seek")}>
|
|
||||||
<SkipForward size={16} /> Seek
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</Panel>
|
||||||
|
|
||||||
<aside className="stack">
|
<div className="console-grid">
|
||||||
<Panel title="Queue">
|
<Panel title="Queue" eyebrow={`${queue.length} sources`}>
|
||||||
{queue.length === 0 ? (
|
{queue.length === 0 ? (
|
||||||
<div className="empty-state">No media queued yet.</div>
|
<EmptyState title="Queue is empty" description="Sources added by room participants will appear here with moderation actions." />
|
||||||
) : (
|
) : (
|
||||||
queue.map((item) => (
|
<div className="queue-list">
|
||||||
<div className="row" key={item.id}>
|
{queue.map((item, index) => (
|
||||||
|
<div className="queue-row" key={item.id}>
|
||||||
|
<span className="queue-index">{index + 1}</span>
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
<span>
|
<span>{item.provider} by {item.by} - {item.createdAt}</span>
|
||||||
{item.provider} by {item.by}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge>{item.createdAt}</StatusBadge>
|
<div className="row-actions">
|
||||||
|
<button className="icon-button compact" type="button" title="Play now" onClick={() => emit("media:set")}>
|
||||||
|
<Play size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button compact" type="button" title="Queue reorder persistence is not implemented yet." disabled>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button compact danger" type="button" title="Queue removal needs a server action." disabled>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title="Participants">
|
|
||||||
|
<Panel title="Participants" eyebrow={`${participants.length} present or allowed`}>
|
||||||
{participants.length === 0 ? (
|
{participants.length === 0 ? (
|
||||||
<div className="empty-state">No participants listed yet.</div>
|
<EmptyState title="No participants listed" description="Room presence appears when users open the room." />
|
||||||
) : (
|
) : (
|
||||||
participants.map((item) => (
|
participants.map((item) => (
|
||||||
<div className="row" key={item.id}>
|
<div className="row" key={item.id}>
|
||||||
@@ -153,12 +181,27 @@ export function RoomConsole({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title="Activity">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="right-rail">
|
||||||
|
<Panel title="Room Rail" eyebrow="Activity and settings">
|
||||||
|
<div className="rail-tabs" role="tablist" aria-label="Room rail">
|
||||||
|
{["Activity", "Chat", "Settings"].map((item) => (
|
||||||
|
<button key={item} className={rail === item ? "active" : ""} type="button" onClick={() => setRail(item)}>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rail === "Activity" ? (
|
||||||
|
<div className="timeline-list">
|
||||||
{queue.length === 0 ? (
|
{queue.length === 0 ? (
|
||||||
<div className="empty-state">Room activity will appear after users add media.</div>
|
<EmptyState title="No activity yet" description="Playback and queue events will stream here." />
|
||||||
) : (
|
) : (
|
||||||
queue.slice(0, 5).map((item) => (
|
queue.slice(0, 8).map((item) => (
|
||||||
<div className="row" key={`activity-${item.id}`}>
|
<div className="timeline-item" key={`activity-${item.id}`}>
|
||||||
|
<Radio size={15} />
|
||||||
<div className="row-title">
|
<div className="row-title">
|
||||||
<strong>{item.by} added {item.provider.toLowerCase()} media</strong>
|
<strong>{item.by} added {item.provider.toLowerCase()} media</strong>
|
||||||
<span>{item.createdAt}</span>
|
<span>{item.createdAt}</span>
|
||||||
@@ -166,20 +209,43 @@ export function RoomConsole({
|
|||||||
</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>
|
</Panel>
|
||||||
</aside>
|
</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 (
|
return (
|
||||||
<section className="panel">
|
<div className="setting-row">
|
||||||
<div className="panel-header">
|
{icon}
|
||||||
<h3>{title}</h3>
|
<span>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<small>{value}</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body">{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,3 +275,7 @@ function MediaPreview({ provider, playbackUrl, title }: { provider: string; play
|
|||||||
</div>
|
</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("/friends");
|
||||||
|
revalidatePath("/people");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,5 +67,6 @@ async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "D
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/friends");
|
revalidatePath("/friends");
|
||||||
|
revalidatePath("/people");
|
||||||
revalidatePath("/dashboard");
|
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 { compare, hash } from "bcryptjs";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Prisma, type User } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { setSession } from "./session";
|
import { clearSession, setSession } from "./session";
|
||||||
|
|
||||||
function normalizeUsername(value: FormDataEntryValue | null) {
|
function normalizeUsername(value: FormDataEntryValue | null) {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
@@ -17,11 +18,14 @@ export async function registerUser(formData: FormData) {
|
|||||||
const password = String(formData.get("password") || "");
|
const password = String(formData.get("password") || "");
|
||||||
|
|
||||||
if (!username || password.length < 10) {
|
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 passwordHash = await hash(password, 12);
|
||||||
const user = await prisma.user.create({
|
let user: User;
|
||||||
|
|
||||||
|
try {
|
||||||
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
displayName: username,
|
displayName: username,
|
||||||
@@ -35,6 +39,12 @@ export async function registerUser(formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
|
redirect("/register?error=username");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
await setSession(user.id);
|
await setSession(user.id);
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
@@ -46,14 +56,19 @@ export async function loginUser(formData: FormData) {
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username } });
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("Invalid username or password.");
|
redirect("/login?error=credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await compare(password, user.passwordHash);
|
const ok = await compare(password, user.passwordHash);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new Error("Invalid username or password.");
|
redirect("/login?error=credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
await setSession(user.id);
|
await setSession(user.id);
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logoutUser() {
|
||||||
|
await clearSession();
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user