Add social login (Discord/Google) + batch-6 pages

Auth: NextAuth Discord + Google providers (enabled when env id+secret set);
OAuth signIn allowed only if a hotel account matches the email; jwt binds the
session to that account (id/rank/username). Login page gets social buttons.

Batch 6 (parallel agents): /friends (messenger_friendships), /guilds/[id]/forum
(threads), /admin/navigation (navigator config), /admin/maintenance (toggle
maintenance settings), /admin/alerts (alert_logs + send hotel alert via RCON).
Header (Friends) + admin nav (Alerts/Maintenance/Navigator) extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0 (57 page routes).
This commit is contained in:
Simo committed 2026-06-28 14:13:41 +02:00
1 parent 73f0f54624
commit 486ce51559
12 files changed
+911 -3

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type ThreadRow = {
id: number;
openerId: number | null;
subject: string | null;
postsCount: number | null;
createdAt: number | null;
updatedAt: number | null;
pinned: number | null;
locked: number | null;
};
function formatTimestamp(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export default async function GuildForumPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
let guild: { id: number; name: string; badge: string } | null = null;
try {
guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true, name: true, badge: true },
});
} catch {
guild = null;
}
if (!guild) notFound();
// Threads for this guild. Pinned first, then most-recently active.
let threads: ThreadRow[] = [];
try {
threads = await prisma.guildsForumsThreads.findMany({
where: { guildId: guild.id, state: 0 },
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }, { id: "desc" }],
take: 100,
select: {
id: true,
openerId: true,
subject: true,
postsCount: true,
createdAt: true,
updatedAt: true,
pinned: true,
locked: true,
},
});
} catch {
threads = [];
}
// Resolve opener usernames in one query (no relation exists in the schema, so
// join the users table manually by id). Falls back to the raw author id.
const openerIds = Array.from(
new Set(
threads
.map((t) => t.openerId ?? 0)
.filter((v) => v > 0),
),
);
let users: { id: number; username: string }[] = [];
try {
users = openerIds.length
? await prisma.user.findMany({
where: { id: { in: openerIds } },
select: { id: true, username: true },
})
: [];
} catch {
users = [];
}
const usernameById = new Map(users.map((u) => [u.id, u.username]));
return (
<main>
<p style={{ marginTop: 0 }}>
<Link href={`/guilds/${guild.id}`}>← Back to guild</Link>
</p>
<h1 style={{ marginBottom: "0.25rem" }}>
{guild.name || "Unnamed guild"} — Forum
</h1>
<p className="muted" style={{ marginTop: 0 }}>
{threads.length} {threads.length === 1 ? "thread" : "threads"}
</p>
{threads.length === 0 ? (
<p className="muted">No threads in this forum yet.</p>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<th style={{ padding: "0.6rem 0.75rem" }}>Subject</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Author</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Posts</th>
<th style={{ padding: "0.6rem 0.75rem" }}>Date</th>
</tr>
</thead>
<tbody>
{threads.map((t) => {
const opener = t.openerId ?? 0;
const username = opener > 0 ? usernameById.get(opener) : undefined;
return (
<tr key={t.id}>
<td style={{ padding: "0.6rem 0.75rem" }}>
<span style={{ fontWeight: 700 }}>
{t.subject?.trim() || "(no subject)"}
</span>
{t.pinned ? (
<span className="muted" style={{ marginLeft: "0.5rem" }}>
· Pinned
</span>
) : null}
{t.locked ? (
<span className="muted" style={{ marginLeft: "0.5rem" }}>
· Locked
</span>
) : null}
</td>
<td style={{ padding: "0.6rem 0.75rem" }}>
{username ? (
<Link href={`/u/${username}`}>{username}</Link>
) : (
<span className="muted">
{opener > 0 ? `User #${opener}` : "Unknown"}
</span>
)}
</td>
<td style={{ padding: "0.6rem 0.75rem" }}>
{t.postsCount ?? 0}
</td>
<td className="muted" style={{ padding: "0.6rem 0.75rem" }}>
{formatTimestamp(t.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</main>
);
}