Add performance optimizations and component refactors
- 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:
1 parent
54f2bc5e0c
commit
f285a7cd98
42 files changed
+1616
-1205
No files matched your search
@@ -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 = [];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user