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:
1 parent
73f0f54624
commit
486ce51559
12 files changed
+911
-3
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user