Add niche admin + public expansions + self-host Nunito font (batches 3-4)

Built via two more parallel agent workflows (read schema -> return files),
integrated + verified.

Admin: /admin/emulator (emulator_settings + emulator_texts key/value editor),
/admin/badges (give-badge via RCON), /admin/rare-values (CRUD), /admin/housekeeping
(CRUD), /admin/email-templates (CRUD), /admin/photos (moderation).
Public: /rares (+[category]), /leaderboard (credits/diamonds/duckets),
/guilds (+[id] members), /redeem (voucher -> sendCurrency).
Expanded: /u/[username] now shows badges + photos + guestbook (post form);
/news/[slug] now shows reactions + comments (comment form). Reactions tallied
in JS (Prisma groupBy typing avoided).
Self-hosted Nunito via next/font/google wired to --font-nunito (the atom theme
font, no runtime external fetch). Header + admin nav extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0.
This commit is contained in:
Simo committed 2026-06-28 13:44:23 +02:00
1 parent e96b606e1e
commit e8be0461d8
28 files changed
+2347 -5

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
"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 giveBadge(formData: FormData): Promise<void> {
await requireStaff();
const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "").trim().slice(0, 32);
if (!(userId > 0) || code.length === 0) return;
// Fire the emulator command so the badge appears live for online users.
await rcon.giveBadge(userId, code);
// Persist the badge directly so it survives a relog / offline grant.
// users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves.
try {
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
if (!existing) {
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
}
revalidatePath("/admin/badges");
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
const body = String(formData.get("body") ?? "");
const variablesRaw = String(formData.get("variables") ?? "").trim();
const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return;
await prisma.emailTemplates.create({
data: {
name,
subject,
body,
variables: variablesRaw || null,
isActive,
},
});
revalidatePath("/admin/email-templates");
}
export async function updateEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
const body = String(formData.get("body") ?? "");
const variablesRaw = String(formData.get("variables") ?? "").trim();
const isActive = formData.get("isActive") != null;
if (!subject || !body) return;
await prisma.emailTemplates.update({
where: { id },
data: {
subject,
body,
variables: variablesRaw || null,
isActive,
},
});
revalidatePath("/admin/email-templates");
}
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
await prisma.emailTemplates.delete({ where: { id } });
revalidatePath("/admin/email-templates");
}
+36
View File
@@ -0,0 +1,36 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
// Both tables are emulator-owned; we only ever read/update existing rows or add new
// keys via upsert. We never migrate or drop them.
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
const value = String(formData.get("value") ?? "").slice(0, 512);
if (!key) return;
await prisma.emulatorSettings.upsert({
where: { key },
update: { value },
create: { key, value },
});
revalidatePath("/admin/emulator");
}
export async function updateEmulatorText(formData: FormData): Promise<void> {
await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
const value = String(formData.get("value") ?? "").slice(0, 4096);
if (!key) return;
await prisma.emulatorTexts.upsert({
where: { key },
update: { value },
create: { key, value },
});
revalidatePath("/admin/emulator");
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
/**
* Create or update a housekeeping permission (keyed by the unique `permission`
* string). Mirrors AtomCMS' housekeeping permission management.
*/
export async function upsertPermission(formData: FormData): Promise<void> {
await requireStaff();
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
const minRank = Number(formData.get("minRank"));
const descriptionRaw = String(formData.get("description") ?? "").trim().slice(0, 255);
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
try {
await prisma.websiteHousekeepingPermissions.upsert({
where: { permission },
update: { minRank, description },
create: { permission, minRank, description },
});
} catch {
// Swallow: duplicate/constraint issues shouldn't crash the action.
}
revalidatePath("/admin/housekeeping");
}
export async function deletePermission(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
try {
await prisma.websiteHousekeepingPermissions.delete({ where: { id: BigInt(raw) } });
} catch {
// Already gone / invalid id.
}
revalidatePath("/admin/housekeeping");
}
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function deletePhoto(formData: FormData): Promise<void> {
await requireStaff();
const id = Number(formData.get("id"));
if (!(id > 0)) return;
try {
await prisma.cameraWeb.delete({ where: { id } });
} catch {
// Record may have already been removed; ignore.
}
revalidatePath("/admin/photos");
}
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> {
await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const badge = String(formData.get("badge") ?? "").trim().slice(0, 255);
const priorityRaw = Number(formData.get("priority"));
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1;
if (!name || !badge) return;
try {
await prisma.websiteRareValueCategories.create({
data: { name, badge, priority },
});
} catch {
// Unique name collision or DB error — ignore, page will re-render unchanged.
}
revalidatePath("/admin/rare-values");
}
export async function deleteCategory(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
try {
// Remove the category's values first to avoid orphaned rows.
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
await prisma.websiteRareValueCategories.delete({ where: { id } });
} catch {
// Not found or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function createValue(formData: FormData): Promise<void> {
await requireStaff();
const categoryRaw = String(formData.get("categoryId") ?? "");
if (!/^\d+$/.test(categoryRaw)) return;
const categoryId = BigInt(categoryRaw);
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "").trim().slice(0, 255);
if (!name || !furnitureIcon) return;
const itemIdRaw = Number(formData.get("itemId"));
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
const creditValueRaw = String(formData.get("creditValue") ?? "").trim().slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "").trim().slice(0, 255);
const currencyType = String(formData.get("currencyType") ?? "diamonds").trim().slice(0, 255) || "diamonds";
try {
await prisma.websiteRareValues.create({
data: {
categoryId,
itemId,
name,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
},
});
} catch {
// DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function deleteValue(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
try {
await prisma.websiteRareValues.delete({ where: { id } });
} catch {
// Not found or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
+62
View File
@@ -0,0 +1,62 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
const COMMENT_MAX = 255;
/**
* Post a comment on a news article as the SIGNED-IN user. The author id is read
* from the session (re-fetched via auth()), never from the submitted FormData,
* so a crafted form cannot post as another account. The articleId comes from the
* form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT).
*/
export async function postComment(formData: FormData): Promise<void> {
const session = await auth();
if (!session?.user?.id) return;
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
if (!comment) return;
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
if (!/^\d+$/.test(articleIdRaw)) return;
let articleId: bigint;
try {
articleId = BigInt(articleIdRaw);
} catch {
return;
}
let slug: string | null = null;
try {
// Confirm the article exists (and grab its slug for revalidation).
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) return;
slug = article.slug;
const now = new Date();
await prisma.websiteArticleComments.create({
data: {
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
if (slug) revalidatePath(`/news/${slug}`);
}
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
const MESSAGE_MAX = 255;
/**
* Post a guestbook entry on a profile.
*
* The AUTHOR (userId) is re-read from the session via auth() and is never
* trusted from the submitted FormData, so a crafted form cannot impersonate
* another account. Only the PROFILE OWNER id (whose guestbook is written) is
* taken from the form, and we resolve a profile username from the form purely
* to revalidate the right page.
*/
export async function postGuestbook(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) return;
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
if (!message) return;
// Optional: used only to revalidate the correct profile route.
const username = String(formData.get("username") ?? "").trim();
const now = new Date();
try {
await prisma.websiteUserGuestbooks.create({
data: {
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
if (username) revalidatePath(`/u/${username}`);
}
+125
View File
@@ -0,0 +1,125 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
/** Feedback returned to the <RedeemForm/> client component via useActionState. */
export type RedeemState = { ok: boolean; message: string } | null;
/**
* Redeem a shop voucher for the SIGNED-IN user. Faithful to AtomCMS's
* ShopVoucherController:
* - the user id is re-read from the session (auth()), NEVER from FormData,
* so a crafted form cannot redeem on another account;
* - a code that is missing or expired is rejected;
* - each voucher may be redeemed once per user (website_used_shop_vouchers);
* - on success the reward `amount` is granted, the used-row is inserted,
* use_count is incremented, and the voucher is expired once max_uses is hit.
*
* The reward is delivered through sendCurrency({ rcon, db: prisma }); the
* voucher schema carries a single `amount`, granted as the website credits
* wallet currency.
*/
export async function redeem(
_prev: RedeemState,
formData: FormData,
): Promise<RedeemState> {
const session = await auth();
if (!session?.user?.id) {
return { ok: false, message: "You must be signed in to redeem a voucher." };
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) {
return { ok: false, message: "Your session is invalid. Please sign in again." };
}
const code = String(formData.get("code") ?? "").trim();
if (!code) {
return { ok: false, message: "Please enter a voucher code." };
}
// Look up the code (website_shop_vouchers.code is unique).
let voucher: {
id: bigint;
amount: number;
maxUses: number;
useCount: number;
expiresAt: Date | null;
} | null = null;
try {
voucher = await prisma.websiteShopVouchers.findUnique({
where: { code },
select: { id: true, amount: true, maxUses: true, useCount: true, expiresAt: true },
});
} catch {
return { ok: false, message: "We couldn't reach the server. Please try again." };
}
// Not found OR already expired -> generic "no active voucher" (matches AtomCMS).
if (!voucher || (voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())) {
return { ok: false, message: "No active voucher with the given code was found." };
}
// One redemption per user.
try {
const already = await prisma.websiteUsedShopVouchers.findFirst({
where: { userId, voucherId: voucher.id },
select: { id: true },
});
if (already) {
return { ok: false, message: "You can only use each shop voucher once." };
}
} catch {
return { ok: false, message: "We couldn't reach the server. Please try again." };
}
// Record the redemption first so a successful grant can never be double-claimed.
try {
await prisma.websiteUsedShopVouchers.create({
data: { userId, voucherId: voucher.id },
});
} catch {
// Most likely a race (another tab redeemed it) — treat as already used.
return { ok: false, message: "You can only use each shop voucher once." };
}
// Grant the reward. The voucher carries a single amount, delivered as credits.
try {
await sendCurrency({ rcon, db: prisma }, userId, "credits", voucher.amount);
} catch {
// sendCurrency already falls back to a direct DB write; if it still throws,
// the used-row stands and the balance simply wasn't credited — surface that.
return {
ok: false,
message: "Your voucher was accepted but the reward could not be delivered. Contact staff.",
};
}
// Bump use_count and expire the voucher once the cap is reached.
try {
const updated = await prisma.websiteShopVouchers.update({
where: { id: voucher.id },
data: { useCount: { increment: 1 } },
select: { maxUses: true, useCount: true },
});
if (updated.maxUses && updated.useCount >= updated.maxUses) {
await prisma.websiteShopVouchers.update({
where: { id: voucher.id },
data: { expiresAt: new Date() },
});
}
} catch {
// Reward already delivered; the counter bump is best-effort.
}
revalidatePath("/redeem");
return {
ok: true,
message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`,
};
}
+83
View File
@@ -0,0 +1,83 @@
import { giveBadge } from "@/actions/admin-badges";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminBadges() {
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>> = [];
try {
badges = await prisma.websiteBadges.findMany({
orderBy: { badgeName: "asc" },
take: 500,
});
} catch {
badges = [];
}
return (
<main>
<h1>Badges</h1>
<p className="muted">
Website badge catalogue (<code>website_badges</code>). Granting fires the emulator
<code> givebadge</code> RCON command and records the badge in <code>users_badges</code>.
</p>
<form action={giveBadge} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Give badge to user</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
<input name="userId" type="number" min={1} placeholder="User ID" required />
<input
name="code"
placeholder="Badge code (e.g. ADM)"
required
maxLength={32}
style={{ flex: 1, minWidth: 160 }}
list="badge-codes"
/>
<datalist id="badge-codes">
{badges.map((b) => (
<option key={String(b.id)} value={b.badgeKey}>
{b.badgeName}
</option>
))}
</datalist>
<button type="submit" className="btn btn-primary">
Give badge
</button>
</div>
</form>
<table>
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
<th />
</tr>
</thead>
<tbody>
{badges.map((b) => (
<tr key={String(b.id)}>
<td>
<code>{b.badgeKey}</code>
</td>
<td>{b.badgeName}</td>
<td className="muted">{b.badgeDescription}</td>
<td>
<form action={giveBadge} style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input type="hidden" name="code" value={b.badgeKey} />
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: 90 }} />
<button type="submit" className="btn btn-outline">
Give
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{badges.length === 0 ? <p className="muted">No badges in the catalogue.</p> : null}
</main>
);
}
+138
View File
@@ -0,0 +1,138 @@
import {
createEmailTemplate,
deleteEmailTemplate,
updateEmailTemplate,
} from "@/actions/admin-email-templates";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminEmailTemplates() {
let templates: Awaited<ReturnType<typeof prisma.emailTemplates.findMany>> = [];
try {
templates = await prisma.emailTemplates.findMany({ orderBy: { name: "asc" } });
} catch {
templates = [];
}
return (
<main>
<h1>Email Templates</h1>
<p className="muted">
Transactional email bodies. Use <code>{"{{ placeholders }}"}</code> in the body and list
the available ones in the variables field (comma-separated).
</p>
<form
action={createEmailTemplate}
className="card"
style={{ marginBottom: "1.5rem" }}
>
<h3 style={{ marginTop: 0 }}>New template</h3>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginBottom: "0.5rem",
}}
>
<input name="name" placeholder="name (unique)" required style={{ minWidth: 180 }} />
<input
name="subject"
placeholder="subject"
required
style={{ flex: 1, minWidth: 220 }}
/>
<input name="variables" placeholder="variables (comma-separated)" style={{ minWidth: 200 }} />
<label style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}>
<input type="checkbox" name="isActive" defaultChecked style={{ width: "auto" }} />
<span className="muted">Active</span>
</label>
</div>
<textarea
name="body"
placeholder="Email body (HTML or text)"
required
rows={5}
style={{ width: "100%", resize: "vertical", marginBottom: "0.5rem" }}
/>
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
<div className="grid">
{templates.map((t) => {
const id = String(t.id);
return (
<div key={id} className="card">
<form action={updateEmailTemplate}>
<input type="hidden" name="id" value={id} />
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<strong style={{ minWidth: 160 }}>{t.name}</strong>
<input
name="subject"
defaultValue={t.subject}
placeholder="subject"
required
style={{ flex: 1, minWidth: 220 }}
/>
<input
name="variables"
defaultValue={t.variables ?? ""}
placeholder="variables"
style={{ minWidth: 180 }}
/>
<label
style={{ display: "inline-flex", alignItems: "center", gap: "0.35rem" }}
>
<input
type="checkbox"
name="isActive"
defaultChecked={t.isActive}
style={{ width: "auto" }}
/>
<span className="muted">Active</span>
</label>
</div>
<textarea
name="body"
defaultValue={t.body}
required
rows={6}
style={{ width: "100%", resize: "vertical", marginBottom: "0.5rem" }}
/>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="submit" className="btn btn-primary">
Save
</button>
{t.updatedAt ? (
<span className="muted">
Updated {new Date(t.updatedAt).toISOString().slice(0, 16).replace("T", " ")}
</span>
) : null}
</div>
</form>
<form action={deleteEmailTemplate} style={{ marginTop: "0.5rem" }}>
<input type="hidden" name="id" value={id} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</div>
);
})}
</div>
{templates.length === 0 ? <p className="muted">No email templates yet.</p> : null}
</main>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { updateEmulatorSetting, updateEmulatorText } from "@/actions/admin-emulator";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const TEXTS_LIMIT = 300;
export default async function AdminEmulator() {
let settings: { key: string; value: string }[] = [];
try {
settings = await prisma.emulatorSettings.findMany({ orderBy: { key: "asc" } });
} catch {
settings = [];
}
let texts: { key: string; value: string }[] = [];
let textsCount = 0;
try {
textsCount = await prisma.emulatorTexts.count();
} catch {
textsCount = 0;
}
try {
texts = await prisma.emulatorTexts.findMany({
orderBy: { key: "asc" },
take: TEXTS_LIMIT,
});
} catch {
texts = [];
}
const textsTruncated = textsCount > texts.length;
return (
<main>
<h1>Emulator Config</h1>
<p className="muted">
Live key/value config read by the Arcturus emulator from
<code> emulator_settings</code> and <code> emulator_texts</code>. Changes take effect
on the next emulator read (some keys require an emulator reload).
</p>
<h2 style={{ marginTop: "1.5rem" }}>Settings</h2>
<p className="muted">{settings.length} keys.</p>
<form
action={updateEmulatorSetting}
className="card"
style={{ marginBottom: "1.5rem" }}
>
<h3 style={{ marginTop: 0 }}>Add / overwrite setting</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" maxLength={100} required />
<input
name="value"
placeholder="value"
maxLength={512}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<table>
<thead>
<tr>
<th style={{ width: 320 }}>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{settings.map((s) => (
<tr key={s.key}>
<td>
<strong>{s.key}</strong>
</td>
<td>
<form
action={updateEmulatorSetting}
style={{ display: "flex", gap: "0.5rem" }}
>
<input type="hidden" name="key" value={s.key} />
<input
name="value"
defaultValue={s.value}
maxLength={512}
style={{ flex: 1 }}
/>
<button type="submit" className="btn btn-outline">
Save
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{settings.length === 0 ? (
<p className="muted">No emulator settings found.</p>
) : null}
<h2 style={{ marginTop: "2.5rem" }}>Texts</h2>
<p className="muted">
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
</p>
<form
action={updateEmulatorText}
className="card"
style={{ marginBottom: "1.5rem" }}
>
<h3 style={{ marginTop: 0 }}>Add / overwrite text</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" maxLength={100} required />
<input
name="value"
placeholder="value"
maxLength={4096}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<table>
<thead>
<tr>
<th style={{ width: 320 }}>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{texts.map((t) => (
<tr key={t.key}>
<td>
<strong>{t.key}</strong>
</td>
<td>
<form
action={updateEmulatorText}
style={{ display: "flex", gap: "0.5rem" }}
>
<input type="hidden" name="key" value={t.key} />
<input
name="value"
defaultValue={t.value}
maxLength={4096}
style={{ flex: 1 }}
/>
<button type="submit" className="btn btn-outline">
Save
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{texts.length === 0 ? (
<p className="muted">No emulator texts found.</p>
) : null}
{textsTruncated ? (
<p className="muted">
Only the first {texts.length} of {textsCount} text keys are shown. Use the
add/overwrite form above to edit a key beyond this list.
</p>
) : null}
</main>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminHousekeeping() {
let permissions: Awaited<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
> = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
orderBy: [{ minRank: "asc" }, { permission: "asc" }],
});
} catch {
permissions = [];
}
return (
<main>
<h1>Housekeeping Permissions</h1>
<p className="muted">
Minimum rank required to access each housekeeping permission key. Saving an
existing key overwrites its min rank and description.
</p>
<form action={upsertPermission} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="permission" placeholder="permission key" required style={{ minWidth: 200 }} />
<input name="minRank" type="number" min={0} placeholder="Min rank" required style={{ width: 110 }} />
<input name="description" placeholder="description" style={{ flex: 1, minWidth: 160 }} />
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
<table>
<thead>
<tr>
<th>Permission</th>
<th>Description</th>
<th style={{ width: 200 }}>Min rank</th>
<th style={{ width: 80 }} />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<td><strong>{p.permission}</strong></td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form action={upsertPermission} style={{ display: "flex", gap: "0.5rem" }}>
<input type="hidden" name="permission" value={p.permission} />
<input type="hidden" name="description" value={p.description ?? ""} />
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-outline">Save</button>
</form>
</td>
<td>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">✕</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{permissions.length === 0 ? (
<p className="muted">No housekeeping permissions defined yet.</p>
) : null}
</main>
);
}
+6
View File
@@ -18,15 +18,21 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin/users">Users</Link>
<Link href="/admin/articles">Articles</Link>
<Link href="/admin/catalog">Catalog</Link>
<Link href="/admin/badges">Badges</Link>
<Link href="/admin/radio">Radio</Link>
<Link href="/admin/achievements">Achievements</Link>
<Link href="/admin/rare-values">Rare Values</Link>
<Link href="/admin/photos">Photos</Link>
<Link href="/admin/bans">Bans</Link>
<Link href="/admin/applications">Applications</Link>
<Link href="/admin/wordfilter">Word Filter</Link>
<Link href="/admin/ip">IP Management</Link>
<Link href="/admin/logs">Logs</Link>
<Link href="/admin/teams">Teams</Link>
<Link href="/admin/housekeeping">Housekeeping</Link>
<Link href="/admin/permissions">Permissions</Link>
<Link href="/admin/emulator">Emulator</Link>
<Link href="/admin/email-templates">Email</Link>
<Link href="/admin/settings">Settings</Link>
</nav>
</aside>
+54
View File
@@ -0,0 +1,54 @@
import { deletePhoto } from "@/actions/admin-photos";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminPhotos() {
let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
try {
photos = await prisma.cameraWeb.findMany({
orderBy: { timestamp: "desc" },
take: 60,
});
} catch {
photos = [];
}
return (
<main>
<h1>Photo Moderation</h1>
<p className="muted">
Recent in-game camera photos. Deleting removes the record from{" "}
<code>camera_web</code>.
</p>
<div className="grid cols-3">
{photos.map((p) => (
<div key={String(p.id)} className="card" style={{ padding: 0, overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={`Photo ${String(p.id)}`}
style={{ width: "100%", height: 200, objectFit: "cover", display: "block", background: "#f1f5f9" }}
/>
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem", alignItems: "baseline" }}>
<strong>#{String(p.id)}</strong>
<span className="muted">Room {p.roomId}</span>
</div>
<p className="muted" style={{ margin: "0.3rem 0 0" }}>
User {p.userId} ·{" "}
{new Date(p.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")}
</p>
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">Delete</button>
</form>
</div>
</div>
))}
</div>
{photos.length === 0 ? <p className="muted">No photos found.</p> : null}
</main>
);
}
+147
View File
@@ -0,0 +1,147 @@
import {
createCategory,
createValue,
deleteCategory,
deleteValue,
} from "@/actions/admin-rare-values";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const CURRENCY_TYPES = ["diamonds", "credits", "duckets", "points"];
export default async function AdminRareValues() {
let categories: Awaited<ReturnType<typeof prisma.websiteRareValueCategories.findMany>> = [];
try {
categories = await prisma.websiteRareValueCategories.findMany({
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
} catch {
categories = [];
}
let values: Awaited<ReturnType<typeof prisma.websiteRareValues.findMany>> = [];
try {
values = await prisma.websiteRareValues.findMany({
orderBy: { name: "asc" },
});
} catch {
values = [];
}
const valuesByCategory = new Map<string, typeof values>();
for (const v of values) {
const key = String(v.categoryId);
const list = valuesByCategory.get(key);
if (list) list.push(v);
else valuesByCategory.set(key, [v]);
}
return (
<main>
<h1>Rare Values</h1>
<p className="muted">
Catalog of rare furni and their trade values, grouped by category. Each category is
rendered as a tab on the public rare values page.
</p>
<form action={createCategory} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>New category</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="name" placeholder="Category name" required style={{ flex: 1, minWidth: 160 }} />
<input name="badge" placeholder="Badge code" required />
<input name="priority" type="number" min={1} defaultValue={1} placeholder="Priority" style={{ width: 110 }} />
<button type="submit" className="btn btn-primary">Add category</button>
</div>
</form>
{categories.length === 0 ? (
<p className="muted">No categories yet. Add one above to start.</p>
) : null}
<div className="grid">
{categories.map((cat) => {
const catId = String(cat.id);
const rows = valuesByCategory.get(catId) ?? [];
return (
<div key={catId} className="card">
<div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "baseline",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "baseline" }}>
<strong>{cat.name}</strong>
<span className="muted">badge {cat.badge}</span>
<span className="muted">priority {cat.priority}</span>
</div>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<button type="submit" className="btn btn-danger">Delete category</button>
</form>
</div>
<table style={{ marginTop: "0.75rem" }}>
<thead>
<tr>
<th>Name</th>
<th>Item ID</th>
<th>Credit value</th>
<th>Currency value</th>
<th>Currency</th>
<th>Icon</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((v) => (
<tr key={String(v.id)}>
<td>{v.name}</td>
<td className="muted">{v.itemId ?? "—"}</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td className="muted">{v.currencyType}</td>
<td className="muted">{v.furnitureIcon}</td>
<td>
<form action={deleteValue}>
<input type="hidden" name="id" value={String(v.id)} />
<button type="submit" className="btn btn-danger">✕</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 ? (
<p className="muted" style={{ margin: "0.5rem 0 0" }}>
No values in this category yet.
</p>
) : null}
<form action={createValue} style={{ marginTop: "0.75rem" }}>
<input type="hidden" name="categoryId" value={catId} />
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="name" placeholder="Item name" required style={{ flex: 1, minWidth: 140 }} />
<input name="itemId" type="number" min={1} placeholder="Item ID" style={{ width: 110 }} />
<input name="furnitureIcon" placeholder="Furniture icon" required style={{ minWidth: 140 }} />
<input name="creditValue" placeholder="Credit value" style={{ width: 130 }} />
<input name="currencyValue" placeholder="Currency value" style={{ width: 130 }} />
<select name="currencyType" defaultValue="diamonds">
{CURRENCY_TYPES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<button type="submit" className="btn btn-primary">Add value</button>
</div>
</form>
</div>
);
})}
</div>
</main>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@
--navbar-height: 64px;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.06), 0 4px 14px rgba(15, 23, 42, 0.05);
--shadow-card-hover: 0 6px 22px rgba(15, 23, 42, 0.12);
--font-family: "Nunito", ui-rounded, "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
--font-family: var(--font-nunito), ui-rounded, "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
}
* {
+191
View File
@@ -0,0 +1,191 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
type GuildView = {
id: number;
name: string;
description: string;
badge: string;
userId: number;
dateCreated: number;
};
type MemberView = {
userId: number;
username: string;
look: string;
motto: string;
memberSince: number;
isOwner: boolean;
};
function formatTimestamp(ts: number): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export default async function GuildPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
let guild: GuildView | null = null;
try {
guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: {
id: true,
name: true,
description: true,
badge: true,
userId: true,
dateCreated: true,
},
});
} catch {
guild = null;
}
if (!guild) notFound();
// Members come from guilds_members; there is no relation in the schema, so we
// join the users table manually by id.
let memberRows: { userId: number; memberSince: number }[] = [];
try {
memberRows = await prisma.guildsMembers.findMany({
where: { guildId: guild.id },
orderBy: { memberSince: "asc" },
take: 200,
select: { userId: true, memberSince: true },
});
} catch {
memberRows = [];
}
const memberIds = Array.from(
new Set([guild.userId, ...memberRows.map((m) => m.userId)].filter((v) => v > 0)),
);
let users: { id: number; username: string; look: string; motto: string }[] = [];
try {
users = memberIds.length
? await prisma.user.findMany({
where: { id: { in: memberIds } },
select: { id: true, username: true, look: true, motto: true },
})
: [];
} catch {
users = [];
}
const imagerBase = await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
);
const usersById = new Map(users.map((u) => [u.id, u]));
const sinceById = new Map(memberRows.map((m) => [m.userId, m.memberSince]));
const members: MemberView[] = memberIds
.map((uid) => {
const u = usersById.get(uid);
if (!u) return null;
return {
userId: u.id,
username: u.username,
look: u.look,
motto: u.motto,
memberSince: sinceById.get(uid) ?? 0,
isOwner: uid === guild!.userId,
};
})
.filter((m): m is MemberView => m !== null)
// Owner first, then by join date.
.sort((a, b) => {
if (a.isOwner !== b.isOwner) return a.isOwner ? -1 : 1;
return a.memberSince - b.memberSince;
});
const created = formatTimestamp(guild.dateCreated);
return (
<main>
<p style={{ marginTop: 0 }}>
<Link href="/guilds">← All guilds</Link>
</p>
<div
className="card"
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
>
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt={`${guild.name} badge`}
width={48}
height={48}
style={{ flex: "0 0 auto" }}
/>
) : null}
<div style={{ minWidth: 0 }}>
<h1 style={{ margin: "0 0 0.25rem" }}>{guild.name || "Unnamed guild"}</h1>
<p style={{ margin: "0 0 0.5rem" }}>
{guild.description || <span className="muted">No description</span>}
</p>
<p className="muted" style={{ margin: 0 }}>
{members.length} {members.length === 1 ? "member" : "members"}
{created ? ` · Founded ${created}` : ""}
</p>
</div>
</div>
<h2 style={{ marginTop: "1.5rem" }}>Members</h2>
{members.length === 0 ? (
<p className="muted">No members.</p>
) : (
<div className="grid cols-3">
{members.map((m) => (
<div
key={m.userId}
className="card"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatarImageUrl(imagerBase ?? "", m.look, { size: "l" })}
alt={`${m.username} avatar`}
width={50}
height={75}
style={{ flex: "0 0 auto" }}
/>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.15rem" }}>
<Link href={`/u/${m.username}`}>{m.username}</Link>
</h3>
{m.isOwner ? (
<p className="muted" style={{ margin: "0 0 0.15rem" }}>
Owner
</p>
) : null}
<p className="muted" style={{ margin: 0 }}>
{m.motto || "No motto"}
</p>
</div>
</div>
))}
</div>
)}
</main>
);
}
+72
View File
@@ -0,0 +1,72 @@
import Link from "next/link";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type GuildCard = {
id: number;
name: string;
description: string;
badge: string;
};
async function getGuilds(): Promise<GuildCard[]> {
try {
const guilds = await prisma.guilds.findMany({
orderBy: { id: "desc" },
take: 40,
select: { id: true, name: true, description: true, badge: true },
});
return guilds;
} catch {
return [];
}
}
export default async function GuildsPage() {
const guilds = await getGuilds();
return (
<main>
<h1>Guilds</h1>
{guilds.length === 0 ? (
<p className="muted">No guilds yet.</p>
) : (
<div className="grid cols-3">
{guilds.map((g) => (
<Link
key={g.id}
href={`/guilds/${g.id}`}
className="card hover"
style={{
display: "flex",
gap: "0.85rem",
alignItems: "flex-start",
color: "inherit",
textDecoration: "none",
}}
>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt={`${g.name} badge`}
width={40}
height={40}
style={{ flex: "0 0 auto" }}
/>
) : null}
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.25rem" }}>{g.name || "Unnamed guild"}</h3>
<p className="muted" style={{ margin: 0 }}>
{g.description ? excerpt(g.description, 120) : "No description"}
</p>
</div>
</Link>
))}
</div>
)}
</main>
);
}
+10 -1
View File
@@ -1,9 +1,18 @@
import type { Metadata } from "next";
import { Nunito } from "next/font/google";
import type { ReactNode } from "react";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import "./globals.css";
// Self-hosted (no runtime external fetch) — the atom theme's font.
const nunito = Nunito({
subsets: ["latin"],
weight: ["400", "600", "700", "800"],
display: "swap",
variable: "--font-nunito",
});
export const metadata: Metadata = {
title: "AtomCMS",
description: "AtomCMS — retro hotel CMS (Next.js conversion)",
@@ -11,7 +20,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<html lang="en" className={nunito.variable}>
<body>
<div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<SiteHeader />
+155
View File
@@ -0,0 +1,155 @@
import Link from "next/link";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
// AtomCMS-faithful currency type ids (see prisma/schema.prisma UsersCurrency):
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
const TABS = [
{ key: "credits", label: "Credits", coin: "credits" },
{ key: "diamonds", label: "Diamonds", coin: "diamonds" },
{ key: "duckets", label: "Duckets", coin: "duckets" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
const CURRENCY_TYPE: Record<Exclude<TabKey, "credits">, number> = {
diamonds: 5,
duckets: 0,
};
type Row = { username: string; look: string; value: number };
async function loadCreditsRows(): Promise<Row[]> {
try {
const users = await prisma.user.findMany({
orderBy: { credits: "desc" },
take: 20,
select: { username: true, look: true, credits: true },
});
return users.map((u) => ({ username: u.username, look: u.look, value: u.credits }));
} catch {
return [];
}
}
async function loadCurrencyRows(type: number): Promise<Row[]> {
try {
const top = await prisma.usersCurrency.findMany({
where: { type },
orderBy: { amount: "desc" },
take: 20,
select: { userId: true, amount: true },
});
if (top.length === 0) return [];
const users = await prisma.user.findMany({
where: { id: { in: top.map((t) => t.userId) } },
select: { id: true, username: true, look: true },
});
const byId = new Map(users.map((u) => [u.id, u]));
return top
.map((t) => {
const u = byId.get(t.userId);
if (!u) return null;
return { username: u.username, look: u.look, value: t.amount };
})
.filter((r): r is Row => r !== null);
} catch {
return [];
}
}
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<{ type?: string }>;
}) {
const { type } = await searchParams;
const active: TabKey = TABS.some((t) => t.key === type) ? (type as TabKey) : "credits";
const activeTab = TABS.find((t) => t.key === active)!;
const [rows, imagerBase] = await Promise.all([
active === "credits"
? loadCreditsRows()
: loadCurrencyRows(CURRENCY_TYPE[active as Exclude<TabKey, "credits">]),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
]);
return (
<main>
<h1>Leaderboard</h1>
<p className="muted" style={{ marginTop: 0 }}>
Top 20 players by {activeTab.label.toLowerCase()}.
</p>
<div className="nav" style={{ gap: "0.5rem", marginBottom: "1.25rem" }}>
{TABS.map((t) => (
<Link
key={t.key}
href={`/leaderboard?type=${t.key}`}
className={`btn ${t.key === active ? "btn-primary" : "btn-outline"}`}
>
{t.label}
</Link>
))}
</div>
<div className="card">
{rows.length === 0 ? (
<p className="muted" style={{ margin: 0 }}>
No rankings to show yet.
</p>
) : (
<table>
<thead>
<tr>
<th style={{ width: "3rem" }}>#</th>
<th style={{ width: "3.5rem" }}>Avatar</th>
<th>Player</th>
<th style={{ textAlign: "right" }}>{activeTab.label}</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
size: "s",
headOnly: true,
});
return (
<tr key={`${row.username}-${i}`}>
<td style={{ fontWeight: 800 }}>{i + 1}</td>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${row.username} avatar`}
width={40}
height={40}
/>
</td>
<td>
<Link href={`/u/${encodeURIComponent(row.username)}`} style={{ fontWeight: 700 }}>
{row.username}
</Link>
</td>
<td style={{ textAlign: "right" }}>
<span className="currency" style={{ display: "inline-flex" }}>
<span className={`coin ${activeTab.coin}`} />
{row.value.toLocaleString()}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</main>
);
}
+136 -1
View File
@@ -1,18 +1,76 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { postComment } from "@/actions/article-comments";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await prisma.websiteArticles.findUnique({ where: { slug } });
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
try {
article = await prisma.websiteArticles.findUnique({ where: { slug } });
} catch {
article = null;
}
if (!article) notFound();
const articleId = article.id;
// Reaction counts grouped by reaction type for this article.
let reactionGroups: { reaction: string; count: number }[] = [];
try {
const rows = await prisma.websiteArticleReactions.findMany({
where: { articleId, active: true },
select: { reaction: true },
});
const tally = new Map<string, number>();
for (const r of rows) tally.set(r.reaction, (tally.get(r.reaction) ?? 0) + 1);
reactionGroups = [...tally.entries()].map(([reaction, count]) => ({ reaction, count }));
} catch {
reactionGroups = [];
}
// Comments for this article (oldest first).
let comments: Awaited<ReturnType<typeof prisma.websiteArticleComments.findMany>> = [];
try {
comments = await prisma.websiteArticleComments.findMany({
where: { articleId },
orderBy: { createdAt: "asc" },
take: 200,
});
} catch {
comments = [];
}
// Resolve usernames for the commenters (userId is the emulator users.id, Int).
const commenterIds = Array.from(new Set(comments.map((c) => c.userId)));
let usernameById = new Map<number, string>();
if (commenterIds.length > 0) {
try {
const users = await prisma.user.findMany({
where: { id: { in: commenterIds } },
select: { id: true, username: true },
});
usernameById = new Map(users.map((u) => [u.id, u.username]));
} catch {
usernameById = new Map();
}
}
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
return (
<main>
<p className="muted">
@@ -23,6 +81,7 @@ export default async function ArticlePage({
<p className="muted">{article.createdAt.toISOString().slice(0, 10)}</p>
) : null}
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
@@ -30,6 +89,82 @@ export default async function ArticlePage({
/>
) : null}
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div>
{/* ── Reactions ─────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Reactions</h2>
{reactionGroups.length === 0 ? (
<p className="muted">No reactions yet.</p>
) : (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{reactionGroups.map((r) => (
<span key={r.reaction} className="currency">
<span style={{ fontWeight: 800 }}>{r.reaction}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{r.count}
</span>
</span>
))}
</div>
)}
</section>
{/* ── Comments ──────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Comments</h2>
{comments.length === 0 ? (
<p className="muted">No comments yet. Be the first to comment.</p>
) : (
<div className="grid" style={{ marginBottom: "1.5rem" }}>
{comments.map((c) => {
const name = usernameById.get(c.userId);
return (
<article key={String(c.id)} className="card">
<p className="muted" style={{ marginTop: 0, marginBottom: "0.35rem" }}>
{name ? (
<Link href={`/u/${name}`}>{name}</Link>
) : (
<span>User #{c.userId}</span>
)}
{c.createdAt ? <span> · {formatDate(c.createdAt)}</span> : null}
</p>
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>{c.comment}</p>
</article>
);
})}
</div>
)}
{loggedIn ? (
<form action={postComment} className="card">
<input type="hidden" name="articleId" value={String(articleId)} />
<label
htmlFor="comment"
style={{ display: "block", fontWeight: 700, marginBottom: "0.5rem" }}
>
Add a comment
</label>
<textarea
id="comment"
name="comment"
required
maxLength={255}
rows={3}
placeholder="Write your comment…"
style={{ width: "100%", resize: "vertical" }}
/>
<div style={{ marginTop: "0.75rem" }}>
<button type="submit" className="btn btn-primary">
Post comment
</button>
</div>
</form>
) : (
<p className="muted">
<Link href="/login">Log in</Link> to leave a comment.
</p>
)}
</section>
</main>
);
}
+171
View File
@@ -0,0 +1,171 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
type RareRow = {
id: bigint;
name: string;
creditValue: string | null;
currencyValue: string | null;
currencyType: string;
furnitureIcon: string;
itemId: number | null;
};
function prettyCurrency(type: string): string {
switch (type) {
case "0":
case "duckets":
return "Duckets";
case "5":
case "diamonds":
return "Diamonds";
case "-1":
case "credits":
return "Credits";
default:
return type.charAt(0).toUpperCase() + type.slice(1);
}
}
export default async function RareCategoryPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
const { category } = await params;
let categoryId: bigint;
try {
categoryId = BigInt(category);
} catch {
notFound();
}
let cat: { id: bigint; name: string; badge: string } | null = null;
try {
cat = await prisma.websiteRareValueCategories.findUnique({
where: { id: categoryId! },
select: { id: true, name: true, badge: true },
});
} catch {
cat = null;
}
if (!cat) notFound();
let rares: RareRow[] = [];
try {
rares = await prisma.websiteRareValues.findMany({
where: { categoryId: categoryId! },
orderBy: { name: "asc" },
select: {
id: true,
name: true,
creditValue: true,
currencyValue: true,
currencyType: true,
furnitureIcon: true,
itemId: true,
},
});
} catch {
rares = [];
}
const [iconsBase, badgesBase] = await Promise.all([
siteSettings.get("furniture_icons_path", ""),
siteSettings.get("badges_path", ""),
]);
const iconBase = iconsBase ?? "";
const badgeBase = badgesBase ?? "";
const badgeSrc = badgeBase && cat.badge ? `${badgeBase}/${cat.badge}.gif` : "";
return (
<main>
<p style={{ margin: "0 0 0.75rem" }}>
<Link href="/rares">← All rares</Link>
</p>
<div
className="hero"
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
>
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={badgeSrc} alt={`${cat.name} badge`} width={48} height={48} />
) : null}
<div>
<h1 style={{ margin: 0 }}>{cat.name}</h1>
<p className="muted" style={{ marginBottom: 0 }}>
{rares.length} {rares.length === 1 ? "rare" : "rares"} in this category
</p>
</div>
</div>
{rares.length === 0 ? (
<p className="muted">No rares listed in this category yet.</p>
) : (
<div className="card" style={{ padding: 0, overflowX: "auto" }}>
<table>
<thead>
<tr>
<th style={{ width: 56 }}></th>
<th>Rare</th>
<th>Credits</th>
<th>Currency</th>
</tr>
</thead>
<tbody>
{rares.map((r) => {
const iconSrc =
iconBase && r.furnitureIcon ? `${iconBase}/${r.furnitureIcon}` : "";
return (
<tr key={String(r.id)}>
<td>
{iconSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={iconSrc}
alt=""
width={40}
height={40}
style={{ objectFit: "contain" }}
/>
) : (
<span
className="avatar"
style={{ display: "inline-block", width: 40, height: 40 }}
/>
)}
</td>
<td>
<strong>{r.name}</strong>
</td>
<td>
{r.creditValue && r.creditValue !== "" ? (
`${r.creditValue} credits`
) : (
<span className="muted">—</span>
)}
</td>
<td>
{r.currencyValue && r.currencyValue !== "" ? (
`${r.currencyValue} ${prettyCurrency(r.currencyType)}`
) : (
<span className="muted">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</main>
);
}
+96
View File
@@ -0,0 +1,96 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
type CategoryRow = {
id: bigint;
name: string;
badge: string;
priority: number;
};
export default async function RareValuesPage() {
let categories: CategoryRow[] = [];
try {
categories = await prisma.websiteRareValueCategories.findMany({
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, badge: true, priority: true },
});
} catch {
categories = [];
}
// Per-category rare counts (grouped so we avoid N queries).
const counts = new Map<string, number>();
try {
const grouped = await prisma.websiteRareValues.groupBy({
by: ["categoryId"],
_count: { _all: true },
});
for (const g of grouped) {
counts.set(String(g.categoryId), g._count._all);
}
} catch {
// leave counts empty -> shown as 0
}
const badgesBase = (await siteSettings.get("badges_path", "")) ?? "";
return (
<main>
<div className="hero">
<h1 style={{ margin: 0 }}>Rare values</h1>
<p className="muted" style={{ marginBottom: 0 }}>
Get an overview of all of the rares on the hotel, grouped by category.
</p>
</div>
{categories.length === 0 ? (
<p className="muted">We currently have no rares listed here.</p>
) : (
<div className="grid cols-3">
{categories.map((c) => {
const id = String(c.id);
const count = counts.get(id) ?? 0;
const badgeSrc = badgesBase && c.badge ? `${badgesBase}/${c.badge}.gif` : "";
return (
<Link
key={id}
href={`/rares/${id}`}
className="card hover"
style={{
display: "flex",
alignItems: "center",
gap: "0.85rem",
textDecoration: "none",
color: "inherit",
}}
>
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${c.name} badge`}
width={40}
height={40}
style={{ flexShrink: 0 }}
/>
) : (
<span className="avatar" style={{ width: 40, height: 40, flexShrink: 0 }} />
)}
<span style={{ display: "flex", flexDirection: "column" }}>
<strong>{c.name}</strong>
<span className="muted">
{count} {count === 1 ? "rare" : "rares"}
</span>
</span>
</Link>
);
})}
</div>
)}
</main>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useActionState } from "react";
import { redeem, type RedeemState } from "@/actions/voucher";
const initialState: RedeemState = null;
/**
* Client form for /redeem. Uses useActionState so the redeem() server action's
* feedback ({ ok, message }) can be rendered inline without a redirect.
*/
export default function RedeemForm() {
const [state, formAction, pending] = useActionState(redeem, initialState);
return (
<form
action={formAction}
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
>
<input
name="code"
maxLength={255}
placeholder="Enter your voucher code"
aria-label="Voucher code"
autoComplete="off"
required
/>
<div>
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending ? "Redeeming…" : "Redeem voucher"}
</button>
</div>
{state ? (
<p
role="status"
aria-live="polite"
style={{
margin: "0.25rem 0 0",
fontWeight: 700,
color: state.ok ? "var(--color-secondary-hover)" : "var(--color-danger)",
}}
>
{state.message}
</p>
) : null}
</form>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { redirect } from "next/navigation";
import RedeemForm from "./RedeemForm";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function RedeemPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const id = Number(session.user.id);
// Show the signed-in user's current credit balance for context (fail soft).
let user: { username: string; credits: number } | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: { username: true, credits: true },
});
} catch {
user = null;
}
if (!user) redirect("/login");
return (
<main>
<div className="hero">
<h1 style={{ marginBottom: "0.25rem" }}>Redeem a voucher</h1>
<p className="muted" style={{ margin: 0 }}>
Got a voucher code? Enter it below to add the reward to your account.
</p>
</div>
<div className="grid cols-2">
<div className="card">
<h3 style={{ marginTop: 0 }}>Enter your code</h3>
<RedeemForm />
<p className="muted" style={{ marginBottom: 0 }}>
Each voucher can only be redeemed once per account.
</p>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Your balance</h3>
<p style={{ margin: "0 0 0.6rem" }}>
Signed in as <strong>{user.username}</strong>.
</p>
<span className="currency">
<span className="coin credits">cr</span>
{user.credits.toLocaleString()}
</span>
<p className="muted" style={{ marginBottom: 0, marginTop: "0.85rem" }}>
Redeemed rewards are credited instantly and appear in-game the next time you log in.
</p>
</div>
</div>
</main>
);
}
+176 -2
View File
@@ -1,10 +1,24 @@
import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { postGuestbook } from "@/actions/guestbook";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN. Badge codes (e.g. "ADM") map to a .gif here.
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
function formatTimestamp(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function ProfilePage({
params,
}: {
@@ -12,10 +26,11 @@ export default async function ProfilePage({
}) {
const { username } = await params;
const [user, imagerBase] = await Promise.all([
const [user, imagerBase, session] = await Promise.all([
prisma.user.findUnique({
where: { username },
select: {
id: true,
username: true,
motto: true,
look: true,
@@ -26,12 +41,51 @@ export default async function ProfilePage({
},
}),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
auth(),
]);
if (!user) notFound();
const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" });
const registered = new Date(user.accountCreated * 1000).toISOString().slice(0, 10);
const isLoggedIn = Boolean(session?.user?.id);
// Profile sub-sections. Each query is isolated so a DB hiccup degrades that
// single section to empty rather than 500-ing the whole profile.
const badges = await prisma.usersBadges
.findMany({
where: { userId: user.id },
orderBy: { slotId: "asc" },
})
.catch(() => []);
const photos = await prisma.cameraWeb
.findMany({
where: { userId: user.id },
orderBy: { timestamp: "desc" },
take: 6,
})
.catch(() => []);
const guestbook = await prisma.websiteUserGuestbooks
.findMany({
where: { profileId: user.id },
orderBy: { createdAt: "desc" },
take: 30,
})
.catch(() => []);
// Resolve author usernames/looks for the guestbook entries in one query.
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
const authors = authorIds.length
? await prisma.user
.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true, look: true },
})
.catch(() => [])
: [];
const authorById = new Map(authors.map((a) => [a.id, a]));
return (
<main>
@@ -40,13 +94,133 @@ export default async function ProfilePage({
<img src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
<div>
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
<p style={{ margin: "0 0 0.5rem" }}>{user.motto || <span className="muted">No motto</span>}</p>
<p style={{ margin: "0 0 0.5rem" }}>
{user.motto || <span className="muted">No motto</span>}
</p>
<p className="muted" style={{ margin: 0 }}>
Rank {user.rank} · {user.credits} credits · {user.online === "1" ? "Online" : "Offline"} ·
Registered {registered}
</p>
</div>
</div>
{/* ── Badges ───────────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}>
<h2>Badges</h2>
{badges.length === 0 ? (
<p className="muted">No badges yet.</p>
) : (
<div
className="card"
style={{ display: "flex", flexWrap: "wrap", gap: "0.75rem", alignItems: "center" }}
>
{badges.map((b) => (
/* eslint-disable-next-line @next/next/no-img-element */
<img
key={b.id}
src={`${BADGE_IMG_BASE}/${b.badgeCode}.gif`}
alt={b.badgeCode}
title={b.badgeCode}
width={40}
height={40}
/>
))}
</div>
)}
</section>
{/* ── Photos ───────────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}>
<h2>Photos</h2>
{photos.length === 0 ? (
<p className="muted">No photos yet.</p>
) : (
<div className="grid cols-3">
{photos.map((p) => (
<div key={p.id} className="card" style={{ padding: "0.5rem" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={`Photo by ${user.username}`}
style={{ width: "100%", height: "auto", display: "block", borderRadius: "8px" }}
/>
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{formatTimestamp(p.timestamp)}
</p>
</div>
))}
</div>
)}
</section>
{/* ── Guestbook ────────────────────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}>
<h2>Guestbook</h2>
{isLoggedIn ? (
<form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}>
{/* Profile owner id drives the write target; the author is taken from
the session inside the action, never from this form. */}
<input type="hidden" name="profileId" value={String(user.id)} />
<input type="hidden" name="username" value={user.username} />
<label htmlFor="gb-message" className="muted">
Leave a message
</label>
<textarea
id="gb-message"
name="message"
required
maxLength={255}
rows={3}
placeholder={`Write something on ${user.username}'s guestbook…`}
style={{ width: "100%", margin: "0.4rem 0 0.75rem", resize: "vertical" }}
/>
<button type="submit" className="btn btn-primary">
Post
</button>
</form>
) : (
<p className="muted">Log in to leave a guestbook message.</p>
)}
{guestbook.length === 0 ? (
<p className="muted">No guestbook entries yet.</p>
) : (
<div className="grid">
{guestbook.map((g) => {
const author = authorById.get(g.userId);
const authorAvatar = author
? avatarImageUrl(imagerBase ?? "", author.look, { size: "s", headOnly: true })
: null;
return (
<div
key={String(g.id)}
className="card"
style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}
>
{authorAvatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
className="avatar"
src={authorAvatar}
alt={author?.username ?? "author"}
width={40}
height={40}
/>
) : null}
<div style={{ flex: 1 }}>
<p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${g.userId}`}</strong>{" "}
<span className="muted">{formatDate(g.createdAt)}</span>
</p>
<p style={{ margin: 0 }}>{g.message}</p>
</div>
</div>
);
})}
</div>
)}
</section>
</main>
);
}
+12
View File
@@ -54,6 +54,15 @@ export async function SiteHeader() {
<Link className="nav-item" href="/photos">
Photos
</Link>
<Link className="nav-item" href="/leaderboard">
Leaderboard
</Link>
<Link className="nav-item" href="/guilds">
Guilds
</Link>
<Link className="nav-item" href="/rares">
Rares
</Link>
<Link className="nav-item" href="/help">
Help
</Link>
@@ -85,6 +94,9 @@ export async function SiteHeader() {
{diamonds}
</span>
</div>
<Link className="nav-item" href="/redeem">
Redeem
</Link>
<Link className="nav-item" href="/settings">
Settings
</Link>