From 9fbd79c7ef289d5ed4bb334ed284e4b72fe048a4 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Fri, 15 May 2026 20:13:29 +0200 Subject: [PATCH] Redesign WatchLink application UI --- docs/agent-handoff.md | 1 + src/app/account/profile/page.tsx | 86 +++++ src/app/account/sessions/page.tsx | 32 ++ src/app/account/settings/page.tsx | 42 +++ src/app/activity/page.tsx | 152 ++++++++ src/app/admin/page.tsx | 389 +++++++++++++++------ src/app/dashboard/page.tsx | 293 ++++++++-------- src/app/friends/page.tsx | 128 +------ src/app/globals.css | 558 ++++++++++++++++++++++++++++++ src/app/login/page.tsx | 17 +- src/app/people/page.tsx | 265 ++++++++++++++ src/app/register/page.tsx | 18 +- src/app/rooms/[slug]/page.tsx | 41 ++- src/app/rooms/page.tsx | 194 +++++++++++ src/app/setup/page.tsx | 51 ++- src/components/app-shell.tsx | 107 +++++- src/components/room-console.tsx | 276 +++++++++------ src/components/ui.tsx | 117 +++++++ src/lib/friend-actions.ts | 2 + src/lib/room-actions.ts | 53 +++ src/lib/shell.ts | 36 ++ src/lib/user-actions.ts | 45 ++- 22 files changed, 2370 insertions(+), 533 deletions(-) create mode 100644 src/app/account/profile/page.tsx create mode 100644 src/app/account/sessions/page.tsx create mode 100644 src/app/account/settings/page.tsx create mode 100644 src/app/activity/page.tsx create mode 100644 src/app/people/page.tsx create mode 100644 src/app/rooms/page.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/lib/room-actions.ts create mode 100644 src/lib/shell.ts diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index db30a82..551024a 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -17,3 +17,4 @@ Initial implementation created from `codex-agent-repository-kit` guidance. - Gitea Actions can run `npm install`, tests, build, and Docker image publishing. - Local Docker is still unavailable in this Codex environment, but Gitea Actions can build the image. - Docker publish was fixed by updating `REGISTRY_TOKEN`; Gitea issue #1 is closed. +- UI redesign follow-ups that need real backend work: persisted invite records, editable room settings, queue reorder/remove server actions, chat persistence/moderation, user ban/remove-friend actions, a real audit/event table, and socket acknowledgements for sync health instead of client-local connection assumptions. diff --git a/src/app/account/profile/page.tsx b/src/app/account/profile/page.tsx new file mode 100644 index 0000000..ff6d15f --- /dev/null +++ b/src/app/account/profile/page.tsx @@ -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 ( + + + + + + + } + actions={ +
+ +
+ } + /> + +
+ +
+ +
+ {user.displayName || user.username} + @{user.username} +
+
+
+
Display name{user.displayName || user.username}
+
Created{formatDate(user.createdAt)}
+
Roles{user.roles.map((item) => item.role.name).join(", ") || "user"}
+
+
+ + + + + + + + + + + + + {ownedRooms.map((room) => ( + + + + + + ))} + +
NameAccessUpdated
{room.name}{room.visibility.toLowerCase().replace("_", " ")}{formatDate(room.updatedAt)}
+
+
+
+
+ ); +} + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(date); +} diff --git a/src/app/account/sessions/page.tsx b/src/app/account/sessions/page.tsx new file mode 100644 index 0000000..95fab8d --- /dev/null +++ b/src/app/account/sessions/page.tsx @@ -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 ( + + } + actions={ +
+ +
+ } + /> + + + +
+ ); +} diff --git a/src/app/account/settings/page.tsx b/src/app/account/settings/page.tsx new file mode 100644 index 0000000..7abdaeb --- /dev/null +++ b/src/app/account/settings/page.tsx @@ -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 ( + + } + /> + +
+ +
+ + + +
+
+ + + +
+
+ ); +} diff --git a/src/app/activity/page.tsx b/src/app/activity/page.tsx new file mode 100644 index 0000000..74777db --- /dev/null +++ b/src/app/activity/page.tsx @@ -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: , + 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: , + 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: , + 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 ( + + + + + 0 ? "warn" : "neutral"} label={`${requests.length} relationship events`} /> + + } + /> + + + {events.length === 0 ? ( + + ) : ( +
+ {events.map((event) => ( + + {event.icon} +
+ {event.title} + {event.detail} - {formatDate(event.at)} +
+ {relativeDate(event.at)} + + ))} +
+ )} +
+ +
+ + {recentMedia.length === 0 ? ( + + ) : ( + recentMedia.slice(0, 5).map((item) => ( +
+
+ +
+ {item.title || item.originalUrl} + {item.room.name} +
+
+ {item.provider} +
+ )) + )} +
+ + {recentRooms.map((room) => ( +
+
+ {room.name} + {room.owner?.displayName || room.owner?.username || "Unassigned"} - {formatDate(room.updatedAt)} +
+ Open +
+ ))} +
+
+
+ ); +} + +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`; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8410239..d8e5de7 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,16 +1,21 @@ +import Link from "next/link"; +import { AlertTriangle, Database, LockKeyhole, Plus, Search, Shield, UsersRound } from "lucide-react"; +import { redirect } from "next/navigation"; import { AppShell } from "@/components/app-shell"; import { Avatar } from "@/components/avatar"; import { StatusBadge } from "@/components/status-badge"; import { SYSTEM_PERMISSIONS } from "@/lib/access"; import { prisma } from "@/lib/prisma"; +import { getShellContext } from "@/lib/shell"; import { requireCurrentUser, userIsAdmin } from "@/lib/session"; import { requireInitialSetup } from "@/lib/setup"; -import Link from "next/link"; -import { redirect } from "next/navigation"; +import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui"; export const dynamic = "force-dynamic"; -export default async function AdminPage() { +const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"]; + +export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string }> }) { await requireInitialSetup(); const user = await requireCurrentUser(); @@ -18,8 +23,12 @@ export default async function AdminPage() { redirect("/dashboard"); } - const [personalRoom, users, rooms, roles] = await Promise.all([ - prisma.room.findFirst({ where: { ownerId: user.id }, select: { slug: true } }), + const { tab = "Users", q = "" } = await searchParams; + const activeTab = adminTabs.includes(tab) ? tab : "Users"; + const query = q.trim().toLowerCase(); + const shell = await getShellContext(user); + + const [users, rooms, roles, pendingRequests] = await Promise.all([ prisma.user.findMany({ include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } }, orderBy: { createdAt: "asc" } @@ -31,118 +40,292 @@ export default async function AdminPage() { prisma.role.findMany({ include: { _count: { select: { users: true, permissions: true } } }, orderBy: { name: "asc" } - }) + }), + prisma.friendship.count({ where: { status: "PENDING" } }) ]); + const filteredUsers = query + ? users.filter((account) => + [account.username, account.displayName || "", ...account.roles.map((role) => role.role.name)].join(" ").toLowerCase().includes(query) + ) + : users; + const filteredRooms = query + ? rooms.filter((room) => + [room.name, room.slug, room.visibility, room.owner?.username || "", room.owner?.displayName || ""].join(" ").toLowerCase().includes(query) + ) + : rooms; + const filteredRoles = query + ? roles.filter((role) => [role.name, role.description || "", role.scope].join(" ").toLowerCase().includes(query)) + : roles; return ( - -
-
-

Admin

-

Manage roles, rooms, permissions, and users.

-
- Admin -
-
-
-
-

Rooms

- {rooms.length} total -
-
- - - - - - - - - - - {rooms.map((room) => ( - - - - - - - ))} - -
NameOwnerAccessStatus
- {room.name} - {room.owner?.displayName || room.owner?.username || "Unassigned"}{room.visibility}{room._count.members + 1} users / {room._count.mediaSources} media
-
-
-
-
-

Users

- {users.length} accounts -
-
- {users.map((account) => ( -
+ + + + + 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 ( + + + + + + + + + + + + + {users.map((account) => ( + + + + + + + + ))} + +
UserRolesRoomsCreatedActions
- +
{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 ( + + + + + + + + + + + + + {rooms.map((room) => ( + + + + + + + + + ))} + +
RoomOwnerAccessStateUpdated +
+
+ {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 ( - -
-
-

Dashboard

-

{`Signed in as ${user.displayName || user.username}`}

-
-
- Online - System theme -
-
+ + + + + 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 -
-
- - - - - - - - - - - - {rooms.map((room) => ( - - - - - - +
+ Manage all } + > + {rooms.length === 0 ? ( + + ) : ( + +
NameOwnerAccessStatusSource
- {room.name} - {room.owner?.displayName || room.owner?.username || "Unassigned"}{room.visibility}{room._count.members + 1} users{room._count.mediaSources} queued
+ + + + + + + - ))} - -
RoomOwnerAccessQueue
- {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 ( - -
-
-

Friends

-

Add users, accept requests, and enter persistent rooms.

-
- {users.length} users -
- - {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 (
-
+
+
+
+

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 ( + + + + + + + + + + + {rows.map(({ user, relationship }) => { + const roomSlug = user.ownedRooms[0]?.slug; + const accepted = relationship?.status === "ACCEPTED"; + return ( + + + + + + + ); + })} + +
UserRoomRelationship +
+
+ +
+ {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

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 ( - -
-
-

{room.name}

-

{room.owner ? `Owned by ${room.owner.displayName || room.owner.username}` : "Shared watch room"}

-
-
- Online - {room.visibility} -
-
+ + + + 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`} /> + + + } + /> + +
+ + {error ?

Use a room name with at least one letter or number.

: null} +
+ + + +
+
+ + + + + + } + > + ({ + label: tab, + href: `/rooms?view=${encodeURIComponent(tab)}`, + count: tabCounts[tab] + }))} + /> + + {filteredRows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {filteredRows.map((room) => ( + + + + + + + + + ))} + +
RoomOwnerAccessStateUpdated +
+
+ {room.name} + /{room.slug} +
+
{room.owner?.displayName || room.owner?.username || "Unassigned"}{formatVisibility(room.visibility)} + + {room._count.members + 1} + {room._count.mediaSources} + + {formatDate(room.updatedAt)} + + Open + +
+
+ )} +
+
+
+ ); +} + +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.

