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:
1 parent
e96b606e1e
commit
e8be0461d8
28 files changed
+2347
-5
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user