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

+43
View File
@@ -0,0 +1,43 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireStaff } from '@/lib/admin/guard';
import { logStaffActivity } from '@/lib/services/staff-activity';
import { prisma } from '@/lib/prisma';
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
/**
* Delete a radio shout from the DJ moderation page. Re-reads auth via
* requireStaff, writes a staff-activity audit entry and revalidates the
* moderation route. Fails soft if the row is already gone.
*/
export async function deleteShout(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
if (id === null) return;
try {
await prisma.radioShouts.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: 'radio.shout.delete',
description: `Deleted radio shout #${id}`,
targetType: 'radio_shout',
targetId: Number(id),
});
} catch {
// Row may already be gone; ignore so the action does not throw.
}
revalidatePath('/admin/radio/moderation');
}
+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" }}>
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
// TinyMCE community build, loaded from the public jsDelivr CDN. `no-api-key`
// is the documented sentinel for the free self-hosted/CDN build and silences
// the "register for an API key" notification.
const TINYMCE_SRC =
"https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
type TinyMCEGlobal = {
init: (opts: Record<string, unknown>) => Promise<unknown>;
remove: (selector: string) => void;
};
declare global {
interface Window {
tinymce?: TinyMCEGlobal;
}
}
let loaderPromise: Promise<TinyMCEGlobal | null> | null = null;
/** Inject the TinyMCE script once; resolves null if it fails to load. */
function loadTinyMce(): Promise<TinyMCEGlobal | null> {
if (typeof window === "undefined") return Promise.resolve(null);
if (window.tinymce) return Promise.resolve(window.tinymce);
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>("script[data-tinymce]");
const finish = () => resolve(window.tinymce ?? null);
if (existing) {
existing.addEventListener("load", finish, { once: true });
existing.addEventListener("error", () => resolve(null), { once: true });
if (window.tinymce) resolve(window.tinymce);
return;
}
const script = document.createElement("script");
script.src = TINYMCE_SRC;
script.async = true;
script.referrerPolicy = "origin";
script.dataset.tinymce = "1";
script.addEventListener("load", finish, { once: true });
script.addEventListener("error", () => resolve(null), { once: true });
document.head.appendChild(script);
});
return loaderPromise;
}
/**
* WYSIWYG editor over a plain <textarea>. Keeps the original `name` so the
* surrounding server-action form still receives the HTML on submit. If TinyMCE
* fails to load (offline, CDN blocked) it silently degrades to the textarea.
*/
export function RichText({
name,
defaultValue = "",
placeholder,
rows = 10,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
rows?: number;
}) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const reactId = useId();
// Stable, querySelector-safe id (useId() contains ':').
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const selector = `#${editorId}`;
loadTinyMce().then((tinymce) => {
if (cancelled || !tinymce || !textareaRef.current) return;
tinymce
.init({
selector,
menubar: false,
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar:
"undo redo | bold italic underline | bullist numlist | link | removeformat | code",
branding: false,
promotion: false,
license_key: "gpl",
skin: "oxide",
content_style: "body{font-family:inherit;font-size:14px}",
// Mirror typed HTML back into the underlying textarea so the form
// submit (server action) always sees the current value.
setup: (editor: {
on: (ev: string, cb: () => void) => void;
getContent: () => string;
}) => {
const sync = () => {
if (textareaRef.current) textareaRef.current.value = editor.getContent();
};
editor.on("change keyup undo redo SetContent", sync);
},
})
.then(() => {
if (!cancelled) setReady(true);
})
.catch(() => {
/* leave the plain textarea in place */
});
});
return () => {
cancelled = true;
// Detach the editor instance on unmount; ignore if TinyMCE never loaded.
try {
window.tinymce?.remove(selector);
} catch {
/* noop */
}
};
}, [editorId, rows]);
return (
<textarea
id={editorId}
ref={textareaRef}
name={name}
defaultValue={defaultValue}
placeholder={placeholder}
rows={rows}
// Hide the raw textarea only once TinyMCE has mounted (it replaces it with
// its own iframe); until then the plain field is the visible fallback.
style={ready ? { display: "none" } : undefined}
/>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Horizontal, scroll-snap slider for the home page "latest news" row. Keeps the
* exact article-card markup from the server page; the cards are passed in as a
* pre-shaped list so this client component carries no prisma/i18n dependency.
* Prev/next buttons scroll the snap track by one viewport-ish step and disable
* themselves at the ends.
*/
export type SliderArticle = {
slug: string;
title: string;
image: string;
excerpt: string;
};
export function ArticleSlider({ articles }: { articles: SliderArticle[] }) {
const trackRef = useRef<HTMLDivElement>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
const updateEdges = useCallback(() => {
const el = trackRef.current;
if (!el) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setAtStart(el.scrollLeft <= 1);
setAtEnd(el.scrollLeft >= maxScroll - 1);
}, []);
useEffect(() => {
updateEdges();
window.addEventListener("resize", updateEdges);
return () => window.removeEventListener("resize", updateEdges);
}, [updateEdges]);
const scrollByDir = useCallback((dir: 1 | -1) => {
const el = trackRef.current;
if (!el) return;
// Step by roughly one card width (incl. gap) so snapping lands cleanly.
const step = Math.max(el.clientWidth * 0.8, 260);
el.scrollBy({ left: dir * step, behavior: "smooth" });
}, []);
return (
<div style={{ position: "relative" }}>
<button
type="button"
aria-label="Previous"
onClick={() => scrollByDir(-1)}
disabled={atStart}
className="article-slider-nav"
style={{ left: "-0.6rem", opacity: atStart ? 0.35 : 1 }}
>
‹
</button>
<div
ref={trackRef}
onScroll={updateEdges}
style={{
display: "flex",
gap: "1rem",
overflowX: "auto",
scrollSnapType: "x mandatory",
scrollbarWidth: "none",
paddingBottom: "0.25rem",
}}
>
{articles.map((a) => (
<article
key={a.slug}
className="card hover"
style={{
padding: 0,
overflow: "hidden",
flex: "0 0 auto",
width: "clamp(220px, 70vw, 280px)",
scrollSnapAlign: "start",
}}
>
{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 }}>
{a.excerpt}
</p>
</div>
</article>
))}
</div>
<button
type="button"
aria-label="Next"
onClick={() => scrollByDir(1)}
disabled={atEnd}
className="article-slider-nav"
style={{ right: "-0.6rem", opacity: atEnd ? 0.35 : 1 }}
>
›
</button>
<style>{`
.article-slider-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 2.4rem;
height: 2.4rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-card);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s ease, box-shadow 0.15s ease;
}
.article-slider-nav:hover:not(:disabled) {
box-shadow: var(--shadow-card-hover);
}
.article-slider-nav:disabled {
cursor: default;
}
`}</style>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useCallback, useEffect, useState } from "react";
/**
* Client-side photo gallery for the public Photos page. Renders the same
* card grid as before, but each tile is now a button that opens the photo in
* a full-screen lightbox overlay. The overlay is a fixed, full-viewport layer
* toggled by state — closes on backdrop click, on the ✕ button, or with Esc.
*
* Photos are pre-shaped on the server (caption strings already translated) so
* this component carries no prisma/i18n dependency.
*/
export type LightboxPhoto = {
id: string;
url: string;
alt: string;
date: string;
user: string;
};
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
// Index of the photo shown in the lightbox, or null when closed.
const [openIndex, setOpenIndex] = useState<number | null>(null);
const active = openIndex !== null ? photos[openIndex] : null;
const close = useCallback(() => setOpenIndex(null), []);
// Esc-to-close + lock background scroll while the overlay is open.
useEffect(() => {
if (active == null) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [active, close]);
return (
<>
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p, i) => (
<button
key={p.id}
type="button"
onClick={() => setOpenIndex(i)}
className="card hover"
style={{
padding: 0,
overflow: "hidden",
border: 0,
background: "var(--color-surface)",
cursor: "zoom-in",
textAlign: "left",
font: "inherit",
color: "inherit",
display: "block",
width: "100%",
}}
aria-label={p.alt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={p.alt}
style={{ width: "100%", height: "auto", display: "block" }}
/>
<div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.date}
</p>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.user}
</p>
</div>
</button>
))}
</div>
{active ? (
<div
onClick={close}
role="dialog"
aria-modal="true"
aria-label={active.alt}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(2px)",
cursor: "zoom-out",
}}
>
<button
type="button"
onClick={close}
aria-label="Close"
style={{
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
✕
</button>
{/* Stop propagation so clicking the image itself doesn't close. */}
<figure
onClick={(e) => e.stopPropagation()}
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</div>
) : null}
</>
);
}
+3
View File
@@ -105,6 +105,9 @@ export async function TopHeader() {
<Link href="/settings/2fa" className="dropdown-item">
Two-factor
</Link>
<Link href="/settings/sessions" className="dropdown-item">
Login activity
</Link>
<form
action={async () => {
"use server";
+12
View File
@@ -57,6 +57,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}
}
// Record the successful login for the user's "session logs" page.
// Best-effort — never let logging block or fail the sign-in.
try {
const { headers } = await import("next/headers");
const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null;
await prisma.websiteLoginLogs.create({
data: { userId: user.id, ip: await clientIp(), userAgent: ua, createdAt: new Date() },
});
} catch {
/* ignore */
}
return { id: String(user.id), name: user.username, rank: user.rank };
},
}),
+11 -1
View File
@@ -99,7 +99,17 @@
"post": "Post",
"loginToPost": "Log in to leave a guestbook message.",
"guestbookEmpty": "No guestbook entries yet.",
"unknownUser": "User #{id}"
"unknownUser": "User #{id}",
"statDuckets": "Duckets",
"statDiamonds": "Diamonds",
"walletTitle": "Wallet",
"friendsTitle": "Friends",
"friendsSubtitle": "People in the hotel they hang out with",
"friendsEmpty": "No friends to show.",
"roomsTitle": "Rooms",
"roomsSubtitle": "Rooms they own",
"roomsEmpty": "No rooms to show.",
"roomUntitled": "Untitled room"
},
"rankings": {
"title": "Rankings",
+11 -1
View File
@@ -99,7 +99,17 @@
"post": "Pubblica",
"loginToPost": "Accedi per lasciare un messaggio sulla bacheca.",
"guestbookEmpty": "Ancora nessun messaggio in bacheca.",
"unknownUser": "Utente #{id}"
"unknownUser": "Utente #{id}",
"statDuckets": "Duckets",
"statDiamonds": "Diamanti",
"walletTitle": "Portafoglio",
"friendsTitle": "Amici",
"friendsSubtitle": "Le persone con cui passa il tempo nell’hotel",
"friendsEmpty": "Nessun amico da mostrare.",
"roomsTitle": "Stanze",
"roomsSubtitle": "Le stanze che possiede",
"roomsEmpty": "Nessuna stanza da mostrare.",
"roomUntitled": "Stanza senza nome"
},
"rankings": {
"title": "Classifica",