+
+
+
- {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({ ); })} + +
+
+ Your Rooms + + + +
+
+ {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)}
- )} -
-
- - - - setSource(event.target.value)} - placeholder="Source URL" - /> - - -
-
+ } + > +
+ {currentMedia ? ( + + ) : ( +
+ Idle +

No media queued

+

Add a YouTube, Twitch, or direct video URL to start this room.

+
+ )} +
-
); } -function Panel({ title, children }: { title: string; children: React.ReactNode }) { +function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( -
-
-

{title}

-
-
{children}
-
+
+ {icon} + + {label} + {value} + +
); } @@ -209,3 +275,7 @@ function MediaPreview({ provider, playbackUrl, title }: { provider: string; play ); } + +function formatVisibility(value: string) { + return value.toLowerCase().replace("_", " "); +} diff --git a/src/components/ui.tsx b/src/components/ui.tsx new file mode 100644 index 0000000..4fdbb30 --- /dev/null +++ b/src/components/ui.tsx @@ -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 ( +
+
+ {meta ?
{meta}
: null} +

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} + +export function Panel({ + title, + eyebrow, + actions, + children, + className +}: { + title?: string; + eyebrow?: string; + actions?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {title || actions || eyebrow ? ( +
+
+ {eyebrow ? {eyebrow} : null} + {title ?

{title}

: null} +
+ {actions ?
{actions}
: null} +
+ ) : null} +
{children}
+
+ ); +} + +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 ( +
+
+ {label} + {icon ? {icon} : null} +
+ {value} + {detail ?

{detail}

: null} +
+ ); +} + +export function Tabs({ items, active }: { items: Array<{ label: string; href: string; count?: number }>; active: string }) { + return ( + + ); +} + +export function EmptyState({ + title, + description, + action +}: { + title: string; + description?: string; + action?: React.ReactNode; +}) { + return ( +
+ {title} + {description ? {description} : null} + {action ?
{action}
: null} +
+ ); +} + +export function StatusDot({ tone = "neutral", label }: { tone?: string; label: string }) { + return {label}; +} + +export function DataTable({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/lib/friend-actions.ts b/src/lib/friend-actions.ts index ccfa734..43d252d 100644 --- a/src/lib/friend-actions.ts +++ b/src/lib/friend-actions.ts @@ -40,6 +40,7 @@ export async function sendFriendRequest(formData: FormData) { } revalidatePath("/friends"); + revalidatePath("/people"); revalidatePath("/dashboard"); } @@ -66,5 +67,6 @@ async function updateIncomingRequest(formData: FormData, status: "ACCEPTED" | "D }); revalidatePath("/friends"); + revalidatePath("/people"); revalidatePath("/dashboard"); } diff --git a/src/lib/room-actions.ts b/src/lib/room-actions.ts new file mode 100644 index 0000000..7fc43a6 --- /dev/null +++ b/src/lib/room-actions.ts @@ -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)}`); +} diff --git a/src/lib/shell.ts b/src/lib/shell.ts new file mode 100644 index 0000000..3c3de26 --- /dev/null +++ b/src/lib/shell.ts @@ -0,0 +1,36 @@ +import { prisma } from "./prisma"; +import { getCurrentUser, userIsAdmin } from "./session"; + +type CurrentUser = NonNullable>>; + +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 + })) + }; +} diff --git a/src/lib/user-actions.ts b/src/lib/user-actions.ts index 02d4367..f7b6259 100644 --- a/src/lib/user-actions.ts +++ b/src/lib/user-actions.ts @@ -2,8 +2,9 @@ import { compare, hash } from "bcryptjs"; import { redirect } from "next/navigation"; +import { Prisma, type User } from "@prisma/client"; import { prisma } from "./prisma"; -import { setSession } from "./session"; +import { clearSession, setSession } from "./session"; function normalizeUsername(value: FormDataEntryValue | null) { return String(value || "") @@ -17,24 +18,33 @@ export async function registerUser(formData: FormData) { const password = String(formData.get("password") || ""); if (!username || password.length < 10) { - throw new Error("Username is required and password must be at least 10 characters."); + redirect("/register?error=invalid"); } const passwordHash = await hash(password, 12); - const user = await prisma.user.create({ - data: { - username, - displayName: username, - passwordHash, - ownedRooms: { - create: { - slug: `@${username}`, - name: `${username}'s room`, - visibility: "FRIENDS" + let user: User; + + try { + user = await prisma.user.create({ + data: { + username, + displayName: username, + passwordHash, + ownedRooms: { + create: { + slug: `@${username}`, + name: `${username}'s room`, + visibility: "FRIENDS" + } } } + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + redirect("/register?error=username"); } - }); + throw error; + } await setSession(user.id); redirect("/dashboard"); @@ -46,14 +56,19 @@ export async function loginUser(formData: FormData) { const user = await prisma.user.findUnique({ where: { username } }); if (!user) { - throw new Error("Invalid username or password."); + redirect("/login?error=credentials"); } const ok = await compare(password, user.passwordHash); if (!ok) { - throw new Error("Invalid username or password."); + redirect("/login?error=credentials"); } await setSession(user.id); redirect("/dashboard"); } + +export async function logoutUser() { + await clearSession(); + redirect("/login"); +}