Decouple admin from public max-w-7xl via (site) route group.
Local Build and Deploy / deploy (push) Successful in 54s

Public chrome and width live only under (site). /admin and /client inherit the root layout alone, so housekeeping width is no longer tied to the public template.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 21:12:52 +02:00
1 parent a1dc7ff0a7
commit 367f69a439
60 files changed
+29 -30

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyStaffPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyStaff");
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
function formatDeadline(d: Date | null | undefined): string {
if (!d) return t("noDeadline");
return d.toISOString().slice(0, 16).replace("T", " ");
}
// ── Open positions ──────────────────────────────────────────
// Each query is isolated so a DB hiccup degrades that section to empty
// rather than 500-ing the whole page.
const positions = await prisma.websiteOpenPositions
.findMany({ orderBy: { createdAt: "desc" } })
.catch(() => []);
// Resolve display info for each position's rank. The rich rank fields
// (rankName/badge/jobDescription/staffColor) live in website_teams, keyed by
// the same rank id the position's permission_id points at.
const rankIds = Array.from(new Set(positions.map((p) => p.permissionId)));
const teams = rankIds.length
? await prisma.websiteTeams
.findMany({
where: { id: { in: rankIds.map((r) => BigInt(r)) } },
})
.catch(() => [])
: [];
const teamByRankId = new Map(teams.map((t) => [Number(t.id), t]));
// Ranks this user has already applied for (block re-applying).
const myApps = await prisma.websiteStaffApplications
.findMany({ where: { userId }, select: { rankId: true } })
.catch(() => []);
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🛡️"
title={t("title", { hotel: hotelName })}
subtitle={t("subtitle")}
/>
{positions.length === 0 ? (
<ContentCard icon="🛡️" title={t("noPositionsTitle")}>
<EmptyState icon="🛡️">{t("noPositionsBody")}</EmptyState>
</ContentCard>
) : (
<div className="card-grid sm-2">
{positions.map((position) => {
const team = teamByRankId.get(position.permissionId);
const rankName = team?.rankName ?? `Rank #${position.permissionId}`;
const alreadyApplied = appliedRankIds.has(position.permissionId);
return (
<article key={String(position.id)} className="content-card">
<div className="content-card-head">
{team?.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
alt={rankName}
width={40}
height={40}
/>
) : (
<span className="content-card-icon" aria-hidden>
🛡️
</span>
)}
<div className="content-card-head-text">
<p
className="content-card-title"
style={{ color: team?.staffColor || undefined }}
>
{rankName}
</p>
{team?.jobDescription ? (
<p className="content-card-subtitle">
{team.jobDescription}
</p>
) : null}
</div>
</div>
<div className="content-card-body">
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
{t("deadline", { date: formatDeadline(position.applyTo) })}
</p>
{alreadyApplied ? (
<button
type="button"
className="btn btn-danger"
disabled
style={{ width: "100%" }}
>
{t("alreadyApplied", { rank: rankName })}
</button>
) : (
<form action={applyStaff}>
{/* rank_id is the position's permission id; the applicant is
re-read from the session inside the action. */}
<input
type="hidden"
name="rankId"
value={String(position.permissionId)}
/>
<label
htmlFor={`content-${position.id}`}
className="muted"
>
{t("aboutYou")}
</label>
<textarea
id={`content-${position.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={t("aboutPlaceholder", { rank: rankName })}
style={{
width: "100%",
margin: "0.4rem 0 0.75rem",
resize: "vertical",
}}
/>
<button
type="submit"
className="btn btn-secondary"
style={{ width: "100%" }}
>
{t("applyFor", { rank: rankName })}
</button>
</form>
)}
</div>
</article>
);
})}
</div>
)}
</main>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyTeamPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyTeam");
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
// ── Teams open for application ──────────────────────────────
// The Prisma slice exposes teams directly (no separate team-position table
// with position_kind), so the team application concept lists website_teams.
// Hidden ranks are excluded from the public apply page.
const teams = await prisma.websiteTeams
.findMany({
where: { hiddenRank: false },
orderBy: { rankName: "asc" },
})
.catch(() => []);
// Teams this user has already applied to. Team applications reuse the staff
// applications table with rank_id carrying the team id (the team flag).
const myApps = await prisma.websiteStaffApplications
.findMany({ where: { userId }, select: { rankId: true } })
.catch(() => []);
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🤝"
title={t("title", { hotel: hotelName })}
subtitle={t("subtitle")}
/>
{teams.length === 0 ? (
<ContentCard icon="🤝" title={t("noPositionsTitle")}>
<EmptyState icon="🤝">{t("noPositionsBody")}</EmptyState>
</ContentCard>
) : (
<div className="card-grid sm-2">
{teams.map((team) => {
const teamId = Number(team.id);
const alreadyApplied = appliedTeamIds.has(teamId);
return (
<article key={String(team.id)} className="content-card">
<div className="content-card-head">
{team.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}/${team.badge}.gif`}
alt={team.rankName}
width={40}
height={40}
/>
) : (
<span className="content-card-icon" aria-hidden>
🤝
</span>
)}
<div className="content-card-head-text">
<p
className="content-card-title"
style={{ color: team.staffColor || undefined }}
>
{team.rankName}
</p>
{team.jobDescription ? (
<p className="content-card-subtitle">
{team.jobDescription}
</p>
) : null}
</div>
</div>
<div className="content-card-body">
{alreadyApplied ? (
<button
type="button"
className="btn btn-danger"
disabled
style={{ width: "100%" }}
>
{t("pending")}
</button>
) : (
<form action={applyTeam}>
{/* The team id is the application's rank flag; the applicant
is re-read from the session inside the action. */}
<input
type="hidden"
name="teamId"
value={String(team.id)}
/>
<label htmlFor={`content-${team.id}`} className="muted">
{t("aboutYou")}
</label>
<textarea
id={`content-${team.id}`}
name="content"
required
minLength={10}
rows={5}
placeholder={t("aboutPlaceholder", {
rank: team.rankName,
})}
style={{
width: "100%",
margin: "0.4rem 0 0.75rem",
resize: "vertical",
}}
/>
<button
type="submit"
className="btn btn-primary"
style={{ width: "100%" }}
>
{t("applyFor", { rank: team.rankName })}
</button>
</form>
)}
</div>
</article>
);
})}
</div>
)}
</main>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
export const metadata = { title: "Badges" };
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function BadgesPage() {
const t = await getTranslations("pages.badges");
const badges = await prisma.websiteBadges
.findMany({
orderBy: { badgeName: "asc" },
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🏅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={badges.length === 0}>
{badges.length === 0 ? (
<EmptyState icon="🏅">{t("noBadges")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{badges.map((badge) => (
<div
key={String(badge.id)}
className="card hover"
style={{
display: "flex",
gap: "0.9rem",
alignItems: "flex-start",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${BADGE_IMG_BASE}/${badge.badgeKey}.gif`}
alt={badge.badgeName}
title={badge.badgeKey}
width={40}
height={40}
style={{ flexShrink: 0 }}
/>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
{badge.badgeName}
</h3>
<p
className="muted"
style={{ margin: "0 0 0.35rem", fontFamily: "monospace" }}
>
{badge.badgeKey}
</p>
<p style={{ margin: 0, fontSize: "0.9rem" }}>
{badge.badgeDescription}
</p>
</div>
</div>
))}
</div>
)}
</ContentCard>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const FAR_FUTURE = Math.floor(Date.now() / 1000) + 50 * 365 * 24 * 3600;
export default async function BannedPage() {
const t = await getTranslations("pages.banned");
const session = await auth();
let reason = "";
let expire = 0;
if (session?.user?.id) {
try {
const now = Math.floor(Date.now() / 1000);
const ban = await prisma.ban.findFirst({
where: { userId: Number(session.user.id), banExpire: { gt: now } },
orderBy: { banExpire: "desc" },
select: { banReason: true, banExpire: true },
});
if (ban) {
reason = ban.banReason;
expire = ban.banExpire;
}
} catch {
// show generic message
}
}
const expiryText =
expire === 0
? ""
: expire > FAR_FUTURE
? t("permanent")
: t("expires", {
date: new Date(expire * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " "),
});
return (
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard icon="🚫" title={t("title")} subtitle={t("subtitle")}>
<p style={{ marginTop: 0 }}>{t("body")}</p>
{reason ? (
<p>
<strong>{t("reasonLabel")}</strong> {reason}
</p>
) : null}
{expiryText ? <p className="muted">{expiryText}</p> : null}
<p className="muted" style={{ marginBottom: 0 }}>
{t("contactStaff")}
</p>
</ContentCard>
</main>
);
}
+76
View File
@@ -0,0 +1,76 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { ContentCard } from "@/components/public/ui";
export const metadata = { title: "Community" };
const LINKS = [
{
key: "rankings",
href: "/rankings",
icon: "🏆",
},
{
key: "staff",
href: "/staff",
icon: "🛡️",
},
{
key: "news",
href: "/news",
icon: "📰",
},
{
key: "photos",
href: "/photos",
icon: "📸",
},
{
key: "guilds",
href: "/guilds",
icon: "🚪",
},
{
key: "leaderboards",
href: "/leaderboard",
icon: "📊",
},
];
export default function CommunityPage() {
const t = useTranslations("pages.community");
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🌍" title={t("title")} subtitle={t("subtitle")} />
<div className="card-grid sm-2 lg-3">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="content-card hover-lift"
style={{ color: "inherit", textDecoration: "none" }}
>
<div className="content-card-head">
<span className="content-card-icon" aria-hidden>
{l.icon}
</span>
<div className="content-card-head-text">
<p className="content-card-title">
{t(`links.${l.key}.title`)}
</p>
</div>
</div>
<div className="content-card-body">
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
{t(`links.${l.key}.text`)}
</p>
<span className="btn btn-outline">{t("open")}</span>
</div>
</Link>
))}
</div>
</main>
);
}
+475
View File
@@ -0,0 +1,475 @@
import { ContentCard } from "@/components/public/ui";
// Public API documentation. Static, hand-maintained from the routes that
// actually exist under src/app/api — keep this in sync when endpoints change.
export const metadata = { title: "API" };
type Method = "GET" | "POST" | "DELETE";
type Endpoint = {
method: Method;
path: string;
description: string;
/** Requires `Authorization: Bearer <token>`. */
bearer?: boolean;
/** Requires a signed-in web session (NextAuth), not a Bearer token. */
session?: boolean;
};
type Group = {
icon: string;
title: string;
subtitle: string;
endpoints: Endpoint[];
};
// Mirrors the route.ts files under src/app/api. Only documents endpoints that
// really exist; auth flags reflect bearerUserId() / auth() usage in each route.
const GROUPS: Group[] = [
{
icon: "👤",
title: "Users",
subtitle: "Profiles and the signed-in account.",
endpoints: [
{
method: "GET",
path: "/api/users/{username}",
description:
"Public profile for a user by username (look, motto, rank, badges).",
},
{
method: "GET",
path: "/api/me",
description:
"The currently signed-in user, or { user: null } when not authenticated.",
},
],
},
{
icon: "📰",
title: "Content",
subtitle: "News articles, comments and photos.",
endpoints: [
{
method: "GET",
path: "/api/articles",
description:
"List published news articles (paginated via query params).",
},
{
method: "GET",
path: "/api/articles/{slug}",
description: "A single article by slug, with its comments.",
},
{
method: "POST",
path: "/api/articles/{slug}/comment",
description: "Post a comment on an article.",
bearer: true,
},
{
method: "GET",
path: "/api/photos",
description: "Recent in-game camera photos.",
},
],
},
{
icon: "🏙️",
title: "Community",
subtitle: "Hotel population, guilds, staff and teams.",
endpoints: [
{
method: "GET",
path: "/api/home",
description:
"Aggregated home-page payload (settings, news, online stats).",
},
{
method: "GET",
path: "/api/online",
description: "Users currently online.",
},
{
method: "GET",
path: "/api/online/count",
description: "Just the online-user count.",
},
{
method: "GET",
path: "/api/leaderboard",
description: "Player leaderboard (ranked by the requested metric).",
},
{
method: "GET",
path: "/api/guilds",
description: "List guilds.",
},
{
method: "GET",
path: "/api/guilds/{id}",
description: "A single guild with its members.",
},
{
method: "GET",
path: "/api/staff",
description: "Staff members above the configured minimum rank.",
},
{
method: "GET",
path: "/api/teams",
description: "Public staff teams / rank groups.",
},
],
},
{
icon: "💰",
title: "Economy",
subtitle: "Shop catalogue and rare-furniture values.",
endpoints: [
{
method: "GET",
path: "/api/shop",
description: "Shop products (filter by category via query params).",
},
{
method: "GET",
path: "/api/shop/categories",
description: "Shop categories.",
},
{
method: "GET",
path: "/api/values",
description: "Rare-value catalogue.",
},
{
method: "GET",
path: "/api/values/{id}",
description: "A single rare value entry.",
},
{
method: "GET",
path: "/api/values/categories",
description: "Rare-value categories.",
},
],
},
{
icon: "📻",
title: "Radio",
subtitle: "Now-playing, listeners, DJ points and shouts.",
endpoints: [
{
method: "GET",
path: "/api/radio/now-playing",
description: "The track currently on air.",
},
{
method: "GET",
path: "/api/radio/current-dj",
description: "The DJ currently live.",
},
{
method: "GET",
path: "/api/radio/listeners",
description: "Current listener count.",
},
{
method: "GET",
path: "/api/radio/config",
description: "Public radio configuration.",
},
{
method: "GET",
path: "/api/radio/embed-config",
description: "Configuration for the embeddable radio player.",
},
{
method: "GET",
path: "/api/radio/auto-play",
description: "AutoDJ playback state.",
},
{
method: "GET",
path: "/api/radio/stream",
description: "Stream metadata / proxy details.",
},
{
method: "GET",
path: "/api/radio/points/leaderboard",
description: "DJ points leaderboard.",
},
{
method: "GET",
path: "/api/radio/points",
description: "The signed-in user's own DJ points.",
bearer: true,
},
{
method: "GET",
path: "/api/radio/shouts",
description: "Recent radio shout-outs.",
},
{
method: "POST",
path: "/api/radio/shouts",
description: "Submit a shout-out to the current DJ.",
bearer: true,
},
],
},
{
icon: "⚙️",
title: "Settings",
subtitle: "Public site configuration.",
endpoints: [
{
method: "GET",
path: "/api/settings",
description:
"Public, non-sensitive site settings (name, theme, links).",
},
],
},
{
icon: "🔑",
title: "Tokens",
subtitle: "Issue and manage personal access tokens.",
endpoints: [
{
method: "POST",
path: "/api/tokens",
description:
"Mint a new personal access token (the plaintext is shown once).",
session: true,
},
{
method: "GET",
path: "/api/me/tokens",
description: "List your personal access tokens (id, name, last used).",
session: true,
},
{
method: "DELETE",
path: "/api/me/tokens?id={id}",
description: "Revoke one of your tokens by id.",
session: true,
},
],
},
{
icon: "🎫",
title: "Tickets",
subtitle: "Help-center tickets and replies.",
endpoints: [
{
method: "GET",
path: "/api/tickets",
description: "List your own help-center tickets.",
bearer: true,
},
{
method: "POST",
path: "/api/tickets",
description: "Open a new help-center ticket.",
bearer: true,
},
{
method: "GET",
path: "/api/tickets/{id}",
description: "A single ticket you own, with its replies.",
bearer: true,
},
{
method: "POST",
path: "/api/tickets/{id}/reply",
description: "Reply to one of your tickets.",
bearer: true,
},
],
},
{
icon: "❤️",
title: "Health",
subtitle: "Service status.",
endpoints: [
{
method: "GET",
path: "/api/health",
description: "Liveness probe — reports app and database status.",
},
],
},
];
const METHOD_CLASS: Record<Method, string> = {
GET: "bg-green-600/20 text-[var(--color-text)]",
POST: "bg-[var(--color-primary)]/18 text-[var(--color-text)]",
DELETE: "bg-red-500/22 text-[var(--color-text)]",
};
function AuthTag({ endpoint }: { endpoint: Endpoint }) {
if (endpoint.bearer) {
return (
<span
className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]"
title="Requires Authorization: Bearer <token>"
>
🔒 Bearer
</span>
);
}
if (endpoint.session) {
return (
<span
className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]"
title="Requires a signed-in web session"
>
🔒 Session
</span>
);
}
return (
<span
className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]"
title="No authentication required"
>
Public
</span>
);
}
function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem 0.75rem",
padding: "0.7rem 0",
borderTop: "1px solid var(--border-subtle)",
}}
>
<code
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
fontSize: "0.85rem",
background:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
border: "1px solid var(--border-subtle)",
borderRadius: "var(--radius-sm)",
padding: "0.3rem 0.55rem",
whiteSpace: "nowrap",
maxWidth: "100%",
overflowX: "auto",
}}
>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${METHOD_CLASS[endpoint.method]}`}
>
{endpoint.method}
</span>
<span>{endpoint.path}</span>
</code>
<span className="muted" style={{ flex: "1 1 14rem", minWidth: "12rem" }}>
{endpoint.description}
</span>
<AuthTag endpoint={endpoint} />
</div>
);
}
export default function DevelopersPage() {
const totalEndpoints = GROUPS.reduce((n, g) => n + g.endpoints.length, 0);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🧩"
title="Developer API"
subtitle={`A public REST API over the hotel — ${totalEndpoints} endpoints across ${GROUPS.length} groups.`}
>
<p className="muted" style={{ margin: 0 }}>
All endpoints live under <code>/api</code> and return JSON. Most read
endpoints are open; a handful that touch your account need a token.
Browse the groups below — each row shows the method, path, what it
does and whether it needs authentication.
</p>
</ContentCard>
<ContentCard
icon="🔐"
title="Authentication"
subtitle="How to call protected endpoints."
>
<div style={{ display: "grid", gap: "0.75rem" }}>
<p className="muted" style={{ margin: 0 }}>
Most reads are <strong>open</strong> and need no credentials.
Endpoints marked{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
🔒 Bearer
</span>{" "}
require a personal access token sent in the request header:
</p>
<code
style={{
display: "block",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
fontSize: "0.85rem",
background:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
border: "1px solid var(--border-subtle)",
borderRadius: "var(--radius-sm)",
padding: "0.6rem 0.75rem",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
Authorization: Bearer &lt;your-token&gt;
</code>
<p className="muted" style={{ margin: 0 }}>
To get a token, sign in to the website and <code>POST</code> to{" "}
<code>/api/tokens</code> — the plaintext token is returned{" "}
<strong>once</strong> and never shown again, so store it safely. You
can list and revoke your tokens at <code>/api/me/tokens</code>.
These token-management endpoints are marked{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]">
🔒 Session
</span>{" "}
because they use your signed-in web session rather than a Bearer
token.
</p>
<p className="muted" style={{ margin: 0 }}>
Tokens are tied to your account: Bearer endpoints only ever return
or modify your own data (your tickets, your shouts, your DJ points).
</p>
</div>
</ContentCard>
{GROUPS.map((group) => (
<ContentCard
key={group.title}
icon={group.icon}
title={group.title}
subtitle={group.subtitle}
>
<div style={{ display: "grid" }}>
{group.endpoints.map((endpoint) => (
<EndpointRow
key={`${endpoint.method} ${endpoint.path}`}
endpoint={endpoint}
/>
))}
</div>
</ContentCard>
))}
</main>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { redirect } from "next/navigation";
import { buyBadge } from "@/actions/draw-badge";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Reads the live users + website_drawbadges tables and writes credits/badges on
// purchase — must never be statically rendered.
export const dynamic = "force-dynamic";
export const metadata = { title: "Draw a Badge" };
const DEFAULT_PRICE = 50;
type DrawBadge = {
id: bigint;
badgeUrl: string;
badgeName: string;
badgeDesc: string;
};
const BOUGHT_NOTE = (code: string) =>
`Badge "${code}" has been added to your inventory. Enjoy!`;
const ERROR_NOTE: Record<string, string> = {
invalid: "That badge is no longer available.",
credits: "You don't have enough credits to buy this badge.",
fail: "Something went wrong. Please try again.",
};
function getErrorNote(error: string): string {
if (error === "invalid") return ERROR_NOTE.invalid;
if (error === "credits") return ERROR_NOTE.credits;
return ERROR_NOTE.fail;
}
export default async function DrawBadgePage({
searchParams,
}: {
// Next 16: searchParams is a Promise.
searchParams: Promise<{ bought?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const { bought = "", error = "" } = await searchParams;
// Resolve the flat purchase price + the buyer's balance + the published
// badges, all fail-soft so the page still renders if the DB is unreachable.
let price = DEFAULT_PRICE;
let credits = 0;
let badges: DrawBadge[] = [];
try {
const raw = await siteSettings.get(
"drawbadge.price",
String(DEFAULT_PRICE),
);
const n = Number(raw);
if (Number.isFinite(n) && n >= 0) price = Math.floor(n);
} catch {
price = DEFAULT_PRICE;
}
try {
const buyer = await prisma.user.findUnique({
where: { id: Number(session.user.id) },
select: { credits: true },
});
credits = buyer?.credits ?? 0;
} catch {
credits = 0;
}
try {
badges = await prisma.websiteDrawbadges.findMany({
where: { published: true },
select: { id: true, badgeUrl: true, badgeName: true, badgeDesc: true },
orderBy: { id: "desc" },
});
} catch {
badges = [];
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎨"
title="Draw a Badge"
subtitle="Buy a community-designed badge and wear it on your profile."
>
<div className="stat-grid">
<StatBlock
value={credits.toLocaleString()}
label="Your credits"
icon="🪙"
/>
<StatBlock
value={price.toLocaleString()}
label="Price per badge"
icon="🏷️"
/>
</div>
{bought ? (
<p
role="status"
aria-live="polite"
style={{
margin: "1rem 0 0",
fontWeight: 700,
color: "var(--color-secondary-hover)",
}}
>
{BOUGHT_NOTE(bought)}
</p>
) : null}
{error ? (
<p
role="alert"
style={{
margin: "1rem 0 0",
fontWeight: 700,
color: "var(--color-danger)",
}}
>
{getErrorNote(error)}
</p>
) : null}
</ContentCard>
<ContentCard
icon="🏅"
title="Available badges"
subtitle={
badges.length > 0 ? `${badges.length} badge(s) available` : undefined
}
padded={badges.length === 0}
>
{badges.length === 0 ? (
<EmptyState icon="🎨">
No badges have been published yet. Check back soon!
</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{badges.map((b) => {
const affordable = credits >= price;
return (
<article key={String(b.id)} className="card hover">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "0.6rem",
}}
>
<div
style={{
width: 56,
height: 56,
borderRadius: "var(--radius-sm)",
display: "grid",
placeItems: "center",
background: "#f8fafc",
border: "2px solid var(--border-color)",
flexShrink: 0,
overflow: "hidden",
}}
>
{b.badgeUrl ? (
// Badge images are arbitrary external URLs stored by the
// draw-badge tool, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={b.badgeUrl}
alt=""
width={40}
height={40}
style={{ objectFit: "contain", display: "block" }}
/>
) : null}
</div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0 }}>{b.badgeName}</h3>
<span className="currency">
<span className="coin credits">cr</span>
{price.toLocaleString()}
</span>
</div>
</div>
{b.badgeDesc ? (
<p style={{ margin: "0 0 0.85rem" }}>{b.badgeDesc}</p>
) : null}
<form action={buyBadge}>
<input type="hidden" name="id" value={String(b.id)} />
<button
type="submit"
className="btn btn-primary"
style={{ width: "100%" }}
disabled={!affordable}
aria-disabled={!affordable}
>
{affordable ? "Buy badge" : "Not enough credits"}
</button>
</form>
</article>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { requestReset } from "@/actions/password-reset";
import { ContentCard } from "@/components/public/ui";
export default async function ForgotPage({
searchParams,
}: {
searchParams: Promise<{ sent?: string }>;
}) {
const t = await getTranslations("pages.forgot");
const { sent } = await searchParams;
return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard icon="🔑" title={t("title")} subtitle={t("subtitle")}>
{sent ? (
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
{t("sentNotice")}
</p>
) : (
<form
action={requestReset}
style={{ display: "grid", gap: "0.7rem" }}
>
<input
name="email"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
required
/>
<button type="submit" className="btn btn-primary">
{t("sendResetLink")}
</button>
</form>
)}
<p
className="muted"
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
>
<Link href="/login">{t("backToLogin")}</Link>
</p>
</ContentCard>
</main>
);
}
+131
View File
@@ -0,0 +1,131 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export const metadata = { title: "Friends" };
export default async function FriendsPage() {
const t = await getTranslations("pages.friends");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// Friendships are bidirectional and stored once: the session user can appear
// as either user_one_id or user_two_id, with the *other* column being the
// friend. Read both directions, then dedupe the friend ids.
const friendships = await prisma.messengerFriendships
.findMany({
where: {
OR: [{ userOneId: userId }, { userTwoId: userId }],
},
select: { userOneId: true, userTwoId: true },
})
.catch(() => []);
const friendIds = Array.from(
new Set(
friendships
.map((f) => (f.userOneId === userId ? f.userTwoId : f.userOneId))
.filter((id) => id && id !== userId),
),
);
const [friends, imagerBase] = await Promise.all([
friendIds.length
? prisma.user
.findMany({
where: { id: { in: friendIds } },
select: {
id: true,
username: true,
look: true,
motto: true,
online: true,
},
orderBy: { username: "asc" },
})
.catch(() => [])
: Promise.resolve([]),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🤝"
title={t("title")}
subtitle={
friends.length === 0
? t("emptySubtitle")
: t("subtitle", { count: friends.length })
}
/>
<ContentCard padded={friends.length === 0}>
{friends.length === 0 ? (
<EmptyState icon="🤝">{t("emptyState")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{friends.map((friend) => {
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
size: "s",
headOnly: true,
});
const isOnline = friend.online === "1";
return (
<div
key={friend.id}
className="card hover"
style={{
display: "flex",
gap: "0.85rem",
alignItems: "center",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${friend.username} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<Link href={`/u/${friend.username}`}>
{friend.username}
</Link>
</h3>
<p
className="muted"
style={{
margin: "0 0 0.4rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{friend.motto || t("noMotto")}
</p>
<OnlineBadge online={isOnline} />
</div>
</div>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
@@ -0,0 +1,97 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { postThread } from "@/actions/social";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function NewThreadPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.guildForumNew");
const { id } = await params;
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
// Auth gate: only logged-in users may open a thread.
const session = await auth();
if (!session?.user?.id) redirect("/login");
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
let guild: { id: number; name: string } | null = null;
try {
guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true, name: true },
});
} catch {
guild = null;
}
if (!guild) notFound();
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link>
</p>
<ContentCard
icon="✍️"
title={t("title")}
subtitle={t("subtitle", { guild: guild.name || t("unnamedGuild") })}
>
<form action={postThread}>
{/* Guild target drives the write; the author is taken from the session
inside the action, never from this form. */}
<input type="hidden" name="guildId" value={String(guild.id)} />
<label htmlFor="thread-subject" className="muted">
{t("subjectLabel")}
</label>
<input
id="thread-subject"
name="subject"
required
maxLength={255}
placeholder={t("subjectPlaceholder")}
style={{ width: "100%", margin: "0.4rem 0 0.9rem" }}
/>
<label htmlFor="thread-message" className="muted">
{t("messageLabel")}
</label>
<textarea
id="thread-message"
name="message"
required
rows={8}
placeholder={t("messagePlaceholder")}
style={{
width: "100%",
margin: "0.4rem 0 1rem",
resize: "vertical",
}}
/>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button type="submit" className="btn btn-primary">
{t("postThread")}
</button>
<Link
href={`/guilds/${guild.id}/forum`}
className="btn btn-outline"
>
{t("cancel")}
</Link>
</div>
</form>
</ContentCard>
</main>
);
}
+177
View File
@@ -0,0 +1,177 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
type ThreadRow = {
id: number;
openerId: number | null;
subject: string | null;
postsCount: number | null;
createdAt: number | null;
updatedAt: number | null;
pinned: number | null;
locked: number | null;
};
function formatTimestamp(ts: number | null | undefined): string {
if (!ts) return "";
return new Date(ts * 1000).toISOString().slice(0, 10);
}
export default async function GuildForumPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const t = await getTranslations("pages.guildForum");
const { id } = await params;
const guildId = Number(id);
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
let guild: { id: number; name: string; badge: string } | null = null;
try {
guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true, name: true, badge: true },
});
} catch {
guild = null;
}
if (!guild) notFound();
// Threads for this guild. Pinned first, then most-recently active.
let threads: ThreadRow[] = [];
try {
threads = await prisma.guildsForumsThreads.findMany({
where: { guildId: guild.id, state: 0 },
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }, { id: "desc" }],
take: 100,
select: {
id: true,
openerId: true,
subject: true,
postsCount: true,
createdAt: true,
updatedAt: true,
pinned: true,
locked: true,
},
});
} catch {
threads = [];
}
// Resolve opener usernames in one query (no relation exists in the schema, so
// join the users table manually by id). Falls back to the raw author id.
const openerIds = Array.from(
new Set(threads.map((t) => t.openerId ?? 0).filter((v) => v > 0)),
);
let users: { id: number; username: string }[] = [];
try {
users = openerIds.length
? await prisma.user.findMany({
where: { id: { in: openerIds } },
select: { id: true, username: true },
})
: [];
} catch {
users = [];
}
const usernameById = new Map(users.map((u) => [u.id, u.username]));
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}>
<Link href={`/guilds/${guild.id}`}>{t("backToGuild")}</Link>
</p>
<ContentCard
icon="🗣️"
title={t("forumTitle", { guild: guild.name || t("unnamedGuild") })}
subtitle={`${threads.length} ${threads.length === 1 ? t("threadSingular") : t("threadPlural")}`}
action={
<Link
href={`/guilds/${guild.id}/forum/new`}
className="btn btn-primary"
>
{t("newThread")}
</Link>
}
/>
<ContentCard padded={threads.length === 0}>
{threads.length === 0 ? (
<EmptyState icon="🗣️">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th style={{ padding: "0.6rem 0.75rem" }}>{t("colSubject")}</th>
<th style={{ padding: "0.6rem 0.75rem" }}>{t("colAuthor")}</th>
<th style={{ padding: "0.6rem 0.75rem" }}>{t("colPosts")}</th>
<th style={{ padding: "0.6rem 0.75rem" }}>{t("colDate")}</th>
</tr>
</thead>
<tbody>
{threads.map((t2) => {
const opener = t2.openerId ?? 0;
const username =
opener > 0 ? usernameById.get(opener) : undefined;
return (
<tr key={t2.id}>
<td style={{ padding: "0.6rem 0.75rem" }}>
<span style={{ fontWeight: 700 }}>
{t2.subject?.trim() || t("noSubject")}
</span>
{t2.pinned ? (
<span
className="online-badge online"
style={{ marginLeft: "0.5rem" }}
>
📌 {t("pinned")}
</span>
) : null}
{t2.locked ? (
<span
className="online-badge"
style={{ marginLeft: "0.5rem" }}
>
🔒 {t("locked")}
</span>
) : null}
</td>
<td style={{ padding: "0.6rem 0.75rem" }}>
{username ? (
<Link href={`/u/${username}`}>{username}</Link>
) : (
<span className="muted">
{opener > 0
? t("userNumber", { id: opener })
: t("unknown")}
</span>
)}
</td>
<td style={{ padding: "0.6rem 0.75rem" }}>
{t2.postsCount ?? 0}
</td>
<td className="muted" style={{ padding: "0.6rem 0.75rem" }}>
{formatTimestamp(t2.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+241
View File
@@ -0,0 +1,241 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
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 t = await getTranslations("pages.guild");
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 guildOwnerUserId = guild.userId;
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 === guildOwnerUserId,
};
})
.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 style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}>
<Link href="/guilds">{t("allGuilds")}</Link>
</p>
<ContentCard>
<div
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, flex: 1 }}>
<h1 style={{ margin: "0 0 0.35rem" }}>
{guild.name || t("unnamedGuild")}
</h1>
<p style={{ margin: 0 }}>
{guild.description || (
<span className="muted">{t("noDescription")}</span>
)}
</p>
</div>
</div>
<div className="stat-grid" style={{ marginTop: "1.1rem" }}>
<StatBlock
icon="👥"
value={members.length}
label={
members.length === 1 ? t("memberSingular") : t("memberPlural")
}
/>
{created ? (
<StatBlock icon="📅" value={created} label={t("founded")} />
) : null}
<StatBlock
icon="🚪"
value={
<Link
href={`/guilds/${guild.id}/forum`}
style={{ textDecoration: "none" }}
>
{t("forumLink")}
</Link>
}
label={t("discussion")}
/>
</div>
</ContentCard>
<ContentCard
icon="👥"
title={t("membersTitle")}
subtitle={`${members.length} ${members.length === 1 ? t("memberLowerSingular") : t("memberLowerPlural")}`}
padded={members.length === 0}
>
{members.length === 0 ? (
<EmptyState icon="👥">{t("noMembers")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{members.map((m) => (
<div
key={m.userId}
className="card hover"
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.2rem", fontSize: "1rem" }}>
<Link href={`/u/${m.username}`}>{m.username}</Link>
</h3>
{m.isOwner ? (
<span
className="online-badge online"
style={{ marginBottom: "0.25rem" }}
>
👑 {t("owner")}
</span>
) : null}
<p
className="muted"
style={{ margin: m.isOwner ? "0.25rem 0 0" : 0 }}
>
{m.motto || t("noMotto")}
</p>
</div>
</div>
))}
</div>
)}
</ContentCard>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
export const metadata = { title: "Guilds" };
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 t = await getTranslations("pages.guilds");
const guilds = await getGuilds();
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={guilds.length === 0}>
{guilds.length === 0 ? (
<EmptyState icon="🚪">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{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", fontSize: "1rem" }}>
{g.name || t("unnamedGuild")}
</h3>
<p className="muted" style={{ margin: 0 }}>
{g.description
? excerpt(g.description, 120)
: t("noDescription")}
</p>
</div>
</Link>
))}
</div>
)}
</ContentCard>
</main>
);
}
+165
View File
@@ -0,0 +1,165 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
import { sanitize } from "@/lib/sanitize";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
const HELP_IMAGE_BASE = "/assets/images/help-center";
type HelpCategory = {
id: bigint;
name: string;
content: string;
position: number;
imageUrl: string | null;
buttonText: string | null;
buttonUrl: string | null;
buttonColor: string;
buttonBorderColor: string;
smallBox: boolean;
};
type HelpCategoryLink = {
id: bigint;
name: string;
position: number;
};
// Faithful to AtomCMS: name / content / button_text carry a `:hotel` placeholder
// replaced with the configured hotel name before display.
function withHotel(text: string | null | undefined, hotelName: string): string {
return (text ?? "").split(":hotel").join(hotelName);
}
export default async function HelpCategoryPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
const t = await getTranslations("pages.helpCategory");
const { category } = await params;
let categoryId: bigint;
try {
categoryId = BigInt(category);
} catch {
notFound();
}
let cat: HelpCategory | null = null;
try {
cat = await prisma.websiteHelpCenterCategories.findUnique({
where: { id: categoryId },
select: {
id: true,
name: true,
content: true,
position: true,
imageUrl: true,
buttonText: true,
buttonUrl: true,
buttonColor: true,
buttonBorderColor: true,
smallBox: true,
},
});
} catch {
cat = null;
}
if (!cat) notFound();
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
// Sibling help topics, so the reader can jump between categories. Isolated
// query: a DB hiccup degrades this list to empty rather than 500-ing.
let siblings: HelpCategoryLink[] = [];
try {
siblings = await prisma.websiteHelpCenterCategories.findMany({
where: { id: { not: categoryId } },
orderBy: { position: "asc" },
select: { id: true, name: true, position: true },
take: 50,
});
} catch {
siblings = [];
}
const title = withHotel(cat.name, hotelName);
const content = withHotel(cat.content, hotelName);
const buttonText = withHotel(cat.buttonText, hotelName);
const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : "";
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}>
<Link href="/help">{t("back")}</Link>
</p>
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt=""
style={{
float: "right",
maxWidth: 160,
height: "auto",
margin: "0 0 0.75rem 1rem",
}}
/>
) : null}
{/* content is author-supplied HTML — sanitised server-side. */}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised article body */}
<div
style={{ lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: sanitize(content) }}
/>
{hasButton ? (
<div style={{ clear: "both", marginTop: "1rem" }}>
<a
href={cat.buttonUrl ?? "#"}
className="btn"
style={{
backgroundColor: cat.buttonColor,
border: `2px solid ${cat.buttonBorderColor}`,
}}
>
{buttonText}
</a>
</div>
) : null}
</ContentCard>
{siblings.length > 0 ? (
<ContentCard
icon="🧭"
title={t("moreTitle")}
subtitle={t("moreSubtitle")}
>
<div className="card-grid sm-2 lg-3">
{siblings.map((s) => (
<Link
key={String(s.id)}
href={`/help/${String(s.id)}`}
className="card hover"
style={{ display: "block", color: "inherit" }}
>
<strong>{withHotel(s.name, hotelName)}</strong>
</Link>
))}
</div>
</ContentCard>
) : null}
</main>
);
}
+247
View File
@@ -0,0 +1,247 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
type HelpCategory = {
id: bigint;
name: string;
content: string;
imageUrl: string | null;
buttonText: string | null;
buttonUrl: string | null;
buttonColor: string;
buttonBorderColor: string;
smallBox: boolean;
};
type RuleCategory = {
id: bigint;
name: string;
description: string;
badge: string;
};
type Rule = {
id: bigint;
categoryId: bigint | null;
paragraph: string;
rule: string;
};
function isExternal(url: string): boolean {
return /^https?:\/\//i.test(url);
}
export default async function HelpCenterPage() {
const t = await getTranslations("pages.help");
let categories: HelpCategory[] = [];
try {
categories = await prisma.websiteHelpCenterCategories.findMany({
orderBy: [{ position: "asc" }, { id: "asc" }],
select: {
id: true,
name: true,
content: true,
imageUrl: true,
buttonText: true,
buttonUrl: true,
buttonColor: true,
buttonBorderColor: true,
smallBox: true,
},
});
} catch {
// DB unavailable — render the shell without help categories.
}
let ruleCategories: RuleCategory[] = [];
try {
ruleCategories = await prisma.websiteRuleCategories.findMany({
orderBy: { id: "asc" },
select: { id: true, name: true, description: true, badge: true },
});
} catch {
// DB unavailable — render the shell without rule categories.
}
let rules: Rule[] = [];
try {
rules = await prisma.websiteRules.findMany({
orderBy: [{ categoryId: "asc" }, { paragraph: "asc" }, { id: "asc" }],
select: { id: true, categoryId: true, paragraph: true, rule: true },
});
} catch {
// DB unavailable — render the shell without rules.
}
// Group the rules under their categories.
const rulesByCategory = new Map<string, Rule[]>();
for (const r of rules) {
const key = r.categoryId === null ? "" : String(r.categoryId);
const list = rulesByCategory.get(key);
if (list) {
list.push(r);
} else {
rulesByCategory.set(key, [r]);
}
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="❓"
title={t("title")}
subtitle={t("subtitle")}
action={
<Link className="btn btn-outline" href="/help/tickets">
{t("myTickets")}
</Link>
}
/>
<ContentCard
icon="🧭"
title={t("topicsTitle")}
subtitle={t("topicsSubtitle")}
padded={categories.length === 0}
>
{categories.length === 0 ? (
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{categories.map((c) => (
<article key={String(c.id)} className="card hover">
<div
style={{
display: "flex",
gap: "0.85rem",
alignItems: "flex-start",
}}
>
{c.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={c.imageUrl}
alt=""
width={48}
height={48}
style={{ flexShrink: 0, objectFit: "contain" }}
/>
) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>
{c.name}
</h3>
<p className="muted" style={{ margin: 0 }}>
{c.content}
</p>
{c.buttonUrl && c.buttonText ? (
<div style={{ marginTop: "0.85rem" }}>
{isExternal(c.buttonUrl) ? (
<a
className="btn btn-outline"
href={c.buttonUrl}
target="_blank"
rel="noreferrer"
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</a>
) : (
<Link
className="btn btn-outline"
href={c.buttonUrl}
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</Link>
)}
</div>
) : null}
</div>
</div>
</article>
))}
</div>
)}
</ContentCard>
<ContentCard
icon="📜"
title={t("rulesTitle")}
subtitle={t("rulesSubtitle")}
padded={ruleCategories.length === 0}
>
{ruleCategories.length === 0 ? (
<EmptyState icon="📜">{t("rulesEmpty")}</EmptyState>
) : (
<div style={{ display: "grid", gap: "1rem", padding: "1rem" }}>
{ruleCategories.map((cat) => {
const catRules = rulesByCategory.get(String(cat.id)) ?? [];
return (
<article key={String(cat.id)} className="card">
<div
style={{
display: "flex",
alignItems: "baseline",
gap: "0.6rem",
flexWrap: "wrap",
}}
>
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
{cat.name}
</h3>
{cat.badge ? (
<span className="muted">{cat.badge}</span>
) : null}
</div>
{cat.description ? (
<p className="muted" style={{ margin: "0.35rem 0 0" }}>
{cat.description}
</p>
) : null}
{catRules.length === 0 ? (
<p className="muted" style={{ marginBottom: 0 }}>
{t("categoryEmpty")}
</p>
) : (
<ul
style={{ margin: "0.85rem 0 0", paddingLeft: "1.2rem" }}
>
{catRules.map((r) => (
<li
key={String(r.id)}
style={{ marginBottom: "0.5rem" }}
>
{r.paragraph ? (
<strong style={{ marginRight: "0.4rem" }}>
{r.paragraph}
</strong>
) : null}
{r.rule}
</li>
))}
</ul>
)}
</article>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createTicket } from "@/actions/help-tickets";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function HelpTicketsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.helpTickets");
const userId = Number(session.user.id);
let tickets: Awaited<
ReturnType<typeof prisma.websiteHelpCenterTickets.findMany>
> = [];
try {
tickets = await prisma.websiteHelpCenterTickets.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 100,
});
} catch {
tickets = [];
}
const openCount = tickets.filter((t) => t.open).length;
const closedCount = tickets.length - openCount;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎫" title={t("title")} subtitle={t("subtitle")}>
<div className="stat-grid">
<StatBlock icon="🎫" value={tickets.length} label={t("statTotal")} />
<StatBlock icon="📬" value={openCount} label={t("statOpen")} />
<StatBlock icon="✅" value={closedCount} label={t("statClosed")} />
</div>
</ContentCard>
<ContentCard icon="✏️" title={t("newTitle")} subtitle={t("newSubtitle")}>
<form action={createTicket}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}
>
<input
name="title"
placeholder={t("subjectPlaceholder")}
maxLength={255}
required
/>
<textarea
name="content"
placeholder={t("contentPlaceholder")}
rows={5}
maxLength={5000}
required
/>
<div>
<button type="submit" className="btn btn-primary">
{t("submit")}
</button>
</div>
</div>
</form>
</ContentCard>
<ContentCard
icon="📋"
title={t("listTitle")}
subtitle={t("listSubtitle")}
padded={tickets.length === 0}
>
{tickets.length === 0 ? (
<EmptyState icon="🎫">{t("listEmpty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colId")}</th>
<th>{t("colSubject")}</th>
<th>{t("colStatus")}</th>
<th>{t("colCreated")}</th>
</tr>
</thead>
<tbody>
{tickets.map((t2) => (
<tr key={String(t2.id)}>
<td>{String(t2.id)}</td>
<td>{t2.title}</td>
<td>
<span className={`online-badge${t2.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{t2.open ? t("statusOpen") : t("statusClosed")}
</span>
</td>
<td className="muted">{formatDate(t2.createdAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { Navigation } from "@/components/navigation";
import RadioPlayerGate from "@/components/public/radio-player-gate";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header";
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*/
export default function SiteLayout({ children }: { children: ReactNode }) {
return (
<>
<TopHeader />
<SiteHeader />
<Navigation />
<main>
<div className="mx-auto mt-6 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-6 px-4 py-4 md:mt-0 md:p-6">
<div className="col-span-12 min-w-0">{children}</div>
</div>
</main>
<SiteFooter />
<RadioPlayerGate />
</>
);
}
+246
View File
@@ -0,0 +1,246 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
export const metadata = { title: "Leaderboard" };
// 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" },
{ key: "achievement", label: "Achievement", coin: "" },
{ key: "respects", label: "Respects", coin: "" },
{ key: "onlinetime", label: "Online Time", coin: "" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
const CURRENCY_TYPE: Record<"diamonds" | "duckets", number> = {
diamonds: 5,
duckets: 0,
};
function formatValue(key: TabKey, value: number): string {
if (key === "onlinetime") {
const hours = Math.floor(value / 3600);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
return `${hours}h`;
}
return value.toLocaleString();
}
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 [];
}
}
async function loadSettingsRows(
field: "achievementScore" | "respectsReceived" | "onlineTime" | "hofPoints",
): Promise<Row[]> {
try {
const top = await prisma.usersSettings.findMany({
orderBy: { [field]: "desc" },
take: 20,
select: { userId: true, [field]: 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 as unknown as Array<{ userId: number; [k: string]: unknown }>)
.map((t) => {
const u = byId.get(t.userId as number);
if (!u) return null;
// eslint-disable-next-line security/detect-object-injection -- field is union of known keys
return {
username: u.username,
look: u.look,
value: Number(t[field] ?? 0),
};
})
.filter((r): r is Row => r !== null);
} catch {
return [];
}
}
const SETTINGS_FIELD: Record<
"achievement" | "respects" | "onlinetime",
"achievementScore" | "respectsReceived" | "onlineTime"
> = {
achievement: "achievementScore",
respects: "respectsReceived",
onlinetime: "onlineTime",
};
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<{ type?: string }>;
}) {
const t = await getTranslations("pages.leaderboard");
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) ?? TABS[0];
const [rows, imagerBase] = await Promise.all([
active === "credits"
? loadCreditsRows()
: active === "diamonds" || active === "duckets"
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
loadCurrencyRows(CURRENCY_TYPE[active])
: loadSettingsRows(
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
const valueOnly =
active === "achievement" ||
active === "respects" ||
active === "onlinetime";
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="📊"
title={t("title")}
subtitle={t("subtitle", { currency: t(`tab_${active}`).toLowerCase() })}
/>
<div className="nav" style={{ gap: "0.5rem", flexWrap: "wrap" }}>
{TABS.map((t2) => (
<Link
key={t2.key}
href={`/leaderboard?type=${t2.key}`}
className={`btn ${t2.key === active ? "btn-primary" : "btn-outline"}`}
>
{t(`tab_${t2.key}`)}
</Link>
))}
</div>
<ContentCard padded={rows.length === 0}>
{rows.length === 0 ? (
<EmptyState icon="📊">{t("emptyState")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th style={{ width: "3rem" }}>#</th>
<th style={{ width: "3.5rem" }}>{t("colAvatar")}</th>
<th>{t("colPlayer")}</th>
<th style={{ textAlign: "right" }}>{t(`tab_${active}`)}</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>
<RankBadge position={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" }}>
{valueOnly ? (
<strong>{formatValue(active, row.value)}</strong>
) : (
<span
className="currency"
style={{ display: "inline-flex" }}
>
<span className={`coin ${activeTab.coin}`} />
{row.value.toLocaleString()}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
import { ContentCard } from "@/components/public/ui";
export default function LoginPage() {
const t = useTranslations("pages.login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
try {
if (!needs2fa) {
const pre = await precheckLogin(username, password);
if (pre === "invalid") {
setError(t("errorInvalidCredentials"));
return;
}
if (pre === "twofactor") {
setNeeds2fa(true);
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
);
return;
}
window.location.href = "/";
} finally {
setPending(false);
}
}
return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard
icon={needs2fa ? "🔒" : "🔑"}
title={t("title")}
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
>
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
/>
{needs2fa ? (
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
autoFocus
/>
) : null}
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
</button>
</form>
{!needs2fa ? (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
margin: "1rem 0",
}}
>
<span
style={{
flex: 1,
height: 1,
background: "var(--border-color)",
}}
/>
<span className="muted">{t("or")}</span>
<span
style={{
flex: 1,
height: 1,
background: "var(--border-color)",
}}
/>
</div>
<div style={{ display: "grid", gap: "0.5rem" }}>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("discord", { callbackUrl: "/" })}
>
{t("continueWithDiscord")}
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("google", { callbackUrl: "/" })}
>
{t("continueWithGoogle")}
</button>
</div>
</>
) : null}
{error ? (
<p
style={{
color: "var(--color-danger)",
textAlign: "center",
marginBottom: 0,
}}
>
{error}
</p>
) : null}
<p
className="muted"
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
>
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
<Link href="/forgot">{t("forgotPassword")}</Link>
</p>
</ContentCard>
</main>
);
}
+31
View File
@@ -0,0 +1,31 @@
import LogoGenerator from "@/components/public/logo-generator";
import { ContentCard } from "@/components/public/ui";
export const metadata = { title: "Logo generator" };
/**
* Public logo generator (AtomCMS logo-generator.blade). Server wrapper that
* renders the atom-styled ContentCard header and hands off to the fully
* client-side <LogoGenerator>, which does all styling, live preview, and PNG
* export in the browser (no server data, no DB).
*/
export default function LogoPage() {
return (
<main
style={{
display: "grid",
gap: "1.5rem",
maxWidth: 920,
margin: "0 auto",
}}
>
<ContentCard
icon="🎨"
title="Logo generator"
subtitle="Design a logo for your hotel — pick a font, colours and size, then download it as a PNG."
/>
<LogoGenerator />
</main>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
export default async function MaintenancePage() {
const t = await getTranslations("pages.maintenance");
const [hotel, message] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
siteSettings.get(
"maintenance_message",
"We are currently performing maintenance. Please check back soon.",
),
]);
return (
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard
icon="🛠️"
title={t("title", { hotel: hotel ?? "Atom" })}
subtitle={t("subtitle")}
>
<p style={{ marginTop: 0 }}>{message}</p>
<p className="muted" style={{ marginBottom: 0 }}>
{t("staffCanLogIn")}
</p>
</ContentCard>
</main>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
export const metadata = { title: "Marketplace" };
// state == 1 → an active, unsold offer in the Arcturus marketplace.
const STATE_ACTIVE = 1;
type RelativeTimeTranslator = (
key: string,
values?: Record<string, number>,
) => string;
function listedAgo(ts: number, t: RelativeTimeTranslator): string {
const now = Math.floor(Date.now() / 1000);
const diff = Math.max(0, now - ts);
if (diff < 60) return t("justNow");
const mins = Math.floor(diff / 60);
if (mins < 60) return t("minutesAgo", { count: mins });
const hours = Math.floor(mins / 60);
if (hours < 24) return t("hoursAgo", { count: hours });
const days = Math.floor(hours / 24);
return t("daysAgo", { count: days });
}
function listedAt(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function MarketplacePage() {
const t = await getTranslations("pages.marketplace");
let offers: {
id: number;
itemId: number;
userId: number;
price: number;
timestamp: number;
}[] = [];
try {
offers = await prisma.marketplaceItems.findMany({
where: { state: STATE_ACTIVE },
select: {
id: true,
itemId: true,
userId: true,
price: true,
timestamp: true,
},
orderBy: { timestamp: "desc" },
take: 100,
});
} catch {
offers = [];
}
// Resolve seller usernames in a single query (read-only display join).
let sellers = new Map<number, string>();
try {
const ids = [...new Set(offers.map((o) => o.userId))];
if (ids.length > 0) {
const users = await prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, username: true },
});
sellers = new Map(users.map((u) => [u.id, u.username]));
}
} catch {
sellers = new Map();
}
const total = offers.reduce((sum, o) => sum + o.price, 0);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🛍️" title={t("title")} subtitle={t("subtitle")}>
<div className="stat-grid">
<StatBlock
value={offers.length.toLocaleString()}
label={t("activeOffers")}
icon="🏷️"
/>
<StatBlock
value={new Set(offers.map((o) => o.userId)).size.toLocaleString()}
label={t("sellers")}
icon="👥"
/>
<StatBlock
value={`${total.toLocaleString()} cr`}
label={t("totalValue")}
icon="🪙"
/>
</div>
</ContentCard>
<ContentCard
icon="📋"
title={t("activeListings")}
subtitle={offers.length > 0 ? t("mostRecentFirst") : undefined}
padded={offers.length === 0}
>
{offers.length === 0 ? (
<EmptyState icon="🛍️">{t("empty")}</EmptyState>
) : (
<div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>{t("colOffer")}</th>
<th>{t("colItem")}</th>
<th>{t("colSeller")}</th>
<th>{t("colPrice")}</th>
<th>{t("colListed")}</th>
</tr>
</thead>
<tbody>
{offers.map((o) => (
<tr key={o.id}>
<td className="muted">#{o.id}</td>
<td>{t("itemLabel", { id: o.itemId })}</td>
<td>
{sellers.get(o.userId) ??
t("userLabel", { id: o.userId })}
</td>
<td style={{ fontWeight: 700 }}>
{o.price.toLocaleString()} cr
</td>
<td className="muted" title={listedAt(o.timestamp)}>
{listedAgo(o.timestamp, t)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ContentCard>
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
/**
* Copies the user's referral link to the clipboard. Mirrors AtomCMS's
* copyCode() helper on the /me page, but resolves the link to an absolute URL
* (the server only knows a relative path, so we prepend the current origin).
*/
export default function CopyReferralButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
const absolute =
typeof window !== "undefined" && value.startsWith("/")
? `${window.location.origin}${value}`
: value;
try {
await navigator.clipboard.writeText(absolute);
} catch {
// Older browsers / insecure contexts: fall back to a hidden textarea.
const ta = document.createElement("textarea");
ta.value = absolute;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} catch {
/* give up silently */
}
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<button type="button" className="btn btn-secondary" onClick={copy}>
{copied ? "Copied!" : "Copy link"}
</button>
);
}
+363
View File
@@ -0,0 +1,363 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react";
import { claimReferral } from "@/actions/referral";
import {
ContentCard,
EmptyState,
OnlineBadge,
StatBlock,
} from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import CopyReferralButton from "./CopyReferralButton";
export const dynamic = "force-dynamic";
export const metadata = { title: "My dashboard" };
type SearchParams = Promise<{ claimed?: string; error?: string }>;
/** Inline-styled feedback banner using the theme's CSS variables. */
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
const accent =
tone === "error"
? "var(--color-danger)"
: tone === "warning"
? "var(--color-accent)"
: "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
const ERROR_MESSAGES: Record<string, string> = {
not_enough: "You do not have enough referrals to claim your reward yet.",
no_referrals:
"You haven't referred anyone yet — share your link to get started.",
bad_config:
"The referral reward is not configured correctly. Please contact a staff member.",
error: "Something went wrong while claiming your reward. Please try again.",
};
function getErrorMessage(error: string): string {
if (error === "not_enough") return ERROR_MESSAGES.not_enough;
if (error === "no_referrals") return ERROR_MESSAGES.no_referrals;
if (error === "bad_config") return ERROR_MESSAGES.bad_config;
return ERROR_MESSAGES.error;
}
export default async function MePage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
const { claimed, error } = await searchParams;
let content: ReactNode;
try {
// --- Account + reward config -----------------------------------------
const [user, imagerBase, neededRaw, amountRaw, currencyRaw] =
await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
look: true,
motto: true,
rank: true,
credits: true,
accountCreated: true,
},
}),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
prisma.websiteSetting
.findUnique({
where: { key: "referrals_needed" },
select: { value: true },
})
.catch(() => null),
prisma.websiteSetting
.findUnique({
where: { key: "referral_reward_amount" },
select: { value: true },
})
.catch(() => null),
prisma.websiteSetting
.findFirst({
where: {
key: {
in: [
"referral_reward_currency_type",
"referral_reward_currency",
],
},
},
select: { value: true },
})
.catch(() => null),
]);
// The session was already validated above; a missing row here is an
// exceptional state, so surface the error card rather than redirect from
// inside the try (a redirect() throw would be swallowed by the catch).
if (!user) throw new Error("user-not-found");
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
const rewardAmount = Number.parseInt(amountRaw?.value ?? "30", 10) || 0;
const rewardCurrency = (currencyRaw?.value ?? "diamonds")
.trim()
.toLowerCase();
// --- Referral tally ---------------------------------------------------
const referrals = await prisma.userReferrals
.findFirst({
where: { userId },
select: { referralsTotal: true },
orderBy: { id: "desc" },
})
.catch(() => null);
const referralTotal = referrals ? Number(referrals.referralsTotal) : 0;
const canClaim = referralTotal >= needed;
const remaining = Math.max(0, needed - referralTotal);
// --- Online friends ---------------------------------------------------
// Friendships are bidirectional and stored once: the user can be either
// user_one_id or user_two_id; the OTHER column is the friend.
const friendships = await prisma.messengerFriendships
.findMany({
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
select: { userOneId: true, userTwoId: true },
})
.catch(() => []);
const friendIds = Array.from(
new Set(
friendships
.map((f) => (f.userOneId === userId ? f.userTwoId : f.userOneId))
.filter((id) => id && id !== userId),
),
);
const friends = friendIds.length
? await prisma.user
.findMany({
where: { id: { in: friendIds } },
select: {
id: true,
username: true,
look: true,
motto: true,
online: true,
},
})
.catch(() => [])
: [];
const onlineFriends = friends.filter((f) => f.online === "1");
const registered = new Date(user.accountCreated * 1000)
.toISOString()
.slice(0, 10);
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
content = (
<>
{/* Feedback banners --------------------------------------------- */}
{claimed ? (
<div role="status" style={feedbackStyle("success")}>
Woah! You have successfully claimed your reward — keep up the good
work!
</div>
) : null}
{error ? (
<div role="alert" style={feedbackStyle("error")}>
{getErrorMessage(error)}
</div>
) : null}
{/* Header card -------------------------------------------------- */}
<ContentCard
icon="🏠"
title={user.username}
subtitle={user.motto || "Welcome back!"}
/>
{/* Stat tiles --------------------------------------------------- */}
<div className="card-grid sm-2 lg-3">
<StatBlock
icon="💰"
value={user.credits.toLocaleString()}
label="Credits"
/>
<StatBlock icon="🎖️" value={`Rank ${user.rank}`} label="Hotel rank" />
<StatBlock icon="📅" value={registered} label="Registered" />
</div>
{/* Online friends ----------------------------------------------- */}
<ContentCard
icon="🟢"
title="Online friends"
subtitle={
onlineFriends.length === 0
? "None of your friends are online right now"
: `${onlineFriends.length} of your ${friends.length} friends online`
}
action={<Link href="/friends">View all</Link>}
padded={onlineFriends.length === 0}
>
{onlineFriends.length === 0 ? (
<EmptyState icon="💤">
No friends online right now. Check back later!
</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{onlineFriends.map((friend) => {
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
size: "s",
headOnly: true,
});
return (
<div
key={friend.id}
className="card hover"
style={{
display: "flex",
gap: "0.85rem",
alignItems: "center",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${friend.username} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<Link href={`/u/${friend.username}`}>
{friend.username}
</Link>
</h3>
<p
className="muted"
style={{
margin: "0 0 0.4rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{friend.motto || "No motto"}
</p>
<OnlineBadge online />
</div>
</div>
);
})}
</div>
)}
</ContentCard>
{/* Referral card ------------------------------------------------ */}
<ContentCard
icon="🤝"
title={`Refer a friend (${referralTotal}/${needed})`}
subtitle="Refer new users and earn in-game rewards"
>
<div style={{ display: "grid", gap: "1rem" }}>
<p style={{ margin: 0 }}>
{rewardAmount > 0
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
: `Invite ${needed} users through your referral link to claim a reward!`}
</p>
<div role="note" style={feedbackStyle("warning")}>
⚠️ Boosting referrals by making your own accounts will lead to
removal of all progress, currency, inventory and a potential ban.
</div>
<div>
<label
htmlFor="referral-link"
className="muted"
style={{
display: "block",
marginBottom: "0.35rem",
fontSize: "0.85rem",
}}
>
Your referral link
</label>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input
id="referral-link"
type="text"
readOnly
defaultValue={referralLink}
className="input"
style={{ flex: "1 1 240px", minWidth: 0 }}
/>
<CopyReferralButton value={referralLink} />
</div>
</div>
{canClaim ? (
<form action={claimReferral}>
<button
type="submit"
className="btn btn-primary"
style={{ width: "100%" }}
>
Claim your referral reward!
</button>
</form>
) : (
<button
type="button"
disabled
className="btn"
style={{ width: "100%", opacity: 0.6, cursor: "not-allowed" }}
>
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
</button>
)}
</div>
</ContentCard>
</>
);
} catch {
content = (
<ContentCard icon="⚠️" title="My dashboard">
<EmptyState icon="⚠️">
We couldn&apos;t load your dashboard right now. Please try again
shortly.
</EmptyState>
</ContentCard>
);
}
return <main style={{ display: "grid", gap: "1.5rem" }}>{content}</main>;
}
+230
View File
@@ -0,0 +1,230 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { acceptFriend } from "@/actions/messenger";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
function formatTimestamp(seconds: number): string {
if (!seconds) return "";
// sended_on is a unix timestamp in seconds (emulator convention).
return new Date(seconds * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function MessagesPage() {
const t = await getTranslations("pages.messages");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// Pending friend requests addressed to the session user, the most recent
// offline messages they've received, and the avatar-imager base URL.
let requests: { id: number; userFromId: number }[] = [];
let messages: {
id: number;
userFromId: number;
message: string;
sendedOn: number;
}[] = [];
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
try {
const [reqRows, msgRows, base] = await Promise.all([
prisma.messengerFriendrequests.findMany({
where: { userToId: userId },
select: { id: true, userFromId: true },
orderBy: { id: "desc" },
take: 100,
}),
prisma.messengerOffline.findMany({
where: { userId },
select: { id: true, userFromId: true, message: true, sendedOn: true },
orderBy: { sendedOn: "desc" },
take: 50,
}),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
requests = reqRows;
messages = msgRows;
if (base) imagerBase = base;
} catch {
requests = [];
messages = [];
}
// Resolve usernames/looks for every counterparty referenced above.
const otherIds = Array.from(
new Set([
...requests.map((r) => r.userFromId),
...messages.map((m) => m.userFromId),
]),
).filter((id) => id && id > 0);
let userById = new Map<
number,
{ id: number; username: string; look: string }
>();
if (otherIds.length > 0) {
try {
const users = await prisma.user.findMany({
where: { id: { in: otherIds } },
select: { id: true, username: true, look: true },
});
userById = new Map(users.map((u) => [u.id, u]));
} catch {
userById = new Map();
}
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="✉️" title={t("title")} subtitle={t("subtitle")} />
{/* ── Pending friend requests ───────────────────────────── */}
<ContentCard
icon="🙋"
title={t("requestsTitle")}
subtitle={
requests.length === 0
? t("requestsEmptySubtitle")
: t("requestsSubtitle", { count: requests.length })
}
padded={requests.length === 0}
>
{requests.length === 0 ? (
<EmptyState icon="🙋">{t("requestsEmptyState")}</EmptyState>
) : (
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{requests.map((req) => {
const from = userById.get(req.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s",
headOnly: true,
});
return (
<div
key={req.id}
className="card"
style={{
display: "flex",
gap: "0.85rem",
alignItems: "center",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${from?.username ?? "User"} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<p style={{ margin: 0 }}>
{from ? (
<Link href={`/u/${from.username}`}>
<strong>{from.username}</strong>
</Link>
) : (
<strong>
{t("unknownUser", { id: req.userFromId })}
</strong>
)}
</p>
<p className="muted" style={{ margin: 0 }}>
{t("wantsToBeFriend")}
</p>
</div>
<form action={acceptFriend}>
<input
type="hidden"
name="requestId"
value={String(req.id)}
/>
<button type="submit" className="btn btn-secondary">
{t("accept")}
</button>
</form>
</div>
);
})}
</div>
)}
</ContentCard>
{/* ── Offline messages ──────────────────────────────────── */}
<ContentCard
icon="💬"
title={t("offlineTitle")}
subtitle={
messages.length === 0
? t("offlineEmptySubtitle")
: t("offlineSubtitle", { count: messages.length })
}
padded={messages.length === 0}
>
{messages.length === 0 ? (
<EmptyState icon="💬">{t("offlineEmptyState")}</EmptyState>
) : (
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{messages.map((msg) => {
const from = userById.get(msg.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s",
headOnly: true,
});
const when = formatTimestamp(msg.sendedOn);
return (
<article
key={msg.id}
className="card"
style={{
display: "flex",
gap: "0.85rem",
alignItems: "flex-start",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${from?.username ?? "User"} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<p
className="muted"
style={{ marginTop: 0, marginBottom: "0.35rem" }}
>
{from ? (
<Link href={`/u/${from.username}`}>
{from.username}
</Link>
) : (
<span>{t("unknownUser", { id: msg.userFromId })}</span>
)}
{when ? <span> · {when}</span> : null}
</p>
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{msg.message}
</p>
</div>
</article>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+313
View File
@@ -0,0 +1,313 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { sanitize } from "@/lib/sanitize";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const { slug } = await params;
const article = await prisma.websiteArticles
.findUnique({ where: { slug }, select: { title: true, shortStory: true } })
.catch(() => null);
if (!article) return { title: "Article" };
return {
title: article.title,
description: excerpt(article.shortStory, 160),
alternates: { canonical: `${appUrl}/news/${slug}` },
openGraph: {
title: article.title,
description: excerpt(article.shortStory, 160),
type: "article",
url: `${appUrl}/news/${slug}`,
},
twitter: { card: "summary", title: article.title },
};
}
// The reaction set offered by the voting UI. Must stay in sync with
// ALLOWED_REACTIONS in src/actions/article-reactions.ts.
const REACTIONS: { key: string; emoji: string }[] = [
{ key: "like", emoji: "👍" },
{ key: "love", emoji: "❤️" },
{ key: "wow", emoji: "😮" },
];
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 t = await getTranslations("pages.article");
const article = await prisma.websiteArticles
.findUnique({
where: { slug },
select: {
id: true,
slug: true,
title: true,
shortStory: true,
fullStory: true,
image: true,
createdAt: true,
updatedAt: true,
},
})
.catch(() => null);
if (!article) notFound();
const articleId = article.id;
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
const sessionUserId = session?.user?.id ? Number(session.user.id) : null;
// Reaction counts grouped by reaction type for this article, plus the
// signed-in user's currently-active reaction (so its button reads as pressed).
let reactionCounts = new Map<string, number>();
let myReaction: string | null = null;
try {
const rows = await prisma.websiteArticleReactions.findMany({
where: { articleId, active: true },
select: { reaction: true, userId: true },
});
for (const r of rows) {
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
if (sessionUserId !== null && r.userId === sessionUserId)
myReaction = r.reaction;
}
} catch {
reactionCounts = new Map();
myReaction = null;
}
// 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();
}
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="📰"
title={article.title}
subtitle={
article.createdAt
? article.createdAt.toISOString().slice(0, 10)
: undefined
}
action={
<Link className="btn btn-outline" href="/news">
{t("backToNews")}
</Link>
}
>
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
/>
) : null}
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised article body */}
<div
className="article-body"
dangerouslySetInnerHTML={{ __html: sanitize(article.fullStory) }}
/>
</ContentCard>
{/* ── Reactions ─────────────────────────────────────────── */}
<ContentCard
icon="💬"
title={t("reactionsTitle")}
subtitle={t("reactionsSubtitle")}
>
{loggedIn ? (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.map((r) => {
const active = myReaction === r.key;
const count = reactionCounts.get(r.key) ?? 0;
const label = `${r.emoji} ${t(`reaction.${r.key}`)}`;
return (
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
<input
type="hidden"
name="articleId"
value={String(articleId)}
/>
<input type="hidden" name="reaction" value={r.key} />
<button
type="submit"
aria-pressed={active}
className={`btn ${active ? "btn-primary" : "btn-outline"}`}
title={
active
? t("removeReaction")
: t("reactWith", { reaction: r.key })
}
>
<span>{label}</span>
<span
className="muted"
style={{
fontSize: "0.85rem",
color: "inherit",
opacity: 0.85,
}}
>
{count}
</span>
</button>
</form>
);
})}
</div>
) : reactionCounts.size === 0 ? (
<EmptyState icon="💬">
{t("noReactions")} <Link href="/login">{t("logIn")}</Link>{" "}
{t("toReact")}
</EmptyState>
) : (
<>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.filter(
(r) => (reactionCounts.get(r.key) ?? 0) > 0,
).map((r) => (
<span key={r.key} className="currency">
<span
style={{ fontWeight: 800 }}
>{`${r.emoji} ${t(`reaction.${r.key}`)}`}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{reactionCounts.get(r.key) ?? 0}
</span>
</span>
))}
</div>
<p className="muted" style={{ marginTop: "0.6rem" }}>
<Link href="/login">{t("logIn")}</Link> {t("toReact")}
</p>
</>
)}
</ContentCard>
{/* ── Comments ──────────────────────────────────────────── */}
<ContentCard
icon="🗨️"
title={t("commentsTitle")}
subtitle={
comments.length > 0
? t("commentsCount", { count: comments.length })
: t("commentsSubtitle")
}
>
{comments.length === 0 ? (
<EmptyState icon="🗨️">{t("noComments")}</EmptyState>
) : (
<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>{t("userNumber", { id: 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",
}}
>
{t("addComment")}
</label>
<textarea
id="comment"
name="comment"
required
maxLength={255}
rows={3}
placeholder={t("commentPlaceholder")}
style={{ width: "100%", resize: "vertical" }}
/>
<div style={{ marginTop: "0.75rem" }}>
<button type="submit" className="btn btn-primary">
{t("postComment")}
</button>
</div>
</form>
) : (
<p className="muted">
<Link href="/login">{t("logIn")}</Link> {t("toComment")}
</p>
)}
</ContentCard>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
export default function NewsLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
export const metadata = { title: "News" };
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function NewsPage() {
const t = await getTranslations("pages.news");
let articles: Awaited<ReturnType<typeof prisma.websiteArticles.findMany>> =
[];
try {
articles = await prisma.websiteArticles.findMany({
orderBy: { createdAt: "desc" },
take: 30,
});
} catch {
// DB unavailable — render the empty state rather than a 500.
articles = [];
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="📰" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={articles.length === 0}>
{articles.length === 0 ? (
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{articles.map((a) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="card hover"
style={{
display: "flex",
flexDirection: "column",
textDecoration: "none",
padding: 0,
overflow: "hidden",
}}
>
{a.image ? (
<div
style={{
position: "relative",
width: "100%",
aspectRatio: "16/9",
}}
>
<Image
src={a.image}
alt=""
fill
style={{ objectFit: "cover" }}
unoptimized
/>
</div>
) : (
<div
style={{
width: "100%",
aspectRatio: "16/9",
backgroundColor:
"color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))",
}}
/>
)}
<div
style={{
padding: "1rem 1.1rem",
flex: 1,
display: "flex",
flexDirection: "column",
}}
>
<h3
style={{
margin: 0,
fontSize: "1.05rem",
color:
"var(--color-navbar-text-readable, var(--color-navbar-text))",
lineHeight: 1.4,
}}
>
{a.title}
</h3>
<p
className="muted"
style={{ margin: "0.35rem 0 0.6rem", fontSize: "0.78rem" }}
>
{formatDate(a.createdAt)}
</p>
<p
style={{
margin: 0,
color:
"var(--color-navbar-text-readable, var(--color-navbar-text))",
fontSize: "0.88rem",
lineHeight: 1.55,
}}
>
{excerpt(a.shortStory, 150)}
</p>
</div>
</Link>
))}
</div>
)}
</ContentCard>
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
import GuestView from "@/components/home/guest-view";
import UserView from "@/components/home/user-view";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const session = await auth();
if (!session?.user) {
return <GuestView />;
}
const dbUser = await prisma.user
.findUnique({
where: { id: Number(session.user.id) },
select: { username: true, look: true },
})
.catch(() => null);
if (!dbUser) {
return <p className="py-8 text-center text-gray-500">User not found.</p>;
}
return (
<UserView
userId={Number(session.user.id)}
username={dbUser.username}
look={dbUser.look}
/>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { getTranslations } from "next-intl/server";
import {
type LightboxPhoto,
PhotoLightbox,
} from "@/components/public/photo-lightbox";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
export const metadata = { title: "Photos" };
type Photo = {
id: number;
userId: number;
roomId: number;
timestamp: number;
url: string;
};
function formatDate(timestamp: number): string {
return new Date(timestamp * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ");
}
export default async function PhotosPage() {
const t = await getTranslations("pages.photos");
let photos: Photo[] = [];
try {
photos = await prisma.cameraWeb.findMany({
orderBy: { timestamp: "desc" },
take: 48,
});
} catch {
photos = [];
}
// Pre-shape for the client lightbox: translate captions server-side so the
// client component stays free of i18n/prisma dependencies.
const items: LightboxPhoto[] = photos.map((p) => ({
id: String(p.id),
url: p.url,
alt: t("photoAlt", { id: p.userId }),
date: formatDate(p.timestamp),
user: t("byUser", { id: p.userId }),
}));
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="📸" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={items.length === 0}>
{items.length === 0 ? (
<EmptyState icon="📸">{t("noPhotos")}</EmptyState>
) : (
<PhotoLightbox photos={items} />
)}
</ContentCard>
</main>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { applyDj } from "@/actions/radio-apply";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function RadioApplyPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.radioApply");
const userId = Number(session.user.id);
// Surface the applicant's most recent application (if any) so they aren't
// left guessing. Isolated query: a DB hiccup degrades this to null, not 500.
const existing = await prisma.radioApplications
.findFirst({
where: { userId: BigInt(userId) },
orderBy: { id: "desc" },
select: { id: true, status: true, createdAt: true },
})
.catch(() => null);
const submittedDate = existing?.createdAt
? existing.createdAt.toISOString().slice(0, 10)
: null;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
{existing ? (
<ContentCard icon="📋" title={t("latestTitle")}>
<p style={{ margin: 0 }}>
<strong>{t("statusLabel")}</strong> {existing.status}
{submittedDate ? (
<span className="muted">
{" "}
{t("submittedOn", { date: submittedDate })}
</span>
) : null}
</p>
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{t("submitAgain")}
</p>
</ContentCard>
) : null}
<ContentCard
icon="📝"
title={t("formTitle")}
subtitle={t("formSubtitle")}
>
<form action={applyDj}>
<div className="card-grid sm-2">
<div>
<label htmlFor="realName" className="muted">
{t("realNameLabel")}
</label>
<input
id="realName"
name="realName"
type="text"
required
maxLength={255}
placeholder={t("realNamePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div>
<label htmlFor="age" className="muted">
{t("ageLabel")}
</label>
<input
id="age"
name="age"
type="number"
required
min={13}
max={120}
placeholder="18"
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="availability" className="muted">
{t("availabilityLabel")}
</label>
<textarea
id="availability"
name="availability"
required
rows={3}
placeholder={t("availabilityPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="musicStyle" className="muted">
{t("musicStyleLabel")}
</label>
<input
id="musicStyle"
name="musicStyle"
type="text"
maxLength={255}
placeholder={t("musicStylePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="experience" className="muted">
{t("experienceLabel")}{" "}
<span className="muted">{t("optional")}</span>
</label>
<textarea
id="experience"
name="experience"
rows={3}
placeholder={t("experiencePlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<div style={{ marginTop: "1rem" }}>
<label htmlFor="motivation" className="muted">
{t("motivationLabel")}
</label>
<textarea
id="motivation"
name="motivation"
required
rows={4}
placeholder={t("motivationPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem", resize: "vertical" }}
/>
</div>
<button
type="submit"
className="btn btn-primary"
style={{ marginTop: "1.25rem" }}
>
{t("submitButton")}
</button>
</form>
</ContentCard>
</main>
);
}
@@ -0,0 +1,92 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
}
export default async function RadioContestDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
await getTranslations("pages.radioContests");
let contestId: bigint;
try {
contestId = BigInt(id);
} catch {
notFound();
}
const contest = await prisma.radioContests
.findUnique({ where: { id: contestId } })
.catch(() => null);
if (!contest) notFound();
const active = contest.isActive ? "Active" : "Ended";
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p className="muted" style={{ margin: 0 }}>
<Link href="/radio/contests">← Back to contests</Link>
</p>
<ContentCard
icon="🎉"
title={contest.title || `Contest #${contest.id}`}
subtitle={active}
>
<table style={{ width: "100%" }}>
<tbody>
{contest.description ? (
<tr>
<th style={{ verticalAlign: "top", width: 120 }}>
Description
</th>
<td>{contest.description}</td>
</tr>
) : null}
{contest.prize ? (
<tr>
<th>Prize</th>
<td>{contest.prize}</td>
</tr>
) : null}
<tr>
<th>Period</th>
<td>
{contest.startDate ? formatDate(contest.startDate) : "—"}
{" — "}
{contest.endDate ? formatDate(contest.endDate) : "ongoing"}
</td>
</tr>
<tr>
<th>Created</th>
<td>
{formatDate(contest.createdAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
<tr>
<th>Updated</th>
<td>
{formatDate(contest.updatedAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
</tbody>
</table>
</ContentCard>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function RadioContestsPage() {
const t = await getTranslations("pages.radioContests");
const contests = await prisma.radioContests
.findMany({
where: { isActive: true },
orderBy: { startDate: "desc" },
take: 50,
select: {
id: true,
title: true,
prize: true,
startDate: true,
endDate: true,
},
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎉" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={contests.length === 0}>
{contests.length === 0 ? (
<EmptyState icon="🎉">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{contests.map((c) => {
const id = c.id.toString();
return (
<Link
key={id}
href={`/radio/contests/${id}`}
className="card hover"
style={{
color: "inherit",
textDecoration: "none",
display: "grid",
gap: "0.3rem",
}}
>
<h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎉</span>{" "}
{c.title || t("contestLabel", { id })}
</h3>
{c.prize ? (
<p
className="muted"
style={{ margin: 0, fontSize: "0.9rem" }}
>
Prize: {c.prize}
</p>
) : null}
<p
className="muted"
style={{ margin: 0, fontSize: "0.85rem" }}
>
{c.startDate
? `${formatDate(c.startDate)} — ${c.endDate ? formatDate(c.endDate) : "ongoing"}`
: t("dateUnknown")}
</p>
</Link>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
@@ -0,0 +1,97 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
}
export default async function RadioGiveawayDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
await getTranslations("pages.radioGiveaways");
let giveawayId: bigint;
try {
giveawayId = BigInt(id);
} catch {
notFound();
}
const giveaway = await prisma.radioGiveaways
.findUnique({ where: { id: giveawayId } })
.catch(() => null);
if (!giveaway) notFound();
const active = giveaway.isActive ? "Active" : "Ended";
const prizeStr =
giveaway.prize ||
(giveaway.prizeAmount > 0
? `${giveaway.prizeAmount} ${giveaway.prizeCurrency || "credits"}`
: null);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p className="muted" style={{ margin: 0 }}>
<Link href="/radio/giveaways">← Back to giveaways</Link>
</p>
<ContentCard
icon="🎁"
title={giveaway.title || `Giveaway #${giveaway.id}`}
subtitle={active}
>
<table style={{ width: "100%" }}>
<tbody>
{giveaway.description ? (
<tr>
<th style={{ verticalAlign: "top", width: 120 }}>
Description
</th>
<td>{giveaway.description}</td>
</tr>
) : null}
{prizeStr ? (
<tr>
<th>Prize</th>
<td>{prizeStr}</td>
</tr>
) : null}
<tr>
<th>Period</th>
<td>
{giveaway.startDate ? formatDate(giveaway.startDate) : "—"}
{" — "}
{giveaway.endDate ? formatDate(giveaway.endDate) : "ongoing"}
</td>
</tr>
<tr>
<th>Created</th>
<td>
{formatDate(giveaway.createdAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
<tr>
<th>Updated</th>
<td>
{formatDate(giveaway.updatedAt) || (
<span className="muted">—</span>
)}
</td>
</tr>
</tbody>
</table>
</ContentCard>
</main>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
export default async function RadioGiveawaysPage() {
const t = await getTranslations("pages.radioGiveaways");
const giveaways = await prisma.radioGiveaways
.findMany({
where: { isActive: true },
orderBy: { startDate: "desc" },
take: 50,
select: {
id: true,
title: true,
prize: true,
prizeAmount: true,
prizeCurrency: true,
startDate: true,
endDate: true,
},
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎁" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={giveaways.length === 0}>
{giveaways.length === 0 ? (
<EmptyState icon="🎁">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{giveaways.map((g) => {
const id = g.id.toString();
const prizeStr =
g.prize ||
(g.prizeAmount > 0
? `${g.prizeAmount} ${g.prizeCurrency || "credits"}`
: null);
return (
<Link
key={id}
href={`/radio/giveaways/${id}`}
className="card hover"
style={{
color: "inherit",
textDecoration: "none",
display: "grid",
gap: "0.3rem",
}}
>
<h3 style={{ margin: 0, fontSize: "1rem" }}>
<span aria-hidden>🎁</span>{" "}
{g.title || t("giveawayLabel", { id })}
</h3>
{prizeStr ? (
<p
className="muted"
style={{ margin: 0, fontSize: "0.9rem" }}
>
Prize: {prizeStr}
</p>
) : null}
<p
className="muted"
style={{ margin: 0, fontSize: "0.85rem" }}
>
{g.startDate
? `${formatDate(g.startDate)} — ${g.endDate ? formatDate(g.endDate) : "ongoing"}`
: t("dateUnknown")}
</p>
</Link>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
const RADIO_SIDEBAR_LINKS = [
{ key: "schedule", href: "/radio/schedule", icon: "🗓️" },
{ key: "shouts", href: "/radio/shouts", icon: "📣" },
{ key: "requests", href: "/radio/requests", icon: "🎵" },
{ key: "apply", href: "/radio/apply", icon: "🎤" },
{ key: "leaderboard", href: "/radio/leaderboard", icon: "🏆" },
] as const;
export default async function RadioLayout({
children,
}: {
children: ReactNode;
}) {
const t = await getTranslations("pages.radio");
return (
<div
className="radio-layout"
style={{ display: "flex", gap: "1.5rem", alignItems: "flex-start" }}
>
<aside
className="radio-sidebar"
style={{
flex: "none",
width: "200px",
position: "sticky",
top: "5rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
<Link
href="/radio"
className="radio-sidebar-head"
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.55rem 0.75rem",
fontWeight: 700,
fontSize: "1rem",
color: "var(--color-text-readable, var(--color-text))",
textDecoration: "none",
borderBottom:
"1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
marginBottom: "0.25rem",
}}
>
<span aria-hidden style={{ fontSize: "1.2rem" }}>
📻
</span>
Radio
</Link>
{RADIO_SIDEBAR_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="radio-sidebar-link"
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0.75rem",
borderRadius: "8px",
color: "var(--color-text-readable, var(--color-text))",
textDecoration: "none",
fontSize: "0.9rem",
transition: "background 0.15s",
}}
>
<span aria-hidden style={{ flex: "none", fontSize: "1rem" }}>
{link.icon}
</span>
{link.key === "schedule"
? t("linkSchedule")
: link.key === "shouts"
? t("linkShouts")
: link.key === "requests"
? t("linkRequests")
: link.key === "apply"
? t("linkApply")
: t("linkLeaderboard")}
</Link>
))}
</aside>
<section style={{ flex: 1, minWidth: 0 }}>{children}</section>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
// The radio_listener_points table in the live AtomCMS DB carries user_id +
// points columns, but the generated Prisma model is a stub (id/timestamps only),
// so it has no `points`/`userId` delegate fields. We therefore read it via
// $queryRaw, joining users for the username/look — the same approach used for
// the keyless/@@ignore'd emulator tables (see src/app/admin/logs). MySQL may
// return BigInt columns as bigint, so values are coerced defensively.
type LeaderRow = {
username: string;
look: string;
points: number | bigint;
};
async function loadRows(): Promise<
{ username: string; look: string; points: number }[]
> {
try {
const rows = await prisma.$queryRaw<LeaderRow[]>`
SELECT u.username AS username, u.look AS look, p.points AS points
FROM radio_listener_points p
INNER JOIN users u ON u.id = p.user_id
ORDER BY p.points DESC
LIMIT 50
`;
return rows.map((r) => ({
username: r.username,
look: r.look,
points: Number(r.points),
}));
} catch {
return [];
}
}
export default async function RadioLeaderboardPage() {
const t = await getTranslations("pages.radioLeaderboard");
const [rows, imagerBase] = await Promise.all([
loadRows(),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={rows.length === 0}>
{rows.length === 0 ? (
<EmptyState icon="🏆">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th style={{ width: "3.5rem" }}>{t("colRank")}</th>
<th style={{ width: "3.5rem" }}>{t("colAvatar")}</th>
<th>{t("colListener")}</th>
<th style={{ textAlign: "right" }}>{t("colPoints")}</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>
<RankBadge position={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", fontWeight: 800 }}>
{row.points.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+252
View File
@@ -0,0 +1,252 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// Radio config + today's schedule are read live from the DB / settings table.
export const revalidate = 300;
// Canonical weekday order so today's slots come out chronologically and we can
// match the current day against whatever casing the `day` column stores.
const WEEKDAYS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
// radio_schedules.start_time / end_time are stored as TIME and surface through
// Prisma as a Date with the time component on a 1970 epoch date — render just
// the HH:MM portion in UTC to avoid timezone drift.
function formatTime(d: Date | null | undefined): string {
if (!d) return "";
return d.toISOString().slice(11, 16);
}
export default async function RadioPage() {
const t = await getTranslations("pages.radio");
// Sub-pages linked from the radio home hub.
const RADIO_LINKS: {
label: string;
href: string;
icon: string;
text: string;
}[] = [
{
label: t("linkSchedule"),
href: "/radio/schedule",
icon: "🗓️",
text: t("linkScheduleText"),
},
{
label: t("linkShouts"),
href: "/radio/shouts",
icon: "📣",
text: t("linkShoutsText"),
},
{
label: t("linkRequests"),
href: "/radio/requests",
icon: "🎵",
text: t("linkRequestsText"),
},
{
label: t("linkApply"),
href: "/radio/apply",
icon: "🎤",
text: t("linkApplyText"),
},
{
label: t("linkLeaderboard"),
href: "/radio/leaderboard",
icon: "🏆",
text: t("linkLeaderboardText"),
},
];
// ── Radio config from website_settings (graceful fallbacks) ──
const [streamUrl, currentDj, currentSong] = await Promise.all([
siteSettings.get("radio_stream_url", ""),
siteSettings.get("radio_current_dj", ""),
siteSettings.get("radio_current_song", ""),
]);
// ── Today's schedule (isolated; a DB hiccup degrades to empty) ──
const today = WEEKDAYS[new Date().getUTCDay()] ?? "";
const schedules = await prisma.radioSchedules
.findMany({
where: { isActive: true },
orderBy: { startTime: "asc" },
})
.catch(() => []);
const todaysSlots = schedules.filter(
(s) => s.day.toLowerCase() === today.toLowerCase(),
);
// Resolve DJ usernames for today's slots in one query. radio_schedules.user_id
// is an UnsignedBigInt while users.id is an Int, so narrow to Number for the
// lookup (DJ ids comfortably fit in a JS-safe integer).
const djIds = Array.from(new Set(todaysSlots.map((s) => Number(s.userId))));
const djs = djIds.length
? await prisma.user
.findMany({
where: { id: { in: djIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const djById = new Map(djs.map((d) => [d.id, d.username]));
const isLive = Boolean(streamUrl);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{/* ── Header: live stream + now playing ─────────────────────── */}
<ContentCard
icon="📻"
title={t("title")}
subtitle={t("subtitle")}
action={<OnlineBadge online={isLive} />}
>
<div className="card-grid sm-2" style={{ alignItems: "stretch" }}>
<div
className="card"
style={{ display: "grid", gap: "0.35rem", alignContent: "center" }}
>
<p
className="muted"
style={{
margin: 0,
fontSize: "0.8rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{t("nowPlaying")}
</p>
<p style={{ margin: 0, fontSize: "1.1rem", fontWeight: 700 }}>
{currentSong ? (
currentSong
) : (
<span className="muted" style={{ fontWeight: 400 }}>
{t("nothingPlaying")}
</span>
)}
</p>
<p className="muted" style={{ margin: 0 }}>
{currentDj ? t("onAir", { dj: currentDj }) : t("noDj")}
</p>
</div>
<div
className="card"
style={{ display: "flex", alignItems: "center" }}
>
{streamUrl ? (
<audio
controls
src={streamUrl}
style={{ width: "100%" }}
preload="none"
>
{t("audioUnsupported")}
</audio>
) : (
<p className="muted" style={{ margin: 0 }}>
{t("streamOffline")}
</p>
)}
</div>
</div>
</ContentCard>
{/* ── Sub-page links ──────────────────────────────────────── */}
<ContentCard
icon="🎚️"
title={t("exploreTitle")}
subtitle={t("exploreSubtitle")}
>
<div className="card-grid sm-2 lg-3">
{RADIO_LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="card hover"
style={{
color: "inherit",
textDecoration: "none",
display: "grid",
gap: "0.4rem",
}}
>
<p style={{ margin: 0, fontWeight: 700 }}>
<span aria-hidden>{l.icon}</span> {l.label}
</p>
<p className="muted" style={{ margin: 0 }}>
{l.text}
</p>
</Link>
))}
</div>
</ContentCard>
{/* ── Today's schedule ────────────────────────────────────── */}
<ContentCard
icon="🗓️"
title={today ? t("todayTitleWithDay", { day: today }) : t("todayTitle")}
subtitle={t("todaySubtitle")}
action={
<Link className="btn btn-outline" href="/radio/schedule">
{t("fullWeek")}
</Link>
}
padded={todaysSlots.length === 0}
>
{todaysSlots.length === 0 ? (
<EmptyState icon="📻">{t("noShowsToday")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colTime")}</th>
<th>{t("colShow")}</th>
<th>{t("colDj")}</th>
</tr>
</thead>
<tbody>
{todaysSlots.map((s) => {
const djName = djById.get(Number(s.userId));
return (
<tr key={String(s.id)}>
<td>
{formatTime(s.startTime)} &ndash; {formatTime(s.endTime)}
</td>
<td>
{s.showName || (
<span className="muted">{t("untitledShow")}</span>
)}
</td>
<td>
{djName ? (
<Link href={`/u/${djName}`}>{djName}</Link>
) : (
<span className="muted">
{t("userNumber", { id: String(s.userId) })}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { getTranslations } from "next-intl/server";
import { submitRequest } from "@/actions/radio-requests";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function RadioRequestsPage() {
const t = await getTranslations("pages.radioRequests");
const genericT = await getTranslations("pages.radio");
const [requests, session] = await Promise.all([
prisma.radioSongRequests
.findMany({
orderBy: { createdAt: "desc" },
take: 50,
})
.catch(() => []),
auth(),
]);
const isLoggedIn = Boolean(session?.user?.id);
const userIds = Array.from(new Set(requests.map((r) => Number(r.userId))));
const users = userIds.length
? await prisma.user
.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const userById = new Map(users.map((u) => [u.id, u.username]));
return (
<>
<ContentCard
icon="🎵"
title={genericT("linkRequests")}
subtitle={genericT("linkRequestsText")}
/>
{isLoggedIn ? (
<ContentCard
icon="✍️"
title={t("formTitle")}
subtitle={t("formSubtitle")}
>
<form action={submitRequest}>
<div className="card-grid sm-2">
<div>
<label htmlFor="songTitle" className="muted">
{t("songLabel")}
</label>
<input
id="songTitle"
name="songTitle"
type="text"
required
maxLength={255}
placeholder={t("songPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
<div>
<label htmlFor="artist" className="muted">
{t("artistLabel")}
</label>
<input
id="artist"
name="artist"
type="text"
maxLength={255}
placeholder={t("artistPlaceholder")}
style={{ width: "100%", marginTop: "0.3rem" }}
/>
</div>
</div>
<button
type="submit"
className="btn btn-primary"
style={{ marginTop: "1rem" }}
>
{t("submitButton")}
</button>
</form>
</ContentCard>
) : (
<ContentCard padded>
<EmptyState icon="🔒">{t("loginToRequest")}</EmptyState>
</ContentCard>
)}
<ContentCard
icon="📋"
title={t("latestTitle")}
subtitle={t("latestSubtitle")}
padded={requests.length === 0}
>
{requests.length === 0 ? (
<EmptyState icon="🎵">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colSong")}</th>
<th>{t("colArtist")}</th>
<th>{t("colRequester")}</th>
<th>{t("colDate")}</th>
<th>{t("colStatus")}</th>
</tr>
</thead>
<tbody>
{requests.map((r) => {
const requester = userById.get(Number(r.userId));
const isPlayed = r.isPlayed;
const isApproved = r.isApproved;
let status = t("statusPending");
if (isPlayed) status = t("statusPlayed");
else if (isApproved) status = t("statusApproved");
return (
<tr key={String(r.id)}>
<td>{r.songTitle || <span className="muted">—</span>}</td>
<td>{r.artist || <span className="muted">—</span>}</td>
<td>
{requester ? (
<span>{requester}</span>
) : (
<span className="muted">#{String(r.userId)}</span>
)}
</td>
<td className="muted">
{r.submittedAt ? formatDate(r.submittedAt) : "—"}
</td>
<td>{status}</td>
</tr>
);
})}
</tbody>
</table>
)}
</ContentCard>
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
// Weekly order so the table reads Monday → Sunday regardless of insert order.
// The `day` column stores a free-form string; we key case-insensitively.
const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] as const;
// `start_time` / `end_time` are MySQL TIME columns; Prisma hydrates them as a
// Date on the 1970-01-01 epoch. Pull just HH:MM in UTC (Time has no zone).
function formatTime(d: Date | null | undefined): string {
if (!d) return "";
return d.toISOString().slice(11, 16);
}
type Slot = {
id: string;
dj: string;
showName: string | null;
description: string | null;
start: string;
end: string;
};
export default async function RadioSchedulePage() {
const t = await getTranslations("pages.radioSchedule");
const schedules = await prisma.radioSchedules
.findMany({
where: { isActive: true },
orderBy: { startTime: "asc" },
})
.catch(() => []);
// Resolve DJ usernames. schedule.userId is BigInt; User.id is Int — convert.
const djIds = Array.from(new Set(schedules.map((s) => Number(s.userId))));
const djs = djIds.length
? await prisma.user
.findMany({
where: { id: { in: djIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const djById = new Map(djs.map((u) => [u.id, u.username]));
// Bucket slots by normalised day name.
const byDay = new Map<string, Slot[]>();
for (const day of DAYS) byDay.set(day, []);
for (const s of schedules) {
const key =
DAYS.find((d) => d.toLowerCase() === s.day.trim().toLowerCase()) ?? s.day;
const slot: Slot = {
id: String(s.id),
dj: djById.get(Number(s.userId)) ?? `User #${s.userId}`,
showName: s.showName,
description: s.description,
start: formatTime(s.startTime),
end: formatTime(s.endTime),
};
const bucket = byDay.get(key);
if (bucket) bucket.push(slot);
else byDay.set(key, [slot]);
}
const hasAny = schedules.length > 0;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🗓️" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={!hasAny}>
{!hasAny ? (
<EmptyState icon="🗓️">{t("empty")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colDay")}</th>
<th>{t("colTime")}</th>
<th>{t("colDj")}</th>
<th>{t("colShow")}</th>
</tr>
</thead>
<tbody>
{DAYS.map((day) => {
const slots = byDay.get(day) ?? [];
if (slots.length === 0) {
return (
<tr key={day}>
<td>
<strong>{day}</strong>
</td>
<td colSpan={3} className="muted">
{t("noShows")}
</td>
</tr>
);
}
return slots.map((slot, i) => (
<tr key={slot.id}>
<td>{i === 0 ? <strong>{day}</strong> : ""}</td>
<td>
{slot.start}
{slot.end ? ` – ${slot.end}` : ""}
</td>
<td>{slot.dj}</td>
<td>
{slot.showName || <span className="muted">—</span>}
{slot.description ? (
<div className="muted">{slot.description}</div>
) : null}
</td>
</tr>
));
})}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { getTranslations } from "next-intl/server";
import { postShout } from "@/actions/radio-shouts";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
}
export default async function RadioShoutsPage() {
const t = await getTranslations("pages.radioShouts");
const [shouts, imagerBase, session] = await Promise.all([
prisma.radioShouts
.findMany({
orderBy: { createdAt: "desc" },
take: 50,
})
.catch(() => []),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
auth(),
]);
const isLoggedIn = Boolean(session?.user?.id);
// Resolve author usernames/looks. shout.userId is BigInt; User.id is Int.
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.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 style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="📣" title={t("title")} subtitle={t("subtitle")} />
{isLoggedIn ? (
<ContentCard
icon="✍️"
title={t("postTitle")}
subtitle={t("postSubtitle")}
>
{/* The author is taken from the session inside the action, never from
this form — no user id is submitted. */}
<form action={postShout}>
<label htmlFor="shout-message" className="muted">
{t("messageLabel")}
</label>
<textarea
id="shout-message"
name="message"
required
maxLength={255}
rows={3}
placeholder={t("messagePlaceholder")}
style={{
width: "100%",
margin: "0.4rem 0 0.75rem",
resize: "vertical",
}}
/>
<button type="submit" className="btn btn-primary">
{t("shoutButton")}
</button>
</form>
</ContentCard>
) : (
<ContentCard padded>
<EmptyState icon="🔒">{t("loginToPost")}</EmptyState>
</ContentCard>
)}
<ContentCard
icon="💬"
title={t("latestTitle")}
subtitle={t("latestSubtitle")}
padded={shouts.length === 0}
>
{shouts.length === 0 ? (
<EmptyState icon="📣">{t("empty")}</EmptyState>
) : (
<div className="card-grid">
{shouts.map((s) => {
const author = authorById.get(Number(s.userId));
const authorAvatar = author
? avatarImageUrl(imagerBase ?? "", author.look, {
size: "s",
headOnly: true,
})
: null;
return (
<div
key={String(s.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, minWidth: 0 }}>
<p style={{ margin: "0 0 0.25rem" }}>
<strong>{author?.username ?? `User #${s.userId}`}</strong>{" "}
<span className="muted">{formatDate(s.createdAt)}</span>
</p>
<p style={{ margin: 0 }}>{s.message}</p>
</div>
</div>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import {
ContentCard,
EmptyState,
OnlineBadge,
RankBadge,
} from "@/components/public/ui";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
export const metadata = { title: "Rankings" };
type TopUser = {
username: string;
look: string;
credits: number;
rank: number;
online: string;
};
export default async function RankingsPage() {
const t = await getTranslations("pages.rankings");
let users: TopUser[] = [];
try {
users = await prisma.user.findMany({
select: {
username: true,
look: true,
credits: true,
rank: true,
online: true,
},
orderBy: { credits: "desc" },
take: 12,
});
} catch {
users = [];
}
const imagerBase = await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={users.length === 0}>
{users.length === 0 ? (
<EmptyState icon="🏆">{t("emptyState")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{users.map((u, i) => {
const avatar = avatarImageUrl(imagerBase ?? "", u.look, {
size: "m",
});
return (
<div
key={u.username}
className="card hover"
style={{
display: "flex",
gap: "0.9rem",
alignItems: "center",
}}
>
<RankBadge position={i + 1} />
<Image
className="avatar"
src={avatar}
alt={`${u.username} avatar`}
width={50}
height={90}
unoptimized
/>
<div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
<Link href={`/u/${u.username}`}>{u.username}</Link>
</h3>
<p className="currency" style={{ margin: "0 0 0.3rem" }}>
<span className="coin credits">c</span>
{u.credits.toLocaleString()}
</p>
<OnlineBadge online={u.online === "1"} />
</div>
</div>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+194
View File
@@ -0,0 +1,194 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
type RareRow = {
id: bigint;
name: string;
creditValue: string | null;
currencyValue: string | null;
currencyType: string;
furnitureIcon: string;
itemId: number | null;
};
type CurrencyTranslator = (key: string) => string;
function prettyCurrency(type: string, t: CurrencyTranslator): string {
switch (type) {
case "0":
case "duckets":
return t("duckets");
case "5":
case "diamonds":
return t("diamonds");
case "-1":
case "credits":
return t("credits");
default:
return type.charAt(0).toUpperCase() + type.slice(1);
}
}
export default async function RareCategoryPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
const t = await getTranslations("pages.rareCategory");
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 style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon={badgeSrc ? undefined : "💎"}
title={
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.6rem",
}}
>
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${cat.name} badge`}
width={32}
height={32}
/>
) : null}
{cat.name}
</span>
}
subtitle={t("rareCount", { count: rares.length })}
action={
<Link className="btn btn-outline" href="/rares">
{t("allRares")}
</Link>
}
padded={rares.length === 0}
>
{rares.length === 0 ? (
<EmptyState icon="💎">{t("empty")}</EmptyState>
) : (
<div style={{ padding: "0.25rem 1rem 0.75rem", overflowX: "auto" }}>
<table>
<thead>
<tr>
<th style={{ width: 56 }}></th>
<th>{t("colRare")}</th>
<th>{t("colCredits")}</th>
<th>{t("colCurrency")}</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 !== "" ? (
t("creditsValue", { value: r.creditValue })
) : (
<span className="muted">—</span>
)}
</td>
<td>
{r.currencyValue && r.currencyValue !== "" ? (
`${r.currencyValue} ${prettyCurrency(r.currencyType, t)}`
) : (
<span className="muted">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</ContentCard>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
export const metadata = { title: "Rare values" };
type CategoryRow = {
id: bigint;
name: string;
badge: string;
priority: number;
};
export default async function RareValuesPage() {
const t = await getTranslations("pages.rares");
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 style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="💎" title={t("title")} subtitle={t("subtitle")} />
{categories.length === 0 ? (
<ContentCard padded={false}>
<EmptyState icon="💎">{t("empty")}</EmptyState>
</ContentCard>
) : (
<div className="card-grid sm-2 lg-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="content-card hover-lift"
style={{ color: "inherit", textDecoration: "none" }}
>
<div className="content-card-head">
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${c.name} badge`}
width={36}
height={36}
style={{ flexShrink: 0 }}
/>
) : (
<span className="content-card-icon" aria-hidden>
💎
</span>
)}
<div className="content-card-head-text">
<p className="content-card-title">{c.name}</p>
<p className="content-card-subtitle">
{t("rareCount", { count })}
</p>
</div>
</div>
<div className="content-card-body">
<span className="btn btn-outline">{t("viewRares")}</span>
</div>
</Link>
);
})}
</div>
)}
</main>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useActionState } from "react";
import { type RedeemState, redeem } 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>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import RedeemForm from "./RedeemForm";
export const dynamic = "force-dynamic";
export default async function RedeemPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.redeem");
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 style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🎟️" title={t("title")} subtitle={t("subtitle")} />
<div className="card-grid sm-2">
<ContentCard icon="🔑" title={t("enterCode")}>
<RedeemForm />
<p
className="muted"
style={{ marginBottom: 0, marginTop: "0.85rem" }}
>
{t("oncePerAccount")}
</p>
</ContentCard>
<ContentCard
icon="💰"
title={t("balanceTitle")}
subtitle={t("signedInAs", { username: user.username })}
>
<div className="stat-grid">
<StatBlock
value={user.credits.toLocaleString()}
label={t("credits")}
icon="🪙"
/>
</div>
<p
className="muted"
style={{ marginBottom: 0, marginTop: "0.85rem" }}
>
{t("instantCredit")}
</p>
</ContentCard>
</div>
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { getTranslations } from "next-intl/server";
import { RegisterForm } from "@/components/auth/register-form";
import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
export default async function RegisterPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const t = await getTranslations("pages.register");
const { error } = await searchParams;
const [hotelName, captcha] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
captchaConfig(),
]);
return (
<main className="flex min-h-[calc(100vh-200px)] items-center justify-center px-4 py-8">
<div className="w-full">
<h1 className="sr-only">{t("title")}</h1>
<RegisterForm
hotelName={hotelName ?? "Atom"}
captcha={captcha}
error={error}
/>
</div>
</main>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { resetPassword } from "@/actions/password-reset";
import { ContentCard } from "@/components/public/ui";
export default async function ResetPage({
searchParams,
}: {
searchParams: Promise<{ email?: string; token?: string; error?: string }>;
}) {
const t = await getTranslations("pages.reset");
const { email = "", token = "", error } = await searchParams;
return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard icon="🔐" title={t("title")} subtitle={t("subtitle")}>
<form action={resetPassword} style={{ display: "grid", gap: "0.7rem" }}>
<input type="hidden" name="email" value={email} />
<input type="hidden" name="token" value={token} />
<input
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
required
/>
<button type="submit" className="btn btn-primary">
{t("resetPassword")}
</button>
</form>
{error ? (
<p
style={{
color: "var(--color-danger)",
textAlign: "center",
marginBottom: 0,
}}
>
{error}
</p>
) : null}
<p
className="muted"
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
>
<Link href="/login">{t("backToLogin")}</Link>
</p>
</ContentCard>
</main>
);
}
+169
View File
@@ -0,0 +1,169 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
// Arcturus stores room access as a short string on rooms.state. Map each known
// value to a friendly label + lock icon; unknown values fall back to the raw
// string so we never hide a real (future) state from the visitor.
const STATE_LABELS: Record<string, { label: string; icon: string }> = {
open: { label: "Open", icon: "🔓" },
locked: { label: "Locked (doorbell)", icon: "🔒" },
password: { label: "Password", icon: "🔑" },
invisible: { label: "Invisible", icon: "👻" },
};
function describeState(state: string): { label: string; icon: string } {
// eslint-disable-next-line security/detect-object-injection -- STATE_LABELS has known keys, fallback provided
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
}
/** Parse a route id to a positive integer, or null when it isn't numeric. */
function parseRoomId(raw: string): number | null {
if (!/^\d+$/.test(raw)) return null;
const id = Number(raw);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
async function getRoom(raw: string) {
const id = parseRoomId(raw);
if (id === null) return null;
return prisma.rooms
.findUnique({
where: { id },
select: {
id: true,
ownerId: true,
ownerName: true,
name: true,
description: true,
state: true,
users: true,
usersMax: true,
category: true,
score: true,
tags: true,
},
})
.catch(() => null);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const room = await getRoom(id);
if (!room) return { title: "Room" };
return {
title: room.name || `Room #${room.id}`,
description:
room.description || `Visit ${room.name || "this room"} in the hotel.`,
};
}
export default async function RoomPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Guard non-numeric / absent ids before touching the DB.
if (parseRoomId(id) === null) notFound();
const room = await getRoom(id);
if (!room) notFound();
// Resolve the live owner username by id; fall back to the room's denormalised
// owner_name column if the account row is missing or the query degrades.
const owner =
room.ownerId > 0
? await prisma.user
.findUnique({
where: { id: room.ownerId },
select: { username: true },
})
.catch(() => null)
: null;
const ownerName = owner?.username || room.ownerName || "Unknown";
const state = describeState(room.state);
const tags = room.tags
.split(/[,;\s]+/)
.map((t) => t.trim())
.filter(Boolean);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p style={{ margin: 0 }}>
<Link href="/" className="btn btn-outline">
← Back to the hotel
</Link>
</p>
<ContentCard
icon="🚪"
title={room.name || `Room #${room.id}`}
subtitle={
<>
Owned by{" "}
<Link href={`/u/${encodeURIComponent(ownerName)}`}>
{ownerName}
</Link>
</>
}
>
{room.description ? (
<p style={{ margin: "0 0 1rem" }}>{room.description}</p>
) : (
<p className="muted" style={{ margin: "0 0 1rem" }}>
This room has no description.
</p>
)}
<div className="stat-grid">
<StatBlock
icon="👥"
value={`${room.users.toLocaleString()} / ${room.usersMax.toLocaleString()}`}
label="Users"
/>
<StatBlock icon={state.icon} value={state.label} label="Access" />
<StatBlock
icon="⭐"
value={room.score.toLocaleString()}
label="Rating"
/>
<StatBlock icon="🏷️" value={`#${room.category}`} label="Category" />
</div>
{tags.length > 0 ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
marginTop: "1rem",
}}
>
{tags.map((tag) => (
<span key={tag} className="online-badge">
#{tag}
</span>
))}
</div>
) : null}
</ContentCard>
<ContentCard icon="ℹ️" title="About this room">
<EmptyState icon="🎮">
Enter the hotel to visit {room.name || `room #${room.id}`} in 3D.
</EmptyState>
</ContentCard>
</main>
);
}
+221
View File
@@ -0,0 +1,221 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
beginTwoFactor,
confirmTwoFactor,
disableTwoFactor,
} from "@/actions/twofactor";
import { ContentCard } from "@/components/public/ui";
import { env } from "@/env";
import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { totpKeyUri } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function TwoFactorPage({
searchParams,
}: {
searchParams: Promise<{
error?: string;
enabled?: string;
disabled?: string;
}>;
}) {
const t = await getTranslations("pages.settings2fa");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const sp = await searchParams;
const id = Number(session.user.id);
let user: {
twoFactorSecret: string | null;
twoFactorConfirmedAt: Date | null;
twoFactorRecoveryCodes: string | null;
} | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: {
twoFactorSecret: true,
twoFactorConfirmedAt: true,
twoFactorRecoveryCodes: true,
},
});
} catch {
user = null;
}
const hasAppKey = Boolean(env.APP_KEY);
const enabled = Boolean(user?.twoFactorConfirmedAt);
const pending = Boolean(user?.twoFactorSecret && !user?.twoFactorConfirmedAt);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
let secret = "";
let uri = "";
let recoveryCodes: string[] = [];
if (pending && hasAppKey && user?.twoFactorSecret) {
try {
secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(
user.twoFactorSecret,
);
uri = totpKeyUri(secret, session.user.name ?? "user", hotelName);
if (user.twoFactorRecoveryCodes) {
recoveryCodes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
}
} catch {
secret = "";
}
}
return (
<main
style={{
display: "grid",
gap: "1.5rem",
maxWidth: 560,
margin: "0 auto",
}}
>
<p className="muted" style={{ margin: 0 }}>
<Link href="/settings">{t("backToSettings")}</Link>
</p>
<ContentCard icon="🔒" title={t("title")} subtitle={t("subtitle")}>
{sp.enabled ? (
<>
<p style={{ color: "var(--color-accent)", marginTop: 0 }}>
{t("nowEnabled")}
</p>
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "1rem",
marginTop: "0.5rem",
}}
>
<h4 style={{ margin: "0 0 0.5rem" }}>Recovery Codes</h4>
<p
className="muted"
style={{ fontSize: "0.85rem", margin: "0 0 0.75rem" }}
>
Store these one-time use codes in a safe place. Each can be used
once if you lose access to your authenticator app.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.25rem",
fontFamily: "monospace",
fontSize: "0.9rem",
}}
>
{recoveryCodes.map((code) => (
<code
key={code}
style={{
userSelect: "all",
padding: "0.25rem 0.5rem",
background: "var(--color-background)",
borderRadius: 4,
}}
>
{code}
</code>
))}
</div>
</div>
</>
) : null}
{sp.disabled ? (
<p className="muted" style={{ marginTop: 0 }}>
{t("disabledNotice")}
</p>
) : null}
{sp.error === "badcode" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>
{t("badCode")}
</p>
) : null}
{sp.error === "ratelimit" ? (
<p style={{ color: "var(--color-danger)", marginTop: 0 }}>
{t("rateLimit")}
</p>
) : null}
{!hasAppKey ? (
<p style={{ margin: 0 }}>
{t("noAppKeyPrefix")} <code>APP_KEY</code> {t("noAppKeyMiddle")}
<code> APP_KEY</code> {t("noAppKeySuffix")}
</p>
) : enabled ? (
<>
<p style={{ marginTop: 0 }}>
<strong>{t("isEnabled")}</strong> {t("onYourAccount")}
</p>
<form
action={disableTwoFactor}
style={{ display: "flex", gap: "0.5rem" }}
>
<input
name="code"
placeholder={t("codePlaceholder")}
inputMode="numeric"
required
/>
<button type="submit" className="btn btn-danger">
{t("disable")}
</button>
</form>
</>
) : pending ? (
<>
<h3 style={{ marginTop: 0 }}>{t("scanOrEnter")}</h3>
<p className="muted" style={{ marginTop: 0 }}>
{t("scanInstructions")}
</p>
<p>
{t("manualKey")}{" "}
<code style={{ userSelect: "all" }}>{secret}</code>
</p>
<p
className="muted"
style={{ wordBreak: "break-all", fontSize: "0.8rem" }}
>
{uri}
</p>
<form
action={confirmTwoFactor}
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
>
<input
name="code"
placeholder={t("codePlaceholder")}
inputMode="numeric"
required
/>
<button type="submit" className="btn btn-primary">
{t("confirm")}
</button>
</form>
</>
) : (
<>
<p style={{ marginTop: 0 }}>{t("enableIntro")}</p>
<form action={beginTwoFactor}>
<button type="submit" className="btn btn-primary">
{t("enable")}
</button>
</form>
</>
)}
</ContentCard>
</main>
);
}
@@ -0,0 +1,137 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { env } from "@/env";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// Social providers we can link. `available` reflects whether the OAuth app
// credentials are configured (mirrors src/lib/auth.ts, which only registers a
// provider when both its id + secret env vars are set). When unavailable the
// connect button is disabled so we don't bounce the user to a 404 sign-in URL.
const PROVIDERS = [
{
key: "discord",
label: "Discord",
icon: "🎮",
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
},
{
key: "google",
label: "Google",
icon: "🔵",
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
},
] as const;
export default async function ConnectionsPage() {
const t = await getTranslations("pages.connections");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const id = Number(session.user.id);
// social_accounts is a CMS-only table that may not exist in every install
// (it has no bundled migration). Fall back to "nothing linked" on any error so
// the page still renders the generic connect buttons.
let linked: Set<string> = new Set();
let lookupFailed = false;
try {
const rows = await prisma.socialAccounts.findMany({
where: { userId: BigInt(id) },
select: { provider: true },
});
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
} catch {
lookupFailed = true;
}
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
const callbackUrl = "/settings/connections";
return (
<main
style={{
display: "grid",
gap: "1.5rem",
maxWidth: 620,
margin: "0 auto",
}}
>
<p className="muted" style={{ margin: 0 }}>
<Link href="/settings">{t("backToSettings")}</Link>
</p>
<ContentCard icon="🔗" title={t("title")} subtitle={t("subtitle")}>
<p className="muted" style={{ marginTop: 0 }}>
{t("matchByEmail")} <strong>{session.user.name}</strong>.
</p>
{lookupFailed ? (
<p className="muted" style={{ marginBottom: 0 }}>
{t("lookupFailed")}
</p>
) : null}
</ContentCard>
<div className="card-grid sm-2">
{PROVIDERS.map((p) => {
const isLinked = linked.has(p.key);
return (
<ContentCard
key={p.key}
icon={p.icon}
title={p.label}
subtitle={
!p.available
? t("statusUnavailable")
: isLinked
? t("statusConnected")
: t("statusNotConnected")
}
>
{isLinked ? (
<span
className="btn btn-outline"
style={{ cursor: "default", pointerEvents: "none" }}
aria-disabled="true"
>
{t("connected")}
</span>
) : p.available ? (
// Plain link into NextAuth's built-in sign-in handler. Re-using
// the same OAuth flow as /login binds the provider to the
// matching hotel account (see auth.ts signIn callback).
<a
className="btn btn-primary"
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(callbackUrl)}`}
>
{t("connectProvider", { provider: p.label })}
</a>
) : (
<span
className="btn"
style={{
cursor: "not-allowed",
opacity: 0.55,
pointerEvents: "none",
}}
aria-disabled="true"
>
{t("unavailable")}
</span>
)}
</ContentCard>
);
})}
</div>
<p className="muted" style={{ margin: 0 }}>
{t("footerNote")}
</p>
</main>
);
}
+145
View File
@@ -0,0 +1,145 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { updateMotto } from "@/actions/user-settings";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const t = await getTranslations("pages.settings");
const session = await auth();
if (!session?.user?.id) redirect("/login");
const id = Number(session.user.id);
let user: {
username: string;
motto: string;
look: string;
mail: string | null;
} | null = null;
try {
user = await prisma.user.findUnique({
where: { id },
select: { username: true, motto: true, look: true, mail: true },
});
} catch {
user = null;
}
if (!user) redirect("/login");
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
try {
imagerBase =
(await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
} catch {
// fall back to the default imager base
}
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="⚙️"
title={t("accountSettings")}
subtitle={t("accountSettingsSubtitle")}
/>
<ContentCard
icon="🙂"
title={user.username}
subtitle={t("publicProfile")}
>
<div
style={{
display: "flex",
gap: "1.5rem",
alignItems: "center",
flexWrap: "wrap",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${user.username} avatar`}
width={100}
height={150}
/>
<div style={{ minWidth: 0 }}>
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
{user.motto || <span className="muted">{t("noMotto")}</span>}
</p>
<p className="muted" style={{ margin: 0 }}>
{user.mail || t("noEmail")}
</p>
</div>
</div>
</ContentCard>
<ContentCard
icon="✏️"
title={t("changeMotto")}
subtitle={t("changeMottoSubtitle")}
>
<form
action={updateMotto}
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
maxWidth: 520,
}}
>
<input
name="motto"
defaultValue={user.motto}
maxLength={127}
placeholder={t("mottoPlaceholder")}
aria-label="Motto"
/>
<div>
<button type="submit" className="btn btn-primary">
{t("saveMotto")}
</button>
</div>
</form>
</ContentCard>
<div className="card-grid sm-2">
<ContentCard
icon="🔒"
title={t("securityTitle")}
subtitle={t("securitySubtitle")}
>
<p className="muted" style={{ marginTop: 0 }}>
{t("securityDescription")}
</p>
<Link href="/settings/2fa" className="btn btn-outline">
{t("twoFactorLink")}
</Link>
</ContentCard>
<ContentCard
icon="🔗"
title={t("connectionsTitle")}
subtitle={t("connectionsSubtitle")}
>
<p className="muted" style={{ marginTop: 0 }}>
{t("connectionsDescription")}
</p>
<Link href="/settings/connections" className="btn btn-outline">
{t("manageConnections")}
</Link>
</ContentCard>
</div>
</main>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { redirect } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const metadata = { title: "Login activity" };
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
function device(ua: string | null): string {
if (!ua) return "Unknown device";
const browser = /Edg/.test(ua)
? "Edge"
: /OPR|Opera/.test(ua)
? "Opera"
: /Chrome/.test(ua)
? "Chrome"
: /Firefox/.test(ua)
? "Firefox"
: /Safari/.test(ua)
? "Safari"
: "Browser";
const os = /Windows/.test(ua)
? "Windows"
: /Android/.test(ua)
? "Android"
: /iPhone|iPad|iOS/.test(ua)
? "iOS"
: /Mac OS X|Macintosh/.test(ua)
? "macOS"
: /Linux/.test(ua)
? "Linux"
: "";
return os ? `${browser} on ${os}` : browser;
}
export default async function SessionsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
const logins = await prisma.websiteLoginLogs
.findMany({
where: { userId },
orderBy: { id: "desc" },
take: 30,
select: { id: true, ip: true, userAgent: true, createdAt: true },
})
.catch(() => []);
// Failed attempts on this username (failed_logins is keyless → raw query).
type Failed = { ip_address: string; attempted_at: Date | string };
const failed = await prisma.$queryRaw<Failed[]>`
SELECT ip_address, attempted_at FROM failed_logins
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15
`.catch(() => [] as Failed[]);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🔐"
title="Login activity"
subtitle="Recent sign-ins and failed attempts on your account"
/>
<ContentCard
icon="✅"
title="Recent sign-ins"
padded={logins.length === 0}
>
{logins.length === 0 ? (
<EmptyState icon="🔐">No sign-ins recorded yet.</EmptyState>
) : (
<table>
<thead>
<tr>
<th>When</th>
<th>IP</th>
<th>Device</th>
</tr>
</thead>
<tbody>
{logins.map((l) => (
<tr key={String(l.id)}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{fromDate(l.createdAt)}
</td>
<td className="muted">{l.ip}</td>
<td>{device(l.userAgent)}</td>
</tr>
))}
</tbody>
</table>
)}
</ContentCard>
{failed.length > 0 ? (
<ContentCard
icon="⚠️"
title="Failed attempts"
subtitle="Wrong password on your username"
>
<table>
<thead>
<tr>
<th>When</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{failed.map((f, i) => (
<tr key={`${f.ip_address}-${i}`}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{typeof f.attempted_at === "string"
? f.attempted_at.slice(0, 16).replace("T", " ")
: fromDate(f.attempted_at)}
</td>
<td className="muted">{f.ip_address}</td>
</tr>
))}
</tbody>
</table>
</ContentCard>
) : null}
</main>
);
}
+255
View File
@@ -0,0 +1,255 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
export const revalidate = 300;
export const metadata = { title: "Shop" };
// Faithful to AtomCMS WebsiteShopArticle::price(): costs are stored in cents,
// the displayed price is costs/100 with a floor of 1.
function priceLabel(costs: number): string {
const price = costs < 100 ? 1 : costs / 100;
return `$${price.toFixed(2)}`;
}
type ShopArticle = {
id: bigint;
name: string;
info: string;
iconUrl: string;
color: string;
costs: number;
credits: number | null;
duckets: number | null;
diamonds: number | null;
badges: string | null;
position: number;
};
type ShopCategory = {
id: bigint;
name: string;
icon: string | null;
};
const ARTICLE_SELECT = {
id: true,
name: true,
info: true,
iconUrl: true,
color: true,
costs: true,
credits: true,
duckets: true,
diamonds: true,
badges: true,
position: true,
} as const;
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const t = await getTranslations("pages.shop");
const sp = await searchParams;
const rawCategory = (sp.category ?? "").trim();
const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : "";
// Categories for the filter bar.
let categories: ShopCategory[] = [];
try {
categories = await prisma.websiteShopCategories.findMany({
select: { id: true, name: true, icon: true },
orderBy: { name: "asc" },
});
} catch {
categories = [];
}
// Articles. The category foreign key (website_shop_category_id) is not modelled
// in the typed Prisma client, so when filtering we resolve the matching ids via
// a guarded raw query, then load the rows through the typed client.
let articles: ShopArticle[] = [];
try {
if (categoryId) {
let ids: bigint[] = [];
try {
const rows = await prisma.$queryRawUnsafe<{ id: bigint }[]>(
"SELECT id FROM website_shop_articles WHERE website_shop_category_id = ?",
BigInt(categoryId),
);
ids = rows.map((r) => r.id);
} catch {
ids = [];
}
articles = ids.length
? await prisma.websiteShopArticles.findMany({
where: { id: { in: ids } },
select: ARTICLE_SELECT,
orderBy: { position: "asc" },
})
: [];
} else {
articles = await prisma.websiteShopArticles.findMany({
select: ARTICLE_SELECT,
orderBy: { position: "asc" },
});
}
} catch {
articles = [];
}
const activeCategory = categoryId
? categories.find((c) => String(c.id) === categoryId)
: undefined;
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🛒"
title={t("title")}
subtitle={
activeCategory
? t("browsingCategory", { category: activeCategory.name })
: t("subtitle")
}
>
{categories.length > 0 ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
}}
>
<Link
href="/shop"
className={`btn ${categoryId ? "btn-outline" : "btn-primary"}`}
>
{t("all")}
</Link>
{categories.map((c) => (
<Link
key={String(c.id)}
href={`/shop?category=${String(c.id)}`}
className={`btn ${categoryId === String(c.id) ? "btn-primary" : "btn-outline"}`}
>
{c.name}
</Link>
))}
</div>
) : null}
</ContentCard>
<ContentCard
icon="📦"
title={activeCategory ? activeCategory.name : t("allPackages")}
subtitle={
articles.length > 0
? t("packagesAvailable", { count: articles.length })
: undefined
}
padded={articles.length === 0}
>
{articles.length === 0 ? (
<EmptyState icon="🛒">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{articles.map((a) => (
<article key={String(a.id)} className="card hover">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "0.6rem",
}}
>
<div
style={{
width: 56,
height: 56,
borderRadius: "var(--radius-sm)",
display: "grid",
placeItems: "center",
background: "#f8fafc",
border: `2px solid ${a.color || "var(--border-color)"}`,
flexShrink: 0,
overflow: "hidden",
}}
>
{a.iconUrl ? (
// Article icons are arbitrary external/Imgur URLs from the
// emulator data, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.iconUrl}
alt=""
width={48}
height={48}
style={{ objectFit: "contain", display: "block" }}
/>
) : null}
</div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0 }}>{a.name}</h3>
<span className="muted">{priceLabel(a.costs)}</span>
</div>
</div>
<p style={{ margin: "0 0 0.75rem" }}>{excerpt(a.info, 160)}</p>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginBottom: "0.85rem",
}}
>
{a.credits ? (
<span className="currency">
<span className="coin credits">cr</span>
{a.credits.toLocaleString()}
</span>
) : null}
{a.duckets ? (
<span className="currency">
<span className="coin duckets">du</span>
{a.duckets.toLocaleString()}
</span>
) : null}
{a.diamonds ? (
<span className="currency">
<span className="coin diamonds">di</span>
{a.diamonds.toLocaleString()}
</span>
) : null}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
<span className="btn btn-primary" aria-disabled="true">
{t("buy")}
</span>
</div>
</article>
))}
</div>
)}
</ContentCard>
</main>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
/**
* Client form for /shop/topup. Posts the chosen amount to /api/paypal/create,
* then redirects the browser to PayPal's approval URL. After PayPal returns to
* /shop/topup?token=<orderId>&status=success, it captures via /api/paypal/capture.
*/
export default function TopUpForm({
currency,
creditsPerUnit,
}: {
currency: string;
creditsPerUnit: number;
}) {
const [amount, setAmount] = useState("5.00");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const numeric = Number(amount);
const credits =
Number.isFinite(numeric) && numeric > 0
? Math.floor(numeric * creditsPerUnit)
: 0;
async function startCheckout(e: React.FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
try {
const res = await fetch("/api/paypal/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: numeric }),
});
const data = (await res.json().catch(() => ({}))) as {
approveUrl?: string;
error?: string;
};
if (!res.ok || !data.approveUrl) {
setError(data.error ?? "Could not start the PayPal checkout.");
setPending(false);
return;
}
// Hand off to PayPal for approval.
const redirectUrl = new URL(data.approveUrl);
if (redirectUrl.protocol !== "https:") {
throw new Error("Invalid redirect URL: must be HTTPS");
}
if (
!redirectUrl.hostname.endsWith(".paypal.com") &&
redirectUrl.hostname !== "paypal.com"
) {
throw new Error("Invalid redirect URL: must be a PayPal domain");
}
window.location.href = redirectUrl.href;
} catch {
setError("Network error — please try again.");
setPending(false);
}
}
async function capture(orderId: string) {
setPending(true);
setError(null);
try {
const res = await fetch("/api/paypal/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId }),
});
const data = (await res.json().catch(() => ({}))) as {
ok?: boolean;
credits?: number;
error?: string;
};
if (res.ok && data.ok) {
setSuccess(
`Payment complete! ${(data.credits ?? 0).toLocaleString()} credits were added.`,
);
} else {
setError(
data.error ??
"We couldn't confirm your payment. Contact staff if charged.",
);
}
} catch {
setError(
"Network error confirming payment — contact staff if you were charged.",
);
} finally {
setPending(false);
// Clean the token/status query so a refresh doesn't re-capture.
window.history.replaceState(null, "", "/shop/topup");
}
}
// On return from PayPal (?token=<orderId>&status=success), auto-capture once.
if (typeof window !== "undefined" && !pending && !success) {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token && params.get("status") === "success") {
// Defer to avoid setState during render.
queueMicrotask(() => capture(token));
}
}
if (success) {
return (
<p
role="status"
aria-live="polite"
style={{ margin: 0, fontWeight: 700, color: "var(--color-accent)" }}
>
{success}
</p>
);
}
return (
<form
onSubmit={startCheckout}
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
>
<label htmlFor="amount" style={{ fontWeight: 700 }}>
Amount ({currency})
</label>
<input
id="amount"
name="amount"
type="number"
min={1}
max={500}
step={0.01}
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-label={`Amount in ${currency}`}
required
/>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
}}
>
{["2.00", "5.00", "10.00", "25.00"].map((v) => (
<button
key={v}
type="button"
className={`btn ${amount === v ? "btn-primary" : "btn-outline"}`}
onClick={() => setAmount(v)}
>
{currency} {v}
</button>
))}
</div>
<p className="muted" style={{ margin: 0 }}>
You&apos;ll receive <strong>{credits.toLocaleString()}</strong> credits.
</p>
<div>
<button
type="submit"
className="btn btn-primary"
disabled={pending || credits <= 0}
>
{pending ? "Redirecting…" : "Pay with PayPal"}
</button>
</div>
{error ? (
<p
role="alert"
aria-live="polite"
style={{ margin: 0, fontWeight: 700, color: "var(--color-danger)" }}
>
{error}
</p>
) : null}
</form>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
creditsPerUnit,
isPayPalConfigured,
PAYPAL_CURRENCY,
} from "@/lib/services/paypal";
import TopUpForm from "./TopUpForm";
export const dynamic = "force-dynamic";
export default async function TopUpPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.topup");
const sp = await searchParams;
const id = Number(session.user.id);
// Current credits 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");
const configured = isPayPalConfigured();
const rate = creditsPerUnit();
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="💳" title={t("title")} subtitle={t("subtitle")} />
{sp.status === "cancel" ? (
<div className="card" style={{ borderColor: "var(--color-danger)" }}>
<p style={{ margin: 0 }}>{t("cancelled")}</p>
</div>
) : null}
<div className="card-grid sm-2">
<ContentCard icon="🪙" title={t("chooseAmount")}>
{configured ? (
<TopUpForm currency={PAYPAL_CURRENCY} creditsPerUnit={rate} />
) : (
<p className="muted" style={{ margin: 0 }}>
{t("notConfiguredPre")} <code>PAYPAL_CLIENT_ID</code>{" "}
{t("notConfiguredAnd")} <code>PAYPAL_SECRET</code>{" "}
{t("notConfiguredOptional")} <code>PAYPAL_API</code>{" "}
{t("notConfiguredPost")}
</p>
)}
</ContentCard>
<ContentCard
icon="💰"
title={t("balanceTitle")}
subtitle={t("signedInAs", { username: user.username })}
>
<div className="stat-grid">
<StatBlock
value={user.credits.toLocaleString()}
label={t("credits")}
icon="🪙"
/>
<StatBlock
value={rate.toLocaleString()}
label={t("creditsPerUnit", { currency: PAYPAL_CURRENCY })}
/>
</div>
<p
className="muted"
style={{ marginBottom: 0, marginTop: "0.85rem" }}
>
{t.rich("rateLine", {
amount: rate.toLocaleString(),
currency: PAYPAL_CURRENCY,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</ContentCard>
</div>
</main>
);
}
+125
View File
@@ -0,0 +1,125 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const revalidate = 300;
export const metadata = { title: "Staff" };
type StaffMember = {
username: string;
look: string;
rank: number;
motto: string;
};
type Team = {
id: string;
rankName: string;
badge: string | null;
jobDescription: string | null;
staffColor: string;
};
export default async function StaffPage() {
const t = await getTranslations("pages.staff");
const minStaffRank =
Number(await siteSettings.get("min_staff_rank", "7")) || 7;
let staff: StaffMember[] = [];
try {
staff = await prisma.user.findMany({
where: { rank: { gte: minStaffRank } },
select: { username: true, look: true, rank: true, motto: true },
orderBy: [{ rank: "desc" }, { username: "asc" }],
take: 100,
});
} catch {
staff = [];
}
let teams: Team[] = [];
try {
const rows = await prisma.websiteTeams.findMany({
where: { hiddenRank: false },
select: {
id: true,
rankName: true,
badge: true,
jobDescription: true,
staffColor: true,
},
orderBy: { id: "asc" },
});
teams = rows.map((t) => ({ ...t, id: String(t.id) }));
} catch {
teams = [];
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🛡️" title={t("title")} subtitle={t("subtitle")} />
{teams.length > 0 ? (
<ContentCard
icon="⭐"
title={t("ranksTitle")}
subtitle={t("ranksSubtitle")}
>
<div className="card-grid sm-2 lg-3">
{teams.map((t) => (
<div key={t.id} className="card">
<h3 style={{ margin: "0 0 0.25rem", color: t.staffColor }}>
{t.rankName}
</h3>
{t.jobDescription ? (
<p className="muted" style={{ margin: 0 }}>
{t.jobDescription}
</p>
) : null}
</div>
))}
</div>
</ContentCard>
) : null}
<ContentCard
icon="👥"
title={t("membersTitle")}
subtitle={
staff.length === 0
? t("membersEmptySubtitle")
: t("membersSubtitle", { count: staff.length })
}
padded={staff.length === 0}
>
{staff.length === 0 ? (
<EmptyState icon="🛡️">{t("emptyState")}</EmptyState>
) : (
<table>
<thead>
<tr>
<th>{t("colUsername")}</th>
<th>{t("colRank")}</th>
<th>{t("colMotto")}</th>
</tr>
</thead>
<tbody>
{staff.map((u) => (
<tr key={u.username}>
<td>
<Link href={`/u/${u.username}`}>{u.username}</Link>
</td>
<td>{u.rank}</td>
<td className="muted">{u.motto || "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</ContentCard>
</main>
);
}
+580
View File
@@ -0,0 +1,580 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { postGuestbook } from "@/actions/guestbook";
import {
ContentCard,
EmptyState,
OnlineBadge,
StatBlock,
} from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export async function generateMetadata({
params,
}: {
params: Promise<{ username: string }>;
}): Promise<Metadata> {
const { username } = await params;
const user = await prisma.user
.findUnique({
where: { username },
select: { username: true, motto: true },
})
.catch(() => null);
if (!user) return { title: "Profile" };
return {
title: user.username,
description: user.motto || `${user.username}'s profile`,
};
}
// 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";
// Emulator currency type ids (see UsersCurrency model): Duckets=0, Diamonds=5.
const CURRENCY_DUCKETS = 0;
const CURRENCY_DIAMONDS = 5;
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,
}: {
params: Promise<{ username: string }>;
}) {
const t = await getTranslations("pages.profile");
const { username } = await params;
const [user, imagerBase, session] = await Promise.all([
prisma.user.findUnique({
where: { username },
select: {
id: true,
username: true,
motto: true,
look: true,
rank: true,
credits: true,
online: true,
accountCreated: true,
},
}),
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.
// Currency ledger rows (duckets/diamonds). Credits live on the users row
// itself; the alt-currencies sit in the users_currency ledger keyed by type.
const currencyRows = await prisma.usersCurrency
.findMany({
where: {
userId: user.id,
type: { in: [CURRENCY_DUCKETS, CURRENCY_DIAMONDS] },
},
select: { type: true, amount: true },
})
.catch(() => []);
const ducketsAmount =
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
const diamondsAmount =
currencyRows.find((c) => c.type === CURRENCY_DIAMONDS)?.amount ?? 0;
const wallet = [
{
key: "credits",
icon: "💰",
label: t("statCredits"),
value: user.credits,
color: "var(--color-primary-readable, var(--color-primary))",
},
{
key: "duckets",
icon: "🪙",
label: t("statDuckets"),
value: ducketsAmount,
color: "var(--color-accent-readable, var(--color-accent))",
},
{
key: "diamonds",
icon: "💎",
label: t("statDiamonds"),
value: diamondsAmount,
color: "var(--color-text-readable, var(--color-text))",
},
];
// Friends. messenger_friendships stores one directed row per pair keyed on
// user_one_id; resolve the user_two_id side to usernames/looks for the grid.
const friendships = await prisma.messengerFriendships
.findMany({
where: { userOneId: user.id },
orderBy: { friendsSince: "desc" },
take: 18,
select: { userTwoId: true },
})
.catch(() => []);
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
const friendUsers = friendIds.length
? await prisma.user
.findMany({
where: { id: { in: friendIds } },
select: { id: true, username: true, look: true },
})
.catch(() => [])
: [];
const friendById = new Map(friendUsers.map((f) => [f.id, f]));
const friends = friendships
.map((f) => friendById.get(f.userTwoId))
.filter((f): f is NonNullable<typeof f> => Boolean(f));
// Rooms owned by this user.
const rooms = await prisma.rooms
.findMany({
where: { ownerId: user.id },
orderBy: { users: "desc" },
take: 8,
select: {
id: true,
name: true,
users: true,
usersMax: true,
state: true,
},
})
.catch(() => []);
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 style={{ display: "grid", gap: "1.5rem" }}>
{/* ── Profile header ──────────────────── */}
<ContentCard padded={false}>
<div
className="content-card-body"
style={{
display: "flex",
gap: "1.5rem",
alignItems: "center",
flexWrap: "wrap",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={avatar}
alt={`${user.username} avatar`}
width={100}
height={150}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
<p style={{ margin: "0 0 0.6rem" }}>
{user.motto || <span className="muted">{t("noMotto")}</span>}
</p>
<OnlineBadge online={user.online === "1"} />
</div>
</div>
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
<StatBlock icon="🏅" value={user.rank} label={t("statRank")} />
<StatBlock
icon="💰"
value={user.credits.toLocaleString()}
label={t("statCredits")}
/>
<StatBlock icon="📅" value={registered} label={t("statRegistered")} />
</div>
</ContentCard>
{/* ── Wallet / currencies ─────────────── */}
<ContentCard icon="💰" title={t("walletTitle")}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
gap: "0.75rem",
}}
>
{wallet.map((w) => (
<div
key={w.key}
className="card"
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderLeft: `4px solid ${w.color}`,
}}
>
<span
aria-hidden
style={{
fontSize: "1.6rem",
lineHeight: 1,
display: "flex",
width: "2.4rem",
height: "2.4rem",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
background: `color-mix(in srgb, ${w.color} 16%, transparent)`,
}}
>
{w.icon}
</span>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: "1.35rem",
fontWeight: 700,
color: w.color,
}}
>
{w.value.toLocaleString()}
</div>
<div className="muted" style={{ fontSize: "0.85rem" }}>
{w.label}
</div>
</div>
</div>
))}
</div>
</ContentCard>
{/* ── Friends ──────────────────────── */}
<ContentCard
icon="👥"
title={t("friendsTitle")}
subtitle={t("friendsSubtitle", { count: friends.length })}
padded={friends.length === 0}
>
{friends.length === 0 ? (
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
gap: "0.75rem",
}}
>
{friends.map((f) => (
<Link
key={f.id}
href={`/u/${encodeURIComponent(f.username)}`}
className="card"
title={f.username}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.35rem",
padding: "0.6rem 0.4rem",
textAlign: "center",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={avatarImageUrl(imagerBase ?? "", f.look, {
size: "m",
headOnly: true,
})}
alt={f.username}
width={48}
height={48}
style={{ display: "block" }}
/>
<span
style={{
fontSize: "0.8rem",
fontWeight: 600,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{f.username}
</span>
</Link>
))}
</div>
)}
</ContentCard>
{/* ── Rooms ────────────────────────── */}
<ContentCard
icon="🏠"
title={t("roomsTitle")}
subtitle={t("roomsSubtitle", { username: user.username })}
padded={rooms.length === 0}
>
{rooms.length === 0 ? (
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{rooms.map((r) => (
<div
key={r.id}
className="card"
style={{
display: "flex",
flexDirection: "column",
gap: "0.4rem",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span aria-hidden style={{ fontSize: "1.2rem" }}>
{"🏠"}
</span>
<strong
style={{
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.name || t("roomUntitled")}
</strong>
</div>
<div
className="muted"
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.85rem",
}}
>
<span>
{"👤"} {r.users}/{r.usersMax}
</span>
<span style={{ textTransform: "capitalize" }}>{r.state}</span>
</div>
</div>
))}
</div>
)}
</ContentCard>
{/* ── Badges ────────────────────── */}
<ContentCard
icon="🏅"
title={t("badgesTitle")}
padded={badges.length === 0}
>
{badges.length === 0 ? (
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
) : (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
alignItems: "center",
padding: "1rem",
}}
>
{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>
)}
</ContentCard>
{/* ── Photos ────────────────────── */}
<ContentCard
icon="📸"
title={t("photosTitle")}
padded={photos.length === 0}
>
{photos.length === 0 ? (
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{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>
)}
</ContentCard>
{/* ── Guestbook ───────────────────── */}
<ContentCard
icon="📝"
title={t("guestbookTitle")}
subtitle={t("guestbookSubtitle", { username: user.username })}
>
{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">
{t("leaveMessage")}
</label>
<textarea
id="gb-message"
name="message"
required
maxLength={255}
rows={3}
placeholder={t("guestbookPlaceholder", {
username: user.username,
})}
style={{
width: "100%",
margin: "0.4rem 0 0.75rem",
resize: "vertical",
}}
/>
<button type="submit" className="btn btn-primary">
{t("post")}
</button>
</form>
) : (
<p className="muted">{t("loginToPost")}</p>
)}
{guestbook.length === 0 ? (
<EmptyState icon="📝">{t("guestbookEmpty")}</EmptyState>
) : (
<div className="grid" style={{ gap: "0.75rem" }}>
{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 ?? t("unknownUser", { id: g.userId })}
</strong>{" "}
<span className="muted">{formatDate(g.createdAt)}</span>
</p>
<p style={{ margin: 0 }}>{g.message}</p>
</div>
</div>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+171
View File
@@ -0,0 +1,171 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { isValidVerificationToken } from "@/actions/email-verify";
import { DiscordVerifyForm } from "@/components/auth/discord-verify-form";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type Status = "verified" | "already" | "invalid" | "unavailable" | "discord";
export default async function VerifyPage({
searchParams,
}: {
searchParams: Promise<{
token?: string;
email?: string;
method?: string;
linked?: string;
}>;
}) {
const t = await getTranslations("pages.verify");
const {
token = "",
email = "",
method = "",
linked = "",
} = await searchParams;
const normalisedEmail = email.trim().toLowerCase();
let status: Status = "invalid";
let discordAlreadyLinked = false;
if (method === "discord" || linked === "1") {
status = "discord";
const session = await auth();
if (session?.user?.id) {
const userId = Number(session.user.id);
try {
const linkedRow = await prisma.socialAccounts.findFirst({
where: { userId: BigInt(userId), provider: "discord" },
select: { id: true },
});
discordAlreadyLinked = !!linkedRow;
} catch {}
}
} else if (normalisedEmail && token) {
const ok = await isValidVerificationToken(normalisedEmail, token);
if (ok) {
try {
const user = await prisma.user.findFirst({
where: { mail: normalisedEmail },
select: { id: true, mailVerified: true },
});
if (!user) {
status = "invalid";
} else if (user.mailVerified === "1") {
status = "already";
} else {
await prisma.user.update({
where: { id: user.id },
data: { mailVerified: "1" },
});
status = "verified";
}
} catch {
status = "unavailable";
}
}
}
return (
<main style={{ maxWidth: 480, margin: "2rem auto" }}>
{status === "verified" && (
<ContentCard
icon="✅"
title={t("verifiedTitle")}
subtitle={t("verifiedSubtitle")}
>
<p className="muted" style={{ marginTop: 0 }}>
{t("verifiedBody")}
</p>
<Link href="/login" className="btn btn-primary">
{t("goToLogin")}
</Link>
</ContentCard>
)}
{status === "already" && (
<ContentCard
icon="✅"
title={t("alreadyTitle")}
subtitle={t("alreadySubtitle")}
>
<p className="muted" style={{ marginTop: 0 }}>
{t("alreadyBody")}
</p>
<Link href="/login" className="btn btn-primary">
{t("goToLogin")}
</Link>
</ContentCard>
)}
{status === "unavailable" && (
<ContentCard
icon="⏳"
title={t("unavailableTitle")}
subtitle={t("unavailableSubtitle")}
>
<p className="muted" style={{ margin: 0 }}>
{t("unavailableBody")}
</p>
</ContentCard>
)}
{status === "invalid" && (
<ContentCard
icon="📧"
title={t("invalidTitle")}
subtitle={t("invalidSubtitle")}
>
<EmptyState icon="🔗">{t("invalidBody")}</EmptyState>
<div style={{ textAlign: "center" }}>
<Link href="/login" className="btn btn-outline">
{t("backToLogin")}
</Link>
</div>
</ContentCard>
)}
{status === "discord" && (
<ContentCard
icon="🎮"
title="Verifieer via Discord"
subtitle="Koppel je Discord account"
>
{linked === "1" && (
<div
className="rounded-md p-4 text-center mb-4"
style={{ backgroundColor: "#14532d", color: "white" }}
>
<p className="text-lg font-bold mb-1">Gelinkt!</p>
<p className="text-sm opacity-90">
Je Discord account is gekoppeld. Je kunt nu verder.
</p>
</div>
)}
{discordAlreadyLinked ? (
<div
className="rounded-md p-4 text-center"
style={{ backgroundColor: "#14532d", color: "white" }}
>
<p className="text-lg font-bold mb-1">Al geverifieerd!</p>
<p className="text-sm opacity-90">
Je Discord account is al gekoppeld aan dit account.
</p>
</div>
) : (
<DiscordVerifyForm />
)}
<div style={{ textAlign: "center", marginTop: "1rem" }}>
<Link href="/" className="btn btn-primary">
Naar home
</Link>
</div>
</ContentCard>
)}
</main>
);
}