+
+
+
+
+ 0 ? "warn" : "neutral"} label={`${pendingRequests} pending requests`} />
+ >
+ }
+ actions={
+ <>
+
+
+ >
+ }
+ />
+
+
+
+
+
+
+
+ }
+ >
+ ({ label: item, href: `/admin?tab=${encodeURIComponent(item)}` }))} />
+
+ {activeTab === "Users" ? : null}
+ {activeTab === "Rooms" ? : null}
+ {activeTab === "Roles" ? : null}
+ {activeTab === "Invites" ? : null}
+ {activeTab === "Instance" ? : null}
+ {activeTab === "Security" ? : null}
+
+
+ );
+}
+
+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 (
+
+
+
+
+ | User |
+ Roles |
+ Rooms |
+ Created |
+ Actions |
+
+
+
+ {users.map((account) => (
+
+
-
+
{account.displayName || account.username}
- @{account.username} ยท {account._count.ownedRooms + account._count.roomMembers} rooms
+ @{account.username}
+ |
+
+ {account.roles.length === 0 ? user : null}
{account.roles.map((userRole) => (
{userRole.role.name}
))}
- {account.roles.length === 0 ? user : null}
-
- ))}
-
-
-
-
-
-
- Roles
- {roles.length} defined
-
-
- {roles.map((role) => (
-
-
- {role.name}
- {role.description || `${role.scope.toLowerCase()} role`}
-
- {role._count.users} users / {role._count.permissions} permissions
-
- ))}
-
-
-
-
- Permissions
- System
-
-
- {SYSTEM_PERMISSIONS.map((permission) => (
-
-
- {permission}
- Assignable to roles
-
- Enabled
-
- ))}
-
-
-
-
+ |
+ {account._count.ownedRooms + account._count.roomMembers} |
+ {formatDate(account.createdAt)} |
+ |
+
+ ))}
+
+
+
);
}
+
+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 (
+
+
+
+
+ | Room |
+ Owner |
+ Access |
+ State |
+ Updated |
+ |
+
+
+
+ {rooms.map((room) => (
+
+ |
+
+ {room.name}
+ /{room.slug}
+
+ |
+ {room.owner?.displayName || room.owner?.username || "Unassigned"} |
+ {room.visibility.toLowerCase().replace("_", " ")} |
+ {room._count.members + 1} users / {room._count.mediaSources} media |
+ {formatDate(room.updatedAt)} |
+ Open |
+
+ ))}
+
+
+
+ );
+}
+
+function RolesTable({
+ roles
+}: {
+ roles: Array<{
+ id: string;
+ name: string;
+ description: string | null;
+ scope: string;
+ _count: { users: number; permissions: number };
+ }>;
+}) {
+ return roles.length === 0 ? (
+
+ ) : (
+
+
+ {roles.map((role) => (
+
+
+ {role.name}
+ {role.description || `${role.scope.toLowerCase()} role`}
+
+
{role._count.users} users / {role._count.permissions} permissions
+
+ ))}
+
+
+ {SYSTEM_PERMISSIONS.map((permission) => (
+
+
+ {permission}
+ Assignable to system roles
+
+
Enabled
+
+ ))}
+
+
+ );
+}
+
+function InvitesPanel({ pendingRequests }: { pendingRequests: number }) {
+ return (
+
+
+
+
+
+
+
+
+ {pendingRequests} pending friend requests
+ Handled by each receiving user in People.
+
+
+
+
+ );
+}
+
+function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) {
+ return (
+
+
+
+ } label="Database" value="Postgres via DATABASE_URL" />
+ } label="Sessions" value="Signed HTTP-only cookie" />
+ } label="Accounts" value={`${userCount} users`} />
+ } label="Rooms" value={`${roomCount} rooms`} />
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SecurityPanel() {
+ return (
+
+
+
+ } label="Password storage" value="bcrypt hash" />
+ } label="Admin check" value="Server-side role lookup" />
+ } label="Registration mode" value="Open registration" />
+
+
+
+
+
+
+ );
+}
+
+function Setting({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
+ return (
+
+ {icon}
+
+ {label}
+ {value}
+
+
+ );
+}
+
+function formatDate(date: Date) {
+ return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(date);
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 768affb..08a90d7 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,39 +1,30 @@
+import Link from "next/link";
+import { ArrowRight, Bell, Clock3, MonitorPlay, Plus, UsersRound } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Avatar } from "@/components/avatar";
-import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
+import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot } from "@/components/ui";
import { prisma } from "@/lib/prisma";
-import { requireCurrentUser, userIsAdmin } from "@/lib/session";
+import { getShellContext } from "@/lib/shell";
+import { requireCurrentUser } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
-import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
await requireInitialSetup();
const user = await requireCurrentUser();
- const isAdmin = userIsAdmin(user);
+ const shell = await getShellContext(user);
- const [personalRoom, userCount, roomCount, pendingRequests, friendships, rooms] = await Promise.all([
- prisma.room.findFirst({
- where: { ownerId: user.id },
- include: {
- owner: true,
- members: { include: { user: true } },
- mediaSources: { include: { submitter: true }, orderBy: { createdAt: "desc" }, take: 8 }
- }
- }),
+ const [userCount, roomCount, pendingRequests, acceptedFriends, rooms, recentMedia] = await Promise.all([
prisma.user.count(),
prisma.room.count(),
prisma.friendship.count({ where: { receiverId: user.id, status: "PENDING" } }),
- prisma.friendship.findMany({
+ prisma.friendship.count({
where: {
status: "ACCEPTED",
OR: [{ requesterId: user.id }, { receiverId: user.id }]
- },
- include: { requester: true, receiver: true },
- take: 6,
- orderBy: { updatedAt: "desc" }
+ }
}),
prisma.room.findMany({
where: {
@@ -41,141 +32,169 @@ export default async function DashboardPage() {
},
include: { owner: true, _count: { select: { members: true, mediaSources: true } } },
orderBy: { updatedAt: "desc" },
- take: 8
+ take: 7
+ }),
+ prisma.mediaSource.findMany({
+ where: {
+ room: {
+ OR: [{ visibility: "PUBLIC" }, { ownerId: user.id }, { members: { some: { userId: user.id } } }]
+ }
+ },
+ include: { room: true, submitter: true },
+ orderBy: { createdAt: "desc" },
+ take: 6
})
]);
- const acceptedFriends = friendships.length;
- const roomHref = personalRoom ? `/rooms/${encodeURIComponent(personalRoom.slug)}` : "/dashboard";
- const stats = [
- { label: "Users", value: userCount },
- { label: "Rooms", value: roomCount },
- { label: "Friends", value: acceptedFriends },
- { label: "Requests", value: pendingRequests }
- ];
-
return (
-
-
+
+
+
+
+ 0 ? "info" : "neutral"} label={`${shell.activeRoomCount} rooms with queue`} />
+ >
+ }
+ actions={
+ <>
+
+ People
+
+
+ New room
+
+ >
+ }
+ />
-
- {stats.map((stat) => (
-
- {stat.label}
- {stat.value}
-
- ))}
+
+
+
+
+ 0 ? "warn" : "neutral"} />
- {personalRoom ? (
- ({
- 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"
- }))
- ]}
- />
- ) : (
-
-
-
No personal room exists for this account yet.
-
-
- )}
-
-
-
-
-
Rooms
- Persistent
-
-
-
-
-
- | Name |
- Owner |
- Access |
- Status |
- Source |
-
-
-
- {rooms.map((room) => (
-
- |
- {room.name}
- |
- {room.owner?.displayName || room.owner?.username || "Unassigned"} |
- {room.visibility} |
- {room._count.members + 1} users |
- {room._count.mediaSources} queued |
+
+ Manage all }
+ >
+ {rooms.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | Room |
+ Owner |
+ Access |
+ Queue |
+ |
- ))}
-
-
- {rooms.length === 0 ? No accessible rooms found.
: null}
-
-
-
-
-
Friends
- 0 ? "good" : undefined}>{acceptedFriends} linked
-
-
- {friendships.map((friendship) => {
- const friend = friendship.requesterId === user.id ? friendship.receiver : friendship.requester;
- return (
-
+
+
+ {rooms.map((room) => (
+
+ |
+
+ {room.name}
+ /{room.slug}
+
+ |
+ {room.owner?.displayName || room.owner?.username || "Unassigned"} |
+ {formatVisibility(room.visibility)} |
+ {room._count.mediaSources} sources |
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ Create or open a room
+ Manage persistent rooms and access
+
+
+
+
+
+ Review people
+ {pendingRequests} incoming request{pendingRequests === 1 ? "" : "s"}
+
+
+
+
+
+ Audit recent activity
+ Media, rooms, and social changes
+
+
+
+
+
+
+ {recentMedia.length === 0 ? (
+
+ ) : (
+ recentMedia.map((item) => (
+
-
+
- {friend.displayName || friend.username}
- @{friend.username}
+ {item.title || item.originalUrl}
+ {item.room.name} by {item.submitter?.displayName || item.submitter?.username || "Unknown"}
-
Friend
+
{item.provider}
- );
- })}
- {friendships.length === 0 ? No friends yet. Add users from Friends.
: null}
-
-
+ ))
+ )}
+
+
+
+
+ {recentMedia.length === 0 && rooms.length === 0 ? (
+
+ ) : (
+
+ {recentMedia.map((item) => (
+
+
+
+ {item.submitter?.displayName || item.submitter?.username || "Someone"} added {item.provider.toLowerCase()} media
+ {item.room.name} - {formatDate(item.createdAt)}
+
+
+ ))}
+
+ )}
+
);
}
-function formatDate(date: Date) {
- return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
+function formatVisibility(value: string) {
+ return value.toLowerCase().replace("_", " ");
+}
+
+function formatDate(date: Date) {
+ return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
}
diff --git a/src/app/friends/page.tsx b/src/app/friends/page.tsx
index 7c01bd6..6818025 100644
--- a/src/app/friends/page.tsx
+++ b/src/app/friends/page.tsx
@@ -1,127 +1,5 @@
-import { AppShell } from "@/components/app-shell";
-import { Avatar } from "@/components/avatar";
-import { StatusBadge } from "@/components/status-badge";
-import { acceptFriendRequest, declineFriendRequest, sendFriendRequest } from "@/lib/friend-actions";
-import { prisma } from "@/lib/prisma";
-import { requireCurrentUser, userIsAdmin } from "@/lib/session";
-import { requireInitialSetup } from "@/lib/setup";
-import Link from "next/link";
+import { redirect } from "next/navigation";
-export const dynamic = "force-dynamic";
-
-export default async function FriendsPage() {
- await requireInitialSetup();
- const user = await requireCurrentUser();
- const personalRoom = await prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } });
- const [users, friendships] = await Promise.all([
- prisma.user.findMany({
- where: { id: { not: user.id } },
- include: { ownedRooms: { select: { slug: true }, take: 1 } },
- orderBy: { username: "asc" }
- }),
- prisma.friendship.findMany({
- where: { OR: [{ requesterId: user.id }, { receiverId: user.id }] },
- include: { requester: true, receiver: true }
- })
- ]);
-
- const incoming = friendships.filter((item) => item.receiverId === user.id && item.status === "PENDING");
- const relationshipByUserId = new Map();
- 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 (
-
-
-
- {incoming.length > 0 ? (
-
-
-
Incoming requests
- {incoming.length} pending
-
-
- {incoming.map((request) => (
-
-
-
-
- {request.requester.displayName || request.requester.username}
- @{request.requester.username}
-
-
-
-
-
-
-
- ))}
-
-
- ) : null}
-
-
-
-
Users
- Account directory
-
-
- {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 (
-
-
-
-
- {listedUser.displayName || listedUser.username}
- @{listedUser.username}
-
-
-
- {isFriend ? Friend : null}
- {isOutgoing ? Requested : null}
- {isIncoming ? Waiting : null}
- {roomSlug && isFriend ? (
- Enter room
- ) : null}
- {!relationship ? (
-
- ) : null}
-
-
- );
- })}
- {users.length === 0 ?
No other users have registered yet.
: null}
-
-
-
- );
+export default function FriendsRedirectPage() {
+ redirect("/people");
}
diff --git a/src/app/globals.css b/src/app/globals.css
index b0c9c7b..b2ba894 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -207,6 +207,11 @@ select {
color: var(--warn);
}
+.badge.info {
+ border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
+ color: var(--accent-2);
+}
+
.room-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
@@ -470,3 +475,556 @@ select {
grid-template-columns: 1fr;
}
}
+
+.page-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 18px;
+}
+
+.page-header h1 {
+ margin: 4px 0 0;
+ font-size: 28px;
+ line-height: 1.15;
+}
+
+.toolbar,
+.row-actions,
+.account-actions {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 18px;
+}
+
+.metric-tile {
+ display: grid;
+ gap: 8px;
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 14px;
+}
+
+.metric-tile.good {
+ border-left-color: var(--accent);
+}
+
+.metric-tile.warn {
+ border-left-color: var(--warn);
+}
+
+.metric-tile.danger {
+ border-left-color: var(--danger);
+}
+
+.metric-tile.info {
+ border-left-color: var(--accent-2);
+}
+
+.metric-top,
+.metric-tile p {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.metric-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.metric-tile strong {
+ font-size: 28px;
+ font-weight: 800;
+}
+
+.metric-tile p {
+ margin: 0;
+}
+
+.overview-grid,
+.rooms-layout,
+.watch-console,
+.split-grid {
+ display: grid;
+ gap: 18px;
+}
+
+.overview-grid {
+ grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.55fr);
+ margin-bottom: 18px;
+}
+
+.rooms-layout {
+ grid-template-columns: 320px minmax(0, 1fr);
+}
+
+.watch-console {
+ grid-template-columns: minmax(0, 1fr) 360px;
+ align-items: start;
+}
+
+.split-grid,
+.console-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-top: 18px;
+}
+
+.player-column,
+.right-rail,
+.people-list,
+.queue-list,
+.timeline-list,
+.settings-list,
+.action-list {
+ display: grid;
+ gap: 10px;
+}
+
+.player-panel .panel-body {
+ padding: 0;
+}
+
+.transport-bar {
+ display: grid;
+ grid-template-columns: 38px 38px 38px minmax(180px, 1fr) auto;
+ gap: 8px;
+ border-top: 1px solid var(--border);
+ padding: 12px;
+}
+
+.queue-row,
+.timeline-item,
+.action-row,
+.setting-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel-2);
+ padding: 10px;
+}
+
+.timeline-item.interactive:hover,
+.action-row:hover {
+ border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border));
+}
+
+.queue-index {
+ display: inline-grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border-radius: 7px;
+ background: var(--panel);
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.queue-row .row-title,
+.timeline-item .row-title {
+ min-width: 0;
+ flex: 1;
+}
+
+.queue-row .row-title strong,
+.timeline-item .row-title strong,
+.action-row strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.action-row span,
+.setting-row span {
+ display: grid;
+ gap: 2px;
+}
+
+.action-row small,
+.setting-row small {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.tabs,
+.rail-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.tab,
+.rail-tabs button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel-2);
+ color: var(--muted);
+ padding: 7px 10px;
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.tab.active,
+.rail-tabs button.active {
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
+ color: var(--accent);
+}
+
+.tab-count {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.status-dot {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 750;
+}
+
+.status-dot::before {
+ content: "";
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: #94a3b8;
+}
+
+.status-dot.good::before {
+ background: var(--accent);
+}
+
+.status-dot.warn::before {
+ background: var(--warn);
+}
+
+.status-dot.danger::before {
+ background: var(--danger);
+}
+
+.status-dot.info::before {
+ background: var(--accent-2);
+}
+
+.table-wrap {
+ overflow-x: auto;
+}
+
+.compact-button,
+.icon-button.compact {
+ min-height: 30px;
+ padding: 5px 8px;
+ font-size: 12px;
+}
+
+.icon-button {
+ width: 38px;
+ padding: 0;
+}
+
+.icon-button.danger {
+ color: var(--danger);
+}
+
+.text-link,
+.inline-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--accent-2);
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.search-field {
+ display: inline-flex;
+ min-width: 220px;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel-2);
+ color: var(--muted);
+ padding: 7px 9px;
+}
+
+.search-field input,
+.chat-input input {
+ width: 100%;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text);
+}
+
+.compact-form {
+ gap: 10px;
+}
+
+.sidebar {
+ display: flex;
+ min-height: 100vh;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.sidebar-section {
+ min-height: 0;
+ border-top: 1px solid var(--border);
+ padding: 14px 4px 0;
+}
+
+.sidebar-section-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 0 4px 8px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.room-nav-list {
+ display: grid;
+ gap: 6px;
+}
+
+.room-nav-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ border-radius: 8px;
+ padding: 8px;
+}
+
+.room-nav-item:hover {
+ background: var(--panel-2);
+}
+
+.room-nav-item span:first-child,
+.room-nav-meta {
+ display: grid;
+ gap: 3px;
+}
+
+.room-nav-item strong {
+ font-size: 13px;
+}
+
+.room-nav-item small,
+.sidebar-empty {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.room-nav-meta {
+ justify-items: end;
+}
+
+.sidebar-user {
+ margin-top: auto;
+ position: relative;
+}
+
+.account-menu {
+ margin-left: auto;
+ position: relative;
+}
+
+.account-menu summary {
+ list-style: none;
+}
+
+.account-menu summary::-webkit-details-marker {
+ display: none;
+}
+
+.account-menu-popover {
+ position: absolute;
+ right: 0;
+ bottom: 38px;
+ z-index: 20;
+ display: grid;
+ min-width: 170px;
+ gap: 4px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel);
+ box-shadow: var(--shadow);
+ padding: 6px;
+}
+
+.account-menu-popover a,
+.account-menu-popover button {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 8px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--text);
+ padding: 8px;
+ text-align: left;
+}
+
+.account-menu-popover a:hover,
+.account-menu-popover button:hover {
+ background: var(--panel-2);
+}
+
+.instance-status {
+ display: grid;
+ gap: 7px;
+ border-top: 1px solid var(--border);
+ padding: 14px 8px 0;
+}
+
+.instance-metrics {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.instance-metrics span {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.auth-shell {
+ display: grid;
+ width: min(920px, 100%);
+ grid-template-columns: minmax(0, 1fr) 420px;
+ gap: 18px;
+}
+
+.auth-intro {
+ display: grid;
+ align-content: center;
+ gap: 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 24px;
+}
+
+.auth-intro .brand-mark {
+ margin: 0;
+}
+
+.setup-checklist {
+ display: grid;
+ gap: 10px;
+ color: var(--muted);
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.setup-checklist div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.setup-checklist span {
+ width: 9px;
+ height: 9px;
+ border-radius: 999px;
+ background: var(--accent);
+}
+
+.profile-block {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 16px;
+}
+
+.chat-input {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel-2);
+ margin-top: 10px;
+ padding: 9px;
+ color: var(--muted);
+}
+
+.disabled-note {
+ margin: 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+@media (max-width: 1180px) {
+ .overview-grid,
+ .rooms-layout,
+ .watch-console {
+ grid-template-columns: 1fr;
+ }
+
+ .right-rail {
+ order: 2;
+ }
+}
+
+@media (max-width: 860px) {
+ .metrics-grid,
+ .split-grid,
+ .console-grid,
+ .auth-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .page-header {
+ flex-direction: column;
+ }
+
+ .transport-bar {
+ grid-template-columns: repeat(3, 38px) 1fr;
+ }
+
+ .transport-bar .button.primary {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 760px) {
+ .sidebar {
+ min-height: auto;
+ }
+
+ .sidebar-section,
+ .instance-status {
+ display: none;
+ }
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 48357d8..165ebd6 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -5,18 +5,28 @@ import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
-export default async function LoginPage() {
+export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
if (!(await hasAdminUser())) {
redirect("/setup");
}
+ const { error } = await searchParams;
return (
-
+
+
+
+
+
WatchLink
+
Persistent watch rooms with local accounts, friends, and synchronized playback.
+
+
+
Login
-
Enter WatchLink with your username and password.
+
Enter your account to open rooms and manage playback.
+ {error ?
Invalid username or password.
: null}
+
);
diff --git a/src/app/people/page.tsx b/src/app/people/page.tsx
new file mode 100644
index 0000000..88792a3
--- /dev/null
+++ b/src/app/people/page.tsx
@@ -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();
+ 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 = {
+ Friends: accepted.length,
+ Incoming: incoming.length,
+ Outgoing: outgoing.length,
+ Directory: users.length,
+ Blocked: blocked.length
+ };
+
+ return (
+
+
+
+ 0 ? "warn" : "neutral"} label={`${incoming.length} incoming`} />
+ 0 ? "warn" : "neutral"} label={`${outgoing.length} outgoing`} />
+ >
+ }
+ />
+
+
+
+
+
+ }
+ >
+ ({
+ label: tab,
+ href: `/people?view=${encodeURIComponent(tab)}`,
+ count: tabCounts[tab]
+ }))}
+ />
+
+ {activeView === "Friends" ? : null}
+ {activeView === "Incoming" ? : null}
+ {activeView === "Outgoing" ? : null}
+ {activeView === "Blocked" ? : null}
+ {activeView === "Directory" ? : null}
+
+
+ );
+}
+
+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 ;
+ }
+
+ return (
+
+ {friendships.map((friendship) => {
+ const person = friendship.requesterId === currentUserId ? friendship.receiver : friendship.requester;
+ return (
+
+
+
+
+ {person.displayName || person.username}
+ @{person.username}
+
+
+
+ {friendship.status.toLowerCase()}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function IncomingList({ requests }: { requests: FriendshipWithUsers[] }) {
+ if (requests.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {requests.map((request) => (
+
+
+
+
+ {request.requester.displayName || request.requester.username}
+ @{request.requester.username}
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+function Directory({
+ rows
+}: {
+ rows: Array<{
+ user: DirectoryUser;
+ relationship?: FriendshipWithUsers;
+ }>;
+}) {
+ if (rows.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ | User |
+ Room |
+ Relationship |
+ |
+
+
+
+ {rows.map(({ user, relationship }) => {
+ const roomSlug = user.ownedRooms[0]?.slug;
+ const accepted = relationship?.status === "ACCEPTED";
+ return (
+
+
+
+
+
+ {user.displayName || user.username}
+ @{user.username}
+
+
+ |
+ {roomSlug && accepted ? Open personal room : "Locked"} |
+ {relationship?.status.toLowerCase() || "none"} |
+
+ {!relationship ? (
+
+ ) : null}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 9cab39a..a3cb2d6 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -5,18 +5,28 @@ import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
-export default async function RegisterPage() {
+export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
if (!(await hasAdminUser())) {
redirect("/setup");
}
+ const { error } = await searchParams;
return (
+
+
+
+
+
Create Account
+
Each user receives one persistent personal room that friends can enter without temporary links.
+
+
Create account
Register a username and get a persistent room.
+ {error ? {registerError(error)}
: null}
+
);
}
+
+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.";
+}
diff --git a/src/app/rooms/[slug]/page.tsx b/src/app/rooms/[slug]/page.tsx
index 8482c33..fecf552 100644
--- a/src/app/rooms/[slug]/page.tsx
+++ b/src/app/rooms/[slug]/page.tsx
@@ -1,8 +1,10 @@
import { AppShell } from "@/components/app-shell";
import { RoomConsole } from "@/components/room-console";
import { StatusBadge } from "@/components/status-badge";
+import { PageHeader, StatusDot } from "@/components/ui";
import { canEnterRoom } from "@/lib/access";
import { prisma } from "@/lib/prisma";
+import { getShellContext } from "@/lib/shell";
import { requireCurrentUser, userIsAdmin } from "@/lib/session";
import { requireInitialSetup } from "@/lib/setup";
import { redirect } from "next/navigation";
@@ -58,28 +60,33 @@ export default async function RoomPage({ params }: { params: Promise<{ slug: str
redirect("/dashboard");
}
- const personalRoom = await prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } });
+ const shell = await getShellContext(user);
return (
-
-
+
+
+
+ 0 ? "good" : "neutral"} label={`${room.mediaSources.length} queued`} />
+ {room.visibility.toLowerCase().replace("_", " ")}
+ >
+ }
+ actions={
+ <>
+
+
+ >
+ }
+ />
({
id: item.id,
diff --git a/src/app/rooms/page.tsx b/src/app/rooms/page.tsx
new file mode 100644
index 0000000..d218933
--- /dev/null
+++ b/src/app/rooms/page.tsx
@@ -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 = {
+ "My Rooms": owned.length,
+ Shared: shared.length,
+ Public: publicRooms.length,
+ Recent: recent.length,
+ Invites: 0
+ };
+
+ return (
+
+
+
+ 0 ? "good" : "neutral"} label={`${shared.length} shared`} />
+
+ >
+ }
+ />
+
+
+
+ );
+}
+
+function formatVisibility(value: string) {
+ return value.toLowerCase().replace("_", " ");
+}
+
+function formatDate(date: Date) {
+ return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(date);
+}
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx
index aeddde4..edcb2a6 100644
--- a/src/app/setup/page.tsx
+++ b/src/app/setup/page.tsx
@@ -107,25 +107,40 @@ export default async function SetupPage({ searchParams }: { searchParams: Promis
return (
-
-
-
WatchLink first setup
-
Create the first admin account. This screen locks after setup.
+
+
+
+
+
First Setup
+
Create the first administrator, seed permissions, and create the initial persistent room.
+
+
+
Admin role and permissions
+
First user account
+
Personal room route
+
Setup lockout after completion
+
- {error ? {setupErrorMessage(error)}
: null}
-
+
+
+
Administrator
+
This screen is only available while no admin user exists.
+
+ {error ? {setupErrorMessage(error)}
: null}
+
+
);
diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx
index 5bda95b..fb9cdf4 100644
--- a/src/components/app-shell.tsx
+++ b/src/components/app-shell.tsx
@@ -1,28 +1,57 @@
import Link from "next/link";
-import { Gauge, MonitorPlay, Shield, UsersRound } from "lucide-react";
+import {
+ Activity,
+ Database,
+ Gauge,
+ LogOut,
+ MonitorPlay,
+ Plus,
+ Settings,
+ Shield,
+ User,
+ UsersRound,
+ Wifi
+} from "lucide-react";
+import { logoutUser } from "@/lib/user-actions";
import { Avatar } from "./avatar";
+import { StatusBadge } from "./status-badge";
+import { StatusDot } from "./ui";
+
+type ShellRoom = {
+ id: string;
+ name: string;
+ slug: string;
+ href: string;
+ visibility: string;
+ participantCount: number;
+ queueCount: number;
+ isPersonal?: boolean;
+};
export function AppShell({
children,
- active = "Dashboard",
+ active = "Overview",
isAdmin = false,
- roomHref = "/dashboard",
- userName
+ userName,
+ rooms = [],
+ pendingRequests = 0,
+ activeRoomCount = 0
}: {
children: React.ReactNode;
active?: string;
isAdmin?: boolean;
- roomHref?: string;
userName?: string;
+ rooms?: ShellRoom[];
+ pendingRequests?: number;
+ activeRoomCount?: number;
}) {
const nav = [
- { href: "/dashboard", label: "Dashboard", icon: Gauge },
- { href: roomHref, label: "Rooms", icon: MonitorPlay },
- { href: "/friends", label: "Friends", icon: UsersRound }
+ { href: "/dashboard", label: "Overview", icon: Gauge },
+ { href: "/rooms", label: "Rooms", icon: MonitorPlay },
+ { href: "/people", label: "People", icon: UsersRound },
+ { href: "/activity", label: "Activity", icon: Activity }
];
- const visibleNav = isAdmin
- ? [nav[0], nav[1], nav[2], { href: "/admin", label: "Admin", icon: Shield }]
- : nav;
+ const visibleNav = isAdmin ? [...nav, { href: "/admin", label: "Admin", icon: Shield }] : nav;
return (
@@ -42,6 +71,34 @@ export function AppShell({
);
})}
+
+
+
+
+ {rooms.length === 0 ? (
+
No rooms yet
+ ) : (
+ rooms.slice(0, 6).map((room) => (
+
+
+ {room.name}
+ {room.visibility.toLowerCase().replace("_", " ")}
+
+
+ {room.queueCount > 0 ? Queued : Empty}
+ {room.participantCount}
+
+
+ ))
+ )}
+
+
+
{userName ? (
@@ -49,8 +106,36 @@ export function AppShell({
{userName}
{isAdmin ? "Administrator" : "Member"}
+
+
+
+
+
+ Profile
+ Settings
+ Sessions
+
+
+
) : null}
+
+
+
+
+
0 ? "info" : "neutral"} label={`${activeRoomCount} rooms with queue`} />
+ {pendingRequests > 0 ? : null}
+
+
+ Socket endpoint
+
+
+ v0.1.0
+
+
+
{children}
diff --git a/src/components/room-console.tsx b/src/components/room-console.tsx
index 79a9857..4bc0d51 100644
--- a/src/components/room-console.tsx
+++ b/src/components/room-console.tsx
@@ -1,12 +1,13 @@
"use client";
import { useMemo, useState } from "react";
-import { Pause, Play, Radio, SkipForward } from "lucide-react";
+import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2 } from "lucide-react";
import { io } from "socket.io-client";
import { normalizeMediaUrl } from "@/lib/media";
-import { StatusBadge } from "./status-badge";
-import { Avatar } from "./avatar";
import { addMediaToRoom } from "@/lib/media-actions";
+import { Avatar } from "./avatar";
+import { StatusBadge } from "./status-badge";
+import { EmptyState, Panel, StatusDot } from "./ui";
const socket = io({
path: "/api/socket",
@@ -33,18 +34,25 @@ type Participant = {
export function RoomConsole({
roomId,
roomSlug,
+ roomName,
+ roomVisibility,
+ ownerName,
currentUser,
queue = [],
participants = []
}: {
roomId: string;
roomSlug: string;
+ roomName: string;
+ roomVisibility: string;
+ ownerName: string;
currentUser: string;
queue?: QueueItem[];
participants?: Participant[];
}) {
const [connected, setConnected] = useState(false);
const [source, setSource] = useState("");
+ const [rail, setRail] = useState("Activity");
const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]);
const currentMedia = previewMedia || queue[0] || null;
@@ -68,118 +76,176 @@ export function RoomConsole({
}
return (
-
-
-
-
-
{roomSlug}
- Persistent room
-
-
{connected ? "Online" : "Local preview"}
-
-
- {currentMedia ? (
-
- ) : (
-
-
Idle
-
No media queued
-
Add a YouTube, Twitch, or direct video URL to start this room.
+
+
+
+
+ {formatVisibility(roomVisibility)}
- )}
-
-
-
+ }
+ >
+
+ {currentMedia ? (
+
+ ) : (
+
+
Idle
+
No media queued
+
Add a YouTube, Twitch, or direct video URL to start this room.
+
+ )}
+
-