1 Commits
Author SHA1 Message Date
remco e262c173d2 chore(deps): update All dependencies
CI / check (pull_request) Failing after 10s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-17 09:00:39 +00:00
44 changed files with 1243 additions and 1654 deletions

No files matched your search

+6 -5
View File
@@ -34,11 +34,11 @@ const securityHeaders = [
]; ];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
cacheComponents: true, cacheComponents: true, // <-- Dit activeert Cache Components en lost de 'instant' build-fout op!
deploymentId: resolveDeploymentId(), deploymentId: resolveDeploymentId(),
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"], serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"],
// Silence Turbopack warnings for the broad file patterns in src/lib. // Onderdruk de specifieke Turbopack waarschuwingen voor brede bestandspatronen in de lib-map
turbopack: { turbopack: {
ignoreIssue: [ ignoreIssue: [
{ {
@@ -47,19 +47,20 @@ const nextConfig: NextConfig = {
], ],
}, },
// Voorkom dat type-warnings (zoals ongebruikte imports) de productiebuild blokkeren
typescript: { typescript: {
ignoreBuildErrors: false, ignoreBuildErrors: false,
}, },
// Compress responses with gzip/brotli. // Compress responses with gzip/brotli
compress: true, compress: true,
// Disable Next.js telemetry and browser sourcemaps in production. // Disable Next.js telemetry and browser sourcemaps in production
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
experimental: { experimental: {
useTypeScriptCli: true, useTypeScriptCli: true,
hideLogsAfterAbort: true, hideLogsAfterAbort: true, // <-- Dit verbergt de prerender bail-out logberichten!
}, },
// Optimize images served through next/image with sharp → AVIF/WebP. // Optimize images served through next/image with sharp → AVIF/WebP.
+1 -1
View File
@@ -66,7 +66,7 @@
"zod": "4.4.3" "zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.9", "@biomejs/biome": "2.5.8",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
+37 -37
View File
@@ -138,8 +138,8 @@ importers:
version: 4.4.3 version: 4.4.3
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.5.9 specifier: 2.5.8
version: 2.5.9 version: 2.5.8
'@tailwindcss/forms': '@tailwindcss/forms':
specifier: ^0.5.11 specifier: ^0.5.11
version: 0.5.11([email protected]) version: 0.5.11([email protected])
@@ -267,59 +267,59 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g==} resolution: {integrity: sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
hasBin: true hasBin: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-am22pX2aBqznqq1eMyIj/bZ++riF3Lk6ct7cbv+gQK0csFhr+d8O0RkOi2FF2qSgFgANbqNkIZ0/PxlnW2pLFg==} resolution: {integrity: sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-l44KWDHLDvEnD0N/XcrVs7VXb3A18xL7QS3WB0eL93wbmk529ffIG55vleGCqaunpRUjLrdnjK05Qki1dsjylg==} resolution: {integrity: sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-7ImVPwBLCtkmpR5esd8RHhTqW94f0JLJQum6AneYcy94jRm18TaPPm7slaigGzFhfgt3QiD1Vj52LKmBAnKizA==} resolution: {integrity: sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-ICaK+IYaVZvKbBxX2rwrPT0DdUDMnE9Vm3nQGe+mltQPmUg19pONzkPWGdY4FCsoreDETWDynvdt4ysCbF5gNQ==} resolution: {integrity: sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-RXGaD0o1/pTTguYw1aeDJh9ad6Lfrui0fI7mBderTyGr7WuUJkBIttgLkR3XJyoxOkkgfBDspaUT8wXArTqLZw==} resolution: {integrity: sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-z22Q/zFYSvbIJfW1CbfZPu4X8PddS6Qd2ORbc6h+aT6EcwAxUF3m6fA4HjNvA3TU4X0dTJRwNPB165ES3PJXzg==} resolution: {integrity: sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-nHK+/HHC+D0ogAHUxomgoSTdjImb6fmNNVTKmf0tyu4eDL1DqPKIHc+i+UL8+b0RnAu8224qo8F2tCVnaT0A3w==} resolution: {integrity: sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
resolution: {integrity: sha512-Yiq0H56LjXSSw/hd9YkXgSLQfzyDJzbzU2TezozxyNw+uKWAqOtqGVvBfzKRRDiaFF5avGAhHdWKx7LtDOShUw==} resolution: {integrity: sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -3168,39 +3168,39 @@ snapshots:
'@bcoe/[email protected]': {} '@bcoe/[email protected]': {}
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optionalDependencies: optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.5.9 '@biomejs/cli-darwin-arm64': 2.5.8
'@biomejs/cli-darwin-x64': 2.5.9 '@biomejs/cli-darwin-x64': 2.5.8
'@biomejs/cli-linux-arm64': 2.5.9 '@biomejs/cli-linux-arm64': 2.5.8
'@biomejs/cli-linux-arm64-musl': 2.5.9 '@biomejs/cli-linux-arm64-musl': 2.5.8
'@biomejs/cli-linux-x64': 2.5.9 '@biomejs/cli-linux-x64': 2.5.8
'@biomejs/cli-linux-x64-musl': 2.5.9 '@biomejs/cli-linux-x64-musl': 2.5.8
'@biomejs/cli-win32-arm64': 2.5.9 '@biomejs/cli-win32-arm64': 2.5.8
'@biomejs/cli-win32-x64': 2.5.9 '@biomejs/cli-win32-x64': 2.5.8
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@biomejs/[email protected].9': '@biomejs/[email protected].8':
optional: true optional: true
'@borewit/[email protected]': {} '@borewit/[email protected]': {}
+5 -4
View File
@@ -5,7 +5,6 @@ import {
PhotoLightbox, PhotoLightbox,
} from "@/components/public/photo-lightbox"; } from "@/components/public/photo-lightbox";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { cached } from "@/lib/cache";
import { CameraWeb, db } from "@/lib/db"; import { CameraWeb, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
@@ -24,9 +23,11 @@ export default async function PhotosPage() {
let photos: Photo[] = []; let photos: Photo[] = [];
try { try {
photos = await cached("photos:grid", 60_000, async () => photos = await db
db.select().from(CameraWeb).orderBy(desc(CameraWeb.timestamp)).limit(48), .select()
); .from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(48);
} catch { } catch {
photos = []; photos = [];
} }
+8 -9
View File
@@ -60,11 +60,9 @@ export default async function PollDetailPage({
const isOpen = poll.status === "active" && !hasEnded; const isOpen = poll.status === "active" && !hasEnded;
const questionIds = questions.map((q) => q.id); const questionIds = questions.map((q) => q.id);
const myVotes =
// My votes and the aggregate results are independent — load them together.
const [myVotes, allVotes] = await Promise.all([
Number.isInteger(userId) && userId > 0 && questionIds.length > 0 Number.isInteger(userId) && userId > 0 && questionIds.length > 0
? db ? await db
.select() .select()
.from(WebsitePollVote) .from(WebsitePollVote)
.where( .where(
@@ -74,9 +72,12 @@ export default async function PollDetailPage({
), ),
) )
.catch(() => []) .catch(() => [])
: Promise.resolve([]), : [];
const hasVoted = myVotes.length > 0;
const allVotes =
poll.showResults === 1 && questionIds.length > 0 poll.showResults === 1 && questionIds.length > 0
? db ? await db
.select({ .select({
questionId: WebsitePollVote.questionId, questionId: WebsitePollVote.questionId,
answer: WebsitePollVote.answer, answer: WebsitePollVote.answer,
@@ -84,9 +85,7 @@ export default async function PollDetailPage({
.from(WebsitePollVote) .from(WebsitePollVote)
.where(inArray(WebsitePollVote.questionId, questionIds)) .where(inArray(WebsitePollVote.questionId, questionIds))
.catch(() => []) .catch(() => [])
: Promise.resolve([]), : [];
]);
const hasVoted = myVotes.length > 0;
const votesByQuestion = new Map<number, string[]>(); const votesByQuestion = new Map<number, string[]>();
for (const vote of allVotes) { for (const vote of allVotes) {
+11 -14
View File
@@ -9,7 +9,6 @@ import {
} from "@/components/public/ui"; } from "@/components/public/ui";
import { CurrencyIcon } from "@/components/shared/currency-icon"; import { CurrencyIcon } from "@/components/shared/currency-icon";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
export const metadata = { title: "Rankings" }; export const metadata = { title: "Rankings" };
@@ -26,19 +25,17 @@ export default async function RankingsPage() {
const t = await getTranslations("pages.rankings"); const t = await getTranslations("pages.rankings");
let users: TopUser[] = []; let users: TopUser[] = [];
try { try {
users = await cached("rankings:top", 60_000, async () => users = await db
db .select({
.select({ username: User.username,
username: User.username, look: User.look,
look: User.look, credits: User.credits,
credits: User.credits, rank: User.rank,
rank: User.rank, online: User.online,
online: User.online, })
}) .from(User)
.from(User) .orderBy(desc(User.credits))
.orderBy(desc(User.credits)) .limit(12);
.limit(12),
);
} catch { } catch {
users = []; users = [];
} }
+20 -19
View File
@@ -16,11 +16,9 @@ type CategoryRow = {
export default async function RareValuesPage() { export default async function RareValuesPage() {
const t = await getTranslations("pages.rares"); const t = await getTranslations("pages.rares");
let categories: CategoryRow[] = [];
// Categories, per-category rare counts and site settings are all try {
// independent — run them concurrently instead of sequentially. categories = await db
const [categoryRows, grouped, badgesBase] = await Promise.all([
db
.select({ .select({
id: WebsiteRareValueCategories.id, id: WebsiteRareValueCategories.id,
name: WebsiteRareValueCategories.name, name: WebsiteRareValueCategories.name,
@@ -31,27 +29,30 @@ export default async function RareValuesPage() {
.orderBy( .orderBy(
asc(WebsiteRareValueCategories.priority), asc(WebsiteRareValueCategories.priority),
asc(WebsiteRareValueCategories.name), asc(WebsiteRareValueCategories.name),
) );
.catch((): CategoryRow[] => []), } catch {
db categories = [];
}
// Per-category rare counts (grouped so we avoid N queries).
const counts = new Map<string, number>();
try {
const grouped = await db
.select({ .select({
categoryId: WebsiteRareValues.categoryId, categoryId: WebsiteRareValues.categoryId,
value: count(), value: count(),
}) })
.from(WebsiteRareValues) .from(WebsiteRareValues)
.groupBy(WebsiteRareValues.categoryId) .groupBy(WebsiteRareValues.categoryId);
.catch(() => []), for (const g of grouped) {
siteSettings.get("badges_path", ""), counts.set(String(g.categoryId), Number(g.value));
]); }
} catch {
const categories = categoryRows; // leave counts empty -> shown as 0
// Per-category rare counts (grouped so we avoid N queries).
const counts = new Map<string, number>();
for (const g of grouped) {
counts.set(String(g.categoryId), Number(g.value));
} }
const badgesBase = (await siteSettings.get("badges_path", "")) ?? "";
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="💎" title={t("title")} subtitle={t("subtitle")} /> <ContentCard icon="💎" title={t("title")} subtitle={t("subtitle")} />
+26 -27
View File
@@ -36,33 +36,32 @@ export default async function SearchPage({
); );
} }
const [users, rooms] = await Promise.all([ const users = await db
db .select({
.select({ id: User.id,
id: User.id, username: User.username,
username: User.username, look: User.look,
look: User.look, motto: User.motto,
motto: User.motto, online: User.online,
online: User.online, rank: User.rank,
rank: User.rank, })
}) .from(User)
.from(User) .where(like(User.username, `%${query}%`))
.where(like(User.username, `%${query}%`)) .limit(30)
.limit(30) .catch(() => []);
.catch(() => []),
db const rooms = await db
.select({ .select({
id: Rooms.id, id: Rooms.id,
name: Rooms.name, name: Rooms.name,
ownerName: Rooms.ownerName, ownerName: Rooms.ownerName,
users: Rooms.users, users: Rooms.users,
usersMax: Rooms.usersMax, usersMax: Rooms.usersMax,
}) })
.from(Rooms) .from(Rooms)
.where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open"))) .where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open")))
.limit(12) .limit(12)
.catch(() => []), .catch(() => []);
]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
+26 -18
View File
@@ -112,30 +112,38 @@ export default async function ShopPage({
: t("success.bought") : t("success.bought")
: null; : null;
// Categories for the filter bar and the article grid are independent — // Categories for the filter bar.
// fetch them concurrently. let categories: ShopCategory[] = [];
const [categories, articles] = await Promise.all([ try {
db categories = await db
.select({ .select({
id: WebsiteShopCategories.id, id: WebsiteShopCategories.id,
name: WebsiteShopCategories.name, name: WebsiteShopCategories.name,
icon: WebsiteShopCategories.icon, icon: WebsiteShopCategories.icon,
}) })
.from(WebsiteShopCategories) .from(WebsiteShopCategories)
.orderBy(asc(WebsiteShopCategories.name)) .orderBy(asc(WebsiteShopCategories.name));
.catch((): ShopCategory[] => []), } catch {
(categoryId categories = [];
? db }
.select(articleSelect)
.from(WebsiteShopArticles) let articles: ShopArticle[] = [];
.where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId))) try {
.orderBy(asc(WebsiteShopArticles.position)) if (categoryId) {
: db articles = await db
.select(articleSelect) .select(articleSelect)
.from(WebsiteShopArticles) .from(WebsiteShopArticles)
.orderBy(asc(WebsiteShopArticles.position)) .where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId)))
).catch((): ShopArticle[] => []), .orderBy(asc(WebsiteShopArticles.position));
]); } else {
articles = await db
.select(articleSelect)
.from(WebsiteShopArticles)
.orderBy(asc(WebsiteShopArticles.position));
}
} catch {
articles = [];
}
const activeCategory = categoryId const activeCategory = categoryId
? categories.find((c) => String(c.id) === categoryId) ? categories.find((c) => String(c.id) === categoryId)
+16 -10
View File
@@ -27,9 +27,9 @@ export default async function StaffPage() {
const minStaffRank = const minStaffRank =
Number(await siteSettings.get("min_staff_rank", "7")) || 7; Number(await siteSettings.get("min_staff_rank", "7")) || 7;
// Independent queries — staff members and teams load concurrently. let staff: StaffMember[] = [];
const [staff, teams] = await Promise.all([ try {
db staff = await db
.select({ .select({
username: User.username, username: User.username,
look: User.look, look: User.look,
@@ -39,9 +39,14 @@ export default async function StaffPage() {
.from(User) .from(User)
.where(gte(User.rank, minStaffRank)) .where(gte(User.rank, minStaffRank))
.orderBy(desc(User.rank), asc(User.username)) .orderBy(desc(User.rank), asc(User.username))
.limit(100) .limit(100);
.catch((): StaffMember[] => []), } catch {
db staff = [];
}
let teams: Team[] = [];
try {
const rows = await db
.select({ .select({
id: WebsiteTeams.id, id: WebsiteTeams.id,
rankName: WebsiteTeams.rankName, rankName: WebsiteTeams.rankName,
@@ -51,10 +56,11 @@ export default async function StaffPage() {
}) })
.from(WebsiteTeams) .from(WebsiteTeams)
.where(eq(WebsiteTeams.hiddenRank, false)) .where(eq(WebsiteTeams.hiddenRank, false))
.orderBy(asc(WebsiteTeams.id)) .orderBy(asc(WebsiteTeams.id));
.then((rows) => rows.map((row) => ({ ...row, id: String(row.id) }))) teams = rows.map((row) => ({ ...row, id: String(row.id) }));
.catch((): Team[] => []), } catch {
]); teams = [];
}
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
+85 -90
View File
@@ -191,69 +191,19 @@ export default async function ProfilePage({
// Currency ledger rows (duckets/diamonds). Credits live on the users row // Currency ledger rows (duckets/diamonds). Credits live on the users row
// itself; the alt-currencies sit in the users_currency ledger keyed by type. // itself; the alt-currencies sit in the users_currency ledger keyed by type.
// Currency ledger rows (duckets/diamonds). Credits live on the users row const currencyRows = await db
// itself; the alt-currencies sit in the users_currency ledger keyed by type. .select({
// All sub-sections run in parallel (each query keeps its own .catch so a DB type: UsersCurrency.type,
// hiccup still degrades only that section instead of 500-ing the profile). amount: UsersCurrency.amount,
const [currencyRows, friendships, rooms, badges, photos, guestbook] = })
await Promise.all([ .from(UsersCurrency)
db .where(
.select({ and(
type: UsersCurrency.type, eq(UsersCurrency.userId, user.id),
amount: UsersCurrency.amount, inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]),
}) ),
.from(UsersCurrency) )
.where( .catch(() => []);
and(
eq(UsersCurrency.userId, user.id),
inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]),
),
)
.catch(() => []),
// Friends. messenger_friendships stores one directed row per pair
// keyed on user_one_id; resolve the user_two_id side below.
db
.select({ userTwoId: MessengerFriendships.userTwoId })
.from(MessengerFriendships)
.where(eq(MessengerFriendships.userOneId, user.id))
.orderBy(desc(MessengerFriendships.friendsSince))
.limit(18)
.catch(() => []),
// Rooms owned by this user.
db
.select({
id: Rooms.id,
name: Rooms.name,
users: Rooms.users,
usersMax: Rooms.usersMax,
state: Rooms.state,
})
.from(Rooms)
.where(eq(Rooms.ownerId, user.id))
.orderBy(desc(Rooms.users))
.limit(8)
.catch(() => []),
db
.select()
.from(UsersBadges)
.where(eq(UsersBadges.userId, user.id))
.orderBy(asc(UsersBadges.slotId))
.catch(() => []),
db
.select()
.from(CameraWeb)
.where(eq(CameraWeb.userId, user.id))
.orderBy(desc(CameraWeb.timestamp))
.limit(6)
.catch(() => []),
db
.select()
.from(WebsiteUserGuestbooks)
.where(eq(WebsiteUserGuestbooks.profileId, user.id))
.orderBy(desc(WebsiteUserGuestbooks.createdAt))
.limit(30)
.catch(() => []),
]);
const ducketsAmount = const ducketsAmount =
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0; currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
const diamondsAmount = const diamondsAmount =
@@ -282,38 +232,83 @@ export default async function ProfilePage({
}, },
]; ];
// Resolve friend usernames/looks and the guestbook author usernames/looks in // Friends. messenger_friendships stores one directed row per pair keyed on
// two parallel queries (the ids come from the queries above). // user_one_id; resolve the user_two_id side to usernames/looks for the grid.
const friendships = await db
.select({ userTwoId: MessengerFriendships.userTwoId })
.from(MessengerFriendships)
.where(eq(MessengerFriendships.userOneId, user.id))
.orderBy(desc(MessengerFriendships.friendsSince))
.limit(18)
.catch(() => []);
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId))); const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId))); const friendUsers = friendIds.length
const [friendUsers, authors] = await Promise.all([ ? await db
friendIds.length .select({
? db id: User.id,
.select({ username: User.username,
id: User.id, look: User.look,
username: User.username, })
look: User.look, .from(User)
}) .where(inArray(User.id, friendIds))
.from(User) .catch(() => [])
.where(inArray(User.id, friendIds)) : [];
.catch(() => [])
: [],
authorIds.length
? db
.select({
id: User.id,
username: User.username,
look: User.look,
})
.from(User)
.where(inArray(User.id, authorIds))
.catch(() => [])
: [],
]);
const friendById = new Map(friendUsers.map((f) => [f.id, f])); const friendById = new Map(friendUsers.map((f) => [f.id, f]));
const friends = friendships const friends = friendships
.map((f) => friendById.get(f.userTwoId)) .map((f) => friendById.get(f.userTwoId))
.filter((f): f is NonNullable<typeof f> => Boolean(f)); .filter((f): f is NonNullable<typeof f> => Boolean(f));
// Rooms owned by this user.
const rooms = await db
.select({
id: Rooms.id,
name: Rooms.name,
users: Rooms.users,
usersMax: Rooms.usersMax,
state: Rooms.state,
})
.from(Rooms)
.where(eq(Rooms.ownerId, user.id))
.orderBy(desc(Rooms.users))
.limit(8)
.catch(() => []);
const badges = await db
.select()
.from(UsersBadges)
.where(eq(UsersBadges.userId, user.id))
.orderBy(asc(UsersBadges.slotId))
.catch(() => []);
const photos = await db
.select()
.from(CameraWeb)
.where(eq(CameraWeb.userId, user.id))
.orderBy(desc(CameraWeb.timestamp))
.limit(6)
.catch(() => []);
const guestbook = await db
.select()
.from(WebsiteUserGuestbooks)
.where(eq(WebsiteUserGuestbooks.profileId, user.id))
.orderBy(desc(WebsiteUserGuestbooks.createdAt))
.limit(30)
.catch(() => []);
// Resolve author usernames/looks for the guestbook entries in one query.
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
const authors = authorIds.length
? await db
.select({
id: User.id,
username: User.username,
look: User.look,
})
.from(User)
.where(inArray(User.id, authorIds))
.catch(() => [])
: [];
const authorById = new Map(authors.map((a) => [a.id, a])); const authorById = new Map(authors.map((a) => [a.id, a]));
return ( return (
@@ -60,21 +60,99 @@ import {
} from "@/lib/admin/recent-furni-resync"; } from "@/lib/admin/recent-furni-resync";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import {
getFurniIconUrl,
getFurniImageUrl,
handleImgError,
STATUS_LABELS,
} from "./import-furni-icons";
import type {
BatchItemStatus,
BatchSummary,
FurniItem,
FurniStats,
Meta,
} from "./import-furni-types";
import { NitroEditorDialog } from "./nitro-editor-dialog"; import { NitroEditorDialog } from "./nitro-editor-dialog";
import { Pagination } from "./pagination";
interface FurniItem {
id: number;
classname: string;
type: string;
name: string;
description: string;
revision: number;
category: string;
alreadyImported: boolean;
nitroExists: boolean;
}
interface Meta {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
interface BatchItemStatus {
classname: string;
status:
| "pending"
| "started"
| "downloading"
| "converting"
| "writing_db"
| "done"
| "failed";
message?: string;
warnings?: string[];
itemId?: number;
}
interface BatchSummary {
succeeded: number;
failed: number;
warnings: number;
duration: number;
}
interface FurniStats {
totalInDb: number;
inCatalog: number;
missingNitro: number;
notInCatalog: number;
}
function getFurniImageUrl(classname: string, revision: number) {
// Use official Habbo CDN for furniture icons
const base = classname.includes("*")
? classname.substring(0, classname.indexOf("*"))
: classname;
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
}
function getFurniIconUrl(classname: string, revision: number) {
const iconName = classname.replace(/\*/g, "_");
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
}
function handleImgError(
e: React.SyntheticEvent<HTMLImageElement>,
classname: string,
revision: number,
) {
const img = e.target as HTMLImageElement;
const iconName = classname.replace(/\*/g, "_");
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = getFurniIconUrl(classname, revision);
} else if (img.dataset.fallback === "1") {
img.dataset.fallback = "2";
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
} else {
// Show placeholder instead of invisible image
img.style.opacity = "0.3";
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
}
}
const STATUS_LABELS: Record<string, string> = {
pending: "Pending",
started: "Starting",
downloading: "Downloading",
converting: "Converting",
writing_db: "Writing DB",
done: "Done",
failed: "Failed",
};
export function ImportFurniClient({ source }: { source: FurniImportSource }) { export function ImportFurniClient({ source }: { source: FurniImportSource }) {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -806,6 +884,84 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
return { completed, done, failed, total: arr.length, percent }; return { completed, done, failed, total: arr.length, percent };
}, [batchProgress]); }, [batchProgress]);
function renderPagination() {
if (!meta || meta.lastPage <= 1) return null;
const nodes: React.ReactNode[] = [];
if (meta.currentPage > 1) {
nodes.push(
<Button
key="prev"
variant="ghost"
size="sm"
onClick={() => goPage(meta.currentPage - 1)}
disabled={loading}
className="text-xs"
>
&larr; Prev
</Button>,
);
}
for (let p = 1; p <= meta.lastPage; p++) {
if (p === meta.currentPage) {
nodes.push(
<Button
key={p}
variant="default"
size="sm"
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (
Math.abs(p - meta.currentPage) <= 2 ||
p === 1 ||
p === meta.lastPage
) {
nodes.push(
<Button
key={p}
variant="ghost"
size="sm"
onClick={() => goPage(p)}
disabled={loading}
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (Math.abs(p - meta.currentPage) === 3) {
nodes.push(
<span
key={`dots-${p}`}
className="text-muted-foreground text-xs px-1"
>
&hellip;
</span>,
);
}
}
if (meta.currentPage < meta.lastPage) {
nodes.push(
<Button
key="next"
variant="ghost"
size="sm"
onClick={() => goPage(meta.currentPage + 1)}
disabled={loading}
className="text-xs"
>
Next &rarr;
</Button>,
);
}
return (
<div className="flex items-center justify-center gap-1 mt-4 flex-wrap">
{nodes}
</div>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3">
@@ -1831,7 +1987,7 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
)} )}
{/* Pagination */} {/* Pagination */}
<Pagination meta={meta} loading={loading} onPageChange={goPage} /> {renderPagination()}
{/* Nitro Editor Dialog */} {/* Nitro Editor Dialog */}
<NitroEditorDialog <NitroEditorDialog
@@ -1,45 +0,0 @@
import type { SyntheticEvent } from "react";
export function getFurniImageUrl(classname: string, revision: number) {
// Use official Habbo CDN for furniture icons
const base = classname.includes("*")
? classname.substring(0, classname.indexOf("*"))
: classname;
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
}
export function getFurniIconUrl(classname: string, revision: number) {
const iconName = classname.replace(/\*/g, "_");
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
}
export function handleImgError(
e: SyntheticEvent<HTMLImageElement>,
classname: string,
revision: number,
) {
const img = e.target as HTMLImageElement;
const iconName = classname.replace(/\*/g, "_");
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = getFurniIconUrl(classname, revision);
} else if (img.dataset.fallback === "1") {
img.dataset.fallback = "2";
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
} else {
// Show placeholder instead of invisible image
img.style.opacity = "0.3";
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
}
}
export const STATUS_LABELS: Record<string, string> = {
pending: "Pending",
started: "Starting",
downloading: "Downloading",
converting: "Converting",
writing_db: "Writing DB",
done: "Done",
failed: "Failed",
};
@@ -1,47 +0,0 @@
export interface FurniItem {
id: number;
classname: string;
type: string;
name: string;
description: string;
revision: number;
category: string;
alreadyImported: boolean;
nitroExists: boolean;
}
export interface Meta {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
export interface BatchItemStatus {
classname: string;
status:
| "pending"
| "started"
| "downloading"
| "converting"
| "writing_db"
| "done"
| "failed";
message?: string;
warnings?: string[];
itemId?: number;
}
export interface BatchSummary {
succeeded: number;
failed: number;
warnings: number;
duration: number;
}
export interface FurniStats {
totalInDb: number;
inCatalog: number;
missingNitro: number;
notInCatalog: number;
}
-89
View File
@@ -1,89 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import type { Meta } from "./import-furni-types";
export function Pagination({
meta,
loading,
onPageChange,
}: {
meta: Meta | null;
loading: boolean;
onPageChange: (page: number) => void;
}) {
if (!meta || meta.lastPage <= 1) return null;
const nodes: ReactNode[] = [];
if (meta.currentPage > 1) {
nodes.push(
<Button
key="prev"
variant="ghost"
size="sm"
onClick={() => onPageChange(meta.currentPage - 1)}
disabled={loading}
className="text-xs"
>
&larr; Prev
</Button>,
);
}
for (let p = 1; p <= meta.lastPage; p++) {
if (p === meta.currentPage) {
nodes.push(
<Button
key={p}
variant="default"
size="sm"
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (
Math.abs(p - meta.currentPage) <= 2 ||
p === 1 ||
p === meta.lastPage
) {
nodes.push(
<Button
key={p}
variant="ghost"
size="sm"
onClick={() => onPageChange(p)}
disabled={loading}
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (Math.abs(p - meta.currentPage) === 3) {
nodes.push(
<span key={`dots-${p}`} className="text-muted-foreground text-xs px-1">
&hellip;
</span>,
);
}
}
if (meta.currentPage < meta.lastPage) {
nodes.push(
<Button
key="next"
variant="ghost"
size="sm"
onClick={() => onPageChange(meta.currentPage + 1)}
disabled={loading}
className="text-xs"
>
Next &rarr;
</Button>,
);
}
return (
<div className="flex items-center justify-center gap-1 mt-4 flex-wrap">
{nodes}
</div>
);
}
+16 -29
View File
@@ -1,7 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db"; import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
/** /**
* GET /api/articles/:slug — single website_article by slug, including the * GET /api/articles/:slug — single website_article by slug, including the
@@ -14,38 +13,26 @@ export async function GET(
const { slug } = await params; const { slug } = await params;
try { try {
const data = await redisCache( const [article] = await db
apiCacheKey(`articles:${slug}`), .select({
60, id: WebsiteArticles.id,
async () => { title: WebsiteArticles.title,
const [article] = await db slug: WebsiteArticles.slug,
.select({ shortStory: WebsiteArticles.shortStory,
id: WebsiteArticles.id, fullStory: WebsiteArticles.fullStory,
title: WebsiteArticles.title, image: WebsiteArticles.image,
slug: WebsiteArticles.slug, createdAt: WebsiteArticles.createdAt,
shortStory: WebsiteArticles.shortStory, updatedAt: WebsiteArticles.updatedAt,
fullStory: WebsiteArticles.fullStory, })
image: WebsiteArticles.image, .from(WebsiteArticles)
createdAt: WebsiteArticles.createdAt, .where(eq(WebsiteArticles.slug, slug))
updatedAt: WebsiteArticles.updatedAt, .limit(1);
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.limit(1);
if (!article) { if (!article) {
return null;
}
return cacheSafe({ data: article });
},
);
if (data === null) {
return apiJson({ error: "Article not found" }, { status: 404 }); return apiJson({ error: "Article not found" }, { status: 404 });
} }
return apiJson(data); return apiJson({ data: article });
} catch { } catch {
// DB unavailable — treat as not found rather than a 500. // DB unavailable — treat as not found rather than a 500.
return apiJson({ error: "Article not found" }, { status: 200 }); return apiJson({ error: "Article not found" }, { status: 200 });
+26 -36
View File
@@ -1,7 +1,6 @@
import { count, desc } from "drizzle-orm"; import { count, desc } from "drizzle-orm";
import { apiJson, pagination } from "@/lib/api"; import { apiJson, pagination } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db"; import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
/** /**
* GET /api/articles — paginated list of website_articles, newest first. * GET /api/articles — paginated list of website_articles, newest first.
@@ -13,42 +12,33 @@ export async function GET(req: Request) {
const { page, perPage, skip, take } = pagination(sp); const { page, perPage, skip, take } = pagination(sp);
try { try {
const data = await redisCache( const [totalRows, articles] = await Promise.all([
apiCacheKey(`articles:${page}:${perPage}`), db.select({ total: count() }).from(WebsiteArticles),
60, db
async () => { .select({
const [totalRows, articles] = await Promise.all([ id: WebsiteArticles.id,
db.select({ total: count() }).from(WebsiteArticles), title: WebsiteArticles.title,
db slug: WebsiteArticles.slug,
.select({ shortStory: WebsiteArticles.shortStory,
id: WebsiteArticles.id, image: WebsiteArticles.image,
title: WebsiteArticles.title, createdAt: WebsiteArticles.createdAt,
slug: WebsiteArticles.slug, })
shortStory: WebsiteArticles.shortStory, .from(WebsiteArticles)
image: WebsiteArticles.image, .orderBy(desc(WebsiteArticles.createdAt))
createdAt: WebsiteArticles.createdAt, .limit(take)
}) .offset(skip),
.from(WebsiteArticles) ]);
.orderBy(desc(WebsiteArticles.createdAt)) const total = totalRows[0]?.total ?? 0;
.limit(take)
.offset(skip),
]);
return cacheSafe({
data: articles,
meta: {
page,
perPage,
total: totalRows[0]?.total ?? 0,
lastPage: Math.max(
1,
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
),
},
});
},
);
return apiJson(data); return apiJson({
data: articles,
meta: {
page,
perPage,
total,
lastPage: Math.max(1, Math.ceil(total / perPage)),
},
});
} catch { } catch {
// DB unavailable — return an empty payload instead of a 500. // DB unavailable — return an empty payload instead of a 500.
return apiJson( return apiJson(
+150 -214
View File
@@ -5,7 +5,6 @@ import { bearerUserId } from "@/lib/api-auth";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, User, UsersSettings } from "@/lib/db"; import { db, User, UsersSettings } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
type BadgeRarityKey = type BadgeRarityKey =
| "common" | "common"
@@ -29,12 +28,6 @@ interface BadgeLeaderboardBoard {
viewerEntry?: Partial<BadgeLeaderboardEntry>; viewerEntry?: Partial<BadgeLeaderboardEntry>;
} }
/** The shared, viewer-independent part of a leaderboard (cacheable). */
type BoardCore = {
entries: BadgeLeaderboardEntry[];
totalPlayers: number;
};
interface BadgeLeaderboardStat { interface BadgeLeaderboardStat {
badgeCode: string; badgeCode: string;
ownerCount: number; ownerCount: number;
@@ -72,7 +65,9 @@ async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
return rows; return rows;
} }
async function loadTotalBadgesBoard(): Promise<BoardCore> { async function loadTotalBadgesBoard(
userId: number | null,
): Promise<BadgeLeaderboardBoard> {
const rows = await rawRows<{ const rows = await rawRows<{
userId: bigint; userId: bigint;
username: string; username: string;
@@ -108,10 +103,48 @@ async function loadTotalBadgesBoard(): Promise<BoardCore> {
) )
: 0; : 0;
return { entries, totalPlayers }; let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) {
const viewerRow = await rawRows<{ cnt: bigint }>(
sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
);
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore > 0) {
const viewerRank =
entries.length > 0
? Number(
(
await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t
WHERE t.total > ${viewerScore}
`)
)[0]?.cnt ?? entries.length + 1,
)
: 1;
const [viewerUser] = await db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (viewerUser) {
viewerEntry = {
userId,
username: viewerUser.username,
figure: viewerUser.look,
score: viewerScore,
rank: viewerRank,
};
}
}
}
return { entries, totalPlayers, viewerEntry };
} }
async function loadAchievementBoard(): Promise<BoardCore> { async function loadAchievementBoard(
userId: number | null,
): Promise<BadgeLeaderboardBoard> {
const rows = await rawRows<{ const rows = await rawRows<{
userId: bigint; userId: bigint;
username: string; username: string;
@@ -146,10 +179,46 @@ async function loadAchievementBoard(): Promise<BoardCore> {
) )
: 0; : 0;
return { entries, totalPlayers }; let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) {
const [viewerUser] = await db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (viewerUser) {
const [viewerSettings] = await db
.select({ achievementScore: UsersSettings.achievementScore })
.from(UsersSettings)
.where(eq(UsersSettings.userId, userId))
.limit(1);
if (viewerSettings && viewerSettings.achievementScore > 0) {
const viewerRank = Number(
(
await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore}
`)
)[0]?.cnt ?? entries.length + 1,
);
viewerEntry = {
userId,
username: viewerUser.username,
figure: viewerUser.look,
score: viewerSettings.achievementScore,
rank: viewerRank,
};
}
}
}
return { entries, totalPlayers, viewerEntry };
} }
async function loadRarityBoard(badgeCodes: string[]): Promise<BoardCore> { async function loadRarityBoard(
_rarity: BadgeRarityKey,
badgeCodes: string[],
userId: number | null,
): Promise<BadgeLeaderboardBoard> {
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 }; if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
const codes = sql.join( const codes = sql.join(
@@ -193,111 +262,41 @@ async function loadRarityBoard(badgeCodes: string[]): Promise<BoardCore> {
) )
: 0; : 0;
return { entries, totalPlayers }; let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
} if (userId) {
const viewerRow = await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) AS cnt FROM users_badges
WHERE user_id = ${userId} AND badge_code IN (${codes})
`);
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore > 0) {
const [viewerUser] = await db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (viewerUser) {
const viewerRank = Number(
(
await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t
WHERE t.total > ${viewerScore}
`)
)[0]?.cnt ?? entries.length + 1,
);
viewerEntry = {
userId,
username: viewerUser.username,
figure: viewerUser.look,
score: viewerScore,
rank: viewerRank,
};
}
}
}
async function loadTotalBadgesViewer( return { entries, totalPlayers, viewerEntry };
userId: number,
): Promise<Partial<BadgeLeaderboardEntry> | undefined> {
const viewerRow = await rawRows<{ cnt: bigint }>(
sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
);
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore <= 0) return undefined;
const rankRow = await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t
WHERE t.total > ${viewerScore}
`);
const rank = Number(rankRow[0]?.cnt ?? 1);
const [viewerUser] = await db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!viewerUser) return undefined;
return {
userId,
username: viewerUser.username,
figure: viewerUser.look,
score: viewerScore,
rank,
};
}
async function loadAchievementViewer(
userId: number,
): Promise<Partial<BadgeLeaderboardEntry> | undefined> {
const [viewerUser] = await db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!viewerUser) return undefined;
const [viewerSettings] = await db
.select({ achievementScore: UsersSettings.achievementScore })
.from(UsersSettings)
.where(eq(UsersSettings.userId, userId))
.limit(1);
if (!viewerSettings || viewerSettings.achievementScore <= 0) return undefined;
const rankRow = await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore}
`);
const rank = Number(rankRow[0]?.cnt ?? 1);
return {
userId,
username: viewerUser.username,
figure: viewerUser.look,
score: viewerSettings.achievementScore,
rank,
};
}
async function loadRarityViewer(
userId: number,
badgeCodes: string[],
): Promise<Partial<BadgeLeaderboardEntry> | undefined> {
if (badgeCodes.length === 0) return undefined;
const codes = sql.join(
badgeCodes.map((c) => sql`${c}`),
sql`, `,
);
const viewerRow = await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) AS cnt FROM users_badges
WHERE user_id = ${userId} AND badge_code IN (${codes})
`);
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore <= 0) return undefined;
const [viewerUser] = await db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!viewerUser) return undefined;
const rankRow = await rawRows<{ cnt: bigint }>(sql`
SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t
WHERE t.total > ${viewerScore}
`);
const rank = Number(rankRow[0]?.cnt ?? 1);
return {
userId,
username: viewerUser.username,
figure: viewerUser.look,
score: viewerScore,
rank,
};
} }
export async function GET(req: Request) { export async function GET(req: Request) {
@@ -309,6 +308,28 @@ export async function GET(req: Request) {
userId = session?.user?.id ? Number(session.user.id) : null; userId = session?.user?.id ? Number(session.user.id) : null;
} }
const badgeStatsRaw = await rawRows<{
badgeCode: string;
ownerCount: bigint;
}>(sql`
SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
FROM users_badges
GROUP BY badge_code
`);
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
badgeCode: r.badgeCode,
ownerCount: Number(r.ownerCount),
rarity: assignRarity(Number(r.ownerCount)),
}));
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
for (const stat of badgeStats) {
const list = badgesByRarity.get(stat.rarity) ?? [];
list.push(stat.badgeCode);
badgesByRarity.set(stat.rarity, list);
}
const rarityKeys: BadgeRarityKey[] = [ const rarityKeys: BadgeRarityKey[] = [
"common", "common",
"rare", "rare",
@@ -318,109 +339,24 @@ export async function GET(req: Request) {
"unique", "unique",
]; ];
// The heavy global aggregation (badge rarity stats + the 8 leaderboards) const [totalBadges, achievementLevel, ...rarityBoards] = await Promise.all([
// is user-independent, so it is cached and recomputed once per TTL instead loadTotalBadgesBoard(userId),
// of on every request. The viewer's own rank is derived per request. loadAchievementBoard(userId),
const global = await redisCache( ...rarityKeys.map((rk) =>
apiCacheKey("badges:leaderboard:global"), loadRarityBoard(rk, badgesByRarity.get(rk) ?? [], userId),
120, ),
async () => { ]);
const badgeStatsRaw = await rawRows<{
badgeCode: string;
ownerCount: bigint;
}>(sql`
SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
FROM users_badges
GROUP BY badge_code
`);
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({ const rarity = Object.fromEntries(
badgeCode: r.badgeCode, // eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
ownerCount: Number(r.ownerCount), rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
rarity: assignRarity(Number(r.ownerCount)), ) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
}));
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
for (const stat of badgeStats) {
const list = badgesByRarity.get(stat.rarity) ?? [];
list.push(stat.badgeCode);
badgesByRarity.set(stat.rarity, list);
}
const [totalBadges, achievementLevel, ...rarityBoards] =
await Promise.all([
loadTotalBadgesBoard(),
loadAchievementBoard(),
...rarityKeys.map((rk) =>
loadRarityBoard(badgesByRarity.get(rk) ?? []),
),
]);
const rarity = Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
) as Record<BadgeRarityKey, BoardCore>;
return cacheSafe({ badgeStats, totalBadges, achievementLevel, rarity });
},
);
// Per-viewer rank entries (only when a user is signed in).
let totalBadgesViewer: Partial<BadgeLeaderboardEntry> | undefined;
let achievementViewer: Partial<BadgeLeaderboardEntry> | undefined;
let rarityViewer: Record<
BadgeRarityKey,
Partial<BadgeLeaderboardEntry> | undefined
> = {
common: undefined,
rare: undefined,
epic: undefined,
legendary: undefined,
mythical: undefined,
unique: undefined,
};
if (userId) {
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
for (const stat of global.badgeStats) {
const list = badgesByRarity.get(stat.rarity) ?? [];
list.push(stat.badgeCode);
badgesByRarity.set(stat.rarity, list);
}
const [tb, ach, ...rvs] = await Promise.all([
loadTotalBadgesViewer(userId),
loadAchievementViewer(userId),
...rarityKeys.map((rk) =>
loadRarityViewer(userId, badgesByRarity.get(rk) ?? []),
),
]);
totalBadgesViewer = tb;
achievementViewer = ach;
rarityViewer = Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
rarityKeys.map((rk, i) => [rk, rvs[i]]),
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
}
return apiJson({ return apiJson({
viewerUserId: userId ?? 0, viewerUserId: userId ?? 0,
badgeStats: global.badgeStats, badgeStats,
thresholds: THRESHOLDS, thresholds: THRESHOLDS,
leaderboards: { leaderboards: { totalBadges, achievementLevel, rarity },
totalBadges: { ...global.totalBadges, viewerEntry: totalBadgesViewer },
achievementLevel: {
...global.achievementLevel,
viewerEntry: achievementViewer,
},
rarity: Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
rarityKeys.map((rk) => [
rk,
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
]),
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>,
},
}); });
} catch (err) { } catch (err) {
logger.error("Badge leaderboard error", { logger.error("Badge leaderboard error", {
+28 -41
View File
@@ -5,7 +5,6 @@
import { count, eq } from "drizzle-orm"; import { count, eq } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, Guilds, GuildsMembers } from "@/lib/db"; import { db, Guilds, GuildsMembers } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET( export async function GET(
_req: Request, _req: Request,
@@ -19,50 +18,38 @@ export async function GET(
const guildId = Number(id); const guildId = Number(id);
try { try {
const data = await redisCache( const [guild] = await db
apiCacheKey(`guilds:${id}`), .select({
120, id: Guilds.id,
async () => { name: Guilds.name,
const [guild] = await db description: Guilds.description,
.select({ userId: Guilds.userId,
id: Guilds.id, roomId: Guilds.roomId,
name: Guilds.name, badge: Guilds.badge,
description: Guilds.description, dateCreated: Guilds.dateCreated,
userId: Guilds.userId, })
roomId: Guilds.roomId, .from(Guilds)
badge: Guilds.badge, .where(eq(Guilds.id, guildId))
dateCreated: Guilds.dateCreated, .limit(1);
})
.from(Guilds)
.where(eq(Guilds.id, guildId))
.limit(1);
if (!guild) { if (!guild) {
return null;
}
// Member count is a separate guarded query (no relation is modelled).
let memberCount = 0;
try {
const [row] = await db
.select({ total: count() })
.from(GuildsMembers)
.where(eq(GuildsMembers.guildId, guild.id));
memberCount = row?.total ?? 0;
} catch {
memberCount = 0;
}
const { userId, ...rest } = guild;
return cacheSafe({ data: { ...rest, ownerId: userId, memberCount } });
},
);
if (data === null) {
return apiJson({ error: "Guild not found" }, { status: 404 }); return apiJson({ error: "Guild not found" }, { status: 404 });
} }
return apiJson(data); // Member count is a separate guarded query (no relation is modelled).
let memberCount = 0;
try {
const [row] = await db
.select({ total: count() })
.from(GuildsMembers)
.where(eq(GuildsMembers.guildId, guild.id));
memberCount = row?.total ?? 0;
} catch {
memberCount = 0;
}
const { userId, ...rest } = guild;
return apiJson({ data: { ...rest, ownerId: userId, memberCount } });
} catch { } catch {
// DB unavailable — treat as not found rather than a 500. // DB unavailable — treat as not found rather than a 500.
return apiJson({ error: "Guild not found" }, { status: 200 }); return apiJson({ error: "Guild not found" }, { status: 200 });
+28 -39
View File
@@ -5,54 +5,43 @@
import { count, desc } from "drizzle-orm"; import { count, desc } from "drizzle-orm";
import { apiJson, pagination } from "@/lib/api"; import { apiJson, pagination } from "@/lib/api";
import { db, Guilds } from "@/lib/db"; import { db, Guilds } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET(req: Request) { export async function GET(req: Request) {
const sp = new URL(req.url).searchParams; const sp = new URL(req.url).searchParams;
const { page, perPage, skip, take } = pagination(sp); const { page, perPage, skip, take } = pagination(sp);
try { try {
const data = await redisCache( const [totalRows, rows] = await Promise.all([
apiCacheKey(`guilds:${page}:${perPage}`), db.select({ total: count() }).from(Guilds),
120, db
async () => { .select({
const [totalRows, rows] = await Promise.all([ id: Guilds.id,
db.select({ total: count() }).from(Guilds), name: Guilds.name,
db description: Guilds.description,
.select({ userId: Guilds.userId,
id: Guilds.id, })
name: Guilds.name, .from(Guilds)
description: Guilds.description, .orderBy(desc(Guilds.id))
userId: Guilds.userId, .limit(take)
}) .offset(skip),
.from(Guilds) ]);
.orderBy(desc(Guilds.id)) const total = totalRows[0]?.total ?? 0;
.limit(take)
.offset(skip),
]);
// Rename userId → ownerId for the public payload. // Rename userId → ownerId for the public payload.
const renamed = rows.map(({ userId, ...rest }) => ({ const data = rows.map(({ userId, ...rest }) => ({
...rest, ...rest,
ownerId: userId, ownerId: userId,
})); }));
return cacheSafe({ return apiJson({
data: renamed, data,
meta: { meta: {
page, page,
perPage, perPage,
total: totalRows[0]?.total ?? 0, total,
lastPage: Math.max( lastPage: Math.max(1, Math.ceil(total / perPage)),
1,
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
),
},
});
}, },
); });
return apiJson(data);
} catch { } catch {
// DB unreachable — never 500; return an empty, well-formed payload. // DB unreachable — never 500; return an empty, well-formed payload.
return apiJson( return apiJson(
+2 -6
View File
@@ -3,7 +3,7 @@ import { apiJson } from "@/lib/api";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, User, WebsiteArticles } from "@/lib/db"; import { db, User, WebsiteArticles } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; import { apiCacheKey, redisCache } from "@/lib/redis-cache";
/** /**
* GET /api/home — combined landing payload: the latest 4 website_articles and * GET /api/home — combined landing payload: the latest 4 website_articles and
@@ -29,11 +29,7 @@ export async function GET(_req: Request) {
db.select({ total: count() }).from(User).where(eq(User.online, "1")), db.select({ total: count() }).from(User).where(eq(User.online, "1")),
resolveHotelName(), resolveHotelName(),
]); ]);
return cacheSafe({ return { articles, online: onlineRows[0]?.total ?? 0, hotelName };
articles,
online: onlineRows[0]?.total ?? 0,
hotelName,
});
}); });
return apiJson(data); return apiJson(data);
} catch { } catch {
+5 -10
View File
@@ -1,7 +1,6 @@
import { desc, eq, inArray } from "drizzle-orm"; import { desc, eq, inArray } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, User, UsersCurrency } from "@/lib/db"; import { db, User, UsersCurrency } from "@/lib/db";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
// Public REST API — leaderboard. Mirrors src/app/leaderboard/page.tsx. // Public REST API — leaderboard. Mirrors src/app/leaderboard/page.tsx.
// AtomCMS-faithful currency type ids (see UsersCurrency): // AtomCMS-faithful currency type ids (see UsersCurrency):
@@ -76,15 +75,11 @@ export async function GET(req: Request) {
? requested ? requested
: "credits"; : "credits";
const rows = await redisCache( const rows =
apiCacheKey(`leaderboard:${type}`), type === "credits"
60, ? await loadCreditsRows()
async () => : // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
type === "credits" await loadCurrencyRows(CURRENCY_TYPE[type]);
? await loadCreditsRows()
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
await loadCurrencyRows(CURRENCY_TYPE[type]),
);
return apiJson({ type, data: rows }, { status: 200 }); return apiJson({ type, data: rows }, { status: 200 });
} catch { } catch {
+24 -34
View File
@@ -1,7 +1,6 @@
import { count, desc } from "drizzle-orm"; import { count, desc } from "drizzle-orm";
import { apiJson, pagination } from "@/lib/api"; import { apiJson, pagination } from "@/lib/api";
import { CameraWeb, db } from "@/lib/db"; import { CameraWeb, db } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
/** /**
* GET /api/photos — recent community photos (camera_web), newest first. * GET /api/photos — recent community photos (camera_web), newest first.
@@ -13,40 +12,31 @@ export async function GET(req: Request) {
const { page, perPage, skip, take } = pagination(sp); const { page, perPage, skip, take } = pagination(sp);
try { try {
const data = await redisCache( const [totalRows, photos] = await Promise.all([
apiCacheKey(`photos:${page}:${perPage}`), db.select({ total: count() }).from(CameraWeb),
60, db
async () => { .select({
const [totalRows, photos] = await Promise.all([ id: CameraWeb.id,
db.select({ total: count() }).from(CameraWeb), userId: CameraWeb.userId,
db url: CameraWeb.url,
.select({ timestamp: CameraWeb.timestamp,
id: CameraWeb.id, })
userId: CameraWeb.userId, .from(CameraWeb)
url: CameraWeb.url, .orderBy(desc(CameraWeb.timestamp))
timestamp: CameraWeb.timestamp, .limit(take)
}) .offset(skip),
.from(CameraWeb) ]);
.orderBy(desc(CameraWeb.timestamp)) const total = totalRows[0]?.total ?? 0;
.limit(take)
.offset(skip),
]);
return cacheSafe({
data: photos,
meta: {
page,
perPage,
total: totalRows[0]?.total ?? 0,
lastPage: Math.max(
1,
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
),
},
});
},
);
return apiJson(data); return apiJson({
data: photos,
meta: {
page,
perPage,
total,
lastPage: Math.max(1, Math.ceil(total / perPage)),
},
});
} catch { } catch {
// DB unavailable — return an empty payload instead of a 500. // DB unavailable — return an empty payload instead of a 500.
return apiJson( return apiJson(
+15 -28
View File
@@ -1,46 +1,33 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Current on-air DJ. The DJ is set manually via the radio_current_dj_id setting // Current on-air DJ. The DJ is set manually via the radio_current_dj_id setting
// (Manual DJ user ID). When set, resolve that user's username/look; otherwise // (Manual DJ user ID). When set, resolve that user's username/look; otherwise
// there is no DJ on air. Mirrors the AtomCMS radio "on air" widget. // there is no DJ on air. Mirrors the AtomCMS radio "on air" widget.
// The radio widget polls this endpoint frequently — a short cache avoids a DB
// hit per poll. The DJ only changes when a moderator switches it manually.
const GET_TTL_SECONDS = 5;
export async function GET(_req: Request) { export async function GET(_req: Request) {
try { try {
const dj = await redisCache( const raw = await siteSettings.get("radio_current_dj_id", "");
apiCacheKey("radio:current-dj"), const id = Number(raw);
GET_TTL_SECONDS,
async () => {
const raw = await siteSettings.get("radio_current_dj_id", "");
const id = Number(raw);
// No DJ configured (empty / non-numeric / zero). // No DJ configured (empty / non-numeric / zero).
if (!raw || !Number.isFinite(id) || id <= 0) { if (!raw || !Number.isFinite(id) || id <= 0) {
return null; return apiJson({ dj: null });
} }
const [user] = await db const [user] = await db
.select({ username: User.username, look: User.look }) .select({ username: User.username, look: User.look })
.from(User) .from(User)
.where(eq(User.id, id)) .where(eq(User.id, id))
.limit(1); .limit(1);
if (!user) { if (!user) {
return null; return apiJson({ dj: null });
} }
return { username: user.username, look: user.look }; return apiJson({ dj: { username: user.username, look: user.look } });
},
);
return apiJson({ dj });
} catch { } catch {
// DB / settings unavailable — no DJ rather than a 500. // DB / settings unavailable — no DJ rather than a 500.
return apiJson({ dj: null }, { status: 200 }); return apiJson({ dj: null }, { status: 200 });
+35 -39
View File
@@ -1,7 +1,6 @@
import { desc, inArray, sum } from "drizzle-orm"; import { inArray, sum } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, RadioListenerPoints, User } from "@/lib/db"; import { db, RadioListenerPoints, User } from "@/lib/db";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
// Radio listener-points leaderboard: the top 20 users by total points, summed // Radio listener-points leaderboard: the top 20 users by total points, summed
// across radio_listener_points and joined to users for username/look. Public // across radio_listener_points and joined to users for username/look. Public
@@ -9,46 +8,43 @@ import { apiCacheKey, redisCache } from "@/lib/redis-cache";
export async function GET(_req: Request) { export async function GET(_req: Request) {
try { try {
// Cached 60s: the leaderboard only changes while people listen, so a // Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
// short TTL keeps it fresh without re-scanning the table per request. // aggregate ordering, then resolve the top 20 to usernames/looks.
const data = await redisCache( const grouped = await db
apiCacheKey("radio:points:leaderboard"), .select({
60, userId: RadioListenerPoints.userId,
async () => { points: sum(RadioListenerPoints.points),
// Sum points per user and take the top 20 in SQL instead of })
// pulling the whole table into JS to sort/slice. .from(RadioListenerPoints)
const grouped = await db .groupBy(RadioListenerPoints.userId);
.select({
userId: RadioListenerPoints.userId,
points: sum(RadioListenerPoints.points),
})
.from(RadioListenerPoints)
.groupBy(RadioListenerPoints.userId)
.orderBy(desc(sum(RadioListenerPoints.points)))
.limit(20);
if (grouped.length === 0) { const ranked = grouped
return []; .map((g) => ({
} userId: Number(g.userId),
points: Number(g.points ?? 0),
}))
.sort((a, b) => b.points - a.points)
.slice(0, 20);
const userIds = grouped.map((r) => Number(r.userId)); if (ranked.length === 0) {
const users = await db return apiJson({ data: [] });
.select({ id: User.id, username: User.username, look: User.look }) }
.from(User)
.where(inArray(User.id, userIds));
const userById = new Map(users.map((u) => [u.id, u]));
return grouped.map((r) => { const userIds = ranked.map((r) => r.userId);
const uid = Number(r.userId); const users = await db
const u = userById.get(uid); .select({ id: User.id, username: User.username, look: User.look })
return { .from(User)
username: u?.username ?? null, .where(inArray(User.id, userIds));
look: u?.look ?? null, const userById = new Map(users.map((u) => [u.id, u]));
points: Number(r.points ?? 0),
}; const data = ranked.map((r) => {
}); const u = userById.get(r.userId);
}, return {
); username: u?.username ?? null,
look: u?.look ?? null,
points: r.points,
};
});
return apiJson({ data }); return apiJson({ data });
} catch { } catch {
+31 -44
View File
@@ -2,7 +2,6 @@ import { desc, inArray } from "drizzle-orm";
import { apiError, apiJson } from "@/lib/api"; import { apiError, apiJson } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth"; import { bearerUserId } from "@/lib/api-auth";
import { db, RadioShouts, User } from "@/lib/db"; import { db, RadioShouts, User } from "@/lib/db";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
// Latest 50 radio shouts with their author's username/look resolved. Mirrors the // Latest 50 radio shouts with their author's username/look resolved. Mirrors the
// query behind the public /radio/shouts page (radio_shouts ordered by created_at // query behind the public /radio/shouts page (radio_shouts ordered by created_at
@@ -11,53 +10,41 @@ import { apiCacheKey, redisCache } from "@/lib/redis-cache";
// Max shout length (radio_shouts.message is TEXT; cap to keep posts sane). // Max shout length (radio_shouts.message is TEXT; cap to keep posts sane).
const MAX_MESSAGE_LENGTH = 255; const MAX_MESSAGE_LENGTH = 255;
// The shout feed is polled by the radio widget every few seconds — a short
// cache keeps it cheap without delaying new shouts noticeably.
const GET_TTL_SECONDS = 5;
export async function GET(_req: Request) { export async function GET(_req: Request) {
try { try {
const data = await redisCache( const shouts = await db
apiCacheKey("radio:shouts"), .select({
GET_TTL_SECONDS, id: RadioShouts.id,
async () => { userId: RadioShouts.userId,
const shouts = await db message: RadioShouts.message,
.select({ createdAt: RadioShouts.createdAt,
id: RadioShouts.id, })
userId: RadioShouts.userId, .from(RadioShouts)
message: RadioShouts.message, .orderBy(desc(RadioShouts.createdAt))
createdAt: RadioShouts.createdAt, .limit(50);
})
.from(RadioShouts)
.orderBy(desc(RadioShouts.createdAt))
.limit(50);
// Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt // Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt
// while users.id is an Int, so narrow to Number for the lookup. // while users.id is an Int, so narrow to Number for the lookup.
const authorIds = Array.from( const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
new Set(shouts.map((s) => Number(s.userId))), const authors = authorIds.length
); ? await db
const authors = authorIds.length .select({ id: User.id, username: User.username, look: User.look })
? await db .from(User)
.select({ id: User.id, username: User.username, look: User.look }) .where(inArray(User.id, authorIds))
.from(User) : [];
.where(inArray(User.id, authorIds)) const authorById = new Map(authors.map((a) => [a.id, a]));
: [];
const authorById = new Map(authors.map((a) => [a.id, a]));
return shouts.map((s) => { const data = shouts.map((s) => {
const author = authorById.get(Number(s.userId)); const author = authorById.get(Number(s.userId));
return { return {
id: s.id, id: s.id,
userId: s.userId, userId: s.userId,
username: author?.username ?? null, username: author?.username ?? null,
look: author?.look ?? null, look: author?.look ?? null,
message: s.message, message: s.message,
createdAt: s.createdAt, createdAt: s.createdAt,
}; };
}); });
},
);
return apiJson({ shouts: data }); return apiJson({ shouts: data });
} catch { } catch {
+13 -21
View File
@@ -3,30 +3,22 @@
import { asc } from "drizzle-orm"; import { asc } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, WebsiteShopCategories } from "@/lib/db"; import { db, WebsiteShopCategories } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET(_req: Request) { export async function GET(_req: Request) {
try { try {
const data = await redisCache( const data = await db
apiCacheKey("shop:categories"), .select({
60, id: WebsiteShopCategories.id,
async () => name: WebsiteShopCategories.name,
cacheSafe( description: WebsiteShopCategories.description,
await db icon: WebsiteShopCategories.icon,
.select({ order: WebsiteShopCategories.order,
id: WebsiteShopCategories.id, })
name: WebsiteShopCategories.name, .from(WebsiteShopCategories)
description: WebsiteShopCategories.description, .orderBy(
icon: WebsiteShopCategories.icon, asc(WebsiteShopCategories.order),
order: WebsiteShopCategories.order, asc(WebsiteShopCategories.name),
}) );
.from(WebsiteShopCategories)
.orderBy(
asc(WebsiteShopCategories.order),
asc(WebsiteShopCategories.name),
),
),
);
return apiJson({ data }); return apiJson({ data });
} catch { } catch {
+39 -49
View File
@@ -4,7 +4,6 @@
import { asc, count, eq } from "drizzle-orm"; import { asc, count, eq } from "drizzle-orm";
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api"; import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
import { db, WebsiteShopArticles } from "@/lib/db"; import { db, WebsiteShopArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET(req: Request) { export async function GET(req: Request) {
const sp = new URL(req.url).searchParams; const sp = new URL(req.url).searchParams;
@@ -25,55 +24,46 @@ export async function GET(req: Request) {
? eq(WebsiteShopArticles.categoryId, categoryId) ? eq(WebsiteShopArticles.categoryId, categoryId)
: undefined; : undefined;
const data = await redisCache( const [totalRows, data] = await Promise.all([
apiCacheKey(`shop:${categoryId ?? "all"}:${page}:${perPage}`), db.select({ total: count() }).from(WebsiteShopArticles).where(where),
60, db
async () => { .select({
const [totalRows, rows] = await Promise.all([ id: WebsiteShopArticles.id,
db.select({ total: count() }).from(WebsiteShopArticles).where(where), categoryId: WebsiteShopArticles.categoryId,
db name: WebsiteShopArticles.name,
.select({ info: WebsiteShopArticles.info,
id: WebsiteShopArticles.id, iconUrl: WebsiteShopArticles.iconUrl,
categoryId: WebsiteShopArticles.categoryId, color: WebsiteShopArticles.color,
name: WebsiteShopArticles.name, costs: WebsiteShopArticles.costs,
info: WebsiteShopArticles.info, giveRank: WebsiteShopArticles.giveRank,
iconUrl: WebsiteShopArticles.iconUrl, isGiftable: WebsiteShopArticles.isGiftable,
color: WebsiteShopArticles.color, credits: WebsiteShopArticles.credits,
costs: WebsiteShopArticles.costs, duckets: WebsiteShopArticles.duckets,
giveRank: WebsiteShopArticles.giveRank, diamonds: WebsiteShopArticles.diamonds,
isGiftable: WebsiteShopArticles.isGiftable, badges: WebsiteShopArticles.badges,
credits: WebsiteShopArticles.credits, furniture: WebsiteShopArticles.furniture,
duckets: WebsiteShopArticles.duckets, position: WebsiteShopArticles.position,
diamonds: WebsiteShopArticles.diamonds, })
badges: WebsiteShopArticles.badges, .from(WebsiteShopArticles)
furniture: WebsiteShopArticles.furniture, .where(where)
position: WebsiteShopArticles.position, .orderBy(
}) asc(WebsiteShopArticles.position),
.from(WebsiteShopArticles) asc(WebsiteShopArticles.name),
.where(where) )
.orderBy( .limit(take)
asc(WebsiteShopArticles.position), .offset(skip),
asc(WebsiteShopArticles.name), ]);
) const total = totalRows[0]?.total ?? 0;
.limit(take)
.offset(skip),
]);
return cacheSafe({
data: rows,
meta: {
page,
perPage,
total: totalRows[0]?.total ?? 0,
lastPage: Math.max(
1,
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
),
},
});
},
);
return apiJson(data); return apiJson({
data,
meta: {
page,
perPage,
total,
lastPage: Math.max(1, Math.ceil(total / perPage)),
},
});
} catch { } catch {
// DB unreachable — never 500; return an empty, well-formed payload. // DB unreachable — never 500; return an empty, well-formed payload.
return apiJson( return apiJson(
+11 -14
View File
@@ -1,7 +1,6 @@
import { asc, desc, gte } from "drizzle-orm"; import { asc, desc, gte } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Public REST API — staff list. Mirrors src/app/staff/page.tsx: users whose // Public REST API — staff list. Mirrors src/app/staff/page.tsx: users whose
@@ -12,19 +11,17 @@ export async function GET(_req: Request) {
const minStaffRank = const minStaffRank =
Number(await siteSettings.get("min_staff_rank", "7")) || 7; Number(await siteSettings.get("min_staff_rank", "7")) || 7;
const staff = await redisCache(apiCacheKey("staff"), 300, () => const staff = await db
db .select({
.select({ username: User.username,
username: User.username, look: User.look,
look: User.look, rank: User.rank,
rank: User.rank, motto: User.motto,
motto: User.motto, })
}) .from(User)
.from(User) .where(gte(User.rank, minStaffRank))
.where(gte(User.rank, minStaffRank)) .orderBy(desc(User.rank), asc(User.username))
.orderBy(desc(User.rank), asc(User.username)) .limit(100);
.limit(100),
);
return apiJson({ data: staff }, { status: 200 }); return apiJson({ data: staff }, { status: 200 });
} catch { } catch {
+11 -16
View File
@@ -1,28 +1,23 @@
import { asc, eq } from "drizzle-orm"; import { asc, eq } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, WebsiteTeams } from "@/lib/db"; import { db, WebsiteTeams } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
// Public REST API — website teams (staff ranks). Mirrors src/app/staff/page.tsx: // Public REST API — website teams (staff ranks). Mirrors src/app/staff/page.tsx:
// visible ranks (hiddenRank=false) ordered by id. // visible ranks (hiddenRank=false) ordered by id.
export async function GET(_req: Request) { export async function GET(_req: Request) {
try { try {
const rows = await redisCache(apiCacheKey("teams"), 300, async () => const rows = await db
cacheSafe( .select({
await db id: WebsiteTeams.id,
.select({ rankName: WebsiteTeams.rankName,
id: WebsiteTeams.id, badge: WebsiteTeams.badge,
rankName: WebsiteTeams.rankName, jobDescription: WebsiteTeams.jobDescription,
badge: WebsiteTeams.badge, staffColor: WebsiteTeams.staffColor,
jobDescription: WebsiteTeams.jobDescription, })
staffColor: WebsiteTeams.staffColor, .from(WebsiteTeams)
}) .where(eq(WebsiteTeams.hiddenRank, false))
.from(WebsiteTeams) .orderBy(asc(WebsiteTeams.id));
.where(eq(WebsiteTeams.hiddenRank, false))
.orderBy(asc(WebsiteTeams.id)),
),
);
// apiJson serialises BigInt ids → string automatically. // apiJson serialises BigInt ids → string automatically.
return apiJson({ data: rows }, { status: 200 }); return apiJson({ data: rows }, { status: 200 });
+22 -35
View File
@@ -8,7 +8,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET( export async function GET(
_req: Request, _req: Request,
@@ -17,45 +16,33 @@ export async function GET(
const { username } = await params; const { username } = await params;
try { try {
const user = await redisCache( const [user] = await db
apiCacheKey(`users:${username}`), .select({
30, username: User.username,
async () => { look: User.look,
const [row] = await db motto: User.motto,
.select({ rank: User.rank,
username: User.username, credits: User.credits,
look: User.look, online: User.online,
motto: User.motto, accountCreated: User.accountCreated,
rank: User.rank, })
credits: User.credits, .from(User)
online: User.online, .where(eq(User.username, username))
accountCreated: User.accountCreated, .limit(1);
})
.from(User)
.where(eq(User.username, username))
.limit(1);
if (!row) {
return null;
}
return cacheSafe({
username: row.username,
look: row.look,
motto: row.motto,
rank: row.rank,
credits: row.credits,
online: row.online === "1",
accountCreated: row.accountCreated,
});
},
);
if (!user) { if (!user) {
return apiJson({ error: "User not found" }, { status: 404 }); return apiJson({ error: "User not found" }, { status: 404 });
} }
return apiJson(user); return apiJson({
username: user.username,
look: user.look,
motto: user.motto,
rank: user.rank,
credits: user.credits,
online: user.online === "1",
accountCreated: user.accountCreated,
});
} catch { } catch {
// DB unreachable — behave as "not found" rather than 500. // DB unreachable — behave as "not found" rather than 500.
return apiJson({ error: "User not found" }, { status: 404 }); return apiJson({ error: "User not found" }, { status: 404 });
+42 -55
View File
@@ -4,7 +4,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET( export async function GET(
_req: Request, _req: Request,
@@ -25,64 +24,52 @@ export async function GET(
} }
try { try {
const data = await redisCache( const [value] = await db
apiCacheKey(`values:${id}`), .select({
300, id: WebsiteRareValues.id,
async () => { categoryId: WebsiteRareValues.categoryId,
const [value] = await db itemId: WebsiteRareValues.itemId,
.select({ name: WebsiteRareValues.name,
id: WebsiteRareValues.id, creditValue: WebsiteRareValues.creditValue,
categoryId: WebsiteRareValues.categoryId, currencyValue: WebsiteRareValues.currencyValue,
itemId: WebsiteRareValues.itemId, currencyType: WebsiteRareValues.currencyType,
name: WebsiteRareValues.name, furnitureIcon: WebsiteRareValues.furnitureIcon,
creditValue: WebsiteRareValues.creditValue, createdAt: WebsiteRareValues.createdAt,
currencyValue: WebsiteRareValues.currencyValue, updatedAt: WebsiteRareValues.updatedAt,
currencyType: WebsiteRareValues.currencyType, })
furnitureIcon: WebsiteRareValues.furnitureIcon, .from(WebsiteRareValues)
createdAt: WebsiteRareValues.createdAt, .where(eq(WebsiteRareValues.id, valueId))
updatedAt: WebsiteRareValues.updatedAt, .limit(1);
})
.from(WebsiteRareValues)
.where(eq(WebsiteRareValues.id, valueId))
.limit(1);
if (!value) { if (!value) {
return null;
}
// 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 {
const [row] = await db
.select({
id: WebsiteRareValueCategories.id,
name: WebsiteRareValueCategories.name,
badge: WebsiteRareValueCategories.badge,
priority: WebsiteRareValueCategories.priority,
})
.from(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, value.categoryId))
.limit(1);
category = row ?? null;
} catch {
category = null;
}
return cacheSafe({ data: { ...value, category } });
},
);
if (data === null) {
return apiJson({ error: "Rare value not found" }, { status: 404 }); return apiJson({ error: "Rare value not found" }, { status: 404 });
} }
return apiJson(data); // 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 {
const [row] = await db
.select({
id: WebsiteRareValueCategories.id,
name: WebsiteRareValueCategories.name,
badge: WebsiteRareValueCategories.badge,
priority: WebsiteRareValueCategories.priority,
})
.from(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, value.categoryId))
.limit(1);
category = row ?? null;
} catch {
category = null;
}
return apiJson({ data: { ...value, category } });
} catch { } catch {
// DB unavailable — treat as not found rather than a 500. // DB unavailable — treat as not found rather than a 500.
return apiJson({ error: "Rare value not found" }, { status: 200 }); return apiJson({ error: "Rare value not found" }, { status: 200 });
+12 -20
View File
@@ -4,29 +4,21 @@
import { asc } from "drizzle-orm"; import { asc } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, WebsiteRareValueCategories } from "@/lib/db"; import { db, WebsiteRareValueCategories } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET(_req: Request) { export async function GET(_req: Request) {
try { try {
const data = await redisCache( const data = await db
apiCacheKey("values:categories"), .select({
300, id: WebsiteRareValueCategories.id,
async () => name: WebsiteRareValueCategories.name,
cacheSafe( badge: WebsiteRareValueCategories.badge,
await db priority: WebsiteRareValueCategories.priority,
.select({ })
id: WebsiteRareValueCategories.id, .from(WebsiteRareValueCategories)
name: WebsiteRareValueCategories.name, .orderBy(
badge: WebsiteRareValueCategories.badge, asc(WebsiteRareValueCategories.priority),
priority: WebsiteRareValueCategories.priority, asc(WebsiteRareValueCategories.name),
}) );
.from(WebsiteRareValueCategories)
.orderBy(
asc(WebsiteRareValueCategories.priority),
asc(WebsiteRareValueCategories.name),
),
),
);
return apiJson({ data }); return apiJson({ data });
} catch { } catch {
+29 -39
View File
@@ -4,7 +4,6 @@
import { asc, count, eq } from "drizzle-orm"; import { asc, count, eq } from "drizzle-orm";
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api"; import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
import { db, WebsiteRareValues } from "@/lib/db"; import { db, WebsiteRareValues } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET(req: Request) { export async function GET(req: Request) {
const sp = new URL(req.url).searchParams; const sp = new URL(req.url).searchParams;
@@ -25,45 +24,36 @@ export async function GET(req: Request) {
? eq(WebsiteRareValues.categoryId, categoryId) ? eq(WebsiteRareValues.categoryId, categoryId)
: undefined; : undefined;
const data = await redisCache( const [totalRows, data] = await Promise.all([
apiCacheKey(`values:${categoryId ?? "all"}:${page}:${perPage}`), db.select({ total: count() }).from(WebsiteRareValues).where(where),
300, db
async () => { .select({
const [totalRows, rows] = await Promise.all([ id: WebsiteRareValues.id,
db.select({ total: count() }).from(WebsiteRareValues).where(where), categoryId: WebsiteRareValues.categoryId,
db itemId: WebsiteRareValues.itemId,
.select({ name: WebsiteRareValues.name,
id: WebsiteRareValues.id, creditValue: WebsiteRareValues.creditValue,
categoryId: WebsiteRareValues.categoryId, currencyValue: WebsiteRareValues.currencyValue,
itemId: WebsiteRareValues.itemId, currencyType: WebsiteRareValues.currencyType,
name: WebsiteRareValues.name, furnitureIcon: WebsiteRareValues.furnitureIcon,
creditValue: WebsiteRareValues.creditValue, })
currencyValue: WebsiteRareValues.currencyValue, .from(WebsiteRareValues)
currencyType: WebsiteRareValues.currencyType, .where(where)
furnitureIcon: WebsiteRareValues.furnitureIcon, .orderBy(asc(WebsiteRareValues.name))
}) .limit(take)
.from(WebsiteRareValues) .offset(skip),
.where(where) ]);
.orderBy(asc(WebsiteRareValues.name)) const total = totalRows[0]?.total ?? 0;
.limit(take)
.offset(skip),
]);
return cacheSafe({
data: rows,
meta: {
page,
perPage,
total: totalRows[0]?.total ?? 0,
lastPage: Math.max(
1,
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
),
},
});
},
);
return apiJson(data); return apiJson({
data,
meta: {
page,
perPage,
total,
lastPage: Math.max(1, Math.ceil(total / perPage)),
},
});
} catch { } catch {
// DB unreachable — never 500; return an empty, well-formed payload. // DB unreachable — never 500; return an empty, well-formed payload.
return apiJson( return apiJson(
@@ -1,71 +0,0 @@
"use client";
import { Check, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { BatchItemStatus } from "./studio-types";
export function BatchProgress({
progress,
done,
succeeded,
failed,
}: {
progress: Map<string, BatchItemStatus>;
done: boolean;
succeeded: number;
failed: number;
}) {
const entries = [...progress.entries()];
return (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Importing…
</span>
{done ? (
<span
className={cn(
"text-xs font-bold",
failed > 0
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
>
✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""}
</span>
) : (
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
)}
</div>
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
{entries.map(([classname, s]) => (
<div key={classname} className="flex items-center gap-2 text-xs">
{s.status === "done" ? (
<Check
size={12}
className="shrink-0 text-[var(--admin-success)]"
/>
) : s.status === "failed" ? (
<X size={12} className="shrink-0 text-[var(--admin-warning)]" />
) : (
<Loader2
size={12}
className="shrink-0 animate-spin text-[var(--admin-accent)]"
/>
)}
<span className="min-w-0 flex-1 truncate font-mono">
{classname}
</span>
<span className="shrink-0 capitalize text-[var(--admin-text-muted)]">
{s.status.replace(/_/g, " ")}
</span>
</div>
))}
</div>
</div>
);
}
@@ -1,103 +0,0 @@
"use client";
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
export function CatalogRail({
tree,
selectedId,
onSelect,
}: {
tree: TreeNode[];
selectedId: number | null;
onSelect: (id: number | null) => void;
}) {
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const visible = useMemo(() => {
const hidden = new Set<number>();
for (const node of tree) {
if (hidden.has(node.parentId)) {
hidden.add(node.id);
continue;
}
if (collapsed.has(node.parentId)) {
hidden.add(node.id);
}
}
return tree.filter((n) => !hidden.has(n.id));
}, [tree, collapsed]);
const toggle = (id: number) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5">
<FolderTree size={14} className="text-[var(--admin-accent)]" />
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Catalog
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{visible.length === 0 ? (
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]">
No catalog pages found.
</p>
) : (
visible.map((node) => {
const isRoot = node.parentId <= 0;
const hasChildren = node.childCount > 0;
const isCollapsed = collapsed.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => {
if (hasChildren) toggle(node.id);
onSelect(selectedId === node.id ? null : node.id);
}}
className={cn(
"group flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors",
selectedId === node.id
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
)}
style={{ paddingLeft: `${6 + node.depth * 12}px` }}
>
{hasChildren ? (
isCollapsed ? (
<ChevronRight size={12} className="shrink-0" />
) : (
<ChevronDown size={12} className="shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">
{isRoot ? (
<span className="font-semibold">{node.caption}</span>
) : (
node.caption
)}
</span>
<span className="shrink-0 rounded-full bg-[var(--admin-accent)]/10 px-1.5 text-[0.6rem] font-semibold text-[var(--admin-accent)]">
{node.itemCount}
</span>
</button>
);
})
)}
</div>
</aside>
);
}
@@ -1,22 +0,0 @@
"use client";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export function CheckboxDot({ checked }: { checked: boolean }) {
return (
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded border transition-colors",
checked
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-transparent",
)}
>
{checked ? (
<Check size={10} className="text-[var(--admin-accent-foreground)]" />
) : null}
</span>
);
}
+265 -14
View File
@@ -2,9 +2,12 @@
import { import {
Check, Check,
ChevronDown,
ChevronRight,
CircleAlert, CircleAlert,
CloudDownload, CloudDownload,
Download, Download,
FolderTree,
Layers, Layers,
Loader2, Loader2,
Package, Package,
@@ -44,20 +47,251 @@ import {
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog"; import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail"; interface FurniItem {
import { CheckboxDot } from "./checkbox-dot"; id: number;
import { classname: string;
getFurniImageUrl, type: string;
handleImgError, name: string;
TYPE_LABELS, description: string;
} from "./studio-furni-icons"; revision: number;
import type { category: string;
BatchItemStatus, alreadyImported: boolean;
FurniItem, nitroExists: boolean;
FurniStats, /** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
Meta, iconUrl?: string;
} from "./studio-types"; }
interface Meta {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
interface FurniStats {
totalInDb: number;
inCatalog: number;
notInCatalog: number;
missingNitro: number;
}
interface BatchItemStatus {
classname: string;
status:
| "pending"
| "started"
| "downloading"
| "converting"
| "writing_db"
| "done"
| "failed";
message?: string;
warnings?: string[];
itemId?: number;
}
function getFurniImageUrl(item: FurniItem) {
if (item.iconUrl) return item.iconUrl;
const base = item.classname.includes("*")
? item.classname.substring(0, item.classname.indexOf("*"))
: item.classname;
return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`;
}
function getFurniIconUrl(classname: string, revision: number) {
const iconName = classname.replace(/\*/g, "_");
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
}
function handleImgError(
e: React.SyntheticEvent<HTMLImageElement>,
item: FurniItem,
) {
const img = e.target as HTMLImageElement;
const iconName = item.classname.replace(/\*/g, "_");
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = getFurniIconUrl(item.classname, item.revision);
} else if (img.dataset.fallback === "1") {
img.dataset.fallback = "2";
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
} else {
img.style.opacity = "0.3";
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
}
}
const TYPE_LABELS: Record<string, string> = {
flooritem: "Floor",
wallitem: "Wall",
};
// ── Catalog structure rail ─────────────────────────────────────────
function CatalogRail({
tree,
selectedId,
onSelect,
}: {
tree: TreeNode[];
selectedId: number | null;
onSelect: (id: number | null) => void;
}) {
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const visible = useMemo(() => {
const hidden = new Set<number>();
for (const node of tree) {
if (hidden.has(node.parentId)) {
hidden.add(node.id);
continue;
}
if (collapsed.has(node.parentId)) {
hidden.add(node.id);
}
}
return tree.filter((n) => !hidden.has(n.id));
}, [tree, collapsed]);
const toggle = (id: number) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5">
<FolderTree size={14} className="text-[var(--admin-accent)]" />
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Catalog
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{visible.length === 0 ? (
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]">
No catalog pages found.
</p>
) : (
visible.map((node) => {
const isRoot = node.parentId <= 0;
const hasChildren = node.childCount > 0;
const isCollapsed = collapsed.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => {
if (hasChildren) toggle(node.id);
onSelect(selectedId === node.id ? null : node.id);
}}
className={cn(
"group flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors",
selectedId === node.id
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
)}
style={{ paddingLeft: `${6 + node.depth * 12}px` }}
>
{hasChildren ? (
isCollapsed ? (
<ChevronRight size={12} className="shrink-0" />
) : (
<ChevronDown size={12} className="shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">
{isRoot ? (
<span className="font-semibold">{node.caption}</span>
) : (
node.caption
)}
</span>
<span className="shrink-0 rounded-full bg-[var(--admin-accent)]/10 px-1.5 text-[0.6rem] font-semibold text-[var(--admin-accent)]">
{node.itemCount}
</span>
</button>
);
})
)}
</div>
</aside>
);
}
// ── Batch progress panel ───────────────────────────────────────────
function BatchProgress({
progress,
done,
succeeded,
failed,
}: {
progress: Map<string, BatchItemStatus>;
done: boolean;
succeeded: number;
failed: number;
}) {
const entries = [...progress.entries()];
return (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Importing…
</span>
{done ? (
<span
className={cn(
"text-xs font-bold",
failed > 0
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
>
✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""}
</span>
) : (
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
)}
</div>
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
{entries.map(([cn, s]) => (
<div key={cn} className="flex items-center gap-2 text-xs">
{s.status === "done" ? (
<Check
size={12}
className="shrink-0 text-[var(--admin-success)]"
/>
) : s.status === "failed" ? (
<X size={12} className="shrink-0 text-[var(--admin-warning)]" />
) : (
<Loader2
size={12}
className="shrink-0 animate-spin text-[var(--admin-accent)]"
/>
)}
<span className="min-w-0 flex-1 truncate font-mono">{cn}</span>
<span className="shrink-0 capitalize text-[var(--admin-text-muted)]">
{s.status.replace(/_/g, " ")}
</span>
</div>
))}
</div>
</div>
);
}
// ── Studio workspace ───────────────────────────────────────────────
export function StudioClient({ export function StudioClient({
source, source,
@@ -895,3 +1129,20 @@ export function StudioClient({
</div> </div>
); );
} }
function CheckboxDot({ checked }: { checked: boolean }) {
return (
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded border transition-colors",
checked
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-transparent",
)}
>
{checked ? (
<Check size={10} className="text-[var(--admin-accent-foreground)]" />
) : null}
</span>
);
}
@@ -1,40 +0,0 @@
import type { SyntheticEvent } from "react";
import type { FurniItem } from "./studio-types";
export function getFurniImageUrl(item: FurniItem) {
if (item.iconUrl) return item.iconUrl;
const base = item.classname.includes("*")
? item.classname.substring(0, item.classname.indexOf("*"))
: item.classname;
return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`;
}
export function getFurniIconUrl(classname: string, revision: number) {
const iconName = classname.replace(/\*/g, "_");
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
}
export function handleImgError(
e: SyntheticEvent<HTMLImageElement>,
item: FurniItem,
) {
const img = e.target as HTMLImageElement;
const iconName = item.classname.replace(/\*/g, "_");
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = getFurniIconUrl(item.classname, item.revision);
} else if (img.dataset.fallback === "1") {
img.dataset.fallback = "2";
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
} else {
img.style.opacity = "0.3";
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
}
}
export const TYPE_LABELS: Record<string, string> = {
flooritem: "Floor",
wallitem: "Wall",
};
@@ -1,42 +0,0 @@
export interface FurniItem {
id: number;
classname: string;
type: string;
name: string;
description: string;
revision: number;
category: string;
alreadyImported: boolean;
nitroExists: boolean;
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
iconUrl?: string;
}
export interface Meta {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
export interface FurniStats {
totalInDb: number;
inCatalog: number;
notInCatalog: number;
missingNitro: number;
}
export interface BatchItemStatus {
classname: string;
status:
| "pending"
| "started"
| "downloading"
| "converting"
| "writing_db"
| "done"
| "failed";
message?: string;
warnings?: string[];
itemId?: number;
}
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/redis", () => ({
redis: null,
}));
import { cached, invalidateMemory } from "./cache";
describe("cached (memory-only, no Redis)", () => {
it("computes once and serves the cached value", async () => {
const fn = vi.fn(async () => 42);
const key = `once-${Math.random()}`;
expect(await cached(key, 10_000, fn)).toBe(42);
expect(await cached(key, 10_000, fn)).toBe(42);
expect(fn).toHaveBeenCalledTimes(1);
});
it("dedupes concurrent misses into a single computation", async () => {
let resolveFn: (value: number) => void = () => {};
const fn = vi.fn(
() =>
new Promise<number>((resolve) => {
resolveFn = resolve;
}),
);
const key = `dedupe-${Math.random()}`;
const p1 = cached(key, 10_000, fn);
const p2 = cached(key, 10_000, fn);
const p3 = cached(key, 10_000, fn);
resolveFn(7);
expect(await Promise.all([p1, p2, p3])).toEqual([7, 7, 7]);
expect(fn).toHaveBeenCalledTimes(1);
});
it("recomputes after the TTL expires", async () => {
let count = 0;
const fn = vi.fn(async () => ++count);
const key = `ttl-${Math.random()}`;
expect(await cached(key, 1, fn)).toBe(1);
expect(await cached(key, 1, fn)).toBe(1);
await new Promise((r) => setTimeout(r, 10));
expect(await cached(key, 1, fn)).toBe(2);
expect(fn).toHaveBeenCalledTimes(2);
});
it("invalidates a key so the next read recomputes", async () => {
let count = 0;
const fn = vi.fn(async () => ++count);
const key = `inv-${Math.random()}`;
expect(await cached(key, 10_000, fn)).toBe(1);
invalidateMemory(key);
expect(await cached(key, 10_000, fn)).toBe(2);
expect(fn).toHaveBeenCalledTimes(2);
});
it("evicts the oldest entry when the in-memory cache is full", async () => {
const fn = vi.fn(async () => 1);
for (let i = 0; i < 600; i++) {
await cached(`bulk-${i}`, 10_000, fn);
}
// Key "bulk-0" was evicted (insertion order), so it must recompute.
await cached("bulk-0", 10_000, fn);
expect(fn).toHaveBeenCalledTimes(601);
});
});
+20 -62
View File
@@ -5,40 +5,9 @@ import { redis } from "@/lib/redis";
type CacheEntry<T> = { data: T; expiresAt: number }; type CacheEntry<T> = { data: T; expiresAt: number };
const memory = new Map<string, CacheEntry<unknown>>(); const memory = new Map<string, CacheEntry<unknown>>();
// Cap the in-process map so dynamic keys (leaderboard currencies, article
// slugs, …) can never grow it without bound. Oldest entries are evicted.
const MAX_MEMORY_ENTRIES = 500;
// Single-flight: a key being (re)computed is awaited by concurrent callers
// instead of each starting its own `fn()` (cache-stampede protection).
const inFlight = new Map<string, Promise<unknown>>();
/** Drop a key from the in-process cache (used when an upstream value changes). */ /** Drop a key from the in-process cache (used when an upstream value changes). */
export function invalidateMemory(key: string): void { export function invalidateMemory(key: string): void {
memory.delete(key); memory.delete(key);
inFlight.delete(key);
}
function pruneExpired(now: number): void {
for (const [key, entry] of memory) {
if (entry.expiresAt <= now) memory.delete(key);
}
}
function setMemory<T>(key: string, entry: CacheEntry<T>): void {
if (memory.has(key)) {
memory.set(key, entry);
return;
}
if (memory.size >= MAX_MEMORY_ENTRIES) {
// Drop expired entries first, then evict the oldest (insertion order).
pruneExpired(Date.now());
if (memory.size >= MAX_MEMORY_ENTRIES) {
const oldest = memory.keys().next().value;
if (oldest !== undefined) memory.delete(oldest);
}
}
memory.set(key, entry);
} }
/** /**
@@ -61,41 +30,30 @@ export async function cached<T>(
return existing.data as T; return existing.data as T;
} }
// Single-flight: a concurrent request already recomputing this key. // Redis path (shared across instances).
const pending = inFlight.get(key); if (redis && redis.status !== "end") {
if (pending) return (await pending) as T; try {
const cached = await redis.get(key);
const compute = (async (): Promise<T> => { if (cached !== null && cached !== undefined) {
// Redis path (shared across instances). const data = JSON.parse(cached) as T;
if (redis && redis.status !== "end") { memory.set(key, { data, expiresAt: Date.now() + ttlMs });
try { return data;
const cached = await redis.get(key);
if (cached !== null && cached !== undefined) {
const data = JSON.parse(cached) as T;
setMemory(key, { data, expiresAt: Date.now() + ttlMs });
return data;
}
} catch {
/* fall through to fn */
} }
} catch {
/* fall through to fn */
} }
}
const data = await fn(); const data = await fn();
if (redis && redis.status !== "end") { if (redis && redis.status !== "end") {
try { try {
await redis.setex(key, ttlSec, JSON.stringify(data)); await redis.setex(key, ttlSec, JSON.stringify(data));
} catch { } catch {
/* non-critical: memory cache still works */ /* non-critical: memory cache still works */
}
} }
setMemory(key, { data, expiresAt: Date.now() + ttlMs }); }
memory.set(key, { data, expiresAt: Date.now() + ttlMs });
return data; return data;
})().finally(() => {
inFlight.delete(key);
});
inFlight.set(key, compute);
return compute;
} }
-14
View File
@@ -20,17 +20,3 @@ export async function redisCache<T>(
export function apiCacheKey(path: string): string { export function apiCacheKey(path: string): string {
return `api:${path}`; return `api:${path}`;
} }
/**
* Deep-convert a payload to a JSON-cache-safe form: BigInt → string (matching
* the apiJson() wire format) and Date → ISO string, since JSON.stringify throws
* on BigInt. Use for payloads cached through redisCache() that are serialised
* straight to a response and never consumed by page logic.
*/
export function cacheSafe<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_k, v) =>
typeof v === "bigint" ? v.toString() : v,
),
) as T;
}
+7 -22
View File
@@ -29,10 +29,6 @@ const IS_PRERENDER =
class SiteSettings { class SiteSettings {
private cache: { map: Map<string, string>; expiresAt: number } | null = null; private cache: { map: Map<string, string>; expiresAt: number } | null = null;
// Single-flight: one request (re)loads the map, the rest await it — the
// root layout reads settings on every render, so concurrent misses must
// not each hammer Redis/DB (cache-stampede protection).
private inFlight: Promise<Map<string, string>> | null = null;
private async loadFromDb(): Promise<Map<string, string>> { private async loadFromDb(): Promise<Map<string, string>> {
try { try {
@@ -46,7 +42,13 @@ class SiteSettings {
} }
} }
private async loadFromCacheOrDb(): Promise<Map<string, string>> { private async load(): Promise<Map<string, string>> {
if (this.cache !== null) {
if (IS_PRERENDER || this.cache.expiresAt > Date.now()) {
return this.cache.map;
}
}
if (redis) { if (redis) {
try { try {
const cached = await redis.get(REDIS_CACHE_KEY); const cached = await redis.get(REDIS_CACHE_KEY);
@@ -83,22 +85,6 @@ class SiteSettings {
return map; return map;
} }
private async load(): Promise<Map<string, string>> {
if (this.cache !== null) {
if (IS_PRERENDER || this.cache.expiresAt > Date.now()) {
return this.cache.map;
}
}
if (this.inFlight) return this.inFlight;
const run = this.loadFromCacheOrDb().finally(() => {
this.inFlight = null;
});
this.inFlight = run;
return run;
}
async getAll(): Promise<Map<string, string>> { async getAll(): Promise<Map<string, string>> {
return await this.load(); return await this.load();
} }
@@ -141,7 +127,6 @@ class SiteSettings {
async reload(): Promise<void> { async reload(): Promise<void> {
this.cache = null; this.cache = null;
this.inFlight = null;
if (redis) { if (redis) {
try { try {
await redis.del(REDIS_CACHE_KEY); await redis.del(REDIS_CACHE_KEY);