Close the web-feasible 100% gaps: REST write/token API, tickets, draw-badge, /me, sanitisation, dusk, radio SSE

Final parity push (web-tier only):
- REST API write + token auth: POST /api/tokens (issue a personal_access_token
  for the session user), Bearer auth via src/lib/api-auth.ts, POST
  /api/articles/[slug]/comment, GET/DELETE /api/me/tokens, full tickets API
  (/api/tickets +[id] +[id]/reply), radio current-dj/points/points-leaderboard/
  embed-config + POST shouts, and a real-time /api/radio/stream (SSE). 31 public
  API routes total.
- Pages: /draw-badge (buy a custom profile badge → credits + RCON), /me
  dashboard (stats + online friends + referral claim). Wired into the nav.
- HTML sanitisation (sanitize-html) — the HTMLPurifier equivalent — applied to
  writeable boxes + article bodies before dangerouslySetInnerHTML.
- "Dusk" dark theme preset + a default-dark site option honoured by the
  no-flash boot script.

Verified live (prod, amx_test): token issue → Bearer endpoint 200, no-token
401; /api/me/tokens lists it; current-dj/leaderboard JSON; /me + /draw-badge
200; reverted the test user + tokens. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-29 18:15:01 +02:00
1 parent 8cedf5614e
commit f7b3845131
30 files changed
+1734 -10

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
/**
* Copies the user's referral link to the clipboard. Mirrors AtomCMS's
* copyCode() helper on the /me page, but resolves the link to an absolute URL
* (the server only knows a relative path, so we prepend the current origin).
*/
export default function CopyReferralButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
const absolute =
typeof window !== "undefined" && value.startsWith("/")
? `${window.location.origin}${value}`
: value;
try {
await navigator.clipboard.writeText(absolute);
} catch {
// Older browsers / insecure contexts: fall back to a hidden textarea.
const ta = document.createElement("textarea");
ta.value = absolute;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} catch {
/* give up silently */
}
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<button type="button" className="btn btn-secondary" onClick={copy}>
{copied ? "Copied!" : "Copy link"}
</button>
);
}
+299
View File
@@ -0,0 +1,299 @@
import type { CSSProperties } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import CopyReferralButton from "./CopyReferralButton";
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 { claimReferral } from "@/actions/referral";
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)",
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.",
};
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;
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")}>
{ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error}
</div>
) : null}
{/* Header card -------------------------------------------------- */}
<ContentCard
icon="🏠"
title={user.username}
subtitle={user.motto || "Welcome back!"}
/>
{/* Stat tiles --------------------------------------------------- */}
<div className="card-grid sm-2 lg-3">
<StatBlock icon="💰" value={user.credits.toLocaleString()} label="Credits" />
<StatBlock icon="🎖️" value={`Rank ${user.rank}`} label="Hotel rank" />
<StatBlock icon="📅" value={registered} label="Registered" />
</div>
{/* Online friends ----------------------------------------------- */}
<ContentCard
icon="🟢"
title="Online friends"
subtitle={
onlineFriends.length === 0
? "None of your friends are online right now"
: `${onlineFriends.length} of your ${friends.length} friends online`
}
action={<Link href="/friends">View all</Link>}
padded={onlineFriends.length === 0}
>
{onlineFriends.length === 0 ? (
<EmptyState icon="💤">
No friends online right now. Check back later!
</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{onlineFriends.map((friend) => {
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
size: "s",
headOnly: true,
});
return (
<div
key={friend.id}
className="card hover"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${friend.username} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<Link href={`/u/${friend.username}`}>{friend.username}</Link>
</h3>
<p
className="muted"
style={{
margin: "0 0 0.4rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{friend.motto || "No motto"}
</p>
<OnlineBadge online />
</div>
</div>
);
})}
</div>
)}
</ContentCard>
{/* Referral card ------------------------------------------------ */}
<ContentCard
icon="🤝"
title={`Refer a friend (${referralTotal}/${needed})`}
subtitle="Refer new users and earn in-game rewards"
>
<div style={{ display: "grid", gap: "1rem" }}>
<p style={{ margin: 0 }}>
{rewardAmount > 0
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
: `Invite ${needed} users through your referral link to claim a reward!`}
</p>
<div role="note" style={feedbackStyle("warning")}>
⚠️ Boosting referrals by making your own accounts will lead to
removal of all progress, currency, inventory and a potential ban.
</div>
<div>
<label
htmlFor="referral-link"
className="muted"
style={{ display: "block", marginBottom: "0.35rem", fontSize: "0.85rem" }}
>
Your referral link
</label>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input
id="referral-link"
type="text"
readOnly
defaultValue={referralLink}
className="input"
style={{ flex: "1 1 240px", minWidth: 0 }}
/>
<CopyReferralButton value={referralLink} />
</div>
</div>
{canClaim ? (
<form action={claimReferral}>
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
Claim your referral reward!
</button>
</form>
) : (
<button
type="button"
disabled
className="btn"
style={{ width: "100%", opacity: 0.6, cursor: "not-allowed" }}
>
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
</button>
)}
</div>
</ContentCard>
</>
);
} catch {
content = (
<ContentCard icon="⚠️" title="My dashboard">
<EmptyState icon="⚠️">
We couldn&apos;t load your dashboard right now. Please try again shortly.
</EmptyState>
</ContentCard>
);
}
return <main style={{ display: "grid", gap: "1.5rem" }}>{content}</main>;
}