Add performance optimizations and component refactors
CI / check (push) Successful in 34s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m23s

- Cache read-heavy public API routes via redisCache (leaderboard, values,
  shop, articles, photos, guilds, teams, staff, users, home, radio, badges)
- Add single-flight and bounded-memory cache layer with unit tests
- Parallelize independent DB queries on search, rares, shop, staff, polls
  and profile pages
- Push radio points leaderboard aggregation to SQL with a LIMIT
- Split studio-client and import-furni-client into focused modules
- Clean up next.config.ts
This commit is contained in:
openhands committed 2026-08-17 22:02:21 +02:00
1 parent 54f2bc5e0c
commit f285a7cd98
42 files changed
+1616 -1205

No files matched your search

+4 -5
View File
@@ -5,6 +5,7 @@ import {
PhotoLightbox,
} from "@/components/public/photo-lightbox";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { cached } from "@/lib/cache";
import { CameraWeb, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
@@ -23,11 +24,9 @@ export default async function PhotosPage() {
let photos: Photo[] = [];
try {
photos = await db
.select()
.from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(48);
photos = await cached("photos:grid", 60_000, async () =>
db.select().from(CameraWeb).orderBy(desc(CameraWeb.timestamp)).limit(48),
);
} catch {
photos = [];
}
+9 -8
View File
@@ -60,9 +60,11 @@ export default async function PollDetailPage({
const isOpen = poll.status === "active" && !hasEnded;
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
? await db
? db
.select()
.from(WebsitePollVote)
.where(
@@ -72,12 +74,9 @@ export default async function PollDetailPage({
),
)
.catch(() => [])
: [];
const hasVoted = myVotes.length > 0;
const allVotes =
: Promise.resolve([]),
poll.showResults === 1 && questionIds.length > 0
? await db
? db
.select({
questionId: WebsitePollVote.questionId,
answer: WebsitePollVote.answer,
@@ -85,7 +84,9 @@ export default async function PollDetailPage({
.from(WebsitePollVote)
.where(inArray(WebsitePollVote.questionId, questionIds))
.catch(() => [])
: [];
: Promise.resolve([]),
]);
const hasVoted = myVotes.length > 0;
const votesByQuestion = new Map<number, string[]>();
for (const vote of allVotes) {
+14 -11
View File
@@ -9,6 +9,7 @@ import {
} from "@/components/public/ui";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
export const metadata = { title: "Rankings" };
@@ -25,17 +26,19 @@ export default async function RankingsPage() {
const t = await getTranslations("pages.rankings");
let users: TopUser[] = [];
try {
users = await db
.select({
username: User.username,
look: User.look,
credits: User.credits,
rank: User.rank,
online: User.online,
})
.from(User)
.orderBy(desc(User.credits))
.limit(12);
users = await cached("rankings:top", 60_000, async () =>
db
.select({
username: User.username,
look: User.look,
credits: User.credits,
rank: User.rank,
online: User.online,
})
.from(User)
.orderBy(desc(User.credits))
.limit(12),
);
} catch {
users = [];
}
+19 -20
View File
@@ -16,9 +16,11 @@ type CategoryRow = {
export default async function RareValuesPage() {
const t = await getTranslations("pages.rares");
let categories: CategoryRow[] = [];
try {
categories = await db
// Categories, per-category rare counts and site settings are all
// independent — run them concurrently instead of sequentially.
const [categoryRows, grouped, badgesBase] = await Promise.all([
db
.select({
id: WebsiteRareValueCategories.id,
name: WebsiteRareValueCategories.name,
@@ -29,29 +31,26 @@ export default async function RareValuesPage() {
.orderBy(
asc(WebsiteRareValueCategories.priority),
asc(WebsiteRareValueCategories.name),
);
} catch {
categories = [];
}
// Per-category rare counts (grouped so we avoid N queries).
const counts = new Map<string, number>();
try {
const grouped = await db
)
.catch((): CategoryRow[] => []),
db
.select({
categoryId: WebsiteRareValues.categoryId,
value: count(),
})
.from(WebsiteRareValues)
.groupBy(WebsiteRareValues.categoryId);
for (const g of grouped) {
counts.set(String(g.categoryId), Number(g.value));
}
} catch {
// leave counts empty -> shown as 0
}
.groupBy(WebsiteRareValues.categoryId)
.catch(() => []),
siteSettings.get("badges_path", ""),
]);
const badgesBase = (await siteSettings.get("badges_path", "")) ?? "";
const categories = categoryRows;
// 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));
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
+27 -26
View File
@@ -36,32 +36,33 @@ export default async function SearchPage({
);
}
const users = await db
.select({
id: User.id,
username: User.username,
look: User.look,
motto: User.motto,
online: User.online,
rank: User.rank,
})
.from(User)
.where(like(User.username, `%${query}%`))
.limit(30)
.catch(() => []);
const rooms = await db
.select({
id: Rooms.id,
name: Rooms.name,
ownerName: Rooms.ownerName,
users: Rooms.users,
usersMax: Rooms.usersMax,
})
.from(Rooms)
.where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open")))
.limit(12)
.catch(() => []);
const [users, rooms] = await Promise.all([
db
.select({
id: User.id,
username: User.username,
look: User.look,
motto: User.motto,
online: User.online,
rank: User.rank,
})
.from(User)
.where(like(User.username, `%${query}%`))
.limit(30)
.catch(() => []),
db
.select({
id: Rooms.id,
name: Rooms.name,
ownerName: Rooms.ownerName,
users: Rooms.users,
usersMax: Rooms.usersMax,
})
.from(Rooms)
.where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open")))
.limit(12)
.catch(() => []),
]);
return (
<div className="flex flex-col gap-6">
+18 -26
View File
@@ -112,38 +112,30 @@ export default async function ShopPage({
: t("success.bought")
: null;
// Categories for the filter bar.
let categories: ShopCategory[] = [];
try {
categories = await db
// Categories for the filter bar and the article grid are independent —
// fetch them concurrently.
const [categories, articles] = await Promise.all([
db
.select({
id: WebsiteShopCategories.id,
name: WebsiteShopCategories.name,
icon: WebsiteShopCategories.icon,
})
.from(WebsiteShopCategories)
.orderBy(asc(WebsiteShopCategories.name));
} catch {
categories = [];
}
let articles: ShopArticle[] = [];
try {
if (categoryId) {
articles = await db
.select(articleSelect)
.from(WebsiteShopArticles)
.where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId)))
.orderBy(asc(WebsiteShopArticles.position));
} else {
articles = await db
.select(articleSelect)
.from(WebsiteShopArticles)
.orderBy(asc(WebsiteShopArticles.position));
}
} catch {
articles = [];
}
.orderBy(asc(WebsiteShopCategories.name))
.catch((): ShopCategory[] => []),
(categoryId
? db
.select(articleSelect)
.from(WebsiteShopArticles)
.where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId)))
.orderBy(asc(WebsiteShopArticles.position))
: db
.select(articleSelect)
.from(WebsiteShopArticles)
.orderBy(asc(WebsiteShopArticles.position))
).catch((): ShopArticle[] => []),
]);
const activeCategory = categoryId
? categories.find((c) => String(c.id) === categoryId)
+10 -16
View File
@@ -27,9 +27,9 @@ export default async function StaffPage() {
const minStaffRank =
Number(await siteSettings.get("min_staff_rank", "7")) || 7;
let staff: StaffMember[] = [];
try {
staff = await db
// Independent queries — staff members and teams load concurrently.
const [staff, teams] = await Promise.all([
db
.select({
username: User.username,
look: User.look,
@@ -39,14 +39,9 @@ export default async function StaffPage() {
.from(User)
.where(gte(User.rank, minStaffRank))
.orderBy(desc(User.rank), asc(User.username))
.limit(100);
} catch {
staff = [];
}
let teams: Team[] = [];
try {
const rows = await db
.limit(100)
.catch((): StaffMember[] => []),
db
.select({
id: WebsiteTeams.id,
rankName: WebsiteTeams.rankName,
@@ -56,11 +51,10 @@ export default async function StaffPage() {
})
.from(WebsiteTeams)
.where(eq(WebsiteTeams.hiddenRank, false))
.orderBy(asc(WebsiteTeams.id));
teams = rows.map((row) => ({ ...row, id: String(row.id) }));
} catch {
teams = [];
}
.orderBy(asc(WebsiteTeams.id))
.then((rows) => rows.map((row) => ({ ...row, id: String(row.id) })))
.catch((): Team[] => []),
]);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
+90 -85
View File
@@ -191,19 +191,69 @@ export default async function ProfilePage({
// Currency ledger rows (duckets/diamonds). Credits live on the users row
// itself; the alt-currencies sit in the users_currency ledger keyed by type.
const currencyRows = await db
.select({
type: UsersCurrency.type,
amount: UsersCurrency.amount,
})
.from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, user.id),
inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]),
),
)
.catch(() => []);
// Currency ledger rows (duckets/diamonds). Credits live on the users row
// itself; the alt-currencies sit in the users_currency ledger keyed by type.
// All sub-sections run in parallel (each query keeps its own .catch so a DB
// hiccup still degrades only that section instead of 500-ing the profile).
const [currencyRows, friendships, rooms, badges, photos, guestbook] =
await Promise.all([
db
.select({
type: UsersCurrency.type,
amount: UsersCurrency.amount,
})
.from(UsersCurrency)
.where(
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 =
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
const diamondsAmount =
@@ -232,83 +282,38 @@ export default async function ProfilePage({
},
];
// Friends. messenger_friendships stores one directed row per pair keyed on
// user_one_id; resolve the user_two_id side to usernames/looks for the grid.
const friendships = await db
.select({ userTwoId: MessengerFriendships.userTwoId })
.from(MessengerFriendships)
.where(eq(MessengerFriendships.userOneId, user.id))
.orderBy(desc(MessengerFriendships.friendsSince))
.limit(18)
.catch(() => []);
// Resolve friend usernames/looks and the guestbook author usernames/looks in
// two parallel queries (the ids come from the queries above).
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
const friendUsers = friendIds.length
? await db
.select({
id: User.id,
username: User.username,
look: User.look,
})
.from(User)
.where(inArray(User.id, friendIds))
.catch(() => [])
: [];
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
const [friendUsers, authors] = await Promise.all([
friendIds.length
? db
.select({
id: User.id,
username: User.username,
look: User.look,
})
.from(User)
.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 friends = friendships
.map((f) => friendById.get(f.userTwoId))
.filter((f): f is NonNullable<typeof f> => Boolean(f));
// Rooms owned by this user.
const rooms = await 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]));
return (