chore: remove 4 unused files (theme-editor-fields, use-user-bulk-actions, user-columns, use-media-library)
This commit is contained in:
1 parent
9bc0834bbb
commit
9ae03056e2
17 files changed
+149
-1028
No files matched your search
@@ -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.`,
|
||||
};
|
||||
}
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(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 ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -343,20 +317,11 @@ function LibsView() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -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)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(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 ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -726,20 +662,11 @@ function SetsView() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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<Filter>("all");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(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 ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -295,20 +269,11 @@ export function ImportEffectsClient() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -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)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [batchProgress, setBatchProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [busyLib, setBusyLib] = useState<string | null>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(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 ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
@@ -275,20 +249,11 @@ export function ImportPetsClient() {
|
||||
Import {selected.size} selected
|
||||
</Button>
|
||||
)}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{batchProgress.done}/{batchProgress.total}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{batch.progress && (
|
||||
<BatchImportProgress
|
||||
progress={batch.progress}
|
||||
onCancel={batch.cancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -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)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) {
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
Last updated: {formatDate(lastUpdated, "time")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground">
|
||||
{ranks.length} ranks · {totalUsers.toLocaleString()} users
|
||||
total
|
||||
{ranks.length} ranks · {formatNumber(totalUsers)} users total
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -212,7 +212,7 @@ export function PermissionsList({
|
||||
{/* User count */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
{rank.userCount.toLocaleString()} users
|
||||
{formatNumber(rank.userCount)} users
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -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<string, string>;
|
||||
dark: Record<string, string>;
|
||||
blocks: Record<string, string>;
|
||||
layout: Record<string, string | undefined>;
|
||||
effects: Record<string, string | undefined>;
|
||||
media: Record<string, string | undefined>;
|
||||
custom: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// ─── 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] ?? "";
|
||||
}
|
||||
@@ -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}.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="credits" size={18} />
|
||||
<span className="font-semibold">
|
||||
{user.credits.toLocaleString()}
|
||||
{formatNumber(user.credits)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">credits</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="diamonds" size={18} />
|
||||
<span className="font-semibold">
|
||||
{diamonds.toLocaleString()}
|
||||
</span>
|
||||
<span className="font-semibold">{formatNumber(diamonds)}</span>
|
||||
<span className="text-muted-foreground text-xs">diamonds</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="duckets" size={18} />
|
||||
<span className="font-semibold">
|
||||
{duckets.toLocaleString()}
|
||||
</span>
|
||||
<span className="font-semibold">{formatNumber(duckets)}</span>
|
||||
<span className="text-muted-foreground text-xs">duckets</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="pixels" size={18} />
|
||||
<span className="font-semibold">
|
||||
{user.pixels.toLocaleString()}
|
||||
{formatNumber(user.pixels)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">pixels</span>
|
||||
</div>
|
||||
@@ -204,7 +201,7 @@ export function UserHero({
|
||||
</Link>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{chatCount.toLocaleString()}</p>
|
||||
<p className="text-lg font-bold">{formatNumber(chatCount)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Messages</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
>
|
||||
<p className="text-muted-foreground m-0">
|
||||
{result.total.toLocaleString()} users total
|
||||
{formatNumber(result.total)} users total
|
||||
</p>
|
||||
|
||||
<UsersTable
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
bulkAdjustCurrency,
|
||||
bulkBan,
|
||||
bulkGiveBadge,
|
||||
bulkUnban,
|
||||
} from "@/actions/bulk-users";
|
||||
|
||||
type PendingDialog =
|
||||
| { kind: "ban"; ids: number[] }
|
||||
| { kind: "give-currency"; ids: number[] }
|
||||
| { kind: "give-badge"; ids: number[] }
|
||||
| null;
|
||||
|
||||
export function useUserBulkActions() {
|
||||
const [pending, setPending] = useState<PendingDialog>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<number, string> = {
|
||||
1: "User",
|
||||
2: "Bronze",
|
||||
3: "Silver",
|
||||
4: "Gold",
|
||||
5: "Moderator",
|
||||
6: "Admin",
|
||||
7: "Super Admin",
|
||||
};
|
||||
|
||||
export function getUserColumns(
|
||||
canEdit: boolean,
|
||||
listQuery: string,
|
||||
): DataTableColumn<UserRow>[] {
|
||||
const cols: DataTableColumn<UserRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "username",
|
||||
label: "User",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Link
|
||||
href={userDetailHref(row.id, "show", listQuery)}
|
||||
className="flex items-center gap-2.5 hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(row.look, {
|
||||
size: "m",
|
||||
direction: 2,
|
||||
headDirection: 2,
|
||||
})}
|
||||
alt=""
|
||||
width={64}
|
||||
height={110}
|
||||
className="h-[110px] w-16 shrink-0"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium block">{row.username}</span>
|
||||
{row.motto && (
|
||||
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">
|
||||
{row.motto}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ 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 (
|
||||
<Badge
|
||||
variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}
|
||||
>
|
||||
{RANK_LABELS[r] || `Rank ${r}`}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "credits",
|
||||
label: "Credits",
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span className="tabular-nums">{Number(value).toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "online",
|
||||
label: "Status",
|
||||
filterKey: "filter_online",
|
||||
filterOptions: [
|
||||
{ label: "Online", value: "1" },
|
||||
{ label: "Offline", value: "0" },
|
||||
],
|
||||
render: (value) =>
|
||||
value === "1" ? (
|
||||
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
|
||||
<Wifi className="h-3.5 w-3.5" /> Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WifiOff className="h-3.5 w-3.5" /> Offline
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "accountCreated",
|
||||
label: "Registered",
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const ts = Number(value);
|
||||
if (!ts) return "-";
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ts * 1000).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild title="View">
|
||||
<Link href={userDetailHref(row.id, "show", listQuery)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" asChild title="Edit">
|
||||
<Link href={userDetailHref(row.id, "edit", listQuery)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
}
|
||||
@@ -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<UserRow>[] {
|
||||
label: "Credits",
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span className="tabular-nums">{Number(value).toLocaleString()}</span>
|
||||
<span className="tabular-nums">{formatNumber(Number(value))}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<MediaFile[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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 };
|
||||
}
|
||||
+17
-1
@@ -1,7 +1,12 @@
|
||||
export type DateFormatVariant = "date" | "datetime" | "datetime-seconds";
|
||||
export type DateFormatVariant =
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime"
|
||||
| "datetime-seconds";
|
||||
|
||||
const FALLBACKS: Record<DateFormatVariant, string> = {
|
||||
date: "",
|
||||
time: "—",
|
||||
datetime: "—",
|
||||
"datetime-seconds": "—",
|
||||
};
|
||||
@@ -11,6 +16,7 @@ const FALLBACKS: Record<DateFormatVariant, string> = {
|
||||
* 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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user