Remove 19 unused source files (no importers anywhere in src/):
- src/actions/admin-permissions.ts, admin-radio.ts, admin-user-edit.ts,
admin-users.ts (functionality lives in @/actions/users and
@/actions/permissions)
- src/components/admin/confirm-action.tsx, page-header.tsx
- src/components/motion-elements.tsx
- src/lib/format-date.ts
- src/lib/catalog-categories/* (incl. re-export barrel)
- src/lib/foundation/{index,database,middleware,validation}.ts (errors/action
kept, still imported directly)
- src/lib/services/imager/{avatar-renderer,memory-cache}.ts
- src/lib/services/nitro-assets.ts
Update admin-operations-contract test to drop the two removed action-file
gates (their permission coverage already exists in users.ts/permissions.ts).
Add knip.json for repeatable dead-code audits.
Verified: tsc --noEmit clean, next build succeeds, full test suite green
(301/301).
This commit is contained in:
1 parent
60eb45be73
commit
3c9c1311ec
23 files changed
+586
-2158
No files matched your search
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": [
|
||||
"src/app/**/page.{ts,tsx}",
|
||||
"src/app/**/layout.{ts,tsx}",
|
||||
"src/app/**/route.{ts,tsx}",
|
||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||
"src/app/**/actions.{ts,tsx}",
|
||||
"next.config.ts",
|
||||
"src/middleware.ts"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignore": [
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"scripts/**"
|
||||
],
|
||||
"ignoreDependencies": ["@types/*", "knip"]
|
||||
}
|
||||
@@ -74,6 +74,7 @@
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"dotenv": "^16.4.0",
|
||||
"knip": "^6.27.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.19",
|
||||
"prisma": "^7.8.0",
|
||||
|
||||
Generated
+567
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
||||
"use server";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,86 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission, requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const CURRENCIES: ReadonlySet<string> = new Set([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
export async function giveCurrency(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const type = String(formData.get("type"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
type as CurrencyName,
|
||||
amount,
|
||||
);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "give_currency",
|
||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function setMotto(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
if (userId > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { motto } });
|
||||
await rcon.setMotto(userId, motto);
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function setRank(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (userId > 0 && rank > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function alertUser(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (userId > 0 && message) await rcon.alertUser(userId, message);
|
||||
}
|
||||
|
||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "").normalize("NFC");
|
||||
if (userId > 0) await rcon.disconnectUser(userId, username);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ConfirmAction({
|
||||
message,
|
||||
action,
|
||||
userId,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
message: string;
|
||||
action: string;
|
||||
userId: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
action="/api/admin/users/actions"
|
||||
method="post"
|
||||
style={{ display: "inline-block" }}
|
||||
>
|
||||
<input type="hidden" name="userId" value={userId} />
|
||||
<input type="hidden" name="action" value={action} />
|
||||
<button
|
||||
type="submit"
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={(e) => {
|
||||
if (!confirm(message)) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface AdminPageHeaderProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function AdminPageHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: AdminPageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Icon size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { HTMLMotionProps } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeInUp,
|
||||
hoverLift,
|
||||
hoverScale,
|
||||
scaleIn,
|
||||
staggerContainer,
|
||||
staggerItem,
|
||||
} from "@/lib/motion";
|
||||
|
||||
type DivProps = HTMLMotionProps<"div">;
|
||||
|
||||
export function AnimatedSection({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedChild({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div className={className} variants={staggerItem} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FadeIn({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FadeInUp({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={fadeInUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScaleIn({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={scaleIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoverCard({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
{...hoverLift}
|
||||
whileHover={{ y: -4, transition: { duration: 0.2 } }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoverButton({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div className={className} {...hoverScale} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -48,12 +48,10 @@ const ACTION_GATES: Array<[string, string]> = [
|
||||
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
|
||||
["catalog.ts", "PERMS.CATALOG_EDIT"],
|
||||
["rooms.ts", "PERMS.ROOMS_EDIT"],
|
||||
["admin-users.ts", "PERMS.USERS_EDIT"],
|
||||
["admin-vpn.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-ads.ts", "PERMS.PAGES_EDIT"],
|
||||
["admin-vouchers.ts", "PERMS.SHOP_EDIT"],
|
||||
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
||||
["admin-permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
|
||||
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
||||
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
||||
];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./catalog-categories/index";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Release eras for organizing Habbo furniture by revision number.
|
||||
*/
|
||||
|
||||
// ── Release Eras ───────────────────────────────────────────────────
|
||||
|
||||
export interface ReleaseEra {
|
||||
key: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
revisionMin: number;
|
||||
revisionMax: number;
|
||||
}
|
||||
|
||||
export const RELEASE_ERAS: ReleaseEra[] = [
|
||||
{
|
||||
key: "classic",
|
||||
label: "Classici 2000-2003",
|
||||
labelEn: "Classic 2000-2003",
|
||||
revisionMin: 0,
|
||||
revisionMax: 19999,
|
||||
},
|
||||
{
|
||||
key: "mid",
|
||||
label: "Era HC 2004-2006",
|
||||
labelEn: "HC Era 2004-2006",
|
||||
revisionMin: 20000,
|
||||
revisionMax: 39999,
|
||||
},
|
||||
{
|
||||
key: "modern",
|
||||
label: "Moderni 2007-2009",
|
||||
labelEn: "Modern 2007-2009",
|
||||
revisionMin: 40000,
|
||||
revisionMax: 59999,
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
label: "Recenti 2010-2012",
|
||||
labelEn: "Recent 2010-2012",
|
||||
revisionMin: 60000,
|
||||
revisionMax: 79999,
|
||||
},
|
||||
{
|
||||
key: "latest",
|
||||
label: "Ultimi 2013+",
|
||||
labelEn: "Latest 2013+",
|
||||
revisionMin: 80000,
|
||||
revisionMax: Infinity,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Match a revision number to a release era.
|
||||
*/
|
||||
export function matchEra(revision: number): ReleaseEra {
|
||||
for (const era of RELEASE_ERAS) {
|
||||
if (revision >= era.revisionMin && revision <= era.revisionMax) {
|
||||
return era;
|
||||
}
|
||||
}
|
||||
return RELEASE_ERAS[RELEASE_ERAS.length - 1];
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Supreme catalog categories + release eras for organizing Habbo furniture.
|
||||
*
|
||||
* Matching priority (first match wins within each level):
|
||||
* 1. Interaction type (most specific)
|
||||
* 2. Keywords anywhere in item name (normalized, *N stripped)
|
||||
* 3. Regex prefix patterns (on normalized name)
|
||||
* 4. Auto-prefix grouping (handled in route.ts)
|
||||
* 5. Uncategorized fallback
|
||||
*
|
||||
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
|
||||
*/
|
||||
|
||||
export * from "./categories";
|
||||
export * from "./eras";
|
||||
|
||||
import { SUPREME_CATEGORIES, type SupremeCategory } from "./categories";
|
||||
|
||||
// ── Name Normalization ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a Habbo item classname for matching.
|
||||
* Strips the *N color suffix (e.g. "chair_plasto*5" -> "chair_plasto")
|
||||
* and lowercases the result.
|
||||
*/
|
||||
export function normalizeName(itemName: string): string {
|
||||
return itemName.replace(/\*\d+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
// ── Category Detection ──────────────────────────────────────────
|
||||
|
||||
export const UNCATEGORIZED_KEY = "uncategorized";
|
||||
export const AUTO_PREFIX_MIN_ITEMS = 5;
|
||||
|
||||
/**
|
||||
* Find the supreme category for an item by classname and interaction type.
|
||||
*
|
||||
* Priority: interaction type -> keywords (anywhere in normalized name) -> regex patterns.
|
||||
* First match wins within each priority level.
|
||||
* Item names are normalized (strip *N suffix, lowercase) before matching.
|
||||
*/
|
||||
export function matchCategory(
|
||||
itemName: string,
|
||||
interactionType?: string,
|
||||
): SupremeCategory {
|
||||
const lower = normalizeName(itemName);
|
||||
|
||||
// Pass 1: Interaction type (most specific)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Keywords anywhere in name (user priority)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const kw of cat.keywords) {
|
||||
if (lower.includes(kw)) return cat;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Regex prefix patterns (fallback)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const pattern of cat.patterns) {
|
||||
if (pattern.test(lower)) return cat;
|
||||
}
|
||||
}
|
||||
|
||||
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1];
|
||||
}
|
||||
|
||||
// ── Auto-Prefix Grouping ────────────────────────────────────────────
|
||||
|
||||
export interface AutoDetectedCategory {
|
||||
key: string;
|
||||
prefix: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
icon: number;
|
||||
layout: string;
|
||||
autoDetected: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prefix from an item name for auto-grouping.
|
||||
* - Strips *N color suffix first ("chair_plasto*5" -> "chair_plasto")
|
||||
* - If name contains '_', take everything before the first '_'
|
||||
* - If no underscore, take the alphabetic prefix (letters before first digit)
|
||||
* - Lowercase the result; return null if prefix is empty or 1 character
|
||||
*/
|
||||
export function extractPrefix(itemName: string): string | null {
|
||||
const normalized = normalizeName(itemName);
|
||||
const idx = normalized.indexOf("_");
|
||||
if (idx > 1) return normalized.substring(0, idx);
|
||||
const match = normalized.match(/^([a-z]{2,})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Format a Date as 'YYYY-MM-DD HH:MM:SS' — used across admin and radio pages. */
|
||||
export function formatDateTime(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
/** Format a Date as 'YYYY-MM-DD' — used across radio list pages. */
|
||||
export function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,79 +0,0 @@
|
||||
export {
|
||||
actionError,
|
||||
actionOk,
|
||||
adminAction,
|
||||
authAction,
|
||||
handleActionError,
|
||||
} from "./action";
|
||||
|
||||
export { DbService, db } from "./database";
|
||||
export {
|
||||
ConflictError,
|
||||
DatabaseError,
|
||||
DomainError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "./errors";
|
||||
export {
|
||||
addSecurityHeaders,
|
||||
chain,
|
||||
protectAdminRoutes,
|
||||
withRequestContext,
|
||||
} from "./middleware";
|
||||
|
||||
export {
|
||||
createStore,
|
||||
elapsed,
|
||||
getClientIp,
|
||||
getRequestId,
|
||||
getRequestStore,
|
||||
runWithStore,
|
||||
setContextUserId,
|
||||
} from "./request-context";
|
||||
export {
|
||||
canonicalize,
|
||||
canonicalizeFormData,
|
||||
canonicalizeFormValue,
|
||||
extractClientIpAsync,
|
||||
redirectSafe,
|
||||
safeRedirect,
|
||||
sanitizeFilename,
|
||||
setCsrfCookie,
|
||||
validateCsrfToken,
|
||||
} from "./security";
|
||||
export type {
|
||||
ActionContext,
|
||||
ActionFailure,
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
AdminActionContext,
|
||||
AppSession,
|
||||
IpAddress,
|
||||
PaginatedQuery,
|
||||
PaginatedResult,
|
||||
PermissionSet,
|
||||
RankId,
|
||||
RequestContext,
|
||||
RequestId,
|
||||
SessionUser,
|
||||
UserId,
|
||||
} from "./types";
|
||||
export {
|
||||
bigIntString,
|
||||
boolString,
|
||||
buildSearchQuery,
|
||||
email,
|
||||
hexColor,
|
||||
idParam,
|
||||
look,
|
||||
nonNegativeInt,
|
||||
pagination,
|
||||
password,
|
||||
positiveInt,
|
||||
slug,
|
||||
url,
|
||||
username,
|
||||
} from "./validation";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,99 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/;
|
||||
|
||||
export const username = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username is required")
|
||||
.max(32, "Username must be at most 32 characters")
|
||||
.regex(
|
||||
USERNAME_RE,
|
||||
"Username may only contain letters, numbers, hyphens, underscores, and dots",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const password = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters");
|
||||
|
||||
export const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.email("Invalid email address")
|
||||
.max(255, "Email must be at most 255 characters")
|
||||
.transform((v) => v.normalize("NFC").toLowerCase());
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export const hexColor = z
|
||||
.string()
|
||||
.length(7, "Must be exactly 7 characters (e.g. #ff0000)")
|
||||
.regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)");
|
||||
|
||||
export const slug = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required")
|
||||
.max(64, "Slug must be at most 64 characters")
|
||||
.regex(
|
||||
SLUG_RE,
|
||||
"Slug must be lowercase alphanumeric with hyphens only between characters",
|
||||
)
|
||||
.refine(
|
||||
(v) => !v.startsWith("-") && !v.endsWith("-"),
|
||||
"Slug must not start or end with a hyphen",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const url = z
|
||||
.string()
|
||||
.url("Invalid URL")
|
||||
.max(2048, "URL must be at most 2048 characters");
|
||||
|
||||
export const look = z
|
||||
.string()
|
||||
.max(512, "Look string must be at most 512 characters")
|
||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
||||
.optional();
|
||||
|
||||
export const positiveInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.positive("Must be positive");
|
||||
|
||||
export const nonNegativeInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.nonnegative("Must not be negative");
|
||||
|
||||
export const bigIntString = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Must be a numeric string")
|
||||
.transform(BigInt);
|
||||
|
||||
export const idParam = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "ID must be numeric")
|
||||
.transform(Number);
|
||||
|
||||
export const pagination = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
perPage: z.coerce.number().int().min(1).max(250).default(50),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
search: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export const boolString = z
|
||||
.string()
|
||||
.transform((v) => v === "true" || v === "1")
|
||||
.or(z.boolean());
|
||||
|
||||
export function buildSearchQuery(fields: string[], search: string | undefined) {
|
||||
if (!search?.trim()) return undefined;
|
||||
const sanitized = search.normalize("NFC").trim().slice(0, 256);
|
||||
return fields.map((f) => ({ [f]: { contains: sanitized } }));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Tiny LRU + in-flight dedup utilities for the avatar imager.
|
||||
* Pure in-house, zero deps. Map preserves insertion order, so we
|
||||
* implement LRU by deleting + re-inserting on access.
|
||||
*/
|
||||
|
||||
export class LruCache<V> {
|
||||
private map = new Map<string, V>();
|
||||
|
||||
constructor(private readonly capacity: number) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const v = this.map.get(key);
|
||||
if (v === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
if (this.map.size > this.capacity) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight request deduplication. If N callers ask for the same key
|
||||
* concurrently, only the first one runs `loader` and the rest await
|
||||
* the same Promise.
|
||||
*/
|
||||
export class InFlight<V> {
|
||||
private pending = new Map<string, Promise<V>>();
|
||||
|
||||
async dedup(key: string, loader: () => Promise<V>): Promise<V> {
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing;
|
||||
const p = loader().finally(() => {
|
||||
this.pending.delete(key);
|
||||
});
|
||||
this.pending.set(key, p);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative cache: marks a key as "known to fail" for `ttlMs` so we don't
|
||||
* hammer the upstream / re-attempt expensive renders for broken figures.
|
||||
*/
|
||||
export class NegativeCache {
|
||||
private map = new Map<string, number>();
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
|
||||
isMarked(key: string): boolean {
|
||||
const exp = this.map.get(key);
|
||||
if (exp === undefined) return false;
|
||||
if (Date.now() > exp) {
|
||||
this.map.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
mark(key: string): void {
|
||||
this.map.set(key, Date.now() + this.ttlMs);
|
||||
if (this.map.size > 1000) {
|
||||
const now = Date.now();
|
||||
for (const [k, exp] of this.map) {
|
||||
if (now > exp) this.map.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { matchCategory, normalizeName } from "@/lib/catalog-categories";
|
||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
|
||||
/**
|
||||
* Categorize a file using matchCategory logic.
|
||||
* Returns the category key.
|
||||
*/
|
||||
export function categorizeFile(filename: string): string {
|
||||
const name = filename.replace(/\.nitro$/, "");
|
||||
const cat = matchCategory(normalizeName(name));
|
||||
return cat.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested folder path from catalog page ancestors.
|
||||
* Example: page "HC Rares" under "Rares" under "Furni" → "furni/rares/hc-rares"
|
||||
* Includes the root page in the path.
|
||||
*/
|
||||
export async function buildCatalogPath(pageId: number): Promise<string> {
|
||||
const ancestors = await getAncestors(pageId);
|
||||
if (ancestors.length === 0) return "uncategorized";
|
||||
|
||||
return ancestors.map((a) => slugifyPath(a.caption)).join("/");
|
||||
}
|
||||
|
||||
/** Sanitize a catalog page name into a valid folder segment */
|
||||
export function slugifyPath(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "uncategorized"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the flat furniture folder for .nitro files.
|
||||
*/
|
||||
export async function scanFlatFolder(): Promise<string[]> {
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const entries = await fs.readdir(nitroDir);
|
||||
return entries.filter((e) => e.endsWith(".nitro"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan ALL .nitro files recursively (flat + subfolders).
|
||||
* Returns a Set of classnames (filename without .nitro extension).
|
||||
*/
|
||||
export async function scanAllNitroFiles(): Promise<Set<string>> {
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const entries = await fs.readdir(nitroDir, { recursive: true });
|
||||
const classnames = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const name = typeof entry === "string" ? entry : String(entry);
|
||||
if (name.endsWith(".nitro")) {
|
||||
const basename = path.basename(name, ".nitro");
|
||||
classnames.add(basename);
|
||||
}
|
||||
}
|
||||
return classnames;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user