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
@@ -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 {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
CloudDownload,
|
||||
Download,
|
||||
FolderTree,
|
||||
Layers,
|
||||
Loader2,
|
||||
Package,
|
||||
@@ -47,251 +44,20 @@ import {
|
||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TreeNode } from "@/types/catalog";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────
|
||||
import { BatchProgress } from "./batch-progress";
|
||||
import { CatalogRail } from "./catalog-rail";
|
||||
import { CheckboxDot } from "./checkbox-dot";
|
||||
import {
|
||||
getFurniImageUrl,
|
||||
handleImgError,
|
||||
TYPE_LABELS,
|
||||
} from "./studio-furni-icons";
|
||||
import type {
|
||||
BatchItemStatus,
|
||||
FurniItem,
|
||||
FurniStats,
|
||||
Meta,
|
||||
} from "./studio-types";
|
||||
|
||||
export function StudioClient({
|
||||
source,
|
||||
@@ -1129,20 +895,3 @@ export function StudioClient({
|
||||
</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;
|
||||
}
|
||||
Reference in new issue
Block a user