Decouple admin from public max-w-7xl via (site) route group.
Local Build and Deploy / deploy (push) Successful in 54s
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:
1 parent
a1dc7ff0a7
commit
367f69a439
60 files changed
+29
-30
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <your-token>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't load your dashboard right now. Please try again
|
||||
shortly.
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
);
|
||||
}
|
||||
|
||||
return <main style={{ display: "grid", gap: "1.5rem" }}>{content}</main>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} – {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user