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

+2 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { RichText } from "@/components/admin/rich-text";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -30,7 +31,7 @@ export default async function EditArticle({
<input name="title" defaultValue={article.title} required />
<input name="image" defaultValue={article.image} placeholder="Image URL" />
<textarea name="shortStory" defaultValue={article.shortStory} rows={2} />
<textarea name="fullStory" defaultValue={article.fullStory} rows={10} />
<RichText name="fullStory" defaultValue={article.fullStory} rows={10} />
<button type="submit">Save</button>
</form>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
+2 -1
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { createArticle } from "@/actions/admin-articles";
import { RichText } from "@/components/admin/rich-text";
export default function NewArticle() {
return (
@@ -12,7 +13,7 @@ export default function NewArticle() {
<input name="title" placeholder="Title" required />
<input name="image" placeholder="Image URL" />
<textarea name="shortStory" placeholder="Short story (summary)" rows={2} />
<textarea name="fullStory" placeholder="Full story" rows={10} />
<RichText name="fullStory" placeholder="Full story" rows={10} />
<button type="submit">Create</button>
</form>
</main>
+57 -1
View File
@@ -7,7 +7,10 @@ const CRITICAL = new Set(["critical", "error", "danger"]);
// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma
// generates no delegate for them — they are read via $queryRaw against the real
// emulator columns. alert_logs has a proper delegate (prisma.alertLogs).
// emulator columns. chatlogs_private has a delegate (prisma.chatlogsPrivate) but
// is read here via $queryRaw too, mirroring the room-chat section and selecting
// defensively so a column mismatch degrades to an empty list rather than a 500.
// alert_logs has a proper delegate (prisma.alertLogs).
type ChatlogRoomRow = {
room_id: number;
@@ -17,6 +20,13 @@ type ChatlogRoomRow = {
timestamp: number;
};
type ChatlogPrivateRow = {
user_from_id: number;
user_to_id: number;
message: string;
timestamp: number;
};
type CommandlogRow = {
user_id: number;
timestamp: number;
@@ -50,6 +60,21 @@ export default async function AdminLogs() {
chatlogs = [];
}
// Private (whisper) chat. Defensive SELECT + try/catch — the table may be
// absent or have differing columns on some emulators, in which case we show
// an empty section instead of crashing the page.
let privateChatlogs: ChatlogPrivateRow[] = [];
try {
privateChatlogs = await prisma.$queryRaw<ChatlogPrivateRow[]>`
SELECT user_from_id, user_to_id, message, timestamp
FROM chatlogs_private
ORDER BY timestamp DESC
LIMIT 50
`;
} catch {
privateChatlogs = [];
}
let commandlogs: CommandlogRow[] = [];
try {
commandlogs = await prisma.$queryRaw<CommandlogRow[]>`
@@ -81,6 +106,7 @@ export default async function AdminLogs() {
<div className="admin-stats">
<StatusCard label="Room chat" value={chatlogs.length} icon="💬" />
<StatusCard label="Private chat" value={privateChatlogs.length} icon="🔒" />
<StatusCard label="Commands" value={commandlogs.length} icon="⌨️" />
<StatusCard label="Alerts" value={alertLogs.length} icon="🔔" />
</div>
@@ -117,6 +143,36 @@ export default async function AdminLogs() {
)}
</section>
<section className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Private chat ({privateChatlogs.length})</h3>
{privateChatlogs.length === 0 ? (
<p className="muted">No private chat logs.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th>When</th>
<th>From</th>
<th>To</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{privateChatlogs.map((c, i) => (
<tr key={`${c.timestamp}-${c.user_from_id}-${c.user_to_id}-${i}`}>
<td className="muted">{fromUnix(c.timestamp)}</td>
<td>{String(c.user_from_id)}</td>
<td>{c.user_to_id ? String(c.user_to_id) : "—"}</td>
<td>{c.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Commands ({commandlogs.length})</h3>
{commandlogs.length === 0 ? (
+60 -4
View File
@@ -8,6 +8,8 @@ function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
type RankCount = { rank: number; count: number };
export default async function AdminDashboard() {
let users = -1;
let online = 0;
@@ -25,6 +27,15 @@ export default async function AdminDashboard() {
}
const dbOk = users >= 0;
// Users-by-rank breakdown (small inline bar chart, no chart library).
const rankGroups = await prisma.user
.groupBy({ by: ["rank"], _count: { _all: true }, orderBy: { rank: "desc" } })
.catch(() => [] as Array<{ rank: number; _count: { _all: number } }>);
const rankCounts: RankCount[] = rankGroups
.map((g) => ({ rank: g.rank, count: g._count._all }))
.sort((a, b) => b.count - a.count);
const maxRankCount = rankCounts.reduce((m, r) => Math.max(m, r.count), 0);
const activity = await prisma.staffActivities
.findMany({ orderBy: { createdAt: "desc" }, take: 8 })
.catch(() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>);
@@ -47,23 +58,68 @@ export default async function AdminDashboard() {
value={online}
hint={dbOk ? `${users} accounts` : "DB unavailable"}
state={dbOk ? "neutral" : "danger"}
icon="👤"
icon="\u{1F464}"
/>
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="📰" />
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="\u{1F4F0}" />
<StatusCard
label="Active bans"
value={dbOk ? bans : "—"}
state={bans > 0 ? "warn" : "neutral"}
icon="🔨"
icon="\u{1F528}"
/>
<StatusCard
label="Database"
value={dbOk ? "Connected" : "Down"}
state={dbOk ? "ok" : "danger"}
icon="🗄️"
icon="\u{1F5C4}️"
/>
</div>
<section className="admin-section">
<h2 style={{ margin: 0 }}>Users by rank</h2>
{rankCounts.length === 0 ? (
<p className="muted" style={{ marginTop: "0.7rem" }}>
{dbOk ? "No accounts to break down yet." : "DB unavailable."}
</p>
) : (
<div style={{ display: "grid", gap: "0.5rem", marginTop: "0.8rem" }}>
{rankCounts.map((r) => {
const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
return (
<div
key={r.rank}
style={{ display: "grid", gridTemplateColumns: "5.5rem 1fr 3rem", alignItems: "center", gap: "0.6rem" }}
>
<span className="muted" style={{ fontSize: "0.82rem", whiteSpace: "nowrap" }}>
Rank {r.rank}
</span>
<div
style={{
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
borderRadius: "var(--radius-sm)",
height: "0.85rem",
overflow: "hidden",
}}
role="img"
aria-label={`Rank ${r.rank}: ${r.count} accounts`}
>
<div
style={{
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
height: "100%",
background: "var(--color-primary, #eeb425)",
borderRadius: "var(--radius-sm)",
}}
/>
</div>
<strong style={{ fontSize: "0.85rem", textAlign: "right" }}>{r.count}</strong>
</div>
);
})}
</div>
)}
</section>
<section className="admin-section">
<div style={{ display: "flex", alignItems: "baseline", gap: "0.75rem" }}>
<h2 style={{ margin: 0 }}>Recent staff activity</h2>
+96
View File
@@ -0,0 +1,96 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { deleteShout } from '@/actions/admin-radio-moderation';
import { StatusCard } from '@/components/admin/dashboard';
export const dynamic = 'force-dynamic';
type Shout = {
id: bigint;
userId: bigint;
message: string;
createdAt: Date | null;
updatedAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
}
export default async function AdminRadioModerationPage() {
let shouts: Shout[] = [];
try {
shouts = await prisma.radioShouts.findMany({
select: {
id: true,
userId: true,
message: true,
createdAt: true,
updatedAt: true,
},
orderBy: { id: 'desc' },
take: 50,
});
} catch {
shouts = [];
}
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/moderation">Moderation</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<h1>DJ Moderation</h1>
<p className="muted" style={{ marginTop: '-0.4rem' }}>
Review and remove radio shouts (50 most recent).
</p>
<div className="admin-stats">
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
</div>
<section className="admin-section">
<h2>Recent Shouts ({shouts.length})</h2>
{shouts.length === 0 ? (
<p className="muted">No shouts to moderate.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Message</th>
<th>Posted</th>
<th />
</tr>
</thead>
<tbody>
{shouts.map((s) => (
<tr key={String(s.id)}>
<td>{String(s.id)}</td>
<td>{String(s.userId)}</td>
<td>{s.message}</td>
<td className="muted">{formatDate(s.createdAt)}</td>
<td>
<form action={deleteShout}>
<input type="hidden" name="id" value={String(s.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}
+55
View File
@@ -0,0 +1,55 @@
// Public REST: a single guild (guilds) with its member count
// (guilds_members). AtomCMS JSON API parity. Returns { error } (404) when the
// id is unknown. The owner is the user_id column (accessor userId), surfaced
// here as ownerId.
import { apiJson } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
if (!/^\d+$/.test(id)) {
return apiJson({ error: "Guild not found" }, { status: 404 });
}
const guildId = Number(id);
try {
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: {
id: true,
name: true,
description: true,
userId: true,
roomId: true,
badge: true,
dateCreated: true,
},
});
if (!guild) {
return apiJson({ error: "Guild not found" }, { status: 404 });
}
// Member count is a separate guarded query (no relation is modelled).
let memberCount = 0;
try {
memberCount = await prisma.guildsMembers.count({
where: { guildId: guild.id },
});
} catch {
memberCount = 0;
}
const { userId, ...rest } = guild;
return apiJson({ data: { ...rest, ownerId: userId, memberCount } });
} catch {
// DB unavailable — treat as not found rather than a 500.
return apiJson({ error: "Guild not found" }, { status: 200 });
}
}
+47
View File
@@ -0,0 +1,47 @@
// Public REST: hotel guilds (guilds). AtomCMS JSON API parity — read-only,
// paginated list ordered newest first (by id), exposing the basic public
// fields. The owner is stored as the user_id column (accessor userId), surfaced
// here as ownerId for API clarity.
import { apiJson, pagination } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
const sp = new URL(req.url).searchParams;
const { page, perPage, skip, take } = pagination(sp);
try {
const [total, rows] = await Promise.all([
prisma.guilds.count(),
prisma.guilds.findMany({
orderBy: { id: "desc" },
skip,
take,
select: {
id: true,
name: true,
description: true,
userId: true,
},
}),
]);
// Rename userId → ownerId for the public payload.
const data = rows.map(({ userId, ...rest }) => ({
...rest,
ownerId: userId,
}));
return apiJson({
data,
meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) },
});
} catch {
// DB unreachable — never 500; return an empty, well-formed payload.
return apiJson(
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
{ status: 200 },
);
}
}
+38
View File
@@ -0,0 +1,38 @@
// Public REST: whether the radio player should auto-play on load. Reads the
// radio_auto_play website setting (the key the admin radio settings page
// writes), falling back to the legacy radio_autoplay alias if present. Booleans
// are stored as the strings '1' / '0'. Returns { autoplay: boolean }.
import { apiJson } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const AUTOPLAY_KEYS = ["radio_auto_play", "radio_autoplay"];
function truthy(value: string | undefined): boolean {
if (value === undefined) return false;
const s = value.trim().toLowerCase();
return s === "1" || s === "true";
}
export async function GET(_req: Request) {
try {
const rows = await prisma.websiteSetting.findMany({
where: { key: { in: AUTOPLAY_KEYS } },
select: { key: true, value: true },
});
const map: Record<string, string> = {};
for (const row of rows) {
map[row.key] = row.value;
}
// Prefer the canonical key the settings page uses; fall back to the alias.
const autoplay = truthy(map.radio_auto_play ?? map.radio_autoplay);
return apiJson({ autoplay });
} catch {
// DB unavailable — default to no autoplay rather than a 500.
return apiJson({ autoplay: false }, { status: 200 });
}
}
+70
View File
@@ -0,0 +1,70 @@
// Public REST: a single rare furni trade value by id (website_rare_values),
// together with its parent category. AtomCMS JSON API parity. Returns { error }
// (404) when the id is unknown.
import { apiJson } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
// The primary key is a BigInt; reject non-numeric ids up front.
if (!/^\d+$/.test(id)) {
return apiJson({ error: "Rare value not found" }, { status: 404 });
}
let valueId: bigint;
try {
valueId = BigInt(id);
} catch {
return apiJson({ error: "Rare value not found" }, { status: 404 });
}
try {
const value = await prisma.websiteRareValues.findUnique({
where: { id: valueId },
select: {
id: true,
categoryId: true,
itemId: true,
name: true,
creditValue: true,
currencyValue: true,
currencyType: true,
furnitureIcon: true,
createdAt: true,
updatedAt: true,
},
});
if (!value) {
return apiJson({ error: "Rare value not found" }, { status: 404 });
}
// No relation is modelled between the value and its category, so resolve the
// category in a second guarded query.
let category: {
id: bigint;
name: string;
badge: string;
priority: number;
} | null = null;
try {
category = await prisma.websiteRareValueCategories.findUnique({
where: { id: value.categoryId },
select: { id: true, name: true, badge: true, priority: true },
});
} catch {
category = null;
}
return apiJson({ data: { ...value, category } });
} catch {
// DB unavailable — treat as not found rather than a 500.
return apiJson({ error: "Rare value not found" }, { status: 200 });
}
}
+99
View File
@@ -0,0 +1,99 @@
import Link from "next/link";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function FlashClientPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// The Flash client URL may be stored under either key depending on the
// AtomCMS settings seed — try both, then fall back to the Nitro URL so the
// launcher still issues a working SSO ticket.
const [hotelName, flashUrl, flashUrlAlt, nitroUrl] = await Promise.all([
siteSettings.get("hotel_name", "Atom").catch(() => "Atom"),
siteSettings.get("flash_client_url", "").catch(() => ""),
siteSettings.get("client_flash_url", "").catch(() => ""),
siteSettings.get("nitro_client_url", "").catch(() => ""),
]);
const clientUrl = flashUrl || flashUrlAlt || nitroUrl || "";
const hdrs = await headers();
const ip =
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
// Faithful to AtomCMS's client controllers: issue (and persist) the SSO ticket
// on the launcher render, then hand it to the Flash client.
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
const src = clientUrl
? `${clientUrl}${clientUrl.includes("?") ? "&" : "?"}sso=${encodeURIComponent(ticket)}`
: "";
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎮"
title={`Launching ${hotelName ?? "Atom"} (Flash)…`}
subtitle="Your single sign-on ticket is ready"
action={
<Link href="/client" className="btn btn-outline">
Use Nitro client
</Link>
}
/>
{src ? (
<>
<ContentCard
icon="🚀"
title="Open the Flash client"
subtitle="Flash works best in its own tab"
action={
<a
href={src}
target="_blank"
rel="noreferrer"
className="btn btn-primary"
>
Launch in a new tab
</a>
}
/>
<ContentCard padded={false}>
<iframe
title="Flash hotel client"
src={src}
style={{
width: "100%",
height: "80vh",
border: 0,
borderRadius: 10,
display: "block",
}}
/>
</ContentCard>
</>
) : (
<ContentCard
icon="🔌"
title="No Flash client configured"
subtitle="Set flash_client_url in settings"
>
<EmptyState icon="🔌">
No Flash client URL configured (set <code>flash_client_url</code> in settings). SSO
ticket issued for this session: <code>{ticket}</code>
</EmptyState>
</ContentCard>
)}
</main>
);
}
+6
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -36,6 +37,11 @@ export default async function ClientPage() {
icon="🏨"
title={`Launching ${hotelName ?? "Atom"}…`}
subtitle="Your single sign-on ticket is ready"
action={
<Link href="/client/flash" className="btn btn-outline">
Use Flash client
</Link>
}
/>
{src ? (
+12 -25
View File
@@ -1,6 +1,7 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { ArticleSlider, type SliderArticle } from "@/components/public/article-slider";
import { excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { sanitize } from "@/lib/sanitize";
@@ -23,6 +24,15 @@ export default async function HomePage() {
// DB unavailable — show the shell without articles.
}
// Pre-shape for the client slider: compute excerpts server-side so the client
// component carries no prisma/i18n dependency.
const slides: SliderArticle[] = articles.map((a) => ({
slug: a.slug,
title: a.title,
image: a.image,
excerpt: excerpt(a.shortStory, 110),
}));
// Staff-authored content boxes (managed at /admin/writeable-boxes).
const boxes = await prisma.websiteWriteableBoxes
.findMany({
@@ -62,33 +72,10 @@ export default async function HomePage() {
</Link>
}
>
{articles.length === 0 ? (
{slides.length === 0 ? (
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-4">
{articles.map((a) => (
<article
key={a.slug}
className="card hover"
style={{ padding: 0, overflow: "hidden" }}
>
{a.image ? (
// biome-ignore lint/performance/noImgElement: external article image
<img className="article-img" src={a.image} alt="" />
) : (
<div className="article-img" />
)}
<div style={{ padding: "0.85rem" }}>
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<Link href={`/news/${a.slug}`}>{a.title}</Link>
</h3>
<p className="muted" style={{ margin: 0 }}>
{excerpt(a.shortStory, 110)}
</p>
</div>
</article>
))}
</div>
<ArticleSlider articles={slides} />
)}
</ContentCard>
+14 -26
View File
@@ -1,5 +1,6 @@
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { PhotoLightbox, type LightboxPhoto } from "@/components/public/photo-lightbox";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -34,6 +35,16 @@ export default async function PhotosPage() {
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
@@ -42,34 +53,11 @@ export default async function PhotosPage() {
subtitle={t("subtitle")}
/>
<ContentCard padded={photos.length === 0}>
{photos.length === 0 ? (
<ContentCard padded={items.length === 0}>
{items.length === 0 ? (
<EmptyState icon="📸">{t("noPhotos")}</EmptyState>
) : (
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p) => (
<article key={String(p.id)} className="card hover" style={{ padding: 0, overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={t("photoAlt", { id: p.userId })}
style={{
width: "100%",
height: "auto",
display: "block",
}}
/>
<div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{formatDate(p.timestamp)}
</p>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{t("byUser", { id: p.userId })}
</p>
</div>
</article>
))}
</div>
<PhotoLightbox photos={items} />
)}
</ContentCard>
</main>
+123
View File
@@ -0,0 +1,123 @@
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>
);
}
+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" }}>