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

+11
View File
@@ -0,0 +1,11 @@
-- Successful-login history (the AtomCMS "session logs" equivalent; our auth is
-- stateless JWT so there's no sessions table). Idempotent (MariaDB).
CREATE TABLE IF NOT EXISTS website_login_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
ip VARCHAR(45) NOT NULL,
user_agent VARCHAR(512) NULL,
created_at TIMESTAMP NULL,
PRIMARY KEY (id),
KEY website_login_logs_user_id_idx (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+11
View File
@@ -1687,6 +1687,17 @@ model WebsiteWriteableBoxes {
@@map("website_writeable_boxes")
}
model WebsiteLoginLogs {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId Int @map("user_id")
ip String @db.VarChar(45)
userAgent String? @map("user_agent") @db.VarChar(512)
createdAt DateTime? @map("created_at") @db.Timestamp(0)
@@index([userId])
@@map("website_login_logs")
}
model WebsiteBetaCodes {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(255)
+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>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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",