diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts new file mode 100644 index 00000000..a0da24c5 --- /dev/null +++ b/src/actions/admin-applications.ts @@ -0,0 +1,26 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +export async function dismissApplication(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? "").trim(); + if (!raw) return; + + let id: bigint; + try { + id = BigInt(raw); + } catch { + return; + } + + try { + await prisma.websiteStaffApplications.delete({ where: { id } }); + } catch { + // already gone / no DB — nothing to do + } + + revalidatePath("/admin/applications"); +} diff --git a/src/actions/admin-ip.ts b/src/actions/admin-ip.ts new file mode 100644 index 00000000..19db4212 --- /dev/null +++ b/src/actions/admin-ip.ts @@ -0,0 +1,52 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +function parseIp(formData: FormData): string { + return String(formData.get("ipAddress") ?? "").trim().slice(0, 255); +} + +function parseAsn(formData: FormData): string | null { + const asn = String(formData.get("asn") ?? "").trim().slice(0, 255); + return asn || null; +} + +export async function addWhitelist(formData: FormData): Promise { + await requireStaff(); + const ipAddress = parseIp(formData); + if (!ipAddress) return; + const asn = parseAsn(formData); + await prisma.websiteIpWhitelist.create({ + data: { ipAddress, asn, whitelistAsn: asn != null }, + }); + revalidatePath("/admin/ip"); +} + +export async function deleteWhitelist(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? "").trim(); + if (!raw) return; + await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } }); + revalidatePath("/admin/ip"); +} + +export async function addBlacklist(formData: FormData): Promise { + await requireStaff(); + const ipAddress = parseIp(formData); + if (!ipAddress) return; + const asn = parseAsn(formData); + await prisma.websiteIpBlacklist.create({ + data: { ipAddress, asn, blacklistAsn: asn != null }, + }); + revalidatePath("/admin/ip"); +} + +export async function deleteBlacklist(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? "").trim(); + if (!raw) return; + await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } }); + revalidatePath("/admin/ip"); +} diff --git a/src/actions/admin-radio.ts b/src/actions/admin-radio.ts new file mode 100644 index 00000000..07a35fc1 --- /dev/null +++ b/src/actions/admin-radio.ts @@ -0,0 +1,27 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { prisma } from '@/lib/prisma'; +import { requireStaff } from '@/lib/admin/guard'; + +export async function deleteShout(formData: FormData): Promise { + await requireStaff(); + + const raw = formData.get('id'); + if (typeof raw !== 'string' || raw.trim() === '') return; + + let id: bigint; + try { + id = BigInt(raw); + } catch { + return; + } + + try { + await prisma.radioShouts.delete({ where: { id } }); + } catch { + // Row may already be gone; ignore so the action does not throw. + } + + revalidatePath('/admin/radio'); +} diff --git a/src/actions/admin-teams.ts b/src/actions/admin-teams.ts new file mode 100644 index 00000000..d7e1be18 --- /dev/null +++ b/src/actions/admin-teams.ts @@ -0,0 +1,41 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +export async function createTeam(formData: FormData): Promise { + await requireStaff(); + + const rankName = String(formData.get("rankName") ?? "").trim(); + if (!rankName) return; + + const badge = String(formData.get("badge") ?? "").trim(); + const jobDescription = String(formData.get("jobDescription") ?? "").trim(); + const staffColor = String(formData.get("staffColor") ?? "").trim() || "#327fa8"; + const hiddenRank = formData.get("hiddenRank") === "on"; + + const now = new Date(); + await prisma.websiteTeams.create({ + data: { + rankName: rankName.slice(0, 255), + badge: badge ? badge.slice(0, 255) : null, + jobDescription: jobDescription ? jobDescription.slice(0, 255) : null, + staffColor: staffColor.slice(0, 255), + hiddenRank, + createdAt: now, + updatedAt: now, + }, + }); + + revalidatePath("/admin/teams"); +} + +export async function deleteTeam(formData: FormData): Promise { + await requireStaff(); + + const id = BigInt(String(formData.get("id"))); + await prisma.websiteTeams.delete({ where: { id } }); + + revalidatePath("/admin/teams"); +} diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts new file mode 100644 index 00000000..0b34fc8d --- /dev/null +++ b/src/actions/admin-wordfilter.ts @@ -0,0 +1,34 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; + +export async function addWord(formData: FormData): Promise { + await requireStaff(); + const word = String(formData.get("word") ?? "").trim().slice(0, 255); + if (!word) return; + + try { + await prisma.websiteWordfilter.create({ data: { word } }); + await rcon.updateWordFilter(); + } catch { + // ignore (e.g. duplicate word) — page re-renders current state + } + revalidatePath("/admin/wordfilter"); +} + +export async function deleteWord(formData: FormData): Promise { + await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!raw) return; + + try { + await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } }); + await rcon.updateWordFilter(); + } catch { + // ignore (e.g. already removed) + } + revalidatePath("/admin/wordfilter"); +} diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts new file mode 100644 index 00000000..54ddbfac --- /dev/null +++ b/src/actions/help-tickets.ts @@ -0,0 +1,30 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function createTicket(formData: FormData): Promise { + // Re-read the session user id server-side; never trust a form-supplied id. + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) return; + + const title = String(formData.get("title") ?? "").trim().slice(0, 255); + const content = String(formData.get("content") ?? "").trim().slice(0, 5000); + if (!title || !content) return; + + const now = new Date(); + await prisma.websiteHelpCenterTickets.create({ + data: { + userId, + title, + content, + open: true, + createdAt: now, + updatedAt: now, + }, + }); + + revalidatePath("/help/tickets"); +} diff --git a/src/actions/user-settings.ts b/src/actions/user-settings.ts new file mode 100644 index 00000000..d67154a7 --- /dev/null +++ b/src/actions/user-settings.ts @@ -0,0 +1,40 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; + +// Emulator motto column is VARCHAR(127); keep the CMS-side write within bounds. +const MOTTO_MAX = 127; + +/** + * Update the SIGNED-IN user's motto. The id is taken from the session + * (re-fetched via auth()), never from the submitted FormData, so a crafted + * form cannot mutate another account. Mirrors AtomCMS: persist + RCON setmotto + * so an online user sees the change live. + */ +export async function updateMotto(formData: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) return; + + const id = Number(session.user.id); + if (!Number.isFinite(id)) return; + + const motto = String(formData.get("motto") ?? "").slice(0, MOTTO_MAX); + + try { + await prisma.user.update({ where: { id }, data: { motto } }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + try { + await rcon.setMotto(id, motto); + } catch { + // RCON is best-effort; the change is already persisted. + } + + revalidatePath("/settings"); +} diff --git a/src/app/admin/achievements/page.tsx b/src/app/admin/achievements/page.tsx new file mode 100644 index 00000000..a0f05477 --- /dev/null +++ b/src/app/admin/achievements/page.tsx @@ -0,0 +1,87 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type Achievement = { + name: string; + category: string; + level: number; + rewardAmount: number; + rewardType: number; + points: number | null; + progressNeeded: number; +}; + +export default async function AdminAchievements() { + let achievements: Achievement[] = []; + try { + achievements = await prisma.achievements.findMany({ + select: { + name: true, + category: true, + level: true, + rewardAmount: true, + rewardType: true, + points: true, + progressNeeded: true, + }, + orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }], + }); + } catch { + achievements = []; + } + + // Group by category (already sorted by category, then name, then level). + const groups = new Map(); + for (const a of achievements) { + const list = groups.get(a.category) ?? []; + list.push(a); + groups.set(a.category, list); + } + + return ( +
+

Achievements

+

+ Read-only · {achievements.length} rows across {groups.size} categories. +

+ + {groups.size === 0 ? ( +

No achievements found.

+ ) : ( + [...groups.entries()].map(([category, rows]) => ( +
+

{category}

+ + + + + + + + + + + + + {rows.map((a) => ( + + + + + + + + + ))} + +
NameLevelProgress neededReward typeReward amountPoints
{a.name}{a.level}{a.progressNeeded}{a.rewardType}{a.rewardAmount}{a.points ?? 0}
+
+ )) + )} +
+ ); +} diff --git a/src/app/admin/applications/page.tsx b/src/app/admin/applications/page.tsx new file mode 100644 index 00000000..a5b276df --- /dev/null +++ b/src/app/admin/applications/page.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; +import { dismissApplication } from "@/actions/admin-applications"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type ApplicationRow = { + id: bigint; + userId: number; + rankId: number; + content: string; + createdAt: Date | null; +}; + +function formatDate(d: Date | null): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : ""; +} + +export default async function AdminApplications() { + let applications: ApplicationRow[] = []; + try { + applications = await prisma.websiteStaffApplications.findMany({ + orderBy: { createdAt: "desc" }, + take: 100, + }); + } catch { + applications = []; + } + + // Resolve applicant usernames (no relation defined on the model). + const userMap = new Map(); + if (applications.length > 0) { + try { + const ids = Array.from(new Set(applications.map((a) => a.userId))); + const users = await prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, username: true }, + }); + for (const u of users) userMap.set(u.id, u.username); + } catch { + // no DB — fall back to raw ids + } + } + + return ( +
+

Staff Applications

+ + {applications.length === 0 ? ( +

No pending applications.

+ ) : ( +
+ {applications.map((a) => { + const username = userMap.get(a.userId); + return ( +
+
+

+ {username ? ( + {username} + ) : ( + <>User #{a.userId} + )} +

+ Rank {a.rankId} +
+

+ {formatDate(a.createdAt)} +

+

{a.content}

+
+ + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/admin/catalog/[id]/page.tsx b/src/app/admin/catalog/[id]/page.tsx new file mode 100644 index 00000000..92d55386 --- /dev/null +++ b/src/app/admin/catalog/[id]/page.tsx @@ -0,0 +1,161 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type CatalogPageRow = { + id: number; + caption: string; + parentId: number; + pageLayout: string; + minRank: number; + visible: string; + enabled: string; + clubOnly: string; + vipOnly: string; +}; + +type CatalogItemRow = { + id: number; + catalogName: string; + itemIds: string; + amount: number; + costCredits: number; + costPoints: number; + pointsType: number; + limitedStack: number; + limitedSells: number; + orderNumber: number; + clubOnly: string; +}; + +export default async function AdminCatalogPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const pageId = Number(id); + if (!Number.isInteger(pageId)) notFound(); + + let page: CatalogPageRow | null = null; + try { + page = await prisma.catalogPages.findUnique({ + where: { id: pageId }, + select: { + id: true, + caption: true, + parentId: true, + pageLayout: true, + minRank: true, + visible: true, + enabled: true, + clubOnly: true, + vipOnly: true, + }, + }); + } catch { + page = null; + } + if (!page) notFound(); + + let items: CatalogItemRow[] = []; + try { + items = await prisma.catalogItems.findMany({ + where: { pageId }, + select: { + id: true, + catalogName: true, + itemIds: true, + amount: true, + costCredits: true, + costPoints: true, + pointsType: true, + limitedStack: true, + limitedSells: true, + orderNumber: true, + clubOnly: true, + }, + orderBy: [{ orderNumber: "asc" }, { id: "asc" }], + take: 500, + }); + } catch { + items = []; + } + + const hidden = page.visible !== "1" || page.enabled !== "1"; + + return ( +
+

+ ← Catalog +

+

{page.caption}

+

+ Page #{page.id} · layout {page.pageLayout} · min rank {page.minRank} ·{" "} + {hidden ? "Hidden" : "Visible"} + {page.parentId > 0 ? ` · parent #${page.parentId}` : ""} + {page.clubOnly === "1" ? " · HC only" : ""} + {page.vipOnly === "1" ? " · VIP only" : ""} +

+ +

Items

+ {items.length === 0 ? ( +

No items on this page.

+ ) : ( + + + + + + + + + + + + + + + {items.map((it) => ( + + + + + + + + + + + ))} + +
IDCatalog nameItem IDsAmountCreditsPointsLimitedOrder
{it.id} + {it.catalogName} + {it.clubOnly === "1" ? ( + · HC + ) : null} + {it.itemIds}{it.amount} + {it.costCredits > 0 ? ( + {it.costCredits} + ) : ( + — + )} + + {it.costPoints > 0 + ? `${it.costPoints} (type ${it.pointsType})` + : "—"} + + {it.limitedStack > 0 + ? `${it.limitedSells}/${it.limitedStack}` + : "—"} + {it.orderNumber}
+ )} + +

+ {items.length} item{items.length === 1 ? "" : "s"} +

+
+ ); +} diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx new file mode 100644 index 00000000..a5699141 --- /dev/null +++ b/src/app/admin/catalog/page.tsx @@ -0,0 +1,96 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type CatalogPageRow = { + id: number; + caption: string; + parentId: number; + pageLayout: string; + minRank: number; + orderNum: number; + visible: string; + enabled: string; +}; + +export default async function AdminCatalog() { + let pages: CatalogPageRow[] = []; + try { + pages = await prisma.catalogPages.findMany({ + select: { + id: true, + caption: true, + parentId: true, + pageLayout: true, + minRank: true, + orderNum: true, + visible: true, + enabled: true, + }, + orderBy: [{ parentId: "asc" }, { orderNum: "asc" }, { id: "asc" }], + take: 500, + }); + } catch { + pages = []; + } + + // Build a quick caption lookup so we can show parent names instead of raw ids. + const captionById = new Map(); + for (const p of pages) captionById.set(p.id, p.caption); + + return ( +
+

Catalog

+

Browse catalog pages and the items they contain. Read-only.

+ + {pages.length === 0 ? ( +

No catalog pages found.

+ ) : ( + + + + + + + + + + + + + + {pages.map((p) => { + const parentName = + p.parentId > 0 ? captionById.get(p.parentId) : null; + const hidden = p.visible !== "1" || p.enabled !== "1"; + return ( + + + + + + + + + + ); + })} + +
IDCaptionParentLayoutMin rankOrderStatus
{p.id} + {p.caption} + + {p.parentId <= 0 + ? "—" + : parentName + ? `${parentName} (#${p.parentId})` + : `#${p.parentId}`} + {p.pageLayout}{p.minRank}{p.orderNum}{hidden ? "Hidden" : "Visible"}
+ )} + +

+ {pages.length} page{pages.length === 1 ? "" : "s"} +

+
+ ); +} diff --git a/src/app/admin/ip/page.tsx b/src/app/admin/ip/page.tsx new file mode 100644 index 00000000..f263dc18 --- /dev/null +++ b/src/app/admin/ip/page.tsx @@ -0,0 +1,149 @@ +import { + addBlacklist, + addWhitelist, + deleteBlacklist, + deleteWhitelist, +} from "@/actions/admin-ip"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type IpRow = { + id: bigint; + ipAddress: string; + asn: string | null; + createdAt: Date | null; +}; + +function formatDate(d: Date | null): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : ""; +} + +export default async function AdminIp() { + let whitelist: IpRow[] = []; + let blacklist: IpRow[] = []; + + try { + whitelist = await prisma.websiteIpWhitelist.findMany({ + orderBy: { id: "desc" }, + take: 200, + }); + } catch { + whitelist = []; + } + + try { + blacklist = await prisma.websiteIpBlacklist.findMany({ + orderBy: { id: "desc" }, + take: 200, + }); + } catch { + blacklist = []; + } + + return ( +
+

IP Management

+ +
+ {/* ── Whitelist ─────────────────────────────── */} +
+

Whitelist

+ +
+
+ + + +
+
+ + + + + + + + + + + {whitelist.map((w) => ( + + + + + + + ))} + +
IPASNAdded +
{w.ipAddress}{w.asn ?? "—"}{formatDate(w.createdAt)} +
+ + +
+
+ {whitelist.length === 0 ?

No whitelisted IPs.

: null} +
+ + {/* ── Blacklist ─────────────────────────────── */} +
+

Blacklist

+ +
+
+ + + +
+
+ + + + + + + + + + + {blacklist.map((b) => ( + + + + + + + ))} + +
IPASNAdded +
{b.ipAddress}{b.asn ?? "—"}{formatDate(b.createdAt)} +
+ + +
+
+ {blacklist.length === 0 ?

No blacklisted IPs.

: null} +
+
+
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 0bcf846c..9cd3cb04 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -17,7 +17,16 @@ export default async function AdminLayout({ children }: { children: ReactNode }) Dashboard Users Articles + Catalog + Radio + Achievements Bans + Applications + Word Filter + IP Management + Logs + Teams + Permissions Settings diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx new file mode 100644 index 00000000..d2c62889 --- /dev/null +++ b/src/app/admin/logs/page.tsx @@ -0,0 +1,176 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma +// generates no delegate for them — they are read via $queryRaw against the real +// emulator columns. alert_logs has a proper delegate (prisma.alertLogs). + +type ChatlogRoomRow = { + room_id: number; + user_from_id: number; + user_to_id: number; + message: string; + timestamp: number; +}; + +type CommandlogRow = { + user_id: number; + timestamp: number; + command: string; + params: string; + succes: string; +}; + +function fromUnix(ts: number | null | undefined): string { + if (!ts || Number(ts) <= 0) return "—"; + return new Date(Number(ts) * 1000) + .toISOString() + .slice(0, 16) + .replace("T", " "); +} + +function fromDate(d: Date | null | undefined): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—"; +} + +export default async function AdminLogs() { + let chatlogs: ChatlogRoomRow[] = []; + try { + chatlogs = await prisma.$queryRaw` + SELECT room_id, user_from_id, user_to_id, message, timestamp + FROM chatlogs_room + ORDER BY timestamp DESC + LIMIT 50 + `; + } catch { + chatlogs = []; + } + + let commandlogs: CommandlogRow[] = []; + try { + commandlogs = await prisma.$queryRaw` + SELECT user_id, timestamp, command, params, succes + FROM commandlogs + ORDER BY timestamp DESC + LIMIT 50 + `; + } catch { + commandlogs = []; + } + + let alertLogs: Awaited> = []; + try { + alertLogs = await prisma.alertLogs.findMany({ + orderBy: { id: "desc" }, + take: 50, + }); + } catch { + alertLogs = []; + } + + return ( +
+

Logs

+

+ Read-only · 50 most recent of each. +

+ +
+

Room chat ({chatlogs.length})

+ {chatlogs.length === 0 ? ( +

No room chat logs.

+ ) : ( +
+ + + + + + + + + + + + {chatlogs.map((c, i) => ( + + + + + + + + ))} + +
WhenRoomFromToMessage
{fromUnix(c.timestamp)}{String(c.room_id)}{String(c.user_from_id)}{c.user_to_id ? String(c.user_to_id) : "—"}{c.message}
+
+ )} +
+ +
+

Commands ({commandlogs.length})

+ {commandlogs.length === 0 ? ( +

No command logs.

+ ) : ( +
+ + + + + + + + + + + + {commandlogs.map((c, i) => ( + + + + + + + + ))} + +
WhenUserCommandParamsOK
{fromUnix(c.timestamp)}{String(c.user_id)}{c.command}{c.params}{c.succes === "yes" ? "✓" : "✗"}
+
+ )} +
+ +
+

Alerts ({alertLogs.length})

+ {alertLogs.length === 0 ? ( +

No alert logs.

+ ) : ( +
+ + + + + + + + + + + + {alertLogs.map((a) => ( + + + + + + + + ))} + +
WhenTypeSeverityMessageRead
{fromDate(a.createdAt)}{a.type}{a.severity}{a.message}{a.isRead ? "✓" : "○"}
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx new file mode 100644 index 00000000..b1191aaf --- /dev/null +++ b/src/app/admin/permissions/page.tsx @@ -0,0 +1,62 @@ +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +// NOTE: The emulator's standalone `permissions` table is not present in the +// Prisma schema (there is no `prisma.permissions` accessor). The real, +// queryable rank-permission table in this schema is `website_housekeeping_permissions` +// (model WebsiteHousekeepingPermissions), which gates each permission by the +// minimum rank that holds it — exactly the rank-permission data this page lists. +type PermissionRow = { + id: bigint; + permission: string; + minRank: number; + description: string | null; +}; + +export default async function AdminPermissions() { + let permissions: PermissionRow[] = []; + try { + permissions = await prisma.websiteHousekeepingPermissions.findMany({ + select: { id: true, permission: true, minRank: true, description: true }, + orderBy: [{ minRank: "asc" }, { permission: "asc" }], + }); + } catch { + permissions = []; + } + + return ( +
+

Permissions

+

+ Read-only view of rank permissions. Each entry is the minimum rank that + grants the permission. +

+ + {permissions.length === 0 ? ( +

No permissions found.

+ ) : ( + + + + + + + + + + + {permissions.map((p) => ( + + + + + + + ))} + +
IDPermissionMin rankDescription
{String(p.id)}{p.permission}{p.minRank}{p.description ?? "—"}
+ )} +
+ ); +} diff --git a/src/app/admin/radio/page.tsx b/src/app/admin/radio/page.tsx new file mode 100644 index 00000000..236002db --- /dev/null +++ b/src/app/admin/radio/page.tsx @@ -0,0 +1,206 @@ +import { prisma } from '@/lib/prisma'; +import { deleteShout } from '@/actions/admin-radio'; + +export const dynamic = 'force-dynamic'; + +type Shout = { + id: bigint; + userId: bigint; + message: string; + createdAt: Date | null; +}; + +type Application = { + id: bigint; + userId: bigint; + realName: string; + age: number; + status: string; + musicStyle: string | null; + discordUsername: string | null; + createdAt: Date | null; +}; + +type Schedule = { + id: bigint; + userId: bigint; + day: string; + startTime: Date; + endTime: Date; + showName: string | null; + isActive: boolean; +}; + +function formatDate(d: Date | null): string { + return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—'; +} + +function formatTime(d: Date | null): string { + return d ? d.toISOString().slice(11, 16) : '—'; +} + +export default async function AdminRadioPage() { + let shouts: Shout[] = []; + let applications: Application[] = []; + let schedules: Schedule[] = []; + + try { + shouts = await prisma.radioShouts.findMany({ + select: { id: true, userId: true, message: true, createdAt: true }, + orderBy: { id: 'desc' }, + take: 25, + }); + } catch { + shouts = []; + } + + try { + applications = await prisma.radioApplications.findMany({ + select: { + id: true, + userId: true, + realName: true, + age: true, + status: true, + musicStyle: true, + discordUsername: true, + createdAt: true, + }, + orderBy: { id: 'desc' }, + take: 25, + }); + } catch { + applications = []; + } + + try { + schedules = await prisma.radioSchedules.findMany({ + select: { + id: true, + userId: true, + day: true, + startTime: true, + endTime: true, + showName: true, + isActive: true, + }, + orderBy: { id: 'desc' }, + take: 25, + }); + } catch { + schedules = []; + } + + return ( +
+

Radio Moderation

+ +
+

Recent Shouts

+ {shouts.length === 0 ? ( +

No shouts.

+ ) : ( + + + + + + + + + + + {shouts.map((s) => ( + + + + + + + + ))} + +
IDUserMessagePosted +
{String(s.id)}{String(s.userId)}{s.message}{formatDate(s.createdAt)} +
+ + +
+
+ )} +
+ +
+

DJ Applications

+ {applications.length === 0 ? ( +

No applications.

+ ) : ( + + + + + + + + + + + + + + + {applications.map((a) => ( + + + + + + + + + + + ))} + +
IDUserReal NameAgeStyleDiscordStatusApplied
{String(a.id)}{String(a.userId)}{a.realName}{a.age}{a.musicStyle ?? '—'}{a.discordUsername ?? '—'}{a.status}{formatDate(a.createdAt)}
+ )} +
+ +
+

Schedules

+ {schedules.length === 0 ? ( +

No schedules.

+ ) : ( + + + + + + + + + + + + + + {schedules.map((sc) => ( + + + + + + + + + + ))} + +
IDUserDayStartEndShowActive
{String(sc.id)}{String(sc.userId)}{sc.day}{formatTime(sc.startTime)}{formatTime(sc.endTime)}{sc.showName ?? '—'}{sc.isActive ? '●' : '○'}
+ )} +
+
+ ); +} diff --git a/src/app/admin/teams/page.tsx b/src/app/admin/teams/page.tsx new file mode 100644 index 00000000..27291ca9 --- /dev/null +++ b/src/app/admin/teams/page.tsx @@ -0,0 +1,129 @@ +import { createTeam, deleteTeam } from "@/actions/admin-teams"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type TeamRow = { + id: bigint; + rankName: string; + badge: string | null; + jobDescription: string | null; + staffColor: string; + hiddenRank: boolean; +}; + +export default async function AdminTeams() { + let teams: TeamRow[] = []; + try { + teams = await prisma.websiteTeams.findMany({ + select: { + id: true, + rankName: true, + badge: true, + jobDescription: true, + staffColor: true, + hiddenRank: true, + }, + orderBy: { id: "asc" }, + }); + } catch { + teams = []; + } + + return ( +
+

Teams

+ +
+

New team rank

+
+
+ + + + +
+ +
+ +
+
+
+ + {teams.length === 0 ? ( +

No teams yet.

+ ) : ( + + + + + + + + + + + + + + {teams.map((t) => ( + + + + + + + + + + ))} + +
IDRank nameBadgeJob descriptionColorHidden
{String(t.id)}{t.rankName}{t.badge ?? "—"}{t.jobDescription ?? "—"} + + + {t.staffColor} + + {t.hiddenRank ? "Yes" : "No"} +
+ + +
+
+ )} +
+ ); +} diff --git a/src/app/admin/wordfilter/page.tsx b/src/app/admin/wordfilter/page.tsx new file mode 100644 index 00000000..17645d90 --- /dev/null +++ b/src/app/admin/wordfilter/page.tsx @@ -0,0 +1,59 @@ +import { addWord, deleteWord } from "@/actions/admin-wordfilter"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type WordRow = { id: bigint; word: string }; + +export default async function AdminWordFilter() { + let words: WordRow[] = []; + try { + words = await prisma.websiteWordfilter.findMany({ + select: { id: true, word: true }, + orderBy: { word: "asc" }, + take: 500, + }); + } catch { + words = []; + } + + return ( +
+

Word Filter

+

Words added here are blocked in-game. Saving pushes the list to the emulator.

+ +
+

Add word

+
+ + +
+
+ + + + + + + + + + {words.map((w) => ( + + + + + + ))} + +
IDWord +
{String(w.id)}{w.word} +
+ + +
+
+ {words.length === 0 ?

No filtered words.

: null} +
+ ); +} diff --git a/src/app/community/page.tsx b/src/app/community/page.tsx new file mode 100644 index 00000000..44811177 --- /dev/null +++ b/src/app/community/page.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +const LINKS = [ + { + href: "/rankings", + title: "Rankings", + text: "See the wealthiest players in the hotel, ranked by credits.", + }, + { + href: "/staff", + title: "Staff", + text: "Meet the team that keeps the hotel running every day.", + }, + { + href: "/news", + title: "News", + text: "The latest announcements, events and updates.", + }, +]; + +export default function CommunityPage() { + return ( +
+
+

Community

+

+ Everything happening around the hotel in one place. +

+
+ +
+ {LINKS.map((l) => ( + +

{l.title}

+

+ {l.text} +

+ Open → + + ))} +
+
+ ); +} diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx new file mode 100644 index 00000000..db191ebb --- /dev/null +++ b/src/app/help/page.tsx @@ -0,0 +1,208 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type HelpCategory = { + id: bigint; + name: string; + content: string; + imageUrl: string | null; + buttonText: string | null; + buttonUrl: string | null; + buttonColor: string; + buttonBorderColor: string; + smallBox: boolean; +}; + +type RuleCategory = { + id: bigint; + name: string; + description: string; + badge: string; +}; + +type Rule = { + id: bigint; + categoryId: bigint | null; + paragraph: string; + rule: string; +}; + +function isExternal(url: string): boolean { + return /^https?:\/\//i.test(url); +} + +export default async function HelpCenterPage() { + let categories: HelpCategory[] = []; + try { + categories = await prisma.websiteHelpCenterCategories.findMany({ + orderBy: [{ position: "asc" }, { id: "asc" }], + select: { + id: true, + name: true, + content: true, + imageUrl: true, + buttonText: true, + buttonUrl: true, + buttonColor: true, + buttonBorderColor: true, + smallBox: true, + }, + }); + } catch { + // DB unavailable — render the shell without help categories. + } + + let ruleCategories: RuleCategory[] = []; + try { + ruleCategories = await prisma.websiteRuleCategories.findMany({ + orderBy: { id: "asc" }, + select: { id: true, name: true, description: true, badge: true }, + }); + } catch { + // DB unavailable — render the shell without rule categories. + } + + let rules: Rule[] = []; + try { + rules = await prisma.websiteRules.findMany({ + orderBy: [{ categoryId: "asc" }, { paragraph: "asc" }, { id: "asc" }], + select: { id: true, categoryId: true, paragraph: true, rule: true }, + }); + } catch { + // DB unavailable — render the shell without rules. + } + + // Group the rules under their categories. + const rulesByCategory = new Map(); + for (const r of rules) { + const key = r.categoryId === null ? "" : String(r.categoryId); + const list = rulesByCategory.get(key); + if (list) { + list.push(r); + } else { + rulesByCategory.set(key, [r]); + } + } + + return ( +
+
+

Help Center

+

+ Find guides, support topics, and the hotel rules — everything you need to get help. +

+
+ +

Support topics

+ {categories.length === 0 ? ( +

No help topics yet.

+ ) : ( +
+ {categories.map((c) => ( +
+
+ {c.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+

{c.name}

+

+ {c.content} +

+ {c.buttonUrl && c.buttonText ? ( +
+ {isExternal(c.buttonUrl) ? ( + + {c.buttonText} + + ) : ( + + {c.buttonText} + + )} +
+ ) : null} +
+
+
+ ))} +
+ )} + +

Hotel rules

+ {ruleCategories.length === 0 ? ( +

No rules published yet.

+ ) : ( +
+ {ruleCategories.map((cat) => { + const catRules = rulesByCategory.get(String(cat.id)) ?? []; + return ( +
+
+

{cat.name}

+ {cat.badge ? {cat.badge} : null} +
+ {cat.description ? ( +

+ {cat.description} +

+ ) : null} + + {catRules.length === 0 ? ( +

+ No rules in this category. +

+ ) : ( +
    + {catRules.map((r) => ( +
  • + {r.paragraph ? ( + {r.paragraph} + ) : null} + {r.rule} +
  • + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/help/tickets/page.tsx b/src/app/help/tickets/page.tsx new file mode 100644 index 00000000..6e4b9afc --- /dev/null +++ b/src/app/help/tickets/page.tsx @@ -0,0 +1,76 @@ +import { redirect } from "next/navigation"; +import { createTicket } from "@/actions/help-tickets"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +function formatDate(d: Date | null): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : ""; +} + +export default async function HelpTicketsPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const userId = Number(session.user.id); + + let tickets: Awaited> = []; + try { + tickets = await prisma.websiteHelpCenterTickets.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: 100, + }); + } catch { + tickets = []; + } + + return ( +
+

Help Tickets

+

Submit a ticket to staff and track your open requests.

+ +
+

New ticket

+
+ +