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
@@ -34,11 +34,11 @@ const securityHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
cacheComponents: true, // <-- Dit activeert Cache Components en lost de 'instant' build-fout op!
|
cacheComponents: true,
|
||||||
deploymentId: resolveDeploymentId(),
|
deploymentId: resolveDeploymentId(),
|
||||||
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"],
|
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"],
|
||||||
|
|
||||||
// Onderdruk de specifieke Turbopack waarschuwingen voor brede bestandspatronen in de lib-map
|
// Silence Turbopack warnings for the broad file patterns in src/lib.
|
||||||
turbopack: {
|
turbopack: {
|
||||||
ignoreIssue: [
|
ignoreIssue: [
|
||||||
{
|
{
|
||||||
@@ -47,20 +47,19 @@ 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, // <-- Dit verbergt de prerender bail-out logberichten!
|
hideLogsAfterAbort: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Optimize images served through next/image with sharp → AVIF/WebP.
|
// Optimize images served through next/image with sharp → AVIF/WebP.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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";
|
||||||
|
|
||||||
@@ -23,11 +24,9 @@ export default async function PhotosPage() {
|
|||||||
|
|
||||||
let photos: Photo[] = [];
|
let photos: Photo[] = [];
|
||||||
try {
|
try {
|
||||||
photos = await db
|
photos = await cached("photos:grid", 60_000, async () =>
|
||||||
.select()
|
db.select().from(CameraWeb).orderBy(desc(CameraWeb.timestamp)).limit(48),
|
||||||
.from(CameraWeb)
|
);
|
||||||
.orderBy(desc(CameraWeb.timestamp))
|
|
||||||
.limit(48);
|
|
||||||
} catch {
|
} catch {
|
||||||
photos = [];
|
photos = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ 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
|
||||||
? await db
|
? db
|
||||||
.select()
|
.select()
|
||||||
.from(WebsitePollVote)
|
.from(WebsitePollVote)
|
||||||
.where(
|
.where(
|
||||||
@@ -72,12 +74,9 @@ 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
|
||||||
? await db
|
? db
|
||||||
.select({
|
.select({
|
||||||
questionId: WebsitePollVote.questionId,
|
questionId: WebsitePollVote.questionId,
|
||||||
answer: WebsitePollVote.answer,
|
answer: WebsitePollVote.answer,
|
||||||
@@ -85,7 +84,9 @@ 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) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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" };
|
||||||
@@ -25,17 +26,19 @@ 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 db
|
users = await cached("rankings:top", 60_000, async () =>
|
||||||
.select({
|
db
|
||||||
username: User.username,
|
.select({
|
||||||
look: User.look,
|
username: User.username,
|
||||||
credits: User.credits,
|
look: User.look,
|
||||||
rank: User.rank,
|
credits: User.credits,
|
||||||
online: User.online,
|
rank: User.rank,
|
||||||
})
|
online: User.online,
|
||||||
.from(User)
|
})
|
||||||
.orderBy(desc(User.credits))
|
.from(User)
|
||||||
.limit(12);
|
.orderBy(desc(User.credits))
|
||||||
|
.limit(12),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
users = [];
|
users = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ 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[] = [];
|
|
||||||
try {
|
// Categories, per-category rare counts and site settings are all
|
||||||
categories = await db
|
// independent — run them concurrently instead of sequentially.
|
||||||
|
const [categoryRows, grouped, badgesBase] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
id: WebsiteRareValueCategories.id,
|
id: WebsiteRareValueCategories.id,
|
||||||
name: WebsiteRareValueCategories.name,
|
name: WebsiteRareValueCategories.name,
|
||||||
@@ -29,29 +31,26 @@ export default async function RareValuesPage() {
|
|||||||
.orderBy(
|
.orderBy(
|
||||||
asc(WebsiteRareValueCategories.priority),
|
asc(WebsiteRareValueCategories.priority),
|
||||||
asc(WebsiteRareValueCategories.name),
|
asc(WebsiteRareValueCategories.name),
|
||||||
);
|
)
|
||||||
} catch {
|
.catch((): CategoryRow[] => []),
|
||||||
categories = [];
|
db
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
||||||
for (const g of grouped) {
|
.catch(() => []),
|
||||||
counts.set(String(g.categoryId), Number(g.value));
|
siteSettings.get("badges_path", ""),
|
||||||
}
|
]);
|
||||||
} catch {
|
|
||||||
// leave counts empty -> shown as 0
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
|||||||
@@ -36,32 +36,33 @@ export default async function SearchPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await db
|
const [users, rooms] = await Promise.all([
|
||||||
.select({
|
db
|
||||||
id: User.id,
|
.select({
|
||||||
username: User.username,
|
id: User.id,
|
||||||
look: User.look,
|
username: User.username,
|
||||||
motto: User.motto,
|
look: User.look,
|
||||||
online: User.online,
|
motto: User.motto,
|
||||||
rank: User.rank,
|
online: User.online,
|
||||||
})
|
rank: User.rank,
|
||||||
.from(User)
|
})
|
||||||
.where(like(User.username, `%${query}%`))
|
.from(User)
|
||||||
.limit(30)
|
.where(like(User.username, `%${query}%`))
|
||||||
.catch(() => []);
|
.limit(30)
|
||||||
|
.catch(() => []),
|
||||||
const rooms = await db
|
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">
|
||||||
|
|||||||
@@ -112,38 +112,30 @@ export default async function ShopPage({
|
|||||||
: t("success.bought")
|
: t("success.bought")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Categories for the filter bar.
|
// Categories for the filter bar and the article grid are independent —
|
||||||
let categories: ShopCategory[] = [];
|
// fetch them concurrently.
|
||||||
try {
|
const [categories, articles] = await Promise.all([
|
||||||
categories = await db
|
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 {
|
.catch((): ShopCategory[] => []),
|
||||||
categories = [];
|
(categoryId
|
||||||
}
|
? db
|
||||||
|
.select(articleSelect)
|
||||||
let articles: ShopArticle[] = [];
|
.from(WebsiteShopArticles)
|
||||||
try {
|
.where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId)))
|
||||||
if (categoryId) {
|
.orderBy(asc(WebsiteShopArticles.position))
|
||||||
articles = await db
|
: db
|
||||||
.select(articleSelect)
|
.select(articleSelect)
|
||||||
.from(WebsiteShopArticles)
|
.from(WebsiteShopArticles)
|
||||||
.where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId)))
|
.orderBy(asc(WebsiteShopArticles.position))
|
||||||
.orderBy(asc(WebsiteShopArticles.position));
|
).catch((): ShopArticle[] => []),
|
||||||
} 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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
let staff: StaffMember[] = [];
|
// Independent queries — staff members and teams load concurrently.
|
||||||
try {
|
const [staff, teams] = await Promise.all([
|
||||||
staff = await db
|
db
|
||||||
.select({
|
.select({
|
||||||
username: User.username,
|
username: User.username,
|
||||||
look: User.look,
|
look: User.look,
|
||||||
@@ -39,14 +39,9 @@ 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 {
|
.catch((): StaffMember[] => []),
|
||||||
staff = [];
|
db
|
||||||
}
|
|
||||||
|
|
||||||
let teams: Team[] = [];
|
|
||||||
try {
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
.select({
|
||||||
id: WebsiteTeams.id,
|
id: WebsiteTeams.id,
|
||||||
rankName: WebsiteTeams.rankName,
|
rankName: WebsiteTeams.rankName,
|
||||||
@@ -56,11 +51,10 @@ 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))
|
||||||
teams = rows.map((row) => ({ ...row, id: String(row.id) }));
|
.then((rows) => rows.map((row) => ({ ...row, id: String(row.id) })))
|
||||||
} catch {
|
.catch((): Team[] => []),
|
||||||
teams = [];
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<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
|
// 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.
|
||||||
const currencyRows = await db
|
// Currency ledger rows (duckets/diamonds). Credits live on the users row
|
||||||
.select({
|
// itself; the alt-currencies sit in the users_currency ledger keyed by type.
|
||||||
type: UsersCurrency.type,
|
// All sub-sections run in parallel (each query keeps its own .catch so a DB
|
||||||
amount: UsersCurrency.amount,
|
// hiccup still degrades only that section instead of 500-ing the profile).
|
||||||
})
|
const [currencyRows, friendships, rooms, badges, photos, guestbook] =
|
||||||
.from(UsersCurrency)
|
await Promise.all([
|
||||||
.where(
|
db
|
||||||
and(
|
.select({
|
||||||
eq(UsersCurrency.userId, user.id),
|
type: UsersCurrency.type,
|
||||||
inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]),
|
amount: UsersCurrency.amount,
|
||||||
),
|
})
|
||||||
)
|
.from(UsersCurrency)
|
||||||
.catch(() => []);
|
.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 =
|
const ducketsAmount =
|
||||||
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
|
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
|
||||||
const diamondsAmount =
|
const diamondsAmount =
|
||||||
@@ -232,83 +282,38 @@ export default async function ProfilePage({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Friends. messenger_friendships stores one directed row per pair keyed on
|
// Resolve friend usernames/looks and the guestbook author usernames/looks in
|
||||||
// user_one_id; resolve the user_two_id side to usernames/looks for the grid.
|
// two parallel queries (the ids come from the queries above).
|
||||||
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 friendUsers = friendIds.length
|
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
|
||||||
? await db
|
const [friendUsers, authors] = await Promise.all([
|
||||||
.select({
|
friendIds.length
|
||||||
id: User.id,
|
? db
|
||||||
username: User.username,
|
.select({
|
||||||
look: User.look,
|
id: User.id,
|
||||||
})
|
username: User.username,
|
||||||
.from(User)
|
look: User.look,
|
||||||
.where(inArray(User.id, friendIds))
|
})
|
||||||
.catch(() => [])
|
.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 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,99 +60,21 @@ 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("");
|
||||||
@@ -884,84 +806,6 @@ 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"
|
|
||||||
>
|
|
||||||
← 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"
|
|
||||||
>
|
|
||||||
…
|
|
||||||
</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 →
|
|
||||||
</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">
|
||||||
@@ -1987,7 +1831,7 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{renderPagination()}
|
<Pagination meta={meta} loading={loading} onPageChange={goPage} />
|
||||||
|
|
||||||
{/* Nitro Editor Dialog */}
|
{/* Nitro Editor Dialog */}
|
||||||
<NitroEditorDialog
|
<NitroEditorDialog
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"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"
|
||||||
|
>
|
||||||
|
← 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">
|
||||||
|
…
|
||||||
|
</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 →
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1 mt-4 flex-wrap">
|
||||||
|
{nodes}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -13,26 +14,38 @@ export async function GET(
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [article] = await db
|
const data = await redisCache(
|
||||||
.select({
|
apiCacheKey(`articles:${slug}`),
|
||||||
id: WebsiteArticles.id,
|
60,
|
||||||
title: WebsiteArticles.title,
|
async () => {
|
||||||
slug: WebsiteArticles.slug,
|
const [article] = await db
|
||||||
shortStory: WebsiteArticles.shortStory,
|
.select({
|
||||||
fullStory: WebsiteArticles.fullStory,
|
id: WebsiteArticles.id,
|
||||||
image: WebsiteArticles.image,
|
title: WebsiteArticles.title,
|
||||||
createdAt: WebsiteArticles.createdAt,
|
slug: WebsiteArticles.slug,
|
||||||
updatedAt: WebsiteArticles.updatedAt,
|
shortStory: WebsiteArticles.shortStory,
|
||||||
})
|
fullStory: WebsiteArticles.fullStory,
|
||||||
.from(WebsiteArticles)
|
image: WebsiteArticles.image,
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
createdAt: WebsiteArticles.createdAt,
|
||||||
.limit(1);
|
updatedAt: WebsiteArticles.updatedAt,
|
||||||
|
})
|
||||||
|
.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: article });
|
return apiJson(data);
|
||||||
} 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 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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.
|
||||||
@@ -12,33 +13,42 @@ export async function GET(req: Request) {
|
|||||||
const { page, perPage, skip, take } = pagination(sp);
|
const { page, perPage, skip, take } = pagination(sp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [totalRows, articles] = await Promise.all([
|
const data = await redisCache(
|
||||||
db.select({ total: count() }).from(WebsiteArticles),
|
apiCacheKey(`articles:${page}:${perPage}`),
|
||||||
db
|
60,
|
||||||
.select({
|
async () => {
|
||||||
id: WebsiteArticles.id,
|
const [totalRows, articles] = await Promise.all([
|
||||||
title: WebsiteArticles.title,
|
db.select({ total: count() }).from(WebsiteArticles),
|
||||||
slug: WebsiteArticles.slug,
|
db
|
||||||
shortStory: WebsiteArticles.shortStory,
|
.select({
|
||||||
image: WebsiteArticles.image,
|
id: WebsiteArticles.id,
|
||||||
createdAt: WebsiteArticles.createdAt,
|
title: WebsiteArticles.title,
|
||||||
})
|
slug: WebsiteArticles.slug,
|
||||||
.from(WebsiteArticles)
|
shortStory: WebsiteArticles.shortStory,
|
||||||
.orderBy(desc(WebsiteArticles.createdAt))
|
image: WebsiteArticles.image,
|
||||||
.limit(take)
|
createdAt: WebsiteArticles.createdAt,
|
||||||
.offset(skip),
|
})
|
||||||
]);
|
.from(WebsiteArticles)
|
||||||
const total = totalRows[0]?.total ?? 0;
|
.orderBy(desc(WebsiteArticles.createdAt))
|
||||||
|
.limit(take)
|
||||||
return apiJson({
|
.offset(skip),
|
||||||
data: articles,
|
]);
|
||||||
meta: {
|
return cacheSafe({
|
||||||
page,
|
data: articles,
|
||||||
perPage,
|
meta: {
|
||||||
total,
|
page,
|
||||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
perPage,
|
||||||
|
total: totalRows[0]?.total ?? 0,
|
||||||
|
lastPage: Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
|
return apiJson(data);
|
||||||
} 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(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
@@ -28,6 +29,12 @@ 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;
|
||||||
@@ -65,9 +72,7 @@ async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTotalBadgesBoard(
|
async function loadTotalBadgesBoard(): Promise<BoardCore> {
|
||||||
userId: number | null,
|
|
||||||
): Promise<BadgeLeaderboardBoard> {
|
|
||||||
const rows = await rawRows<{
|
const rows = await rawRows<{
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -103,48 +108,10 @@ async function loadTotalBadgesBoard(
|
|||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
return { entries, totalPlayers };
|
||||||
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(
|
async function loadAchievementBoard(): Promise<BoardCore> {
|
||||||
userId: number | null,
|
|
||||||
): Promise<BadgeLeaderboardBoard> {
|
|
||||||
const rows = await rawRows<{
|
const rows = await rawRows<{
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -179,46 +146,10 @@ async function loadAchievementBoard(
|
|||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
return { entries, totalPlayers };
|
||||||
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(
|
async function loadRarityBoard(badgeCodes: string[]): Promise<BoardCore> {
|
||||||
_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(
|
||||||
@@ -262,41 +193,111 @@ async function loadRarityBoard(
|
|||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
return { entries, totalPlayers };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { entries, totalPlayers, viewerEntry };
|
async function loadTotalBadgesViewer(
|
||||||
|
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) {
|
||||||
@@ -308,28 +309,6 @@ 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",
|
||||||
@@ -339,24 +318,109 @@ export async function GET(req: Request) {
|
|||||||
"unique",
|
"unique",
|
||||||
];
|
];
|
||||||
|
|
||||||
const [totalBadges, achievementLevel, ...rarityBoards] = await Promise.all([
|
// The heavy global aggregation (badge rarity stats + the 8 leaderboards)
|
||||||
loadTotalBadgesBoard(userId),
|
// is user-independent, so it is cached and recomputed once per TTL instead
|
||||||
loadAchievementBoard(userId),
|
// of on every request. The viewer's own rank is derived per request.
|
||||||
...rarityKeys.map((rk) =>
|
const global = await redisCache(
|
||||||
loadRarityBoard(rk, badgesByRarity.get(rk) ?? [], userId),
|
apiCacheKey("badges:leaderboard:global"),
|
||||||
),
|
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 rarity = Object.fromEntries(
|
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
badgeCode: r.badgeCode,
|
||||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
ownerCount: Number(r.ownerCount),
|
||||||
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
|
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 [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,
|
badgeStats: global.badgeStats,
|
||||||
thresholds: THRESHOLDS,
|
thresholds: THRESHOLDS,
|
||||||
leaderboards: { totalBadges, achievementLevel, rarity },
|
leaderboards: {
|
||||||
|
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", {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
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,
|
||||||
@@ -18,38 +19,50 @@ export async function GET(
|
|||||||
const guildId = Number(id);
|
const guildId = Number(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [guild] = await db
|
const data = await redisCache(
|
||||||
.select({
|
apiCacheKey(`guilds:${id}`),
|
||||||
id: Guilds.id,
|
120,
|
||||||
name: Guilds.name,
|
async () => {
|
||||||
description: Guilds.description,
|
const [guild] = await db
|
||||||
userId: Guilds.userId,
|
.select({
|
||||||
roomId: Guilds.roomId,
|
id: Guilds.id,
|
||||||
badge: Guilds.badge,
|
name: Guilds.name,
|
||||||
dateCreated: Guilds.dateCreated,
|
description: Guilds.description,
|
||||||
})
|
userId: Guilds.userId,
|
||||||
.from(Guilds)
|
roomId: Guilds.roomId,
|
||||||
.where(eq(Guilds.id, guildId))
|
badge: Guilds.badge,
|
||||||
.limit(1);
|
dateCreated: Guilds.dateCreated,
|
||||||
|
})
|
||||||
|
.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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Member count is a separate guarded query (no relation is modelled).
|
return apiJson(data);
|
||||||
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 });
|
||||||
|
|||||||
+39
-28
@@ -5,43 +5,54 @@
|
|||||||
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 [totalRows, rows] = await Promise.all([
|
const data = await redisCache(
|
||||||
db.select({ total: count() }).from(Guilds),
|
apiCacheKey(`guilds:${page}:${perPage}`),
|
||||||
db
|
120,
|
||||||
.select({
|
async () => {
|
||||||
id: Guilds.id,
|
const [totalRows, rows] = await Promise.all([
|
||||||
name: Guilds.name,
|
db.select({ total: count() }).from(Guilds),
|
||||||
description: Guilds.description,
|
db
|
||||||
userId: Guilds.userId,
|
.select({
|
||||||
})
|
id: Guilds.id,
|
||||||
.from(Guilds)
|
name: Guilds.name,
|
||||||
.orderBy(desc(Guilds.id))
|
description: Guilds.description,
|
||||||
.limit(take)
|
userId: Guilds.userId,
|
||||||
.offset(skip),
|
})
|
||||||
]);
|
.from(Guilds)
|
||||||
const total = totalRows[0]?.total ?? 0;
|
.orderBy(desc(Guilds.id))
|
||||||
|
.limit(take)
|
||||||
|
.offset(skip),
|
||||||
|
]);
|
||||||
|
|
||||||
// Rename userId → ownerId for the public payload.
|
// Rename userId → ownerId for the public payload.
|
||||||
const data = rows.map(({ userId, ...rest }) => ({
|
const renamed = rows.map(({ userId, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return apiJson({
|
return cacheSafe({
|
||||||
data,
|
data: renamed,
|
||||||
meta: {
|
meta: {
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
total,
|
total: totalRows[0]?.total ?? 0,
|
||||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
lastPage: Math.max(
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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, redisCache } from "@/lib/redis-cache";
|
import { apiCacheKey, cacheSafe, 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,7 +29,11 @@ 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 { articles, online: onlineRows[0]?.total ?? 0, hotelName };
|
return cacheSafe({
|
||||||
|
articles,
|
||||||
|
online: onlineRows[0]?.total ?? 0,
|
||||||
|
hotelName,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return apiJson(data);
|
return apiJson(data);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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):
|
||||||
@@ -75,11 +76,15 @@ export async function GET(req: Request) {
|
|||||||
? requested
|
? requested
|
||||||
: "credits";
|
: "credits";
|
||||||
|
|
||||||
const rows =
|
const rows = await redisCache(
|
||||||
type === "credits"
|
apiCacheKey(`leaderboard:${type}`),
|
||||||
? await loadCreditsRows()
|
60,
|
||||||
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
async () =>
|
||||||
await loadCurrencyRows(CURRENCY_TYPE[type]);
|
type === "credits"
|
||||||
|
? 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 {
|
||||||
|
|||||||
+34
-24
@@ -1,6 +1,7 @@
|
|||||||
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.
|
||||||
@@ -12,31 +13,40 @@ export async function GET(req: Request) {
|
|||||||
const { page, perPage, skip, take } = pagination(sp);
|
const { page, perPage, skip, take } = pagination(sp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [totalRows, photos] = await Promise.all([
|
const data = await redisCache(
|
||||||
db.select({ total: count() }).from(CameraWeb),
|
apiCacheKey(`photos:${page}:${perPage}`),
|
||||||
db
|
60,
|
||||||
.select({
|
async () => {
|
||||||
id: CameraWeb.id,
|
const [totalRows, photos] = await Promise.all([
|
||||||
userId: CameraWeb.userId,
|
db.select({ total: count() }).from(CameraWeb),
|
||||||
url: CameraWeb.url,
|
db
|
||||||
timestamp: CameraWeb.timestamp,
|
.select({
|
||||||
})
|
id: CameraWeb.id,
|
||||||
.from(CameraWeb)
|
userId: CameraWeb.userId,
|
||||||
.orderBy(desc(CameraWeb.timestamp))
|
url: CameraWeb.url,
|
||||||
.limit(take)
|
timestamp: CameraWeb.timestamp,
|
||||||
.offset(skip),
|
})
|
||||||
]);
|
.from(CameraWeb)
|
||||||
const total = totalRows[0]?.total ?? 0;
|
.orderBy(desc(CameraWeb.timestamp))
|
||||||
|
.limit(take)
|
||||||
return apiJson({
|
.offset(skip),
|
||||||
data: photos,
|
]);
|
||||||
meta: {
|
return cacheSafe({
|
||||||
page,
|
data: photos,
|
||||||
perPage,
|
meta: {
|
||||||
total,
|
page,
|
||||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
perPage,
|
||||||
|
total: totalRows[0]?.total ?? 0,
|
||||||
|
lastPage: Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
|
return apiJson(data);
|
||||||
} 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(
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
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 raw = await siteSettings.get("radio_current_dj_id", "");
|
const dj = await redisCache(
|
||||||
const id = Number(raw);
|
apiCacheKey("radio:current-dj"),
|
||||||
|
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 apiJson({ dj: null });
|
return 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 apiJson({ dj: null });
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiJson({ dj: { username: user.username, look: user.look } });
|
return { 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 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inArray, sum } from "drizzle-orm";
|
import { desc, 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
|
||||||
@@ -8,43 +9,46 @@ import { db, RadioListenerPoints, User } from "@/lib/db";
|
|||||||
|
|
||||||
export async function GET(_req: Request) {
|
export async function GET(_req: Request) {
|
||||||
try {
|
try {
|
||||||
// Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
|
// Cached 60s: the leaderboard only changes while people listen, so a
|
||||||
// aggregate ordering, then resolve the top 20 to usernames/looks.
|
// short TTL keeps it fresh without re-scanning the table per request.
|
||||||
const grouped = await db
|
const data = await redisCache(
|
||||||
.select({
|
apiCacheKey("radio:points:leaderboard"),
|
||||||
userId: RadioListenerPoints.userId,
|
60,
|
||||||
points: sum(RadioListenerPoints.points),
|
async () => {
|
||||||
})
|
// Sum points per user and take the top 20 in SQL instead of
|
||||||
.from(RadioListenerPoints)
|
// pulling the whole table into JS to sort/slice.
|
||||||
.groupBy(RadioListenerPoints.userId);
|
const grouped = await db
|
||||||
|
.select({
|
||||||
|
userId: RadioListenerPoints.userId,
|
||||||
|
points: sum(RadioListenerPoints.points),
|
||||||
|
})
|
||||||
|
.from(RadioListenerPoints)
|
||||||
|
.groupBy(RadioListenerPoints.userId)
|
||||||
|
.orderBy(desc(sum(RadioListenerPoints.points)))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
const ranked = grouped
|
if (grouped.length === 0) {
|
||||||
.map((g) => ({
|
return [];
|
||||||
userId: Number(g.userId),
|
}
|
||||||
points: Number(g.points ?? 0),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.points - a.points)
|
|
||||||
.slice(0, 20);
|
|
||||||
|
|
||||||
if (ranked.length === 0) {
|
const userIds = grouped.map((r) => Number(r.userId));
|
||||||
return apiJson({ data: [] });
|
const users = await db
|
||||||
}
|
.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]));
|
||||||
|
|
||||||
const userIds = ranked.map((r) => r.userId);
|
return grouped.map((r) => {
|
||||||
const users = await db
|
const uid = Number(r.userId);
|
||||||
.select({ id: User.id, username: User.username, look: User.look })
|
const u = userById.get(uid);
|
||||||
.from(User)
|
return {
|
||||||
.where(inArray(User.id, userIds));
|
username: u?.username ?? null,
|
||||||
const userById = new Map(users.map((u) => [u.id, u]));
|
look: u?.look ?? null,
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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
|
||||||
@@ -10,41 +11,53 @@ import { db, RadioShouts, User } from "@/lib/db";
|
|||||||
// 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 shouts = await db
|
const data = await redisCache(
|
||||||
.select({
|
apiCacheKey("radio:shouts"),
|
||||||
id: RadioShouts.id,
|
GET_TTL_SECONDS,
|
||||||
userId: RadioShouts.userId,
|
async () => {
|
||||||
message: RadioShouts.message,
|
const shouts = await db
|
||||||
createdAt: RadioShouts.createdAt,
|
.select({
|
||||||
})
|
id: RadioShouts.id,
|
||||||
.from(RadioShouts)
|
userId: RadioShouts.userId,
|
||||||
.orderBy(desc(RadioShouts.createdAt))
|
message: RadioShouts.message,
|
||||||
.limit(50);
|
createdAt: RadioShouts.createdAt,
|
||||||
|
})
|
||||||
|
.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(new Set(shouts.map((s) => Number(s.userId))));
|
const authorIds = Array.from(
|
||||||
const authors = authorIds.length
|
new Set(shouts.map((s) => Number(s.userId))),
|
||||||
? await db
|
);
|
||||||
.select({ id: User.id, username: User.username, look: User.look })
|
const authors = authorIds.length
|
||||||
.from(User)
|
? await db
|
||||||
.where(inArray(User.id, authorIds))
|
.select({ id: User.id, username: User.username, look: User.look })
|
||||||
: [];
|
.from(User)
|
||||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
.where(inArray(User.id, authorIds))
|
||||||
|
: [];
|
||||||
|
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||||
|
|
||||||
const data = shouts.map((s) => {
|
return 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 {
|
||||||
|
|||||||
@@ -3,22 +3,30 @@
|
|||||||
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 db
|
const data = await redisCache(
|
||||||
.select({
|
apiCacheKey("shop:categories"),
|
||||||
id: WebsiteShopCategories.id,
|
60,
|
||||||
name: WebsiteShopCategories.name,
|
async () =>
|
||||||
description: WebsiteShopCategories.description,
|
cacheSafe(
|
||||||
icon: WebsiteShopCategories.icon,
|
await db
|
||||||
order: WebsiteShopCategories.order,
|
.select({
|
||||||
})
|
id: WebsiteShopCategories.id,
|
||||||
.from(WebsiteShopCategories)
|
name: WebsiteShopCategories.name,
|
||||||
.orderBy(
|
description: WebsiteShopCategories.description,
|
||||||
asc(WebsiteShopCategories.order),
|
icon: WebsiteShopCategories.icon,
|
||||||
asc(WebsiteShopCategories.name),
|
order: WebsiteShopCategories.order,
|
||||||
);
|
})
|
||||||
|
.from(WebsiteShopCategories)
|
||||||
|
.orderBy(
|
||||||
|
asc(WebsiteShopCategories.order),
|
||||||
|
asc(WebsiteShopCategories.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return apiJson({ data });
|
return apiJson({ data });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+49
-39
@@ -4,6 +4,7 @@
|
|||||||
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;
|
||||||
@@ -24,46 +25,55 @@ export async function GET(req: Request) {
|
|||||||
? eq(WebsiteShopArticles.categoryId, categoryId)
|
? eq(WebsiteShopArticles.categoryId, categoryId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [totalRows, data] = await Promise.all([
|
const data = await redisCache(
|
||||||
db.select({ total: count() }).from(WebsiteShopArticles).where(where),
|
apiCacheKey(`shop:${categoryId ?? "all"}:${page}:${perPage}`),
|
||||||
db
|
60,
|
||||||
.select({
|
async () => {
|
||||||
id: WebsiteShopArticles.id,
|
const [totalRows, rows] = await Promise.all([
|
||||||
categoryId: WebsiteShopArticles.categoryId,
|
db.select({ total: count() }).from(WebsiteShopArticles).where(where),
|
||||||
name: WebsiteShopArticles.name,
|
db
|
||||||
info: WebsiteShopArticles.info,
|
.select({
|
||||||
iconUrl: WebsiteShopArticles.iconUrl,
|
id: WebsiteShopArticles.id,
|
||||||
color: WebsiteShopArticles.color,
|
categoryId: WebsiteShopArticles.categoryId,
|
||||||
costs: WebsiteShopArticles.costs,
|
name: WebsiteShopArticles.name,
|
||||||
giveRank: WebsiteShopArticles.giveRank,
|
info: WebsiteShopArticles.info,
|
||||||
isGiftable: WebsiteShopArticles.isGiftable,
|
iconUrl: WebsiteShopArticles.iconUrl,
|
||||||
credits: WebsiteShopArticles.credits,
|
color: WebsiteShopArticles.color,
|
||||||
duckets: WebsiteShopArticles.duckets,
|
costs: WebsiteShopArticles.costs,
|
||||||
diamonds: WebsiteShopArticles.diamonds,
|
giveRank: WebsiteShopArticles.giveRank,
|
||||||
badges: WebsiteShopArticles.badges,
|
isGiftable: WebsiteShopArticles.isGiftable,
|
||||||
furniture: WebsiteShopArticles.furniture,
|
credits: WebsiteShopArticles.credits,
|
||||||
position: WebsiteShopArticles.position,
|
duckets: WebsiteShopArticles.duckets,
|
||||||
})
|
diamonds: WebsiteShopArticles.diamonds,
|
||||||
.from(WebsiteShopArticles)
|
badges: WebsiteShopArticles.badges,
|
||||||
.where(where)
|
furniture: WebsiteShopArticles.furniture,
|
||||||
.orderBy(
|
position: WebsiteShopArticles.position,
|
||||||
asc(WebsiteShopArticles.position),
|
})
|
||||||
asc(WebsiteShopArticles.name),
|
.from(WebsiteShopArticles)
|
||||||
)
|
.where(where)
|
||||||
.limit(take)
|
.orderBy(
|
||||||
.offset(skip),
|
asc(WebsiteShopArticles.position),
|
||||||
]);
|
asc(WebsiteShopArticles.name),
|
||||||
const total = totalRows[0]?.total ?? 0;
|
)
|
||||||
|
.limit(take)
|
||||||
return apiJson({
|
.offset(skip),
|
||||||
data,
|
]);
|
||||||
meta: {
|
return cacheSafe({
|
||||||
page,
|
data: rows,
|
||||||
perPage,
|
meta: {
|
||||||
total,
|
page,
|
||||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
perPage,
|
||||||
|
total: totalRows[0]?.total ?? 0,
|
||||||
|
lastPage: Math.max(
|
||||||
|
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(
|
||||||
|
|||||||
+14
-11
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -11,17 +12,19 @@ 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 db
|
const staff = await redisCache(apiCacheKey("staff"), 300, () =>
|
||||||
.select({
|
db
|
||||||
username: User.username,
|
.select({
|
||||||
look: User.look,
|
username: User.username,
|
||||||
rank: User.rank,
|
look: User.look,
|
||||||
motto: User.motto,
|
rank: User.rank,
|
||||||
})
|
motto: User.motto,
|
||||||
.from(User)
|
})
|
||||||
.where(gte(User.rank, minStaffRank))
|
.from(User)
|
||||||
.orderBy(desc(User.rank), asc(User.username))
|
.where(gte(User.rank, minStaffRank))
|
||||||
.limit(100);
|
.orderBy(desc(User.rank), asc(User.username))
|
||||||
|
.limit(100),
|
||||||
|
);
|
||||||
|
|
||||||
return apiJson({ data: staff }, { status: 200 });
|
return apiJson({ data: staff }, { status: 200 });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+16
-11
@@ -1,23 +1,28 @@
|
|||||||
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 db
|
const rows = await redisCache(apiCacheKey("teams"), 300, async () =>
|
||||||
.select({
|
cacheSafe(
|
||||||
id: WebsiteTeams.id,
|
await db
|
||||||
rankName: WebsiteTeams.rankName,
|
.select({
|
||||||
badge: WebsiteTeams.badge,
|
id: WebsiteTeams.id,
|
||||||
jobDescription: WebsiteTeams.jobDescription,
|
rankName: WebsiteTeams.rankName,
|
||||||
staffColor: WebsiteTeams.staffColor,
|
badge: WebsiteTeams.badge,
|
||||||
})
|
jobDescription: WebsiteTeams.jobDescription,
|
||||||
.from(WebsiteTeams)
|
staffColor: WebsiteTeams.staffColor,
|
||||||
.where(eq(WebsiteTeams.hiddenRank, false))
|
})
|
||||||
.orderBy(asc(WebsiteTeams.id));
|
.from(WebsiteTeams)
|
||||||
|
.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 });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
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,
|
||||||
@@ -16,33 +17,45 @@ export async function GET(
|
|||||||
const { username } = await params;
|
const { username } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [user] = await db
|
const user = await redisCache(
|
||||||
.select({
|
apiCacheKey(`users:${username}`),
|
||||||
username: User.username,
|
30,
|
||||||
look: User.look,
|
async () => {
|
||||||
motto: User.motto,
|
const [row] = await db
|
||||||
rank: User.rank,
|
.select({
|
||||||
credits: User.credits,
|
username: User.username,
|
||||||
online: User.online,
|
look: User.look,
|
||||||
accountCreated: User.accountCreated,
|
motto: User.motto,
|
||||||
})
|
rank: User.rank,
|
||||||
.from(User)
|
credits: User.credits,
|
||||||
.where(eq(User.username, username))
|
online: User.online,
|
||||||
.limit(1);
|
accountCreated: User.accountCreated,
|
||||||
|
})
|
||||||
|
.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({
|
return apiJson(user);
|
||||||
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 });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
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,
|
||||||
@@ -24,52 +25,64 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [value] = await db
|
const data = await redisCache(
|
||||||
.select({
|
apiCacheKey(`values:${id}`),
|
||||||
id: WebsiteRareValues.id,
|
300,
|
||||||
categoryId: WebsiteRareValues.categoryId,
|
async () => {
|
||||||
itemId: WebsiteRareValues.itemId,
|
const [value] = await db
|
||||||
name: WebsiteRareValues.name,
|
.select({
|
||||||
creditValue: WebsiteRareValues.creditValue,
|
id: WebsiteRareValues.id,
|
||||||
currencyValue: WebsiteRareValues.currencyValue,
|
categoryId: WebsiteRareValues.categoryId,
|
||||||
currencyType: WebsiteRareValues.currencyType,
|
itemId: WebsiteRareValues.itemId,
|
||||||
furnitureIcon: WebsiteRareValues.furnitureIcon,
|
name: WebsiteRareValues.name,
|
||||||
createdAt: WebsiteRareValues.createdAt,
|
creditValue: WebsiteRareValues.creditValue,
|
||||||
updatedAt: WebsiteRareValues.updatedAt,
|
currencyValue: WebsiteRareValues.currencyValue,
|
||||||
})
|
currencyType: WebsiteRareValues.currencyType,
|
||||||
.from(WebsiteRareValues)
|
furnitureIcon: WebsiteRareValues.furnitureIcon,
|
||||||
.where(eq(WebsiteRareValues.id, valueId))
|
createdAt: WebsiteRareValues.createdAt,
|
||||||
.limit(1);
|
updatedAt: WebsiteRareValues.updatedAt,
|
||||||
|
})
|
||||||
|
.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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// No relation is modelled between the value and its category, so resolve the
|
return apiJson(data);
|
||||||
// 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 });
|
||||||
|
|||||||
@@ -4,21 +4,29 @@
|
|||||||
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 db
|
const data = await redisCache(
|
||||||
.select({
|
apiCacheKey("values:categories"),
|
||||||
id: WebsiteRareValueCategories.id,
|
300,
|
||||||
name: WebsiteRareValueCategories.name,
|
async () =>
|
||||||
badge: WebsiteRareValueCategories.badge,
|
cacheSafe(
|
||||||
priority: WebsiteRareValueCategories.priority,
|
await db
|
||||||
})
|
.select({
|
||||||
.from(WebsiteRareValueCategories)
|
id: WebsiteRareValueCategories.id,
|
||||||
.orderBy(
|
name: WebsiteRareValueCategories.name,
|
||||||
asc(WebsiteRareValueCategories.priority),
|
badge: WebsiteRareValueCategories.badge,
|
||||||
asc(WebsiteRareValueCategories.name),
|
priority: WebsiteRareValueCategories.priority,
|
||||||
);
|
})
|
||||||
|
.from(WebsiteRareValueCategories)
|
||||||
|
.orderBy(
|
||||||
|
asc(WebsiteRareValueCategories.priority),
|
||||||
|
asc(WebsiteRareValueCategories.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return apiJson({ data });
|
return apiJson({ data });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+39
-29
@@ -4,6 +4,7 @@
|
|||||||
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;
|
||||||
@@ -24,36 +25,45 @@ export async function GET(req: Request) {
|
|||||||
? eq(WebsiteRareValues.categoryId, categoryId)
|
? eq(WebsiteRareValues.categoryId, categoryId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [totalRows, data] = await Promise.all([
|
const data = await redisCache(
|
||||||
db.select({ total: count() }).from(WebsiteRareValues).where(where),
|
apiCacheKey(`values:${categoryId ?? "all"}:${page}:${perPage}`),
|
||||||
db
|
300,
|
||||||
.select({
|
async () => {
|
||||||
id: WebsiteRareValues.id,
|
const [totalRows, rows] = await Promise.all([
|
||||||
categoryId: WebsiteRareValues.categoryId,
|
db.select({ total: count() }).from(WebsiteRareValues).where(where),
|
||||||
itemId: WebsiteRareValues.itemId,
|
db
|
||||||
name: WebsiteRareValues.name,
|
.select({
|
||||||
creditValue: WebsiteRareValues.creditValue,
|
id: WebsiteRareValues.id,
|
||||||
currencyValue: WebsiteRareValues.currencyValue,
|
categoryId: WebsiteRareValues.categoryId,
|
||||||
currencyType: WebsiteRareValues.currencyType,
|
itemId: WebsiteRareValues.itemId,
|
||||||
furnitureIcon: WebsiteRareValues.furnitureIcon,
|
name: WebsiteRareValues.name,
|
||||||
})
|
creditValue: WebsiteRareValues.creditValue,
|
||||||
.from(WebsiteRareValues)
|
currencyValue: WebsiteRareValues.currencyValue,
|
||||||
.where(where)
|
currencyType: WebsiteRareValues.currencyType,
|
||||||
.orderBy(asc(WebsiteRareValues.name))
|
furnitureIcon: WebsiteRareValues.furnitureIcon,
|
||||||
.limit(take)
|
})
|
||||||
.offset(skip),
|
.from(WebsiteRareValues)
|
||||||
]);
|
.where(where)
|
||||||
const total = totalRows[0]?.total ?? 0;
|
.orderBy(asc(WebsiteRareValues.name))
|
||||||
|
.limit(take)
|
||||||
return apiJson({
|
.offset(skip),
|
||||||
data,
|
]);
|
||||||
meta: {
|
return cacheSafe({
|
||||||
page,
|
data: rows,
|
||||||
perPage,
|
meta: {
|
||||||
total,
|
page,
|
||||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
perPage,
|
||||||
|
total: totalRows[0]?.total ?? 0,
|
||||||
|
lastPage: Math.max(
|
||||||
|
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(
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
Download,
|
Download,
|
||||||
FolderTree,
|
|
||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
Package,
|
Package,
|
||||||
@@ -47,251 +44,20 @@ 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";
|
||||||
interface FurniItem {
|
import { CatalogRail } from "./catalog-rail";
|
||||||
id: number;
|
import { CheckboxDot } from "./checkbox-dot";
|
||||||
classname: string;
|
import {
|
||||||
type: string;
|
getFurniImageUrl,
|
||||||
name: string;
|
handleImgError,
|
||||||
description: string;
|
TYPE_LABELS,
|
||||||
revision: number;
|
} from "./studio-furni-icons";
|
||||||
category: string;
|
import type {
|
||||||
alreadyImported: boolean;
|
BatchItemStatus,
|
||||||
nitroExists: boolean;
|
FurniItem,
|
||||||
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
|
FurniStats,
|
||||||
iconUrl?: string;
|
Meta,
|
||||||
}
|
} 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,
|
||||||
@@ -1129,20 +895,3 @@ 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+62
-20
@@ -5,9 +5,40 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,30 +61,41 @@ export async function cached<T>(
|
|||||||
return existing.data as T;
|
return existing.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redis path (shared across instances).
|
// Single-flight: a concurrent request already recomputing this key.
|
||||||
if (redis && redis.status !== "end") {
|
const pending = inFlight.get(key);
|
||||||
try {
|
if (pending) return (await pending) as T;
|
||||||
const cached = await redis.get(key);
|
|
||||||
if (cached !== null && cached !== undefined) {
|
const compute = (async (): Promise<T> => {
|
||||||
const data = JSON.parse(cached) as T;
|
// Redis path (shared across instances).
|
||||||
memory.set(key, { data, expiresAt: Date.now() + ttlMs });
|
if (redis && redis.status !== "end") {
|
||||||
return data;
|
try {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -20,3 +20,17 @@ 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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ 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 {
|
||||||
@@ -42,13 +46,7 @@ class SiteSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(): Promise<Map<string, string>> {
|
private async loadFromCacheOrDb(): 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);
|
||||||
@@ -85,6 +83,22 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -127,6 +141,7 @@ 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);
|
||||||
|
|||||||
Reference in new issue
Block a user