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

+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 });
}
}