Close remaining web gaps: rich profile, login history, lightbox/slider, flash client, admin chatlog/DJ/chart/WYSIWYG, niche API

- Rich profile (/u/[username]): wallet (credits/duckets/diamonds), friends
  grid (messenger_friendships), and owned rooms sections.
- Login history: new website_login_logs table (model + migration 0007),
  recorded on every successful sign-in (ip + user-agent), surfaced on a new
  /settings/sessions page (with failed-attempt list from failed_logins).
- Photos lightbox + home article slider (client components, no Swiper dep).
- /client/flash launcher (SSO ticket like the Nitro page).
- Admin: private chatlogs section in /admin/logs, /admin/radio/moderation
  (shout moderation), a "users by rank" inline bar chart on the dashboard,
  and a TinyMCE rich-text editor on the article admin forms.
- Niche API: /api/values/[id], /api/guilds(+/[id]), /api/radio/auto-play.

Verified live (prod, amx_test): login recorded → /settings/sessions shows
it with device; profile renders wallet/friends/rooms; dashboard chart +
private-chat logs + /client/flash + /api/guilds all OK. Reverted test data.
tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-29 18:26:34 +02:00
1 parent f7b3845131
commit 4dfe698009
25 files changed
+1463 -64

No files matched your search

+234 -4
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -26,6 +27,10 @@ export async function generateMetadata({
// 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);
@@ -69,6 +74,77 @@ export default async function ProfilePage({
// 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)",
},
{
key: "duckets",
icon: "🪙",
label: t("statDuckets"),
value: ducketsAmount,
color: "var(--color-accent)",
},
{
key: "diamonds",
icon: "💎",
label: t("statDiamonds"),
value: diamondsAmount,
color: "#38bdf8",
},
];
// 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 },
@@ -106,7 +182,7 @@ export default async function ProfilePage({
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{/* ── Profile header ───────────────────────────────────── */}
{/* ── Profile header ──────────────────── */}
<ContentCard padded={false}>
<div
className="content-card-body"
@@ -129,7 +205,161 @@ export default async function ProfilePage({
</div>
</ContentCard>
{/* ── Badges ───────────────────────────────────────────── */}
{/* ── 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>
@@ -158,7 +388,7 @@ export default async function ProfilePage({
)}
</ContentCard>
{/* ── Photos ───────────────────────────────────────────── */}
{/* ── Photos ────────────────────── */}
<ContentCard icon="📸" title={t("photosTitle")} padded={photos.length === 0}>
{photos.length === 0 ? (
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
@@ -181,7 +411,7 @@ export default async function ProfilePage({
)}
</ContentCard>
{/* ── Guestbook ────────────────────────────────────────── */}
{/* ── Guestbook ───────────────────── */}
<ContentCard icon="📝" title={t("guestbookTitle")} subtitle={t("guestbookSubtitle", { username: user.username })}>
{isLoggedIn ? (
<form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}>