From 9ae03056e2a30b320c0259a67e531944fd6322aa Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 5 Sep 2026 23:27:02 +0200 Subject: [PATCH] chore: remove 4 unused files (theme-editor-fields, use-user-bulk-actions, user-columns, use-media-library) --- src/actions/voucher.ts | 3 +- .../clothing/import-clothing-client.tsx | 177 +++------ .../import/effects/import-effects-client.tsx | 87 ++--- .../admin/import/pets/import-pets-client.tsx | 87 ++--- src/app/admin/online/online-table.tsx | 3 +- .../admin/permissions/permissions-list.tsx | 6 +- .../theme-builder/theme-editor-fields.ts | 341 ------------------ .../admin/users/[id]/user-quick-actions.tsx | 3 +- src/app/admin/users/_components/user-hero.tsx | 15 +- src/app/admin/users/page.tsx | 3 +- src/app/admin/users/use-user-bulk-actions.ts | 185 ---------- src/app/admin/users/user-columns.tsx | 166 --------- src/app/admin/users/users-table.tsx | 4 +- src/components/admin/use-media-library.ts | 67 ---- src/lib/format-date.ts | 18 +- src/lib/services/catalog-git-snapshot.test.ts | 5 + src/lib/services/catalog-git-snapshot.ts | 7 +- 17 files changed, 149 insertions(+), 1028 deletions(-) delete mode 100644 src/app/admin/theme-builder/theme-editor-fields.ts delete mode 100644 src/app/admin/users/use-user-bulk-actions.ts delete mode 100644 src/app/admin/users/user-columns.tsx delete mode 100644 src/components/admin/use-media-library.ts diff --git a/src/actions/voucher.ts b/src/actions/voucher.ts index 38714be40e..39637dc566 100644 --- a/src/actions/voucher.ts +++ b/src/actions/voucher.ts @@ -4,6 +4,7 @@ import { and, eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { auth } from "@/lib/auth"; import { db, WebsiteShopVouchers, WebsiteUsedShopVouchers } from "@/lib/db"; +import { formatNumber } from "@/lib/format-date"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { rcon } from "@/lib/services/rcon"; import { currencyDb, sendCurrency } from "@/lib/services/send-currency"; @@ -175,6 +176,6 @@ export async function redeem( return { ok: true, - message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`, + message: `Success! Your balance has been increased by ${formatNumber(voucher.amount)} credits.`, }; } diff --git a/src/app/admin/import/clothing/import-clothing-client.tsx b/src/app/admin/import/clothing/import-clothing-client.tsx index 9714fca743..07140aa028 100644 --- a/src/app/admin/import/clothing/import-clothing-client.tsx +++ b/src/app/admin/import/clothing/import-clothing-client.tsx @@ -10,15 +10,18 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { + BatchImportProgress, + useBatchImport, +} from "@/components/admin/import/use-batch-import"; import { ImportErrorBanner } from "@/components/admin/import-error-banner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; import { getAvatarUrl } from "@/lib/imager"; -import { runSseImport } from "@/lib/sse-client"; import { cn } from "@/lib/utils"; // ── Shared types ───────────────────────────────────────────────────────────── @@ -97,12 +100,14 @@ function LibsView() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [selected, setSelected] = useState>(new Set()); - const [batchProgress, setBatchProgress] = useState<{ - done: number; - total: number; - } | null>(null); const [busyLib, setBusyLib] = useState(null); - const batchAbortRef = useRef(null); + const batch = useBatchImport({ + url: "/api/admin/import/clothing/batch", + successAction: "libraries imported", + cancelledMessage: "Import cancelled", + onItemDone: (label) => markDone(label), + onComplete: () => setSelected(new Set()), + }); const fetchItems = useCallback(async (search: string, pageNum: number) => { setLoading(true); @@ -168,41 +173,10 @@ function LibsView() { } async function importViaSse(toImport: FigureItem[]) { - batchAbortRef.current?.abort(); - const abort = new AbortController(); - batchAbortRef.current = abort; - setBatchProgress({ done: 0, total: toImport.length }); - let done = 0; - try { - await runSseImport( - "/api/admin/import/clothing/batch", - { items: toImport, concurrency: 3 }, - (label) => { - done++; - markDone(label); - setBatchProgress({ done, total: toImport.length }); - }, - (succeeded, failed) => { - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} libraries imported`); - if (failed > 0) toast.error(`${failed} failed`); - }, - abort.signal, - () => { - done++; - setBatchProgress({ done, total: toImport.length }); - }, - ); - } catch (err) { - if ((err as Error)?.name === "AbortError") { - toast.info("Import cancelled"); - } else { - throw err; - } - } finally { - if (batchAbortRef.current === abort) batchAbortRef.current = null; - setBatchProgress(null); - } + await batch.run( + toImport.map((p) => p.lib), + { items: toImport, concurrency: 3 }, + ); } async function importOne(p: FigureItem) { @@ -315,7 +289,7 @@ function LibsView() { variant="outline" size="sm" className="text-xs h-8" - disabled={!!batchProgress} + disabled={batch.running} onClick={() => setSelected((prev) => prev.size === importable.length @@ -333,9 +307,9 @@ function LibsView() { size="sm" className="h-8" onClick={importSelected} - disabled={!!batchProgress} + disabled={batch.running} > - {batchProgress ? ( + {batch.running ? ( ) : ( @@ -343,20 +317,11 @@ function LibsView() { Import {selected.size} selected )} - {batchProgress && ( - <> - - {batchProgress.done}/{batchProgress.total} - - - + {batch.progress && ( + )} )} @@ -423,7 +388,7 @@ function LibsView() { size="sm" className="h-5 w-5 p-0 shrink-0" title="Remove library" - disabled={busyLib === p.lib || !!batchProgress} + disabled={busyLib === p.lib || batch.running} onClick={() => remove(p)} > @@ -434,7 +399,7 @@ function LibsView() { size="sm" variant="outline" className="w-full text-xs h-7" - disabled={busyLib === p.lib || !!batchProgress} + disabled={busyLib === p.lib || batch.running} onClick={() => importOne(p)} > {busyLib === p.lib ? ( @@ -493,12 +458,14 @@ function SetsView() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [selected, setSelected] = useState>(new Set()); - const [batchProgress, setBatchProgress] = useState<{ - done: number; - total: number; - } | null>(null); const [busyKey, setBusyKey] = useState(null); - const batchAbortRef = useRef(null); + const batch = useBatchImport({ + url: "/api/admin/import/clothing/sets/batch", + successAction: "sets imported", + cancelledMessage: "Import cancelled", + onItemDone: (label) => markDone(label), + onComplete: () => setSelected(new Set()), + }); function itemKey(it: ClothingSetItem): string { return `${it.setType}-${it.setId}`; @@ -565,47 +532,16 @@ function SetsView() { } async function importViaSse(toImport: ClothingSetItem[]) { - batchAbortRef.current?.abort(); - const abort = new AbortController(); - batchAbortRef.current = abort; - setBatchProgress({ done: 0, total: toImport.length }); - let done = 0; - try { - await runSseImport( - "/api/admin/import/clothing/sets/batch", - { - items: toImport.map((it) => ({ - setType: it.setType, - setId: it.setId, - })), - concurrency: 3, - }, - (label) => { - done++; - markDone(label); - setBatchProgress({ done, total: toImport.length }); - }, - (succeeded, failed) => { - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} sets imported`); - if (failed > 0) toast.error(`${failed} failed`); - }, - abort.signal, - () => { - done++; - setBatchProgress({ done, total: toImport.length }); - }, - ); - } catch (err) { - if ((err as Error)?.name === "AbortError") { - toast.info("Import cancelled"); - } else { - throw err; - } - } finally { - if (batchAbortRef.current === abort) batchAbortRef.current = null; - setBatchProgress(null); - } + await batch.run( + toImport.map((it) => itemKey(it)), + { + items: toImport.map((it) => ({ + setType: it.setType, + setId: it.setId, + })), + concurrency: 3, + }, + ); } async function importOne(it: ClothingSetItem) { @@ -698,7 +634,7 @@ function SetsView() { variant="outline" size="sm" className="text-xs h-8" - disabled={!!batchProgress} + disabled={batch.running} onClick={() => setSelected((prev) => prev.size === importable.length @@ -716,9 +652,9 @@ function SetsView() { size="sm" className="h-8" onClick={importSelected} - disabled={!!batchProgress} + disabled={batch.running} > - {batchProgress ? ( + {batch.running ? ( ) : ( @@ -726,20 +662,11 @@ function SetsView() { Import {selected.size} selected )} - {batchProgress && ( - <> - - {batchProgress.done}/{batchProgress.total} - - - + {batch.progress && ( + )} )} @@ -824,7 +751,7 @@ function SetsView() { size="sm" variant="outline" className="w-full text-xs h-7" - disabled={busyKey === key || !!batchProgress} + disabled={busyKey === key || batch.running} onClick={() => importOne(it)} > {busyKey === key ? ( diff --git a/src/app/admin/import/effects/import-effects-client.tsx b/src/app/admin/import/effects/import-effects-client.tsx index c74108e76e..f7ec97cb5d 100644 --- a/src/app/admin/import/effects/import-effects-client.tsx +++ b/src/app/admin/import/effects/import-effects-client.tsx @@ -12,14 +12,17 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { + BatchImportProgress, + useBatchImport, +} from "@/components/admin/import/use-batch-import"; import { ImportErrorBanner } from "@/components/admin/import-error-banner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; -import { runSseImport } from "@/lib/sse-client"; import { cn } from "@/lib/utils"; interface EffectItem { @@ -40,12 +43,14 @@ export function ImportEffectsClient() { const [error, setError] = useState(""); const [filter, setFilter] = useState("all"); const [selected, setSelected] = useState>(new Set()); - const [batchProgress, setBatchProgress] = useState<{ - done: number; - total: number; - } | null>(null); const [busyLib, setBusyLib] = useState(null); - const batchAbortRef = useRef(null); + const batch = useBatchImport({ + url: "/api/admin/import/effects/batch", + successAction: "effect(s) imported", + cancelledMessage: "Import cancelled", + onItemDone: (lib) => markDone(lib), + onComplete: () => setSelected(new Set()), + }); const fetchEffects = useCallback(async (search: string) => { setLoading(true); @@ -103,41 +108,10 @@ export function ImportEffectsClient() { } async function importViaSse(items: EffectItem[]) { - batchAbortRef.current?.abort(); - const abort = new AbortController(); - batchAbortRef.current = abort; - setBatchProgress({ done: 0, total: items.length }); - let done = 0; - try { - await runSseImport( - "/api/admin/import/effects/batch", - { items, concurrency: 3 }, - (lib) => { - done++; - markDone(lib); - setBatchProgress({ done, total: items.length }); - }, - (succeeded, failed) => { - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`); - if (failed > 0) toast.error(`${failed} failed`); - }, - abort.signal, - () => { - done++; - setBatchProgress({ done, total: items.length }); - }, - ); - } catch (err) { - if ((err as Error)?.name === "AbortError") { - toast.info("Import cancelled"); - } else { - throw err; - } - } finally { - if (batchAbortRef.current === abort) batchAbortRef.current = null; - setBatchProgress(null); - } + await batch.run( + items.map((e) => e.lib), + { items, concurrency: 3 }, + ); } async function importOne(e: EffectItem) { @@ -267,7 +241,7 @@ export function ImportEffectsClient() { variant="outline" size="sm" className="text-xs h-8" - disabled={!!batchProgress} + disabled={batch.running} onClick={() => setSelected((prev) => prev.size === importable.length @@ -285,9 +259,9 @@ export function ImportEffectsClient() { size="sm" className="h-8" onClick={importSelected} - disabled={!!batchProgress} + disabled={batch.running} > - {batchProgress ? ( + {batch.running ? ( ) : ( @@ -295,20 +269,11 @@ export function ImportEffectsClient() { Import {selected.size} selected )} - {batchProgress && ( - <> - - {batchProgress.done}/{batchProgress.total} - - - + {batch.progress && ( + )} )} @@ -392,7 +357,7 @@ export function ImportEffectsClient() { size="sm" className="h-5 w-5 p-0 shrink-0" title="Remove effect" - disabled={busyLib === e.lib || !!batchProgress} + disabled={busyLib === e.lib || batch.running} onClick={() => remove(e)} > @@ -403,7 +368,7 @@ export function ImportEffectsClient() { size="sm" variant="outline" className="w-full text-xs h-7" - disabled={busyLib === e.lib || !!batchProgress} + disabled={busyLib === e.lib || batch.running} onClick={() => importOne(e)} > {busyLib === e.lib ? ( diff --git a/src/app/admin/import/pets/import-pets-client.tsx b/src/app/admin/import/pets/import-pets-client.tsx index 22e669d0b4..c203d02e35 100644 --- a/src/app/admin/import/pets/import-pets-client.tsx +++ b/src/app/admin/import/pets/import-pets-client.tsx @@ -10,14 +10,17 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { + BatchImportProgress, + useBatchImport, +} from "@/components/admin/import/use-batch-import"; import { ImportErrorBanner } from "@/components/admin/import-error-banner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; -import { runSseImport } from "@/lib/sse-client"; import { cn } from "@/lib/utils"; interface PetItem { @@ -46,12 +49,14 @@ export function ImportPetsClient() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [selected, setSelected] = useState>(new Set()); - const [batchProgress, setBatchProgress] = useState<{ - done: number; - total: number; - } | null>(null); const [busyLib, setBusyLib] = useState(null); - const batchAbortRef = useRef(null); + const batch = useBatchImport({ + url: "/api/admin/import/pets/batch", + successAction: "pet(s) imported", + cancelledMessage: "Import cancelled", + onItemDone: (lib) => markDone(lib), + onComplete: () => setSelected(new Set()), + }); const fetchPets = useCallback(async (search: string) => { setLoading(true); @@ -100,41 +105,10 @@ export function ImportPetsClient() { } async function importViaSse(items: PetItem[]) { - batchAbortRef.current?.abort(); - const abort = new AbortController(); - batchAbortRef.current = abort; - setBatchProgress({ done: 0, total: items.length }); - let done = 0; - try { - await runSseImport( - "/api/admin/import/pets/batch", - { items, concurrency: 3 }, - (lib) => { - done++; - markDone(lib); - setBatchProgress({ done, total: items.length }); - }, - (succeeded, failed) => { - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`); - if (failed > 0) toast.error(`${failed} failed`); - }, - abort.signal, - () => { - done++; - setBatchProgress({ done, total: items.length }); - }, - ); - } catch (err) { - if ((err as Error)?.name === "AbortError") { - toast.info("Import cancelled"); - } else { - throw err; - } - } finally { - if (batchAbortRef.current === abort) batchAbortRef.current = null; - setBatchProgress(null); - } + await batch.run( + items.map((p) => p.lib), + { items, concurrency: 3 }, + ); } async function importOne(p: PetItem) { @@ -247,7 +221,7 @@ export function ImportPetsClient() { variant="outline" size="sm" className="text-xs h-8" - disabled={!!batchProgress} + disabled={batch.running} onClick={() => setSelected((prev) => prev.size === importable.length @@ -265,9 +239,9 @@ export function ImportPetsClient() { size="sm" className="h-8" onClick={importSelected} - disabled={!!batchProgress} + disabled={batch.running} > - {batchProgress ? ( + {batch.running ? ( ) : ( @@ -275,20 +249,11 @@ export function ImportPetsClient() { Import {selected.size} selected )} - {batchProgress && ( - <> - - {batchProgress.done}/{batchProgress.total} - - - + {batch.progress && ( + )} )} @@ -351,7 +316,7 @@ export function ImportPetsClient() { size="sm" className="h-5 w-5 p-0 shrink-0" title="Remove pet" - disabled={busyLib === p.lib || !!batchProgress} + disabled={busyLib === p.lib || batch.running} onClick={() => remove(p)} > @@ -362,7 +327,7 @@ export function ImportPetsClient() { size="sm" variant="outline" className="w-full text-xs h-7" - disabled={busyLib === p.lib || !!batchProgress} + disabled={busyLib === p.lib || batch.running} onClick={() => importOne(p)} > {busyLib === p.lib ? ( diff --git a/src/app/admin/online/online-table.tsx b/src/app/admin/online/online-table.tsx index 088e2bf8aa..4f71975ed1 100644 --- a/src/app/admin/online/online-table.tsx +++ b/src/app/admin/online/online-table.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react"; import { DataTable } from "@/components/admin/data-table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { formatDate } from "@/lib/format-date"; import { getAvatarUrl } from "@/lib/imager"; import type { DataTableColumn, PaginatedResult } from "@/types/common"; @@ -123,7 +124,7 @@ export function OnlineTable({ data }: OnlineTableProps) { )} - Last updated: {lastUpdated.toLocaleTimeString()} + Last updated: {formatDate(lastUpdated, "time")} diff --git a/src/app/admin/permissions/permissions-list.tsx b/src/app/admin/permissions/permissions-list.tsx index 332ef9d538..bc3fa72d44 100644 --- a/src/app/admin/permissions/permissions-list.tsx +++ b/src/app/admin/permissions/permissions-list.tsx @@ -32,6 +32,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useServerAction } from "@/hooks/use-server-action"; +import { formatNumber } from "@/lib/format-date"; interface RankSummary { id: number; @@ -129,8 +130,7 @@ export function PermissionsList({ {/* Header */}

- {ranks.length} ranks · {totalUsers.toLocaleString()} users - total + {ranks.length} ranks · {formatNumber(totalUsers)} users total

{/* Actions */} diff --git a/src/app/admin/theme-builder/theme-editor-fields.ts b/src/app/admin/theme-builder/theme-editor-fields.ts deleted file mode 100644 index b3b588e246..0000000000 --- a/src/app/admin/theme-builder/theme-editor-fields.ts +++ /dev/null @@ -1,341 +0,0 @@ -import type { EffectKey, LayoutKey, MediaKey } from "@/lib/theme-blocks"; -import type { ThemeColorKey } from "@/lib/theme-presets"; -export interface ScopeInfo { - id: number; - name: string; - type: string; - parentId: number | null; - siteDomain: string | null; - routePath: string | null; - moduleId: string | null; - isActive: boolean; -} - -export interface ScopeValues { - light: Record; - dark: Record; - blocks: Record; - layout: Record; - effects: Record; - media: Record; - custom: Record; -} - -// ─── Color fields ──────────────────────────────────────────────────────────── - -export const COLOR_FIELDS: Array<{ - key: ThemeColorKey; - label: string; - def: string; - desc: string; - bgKey?: string; -}> = [ - { - key: "color_background", - label: "Page background", - def: "#f8fafc", - desc: "Main page background", - }, - { - key: "color_surface", - label: "Card / surface", - def: "#ffffff", - desc: "Cards, panels, elevated surfaces", - }, - { - key: "color_dropdown", - label: "Dropdown", - def: "#ffffff", - desc: "Dropdown menus", - }, - { - key: "color_navbar", - label: "Navbar", - def: "#ffffff", - desc: "Navigation bar background", - }, - { - key: "color_navbar_text", - label: "Navbar text", - def: "#1e293b", - desc: "Nav text color", - bgKey: "color_navbar", - }, - { - key: "color_text", - label: "Body text", - def: "#0f172a", - desc: "Main text color", - bgKey: "color_surface", - }, - { - key: "color_text_muted", - label: "Muted text", - def: "#64748b", - desc: "Secondary text", - bgKey: "color_surface", - }, - { - key: "color_primary", - label: "Primary accent", - def: "#f59e0b", - desc: "Main brand color", - }, - { - key: "color_accent", - label: "Secondary accent", - def: "#10b981", - desc: "Secondary brand color", - }, - { - key: "color_success", - label: "Success", - def: "#16a34a", - desc: "Success states", - }, - { - key: "color_warning", - label: "Warning", - def: "#eab308", - desc: "Warning states", - }, - { key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" }, - { - key: "color_info", - label: "Info", - def: "#0ea5e9", - desc: "Information states", - }, - { - key: "button_primary_color", - label: "Button primary", - def: "#f59e0b", - desc: "Primary button background", - }, - { - key: "button_text_color", - label: "Button primary text", - def: "#1e293b", - desc: "Primary button text", - }, - { - key: "button_secondary_color", - label: "Button secondary", - def: "#22c55e", - desc: "Secondary button background", - }, - { - key: "button_secondary_text_color", - label: "Button secondary text", - def: "#ffffff", - desc: "Secondary button text", - }, - { - key: "button_danger_color", - label: "Button danger", - def: "#ef4444", - desc: "Danger button background", - }, - { - key: "button_danger_text_color", - label: "Button danger text", - def: "#ffffff", - desc: "Danger button text", - }, - { - key: "button_outline_color", - label: "Button outline", - def: "#eeb425", - desc: "Outline button border", - }, - { - key: "button_outline_text_color", - label: "Button outline text", - def: "#1a1a2e", - desc: "Outline button text", - }, - { - key: "link_color", - label: "Link color", - def: "#b77900", - desc: "Link text color", - }, - { - key: "link_hover_color", - label: "Link hover", - def: "#8a5b00", - desc: "Link hover color", - }, - { - key: "border_color", - label: "Border color", - def: "#eeb425", - desc: "General borders", - }, - { - key: "panel_border_color", - label: "Panel border", - def: "#e9b124", - desc: "Panel borders", - }, - { - key: "gradient_from", - label: "Gradient start", - def: "#f59e0b", - desc: "Gradient from color", - }, - { - key: "gradient_to", - label: "Gradient end", - def: "#10b981", - desc: "Gradient to color", - }, -]; - -// ─── Layout fields ─────────────────────────────────────────────────────────── - -export const LAYOUT_FIELDS: Array<{ - key: LayoutKey; - label: string; - def: string; - desc: string; - type?: string; -}> = [ - { - key: "layout:max_width", - label: "Max Width", - def: "1280px", - desc: "Maximum content width", - }, - { - key: "layout:spacing", - label: "Grid Spacing", - def: "1.5rem", - desc: "Gap between grid items", - }, - { - key: "layout:grid_columns", - label: "Grid Columns", - def: "12", - desc: "Number of grid columns", - type: "number", - }, - { - key: "layout:content_padding", - label: "Content Padding", - def: "0.75rem", - desc: "Horizontal padding on content", - }, - { - key: "layout:navbar_height", - label: "Navbar Height", - def: "56px", - desc: "Height of the navigation bar", - }, -]; - -// ─── Effect fields ─────────────────────────────────────────────────────────── - -export const EFFECT_FIELDS: Array<{ - key: EffectKey; - label: string; - def: string; - desc: string; - type?: string; -}> = [ - { - key: "effect:card_shadow", - label: "Card Shadow", - def: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)", - desc: "Default card box shadow", - }, - { - key: "effect:card_shadow_hover", - label: "Card Shadow (hover)", - def: "0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)", - desc: "Card box shadow on hover", - }, - { - key: "effect:border_style", - label: "Border Style", - def: "solid", - desc: "CSS border-style (solid, dashed, dotted, etc.)", - }, - { - key: "effect:border_width", - label: "Border Width", - def: "1px", - desc: "Default border width", - }, - { - key: "effect:transition_duration", - label: "Transition Duration", - def: "200ms", - desc: "Duration for hover/transition effects", - }, - { - key: "effect:animation_scale", - label: "Animation Scale", - def: "1", - desc: "Scale factor for animations (1 = normal)", - type: "number", - }, -]; - -// ─── Media fields ──────────────────────────────────────────────────────────── - -export const MEDIA_FIELDS: Array<{ - key: MediaKey; - label: string; - def: string; - desc: string; - type?: string; -}> = [ - { - key: "media:logo_url", - label: "Logo URL", - def: "", - desc: "Custom logo image URL (overrides default)", - }, - { - key: "media:background_image", - label: "Background Image", - def: "", - desc: "Page background image URL", - }, - { - key: "media:background_position", - label: "Background Position", - def: "center", - desc: "CSS background-position", - }, - { - key: "media:background_size", - label: "Background Size", - def: "cover", - desc: "CSS background-size (cover, contain, auto)", - }, - { - key: "media:background_repeat", - label: "Background Repeat", - def: "no-repeat", - desc: "CSS background-repeat", - }, -]; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -export function getVal( - values: ScopeValues | null, - key: string, - mode: "light" | "dark", -): string { - if (!values) return ""; - if (key.startsWith("block:")) return values.blocks[key] ?? ""; - if (key.startsWith("layout:")) return values.layout[key] ?? ""; - if (key.startsWith("effect:")) return values.effects[key] ?? ""; - if (key.startsWith("media:")) return values.media[key] ?? ""; - if (key.startsWith("custom:")) return values.custom[key] ?? ""; - if (mode === "dark") return values.dark[key] ?? ""; - return values.light[key] ?? ""; -} diff --git a/src/app/admin/users/[id]/user-quick-actions.tsx b/src/app/admin/users/[id]/user-quick-actions.tsx index f0479026ae..180d20a35b 100644 --- a/src/app/admin/users/[id]/user-quick-actions.tsx +++ b/src/app/admin/users/[id]/user-quick-actions.tsx @@ -34,6 +34,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useServerAction } from "@/hooks/use-server-action"; +import { formatNumber } from "@/lib/format-date"; interface UserQuickActionsProps { userId: number; @@ -110,7 +111,7 @@ export function UserQuickActions({ const amount = parseInt(creditAmount, 10); if (!amount || amount <= 0) return; run(() => sendCredits({ userId, amount }), { - successMessage: `${amount.toLocaleString()} credits sent to ${username}.`, + successMessage: `${formatNumber(amount)} credits sent to ${username}.`, }); } diff --git a/src/app/admin/users/_components/user-hero.tsx b/src/app/admin/users/_components/user-hero.tsx index 1fe7ce28a9..80178dbba6 100644 --- a/src/app/admin/users/_components/user-hero.tsx +++ b/src/app/admin/users/_components/user-hero.tsx @@ -16,6 +16,7 @@ import Link from "@/components/link"; import { CurrencyIcon } from "@/components/shared/currency-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { formatNumber } from "@/lib/format-date"; import { getAvatarUrl } from "@/lib/imager"; import { userDetailHref, userListHref } from "../user-list-navigation"; @@ -164,28 +165,24 @@ export function UserHero({
- {user.credits.toLocaleString()} + {formatNumber(user.credits)} credits
- - {diamonds.toLocaleString()} - + {formatNumber(diamonds)} diamonds
- - {duckets.toLocaleString()} - + {formatNumber(duckets)} duckets
- {user.pixels.toLocaleString()} + {formatNumber(user.pixels)} pixels
@@ -204,7 +201,7 @@ export function UserHero({
-

{chatCount.toLocaleString()}

+

{formatNumber(chatCount)}

Messages

diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 38e40b7e73..819462c243 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { fetchAdminList } from "@/lib/admin-list"; import { db, User } from "@/lib/db"; +import { formatNumber } from "@/lib/format-date"; import { canAccess, PERMS } from "@/lib/permissions"; import { CreateUserDialog } from "./_components/create-user-dialog"; import { UsersTable } from "./users-table"; @@ -86,7 +87,7 @@ export default async function UsersPage({ } >

- {result.total.toLocaleString()} users total + {formatNumber(result.total)} users total

(null); - const [busy, setBusy] = useState(false); - const [banReason, setBanReason] = useState("Bulk ban"); - const [banDuration, setBanDuration] = useState("-1"); - const [currencyType, setCurrencyType] = useState< - "credits" | "pixels" | "points" - >("credits"); - const [currencyAmount, setCurrencyAmount] = useState("100"); - const [badgeCode, setBadgeCode] = useState(""); - - function reportResult( - label: string, - ok: number, - total: number, - failures = 0, - ) { - if (ok > 0) - toast.success( - `${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`, - ); - if (failures > 0 && ok === 0) - toast.error(`${label} failed for ${failures} users`); - } - - async function handleBulkAction(action: string, ids: (number | string)[]) { - if (busy) return; - const numericIds = ids.map((i) => Number(i)); - - if (action === "unban") { - setBusy(true); - try { - const result = await bulkUnban({ userIds: numericIds }); - if (result.ok) { - const data = result.data as { unbanned: number; total: number }; - reportResult("Unbanned", data.unbanned, data.total); - } else toast.error(result.error); - } catch { - toast.error( - "Network error. The outcome is uncertain; check the user before retrying.", - ); - } finally { - setBusy(false); - } - return; - } - - if (action === "ban") { - setPending({ kind: "ban", ids: numericIds }); - return; - } - - if (action === "give-currency") { - setPending({ kind: "give-currency", ids: numericIds }); - return; - } - - if (action === "give-badge") { - setPending({ kind: "give-badge", ids: numericIds }); - return; - } - } - - async function confirmBan() { - if (pending?.kind !== "ban") return; - setBusy(true); - try { - const result = await bulkBan({ - userIds: pending.ids, - reason: banReason, - duration: Number(banDuration), - }); - if (result.ok) { - const data = result.data as { banned: number }; - reportResult("Banned", data.banned, pending.ids.length); - setPending(null); - } else toast.error(result.error); - } catch { - toast.error( - "Network error. The outcome is uncertain; check the user before retrying.", - ); - } finally { - setBusy(false); - } - } - - async function confirmGiveCurrency() { - if (pending?.kind !== "give-currency") return; - setBusy(true); - try { - const result = await bulkAdjustCurrency({ - userIds: pending.ids, - amount: Number(currencyAmount), - type: currencyType, - }); - if (result.ok) { - const data = result.data as { - adjusted: number; - total: number; - failedIds: { userId: number; reason: string }[]; - }; - reportResult( - `Adjusted ${currencyAmount} ${currencyType}`, - data.adjusted, - data.total, - data.failedIds.length, - ); - setPending(null); - } else toast.error(result.error); - } catch { - toast.error( - "Network error. The outcome is uncertain; check the user before retrying.", - ); - } finally { - setBusy(false); - } - } - - async function confirmGiveBadge() { - if (pending?.kind !== "give-badge") return; - setBusy(true); - try { - const result = await bulkGiveBadge({ - userIds: pending.ids, - badgeCode: badgeCode.trim().toUpperCase(), - }); - if (result.ok) { - const data = result.data as { - given: number; - total: number; - failedIds: { userId: number; reason: string }[]; - }; - reportResult( - `Given badge ${badgeCode}`, - data.given, - data.total, - data.failedIds.length, - ); - setPending(null); - } else toast.error(result.error); - } catch { - toast.error( - "Network error. The outcome is uncertain; check the user before retrying.", - ); - } finally { - setBusy(false); - } - } - - return { - pending, - setPending, - busy, - banReason, - setBanReason, - banDuration, - setBanDuration, - currencyType, - setCurrencyType, - currencyAmount, - setCurrencyAmount, - badgeCode, - setBadgeCode, - handleBulkAction, - confirmBan, - confirmGiveCurrency, - confirmGiveBadge, - }; -} diff --git a/src/app/admin/users/user-columns.tsx b/src/app/admin/users/user-columns.tsx deleted file mode 100644 index 1aff996e97..0000000000 --- a/src/app/admin/users/user-columns.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client"; -import { Eye, Pencil, Wifi, WifiOff } from "lucide-react"; -import Link from "@/components/link"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { getAvatarUrl } from "@/lib/imager"; -import type { DataTableColumn } from "@/types/common"; -import { userDetailHref } from "./user-list-navigation"; -export interface UserRow { - id: number; - username: string; - mail: string | null; - rank: number; - credits: number; - pixels: number; - online: string; - motto: string; - look: string; - ipRegister: string; - accountCreated: number; - lastLogin: number | null; -} - -const RANK_LABELS: Record = { - 1: "User", - 2: "Bronze", - 3: "Silver", - 4: "Gold", - 5: "Moderator", - 6: "Admin", - 7: "Super Admin", -}; - -export function getUserColumns( - canEdit: boolean, - listQuery: string, -): DataTableColumn[] { - const cols: DataTableColumn[] = [ - { key: "id", label: "ID", sortable: true }, - { - key: "username", - label: "User", - sortable: true, - render: (_, row) => ( - - -
- {row.username} - {row.motto && ( - - {row.motto} - - )} -
- - ), - }, - { key: "mail", label: "Email", sortable: true }, - { - key: "rank", - label: "Rank", - sortable: true, - filterKey: "filter_rank", - filterOptions: [ - { label: "User (1)", value: "1" }, - { label: "Bronze (2)", value: "2" }, - { label: "Silver (3)", value: "3" }, - { label: "Gold (4)", value: "4" }, - { label: "Moderator (5)", value: "5" }, - { label: "Admin (6)", value: "6" }, - { label: "Super Admin (7+)", value: "7" }, - ], - render: (value) => { - const r = Number(value); - return ( - = 7 ? "default" : r >= 5 ? "secondary" : "outline"} - > - {RANK_LABELS[r] || `Rank ${r}`} - - ); - }, - }, - { - key: "credits", - label: "Credits", - sortable: true, - render: (value) => ( - {Number(value).toLocaleString()} - ), - }, - { - key: "online", - label: "Status", - filterKey: "filter_online", - filterOptions: [ - { label: "Online", value: "1" }, - { label: "Offline", value: "0" }, - ], - render: (value) => - value === "1" ? ( - - Online - - ) : ( - - Offline - - ), - }, - { - key: "accountCreated", - label: "Registered", - sortable: true, - render: (value) => { - const ts = Number(value); - if (!ts) return "-"; - return ( - - {new Date(ts * 1000).toLocaleDateString("it-IT", { - day: "2-digit", - month: "short", - year: "numeric", - })} - - ); - }, - }, - { - key: "actions", - label: "", - render: (_, row) => ( -
- - {canEdit && ( - - )} -
- ), - }, - ]; - - return cols; -} diff --git a/src/app/admin/users/users-table.tsx b/src/app/admin/users/users-table.tsx index 9cb6ccde60..13cf3d7a45 100644 --- a/src/app/admin/users/users-table.tsx +++ b/src/app/admin/users/users-table.tsx @@ -31,7 +31,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { formatDate } from "@/lib/format-date"; +import { formatDate, formatNumber } from "@/lib/format-date"; import { getAvatarUrl } from "@/lib/imager"; import type { DataTableColumn, PaginatedResult } from "@/types/common"; @@ -126,7 +126,7 @@ function getColumns(canEdit: boolean): DataTableColumn[] { label: "Credits", sortable: true, render: (value) => ( - {Number(value).toLocaleString()} + {formatNumber(Number(value))} ), }, { diff --git a/src/components/admin/use-media-library.ts b/src/components/admin/use-media-library.ts deleted file mode 100644 index c8e8edde4c..0000000000 --- a/src/components/admin/use-media-library.ts +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { uploadMediaAndReturn } from "@/actions/admin-media"; - -type MediaFile = { name: string; url: string }; -export function useMediaLibrary( - open: boolean, - setOpen: (open: boolean) => void, - onSelect: (url: string) => void, -) { - const [files, setFiles] = useState([]); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const [uploading, setUploading] = useState(false); - const fileRef = useRef(null); - - const load = useCallback(async () => { - setLoading(true); - setError(""); - try { - const res = await fetch("/api/media"); - if (!res.ok) throw Error("Could not load media library"); - const data = await res.json(); - setFiles(data.files ?? []); - } catch { - setError("Could not load media library. Please retry."); - } - setLoading(false); - }, []); - - useEffect(() => { - if (open) load(); - }, [open, load]); - - async function handleUpload(e: React.ChangeEvent) { - const chosen = Array.from(e.target.files ?? []); - if (!chosen.length || uploading) return; - setUploading(true); - setError(""); - const urls: string[] = []; - try { - for (const file of chosen) { - const fd = new FormData(); - fd.set("file", file); - const url = await uploadMediaAndReturn(fd); - if (!url) - throw Error( - `Could not upload ${file.name}. Check file type and size.`, - ); - urls.push(url); - } - await load(); - onSelect(urls[urls.length - 1]); - setOpen(false); - } catch (err) { - if (urls.length) await load(); - setError( - err instanceof Error ? err.message : "Upload failed. Please retry.", - ); - } finally { - setUploading(false); - if (fileRef.current) fileRef.current.value = ""; - } - } - - return { files, loading, uploading, fileRef, error, load, handleUpload }; -} diff --git a/src/lib/format-date.ts b/src/lib/format-date.ts index 4eb6be0eda..ba1ca292eb 100644 --- a/src/lib/format-date.ts +++ b/src/lib/format-date.ts @@ -1,7 +1,12 @@ -export type DateFormatVariant = "date" | "datetime" | "datetime-seconds"; +export type DateFormatVariant = + | "date" + | "time" + | "datetime" + | "datetime-seconds"; const FALLBACKS: Record = { date: "", + time: "—", datetime: "—", "datetime-seconds": "—", }; @@ -11,6 +16,7 @@ const FALLBACKS: Record = { * admin/site pages no longer each redefine their own copy. * * - "date" → 2026-07-18 + * - "time" → 18:30 * - "datetime" → 2026-07-18 18:30 * - "datetime-seconds"→ 2026-07-18 18:30:45 * @@ -27,9 +33,19 @@ export function formatDate( switch (variant) { case "date": return iso.slice(0, 10); + case "time": + return iso.slice(11, 16); case "datetime-seconds": return iso.slice(0, 19).replace("T", " "); default: return iso.slice(0, 16).replace("T", " "); } } + +/** + * Format a number with grouped thousands. Uses the provided locale or a + * consistent default for admin pages. Keeps number formatting in one place. + */ +export function formatNumber(n: number, locale: string = "en-US"): string { + return n.toLocaleString(locale); +} diff --git a/src/lib/services/catalog-git-snapshot.test.ts b/src/lib/services/catalog-git-snapshot.test.ts index 81498ae33a..b988a70e50 100644 --- a/src/lib/services/catalog-git-snapshot.test.ts +++ b/src/lib/services/catalog-git-snapshot.test.ts @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ dirs: vi.fn(), data: vi.fn(), settings: vi.fn(), + publicRoot: vi.fn(), })); vi.mock("@/lib/db", () => ({ db: { $client: { getConnection: async () => mocks } }, @@ -36,6 +37,9 @@ vi.mock("@/lib/services/furni-asset-dirs", () => ({ vi.mock("@/lib/services/furni-data", () => ({ getFurnitureDataPath: mocks.data, })); +vi.mock("@/lib/services/public-asset-root", () => ({ + getPublicAssetRoot: mocks.publicRoot, +})); vi.mock("@/lib/services/site-settings", () => ({ siteSettings: { get: mocks.settings }, })); @@ -109,6 +113,7 @@ describe("catalog snapshot", () => { await writeFile(path.join(root, "custom/FigureMap.json"), "{}"); await writeFile(path.join(root, "custom/EffectMap.json"), "{}"); mocks.gamedata.mockResolvedValue(root); + mocks.publicRoot.mockReturnValue(root); mocks.dirs.mockResolvedValue({ nitroDir: path.join(root, "bundled/furniture"), iconDir: path.join(root, "icons"), diff --git a/src/lib/services/catalog-git-snapshot.ts b/src/lib/services/catalog-git-snapshot.ts index 51a7eded05..82d9a247d1 100644 --- a/src/lib/services/catalog-git-snapshot.ts +++ b/src/lib/services/catalog-git-snapshot.ts @@ -10,6 +10,7 @@ import { getGamedataRoot, } from "@/lib/services/furni-asset-dirs"; import { getFurnitureDataPath } from "@/lib/services/furni-data"; +import { getPublicAssetRoot } from "@/lib/services/public-asset-root"; import { siteSettings } from "@/lib/services/site-settings"; import { CATALOG_LANGUAGE_ROOT, @@ -59,12 +60,12 @@ export async function createCatalogSnapshot( const furnitureData = await getFurnitureDataPath(); // Default files first; configured live locations take precedence. await addTree( - path.join(process.cwd(), "public/swf/c_images"), + path.join(getPublicAssetRoot(), "public/swf/c_images"), "Gamedata/c_images", /\.(png|gif|webp|jpe?g)$/i, ); await addTree( - path.join(process.cwd(), "public/nitro-assets/bundled"), + path.join(getPublicAssetRoot(), "public/nitro-assets/bundled"), "Gamedata/bundled", /\.nitro$/i, ); @@ -94,7 +95,7 @@ export async function createCatalogSnapshot( } // Pets currently write directly to the CMS directory. await addTree( - path.join(process.cwd(), "public/nitro-assets/bundled/pet"), + path.join(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"), "Gamedata/bundled/pet", /\.nitro$/i, );