diff --git a/src/app/(site)/friends/loading.tsx b/src/app/(site)/friends/loading.tsx index 66c1d10c..b21a1e1f 100644 --- a/src/app/(site)/friends/loading.tsx +++ b/src/app/(site)/friends/loading.tsx @@ -1,15 +1,5 @@ -export default function FriendsLoading() { - return ( -
diff --git a/src/app/admin/devops/page.tsx b/src/app/admin/devops/page.tsx
index e28e1d58..7ba1b773 100644
--- a/src/app/admin/devops/page.tsx
+++ b/src/app/admin/devops/page.tsx
@@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db";
+import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { HealthCheckClient } from "./health-check-client";
@@ -168,7 +169,7 @@ export default async function DevOpsPage() {
#{error.id}
- {new Date(error.timestamp * 1000).toLocaleString()}
+ {formatDate(new Date(error.timestamp * 1000))}
diff --git a/src/app/admin/events/[id]/event-registrations.tsx b/src/app/admin/events/[id]/event-registrations.tsx
index 63b419a8..e579afea 100644
--- a/src/app/admin/events/[id]/event-registrations.tsx
+++ b/src/app/admin/events/[id]/event-registrations.tsx
@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { formatDate } from "@/lib/format-date";
interface Registration {
id: number;
@@ -36,7 +37,7 @@ export function EventRegistrations({ registrations }: Props) {
>
{r.username}
- {new Date(r.createdAt).toLocaleString()}
+ {formatDate(new Date(r.createdAt))}
))}
diff --git a/src/app/admin/events/[id]/event-winners.tsx b/src/app/admin/events/[id]/event-winners.tsx
index 7843b814..de2e9f34 100644
--- a/src/app/admin/events/[id]/event-winners.tsx
+++ b/src/app/admin/events/[id]/event-winners.tsx
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
+import { formatDate } from "@/lib/format-date";
interface Winner {
id: number;
@@ -93,7 +94,7 @@ export function EventWinnersManager({ eventId, winners }: Props) {
{w.username}
- {new Date(w.createdAt).toLocaleDateString()}
+ {formatDate(new Date(w.createdAt), "date")}
))}
diff --git a/src/app/admin/events/[id]/page.tsx b/src/app/admin/events/[id]/page.tsx
index a59fca3d..50625cfa 100644
--- a/src/app/admin/events/[id]/page.tsx
+++ b/src/app/admin/events/[id]/page.tsx
@@ -6,6 +6,7 @@ import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db, User, WebsiteEvent, WebsiteEventType } from "@/lib/db";
+import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EventForm } from "../event-form";
import { EventPrizesManager } from "./event-prizes";
@@ -94,7 +95,7 @@ export default async function EventDetailPage({
- {event.type.name} · {new Date(event.startsAt).toLocaleString()}
+ {event.type.name} · {formatDate(new Date(event.startsAt))}
@@ -156,7 +157,7 @@ export default async function EventDetailPage({
{event.endsAt && (
{t("endsAt", {
- date: new Date(event.endsAt).toLocaleString(),
+ date: formatDate(new Date(event.endsAt)),
})}
)}
diff --git a/src/app/admin/events/events-table.tsx b/src/app/admin/events/events-table.tsx
index 19d06cc0..fd859fc1 100644
--- a/src/app/admin/events/events-table.tsx
+++ b/src/app/admin/events/events-table.tsx
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
+import { formatDate } from "@/lib/format-date";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface EventRow {
@@ -86,7 +87,7 @@ export function EventsTable({ data }: EventsTableProps) {
sortable: true,
render: (value) => (
- {new Date(value as unknown as string).toLocaleString()}
+ {formatDate(new Date(value as unknown as string))}
),
},
diff --git a/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx b/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx
index 29d89054..3a7ecc61 100644
--- a/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx
+++ b/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
+import { formatDate } from "@/lib/format-date";
interface ThreadMessage {
key: string;
@@ -162,7 +163,7 @@ export function AdminHelpTicketDetail({
{msg.isStaff ? ` (${t("staffBadge")})` : ""}
- {new Date(msg.createdAt).toLocaleString()}
+ {formatDate(new Date(msg.createdAt))}
{t("colCreated")}
- {new Date(ticket.createdAt).toLocaleString()}
+ {formatDate(new Date(ticket.createdAt))}
{t("colUpdated")}
- {new Date(ticket.updatedAt).toLocaleString()}
+ {formatDate(new Date(ticket.updatedAt))}
{t("messageCountLabel")}
diff --git a/src/app/admin/import/badges/import-badges-client.tsx b/src/app/admin/import/badges/import-badges-client.tsx
index 7978234e..7fa8b0f8 100644
--- a/src/app/admin/import/badges/import-badges-client.tsx
+++ b/src/app/admin/import/badges/import-badges-client.tsx
@@ -1,7 +1,6 @@
"use client";
import {
- AlertCircle,
BadgeCheck,
Check,
CheckCircle2,
@@ -15,6 +14,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
+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";
@@ -348,12 +348,7 @@ export function ImportBadgesClient() {
)}
{/* Error */}
- {error && (
-
-
- {error}
-
- )}
+ {error && }
{/* Loading skeleton */}
{loading && badges.length === 0 && (
diff --git a/src/app/admin/import/clone/import-clone-client.tsx b/src/app/admin/import/clone/import-clone-client.tsx
index df7f12f6..4eb3b121 100644
--- a/src/app/admin/import/clone/import-clone-client.tsx
+++ b/src/app/admin/import/clone/import-clone-client.tsx
@@ -1,7 +1,6 @@
"use client";
import {
- AlertCircle,
Check,
CloudDownload,
Download,
@@ -16,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
+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";
@@ -782,12 +782,7 @@ function FurniGrid({ source }: FurniGridProps) {
)}
{/* Error */}
- {error && (
-
-
- {error}
-
- )}
+ {error && }
{/* Empty state */}
{!loading && items.length === 0 && !error && (
diff --git a/src/app/admin/import/clothing/import-clothing-client.tsx b/src/app/admin/import/clothing/import-clothing-client.tsx
index 90324856..9714fca7 100644
--- a/src/app/admin/import/clothing/import-clothing-client.tsx
+++ b/src/app/admin/import/clothing/import-clothing-client.tsx
@@ -1,7 +1,6 @@
"use client";
import {
- AlertCircle,
Check,
Download,
Loader2,
@@ -13,6 +12,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
+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";
@@ -361,12 +361,7 @@ function LibsView() {
)}
- {error && (
-
-
- {error}
-
- )}
+ {error && }
{!loading && items.length === 0 && !error && (
@@ -749,12 +744,7 @@ function SetsView() {
)}
- {error && (
-
-
- {error}
-
- )}
+ {error && }
{!loading && items.length === 0 && !error && (
diff --git a/src/app/admin/import/effects/import-effects-client.tsx b/src/app/admin/import/effects/import-effects-client.tsx
index 4451d902..c74108e7 100644
--- a/src/app/admin/import/effects/import-effects-client.tsx
+++ b/src/app/admin/import/effects/import-effects-client.tsx
@@ -2,7 +2,6 @@
"use client";
import {
- AlertCircle,
Check,
Download,
Loader2,
@@ -15,6 +14,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
+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";
@@ -314,12 +314,7 @@ export function ImportEffectsClient() {
)}
{/* Error */}
- {error && (
-
-
- {error}
-
- )}
+ {error && }
{/* Empty */}
{!loading && visible.length === 0 && !error && (
diff --git a/src/app/admin/import/pets/import-pets-client.tsx b/src/app/admin/import/pets/import-pets-client.tsx
index f2120caf..22e669d0 100644
--- a/src/app/admin/import/pets/import-pets-client.tsx
+++ b/src/app/admin/import/pets/import-pets-client.tsx
@@ -1,7 +1,6 @@
"use client";
import {
- AlertCircle,
Check,
Download,
Loader2,
@@ -13,6 +12,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
+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";
@@ -293,12 +293,7 @@ export function ImportPetsClient() {
)}
- {error && (
-
-
- {error}
-
- )}
+ {error && }
{!loading && pets.length === 0 && !error && (
diff --git a/src/app/admin/loading.tsx b/src/app/admin/loading.tsx
index 2573bc13..2b05206f 100644
--- a/src/app/admin/loading.tsx
+++ b/src/app/admin/loading.tsx
@@ -1,15 +1,5 @@
-export default function AdminLoading() {
- return (
-
-
-
- );
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
+
+export default function Loading() {
+ return ;
}
diff --git a/src/app/admin/logs/_lib/load-ignored-logs.ts b/src/app/admin/logs/_lib/load-ignored-logs.ts
index 348985e0..daf7ae76 100644
--- a/src/app/admin/logs/_lib/load-ignored-logs.ts
+++ b/src/app/admin/logs/_lib/load-ignored-logs.ts
@@ -9,6 +9,16 @@ type ListParamsInput = {
defaultPerPage?: number;
};
+type SqlFragment = ReturnType;
+
+type LogListSpec = {
+ select: SqlFragment;
+ from: SqlFragment;
+ searchWhere: (like: string) => SqlFragment;
+ orderBy: SqlFragment;
+ mapRow: (raw: RawRow, index: number, offset: number) => Row;
+};
+
async function requireLogsView() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
@@ -26,11 +36,56 @@ function parsePageParams(
return { search: parsed.search.trim(), page: parsed.page, perPage };
}
-async function rawRows(query: ReturnType): Promise {
+async function rawRows(query: SqlFragment): Promise {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
}
+/**
+ * Shared list loader for the chat / command / trade log tables. The SQL
+ * fragments come from a per-table spec while the request handling, search
+ * branching, count + pagination and row mapping stay in one place.
+ */
+async function loadLogList(
+ input: ListParamsInput,
+ spec: LogListSpec,
+): Promise<{
+ rows: Row[];
+ total: number;
+ page: number;
+ perPage: number;
+ lastPage: number;
+ offset: number;
+}> {
+ await requireLogsView();
+
+ const rawParams = await input.searchParams;
+ const { search, page, perPage } = parsePageParams(
+ rawParams,
+ input.defaultPerPage ?? 50,
+ );
+ const offset = (page - 1) * perPage;
+ const like = `%${search}%`;
+ const where = search ? sql`WHERE ${spec.searchWhere(like)}` : sql``;
+
+ const [rawRowsResult, countRows] = await Promise.all([
+ rawRows(
+ sql`${spec.select} ${spec.from} ${where} ${spec.orderBy} LIMIT ${perPage} OFFSET ${offset}`,
+ ),
+ rawRows<{ count: bigint }>(
+ sql`SELECT COUNT(*) AS count ${spec.from} ${where}`,
+ ),
+ ]);
+
+ const total = Number(countRows[0]?.count ?? 0);
+ const pagination = calcPagination(total, page, perPage);
+ const rows = rawRowsResult.map((row, index) =>
+ spec.mapRow(row, index, pagination.offset),
+ );
+
+ return { rows, ...pagination };
+}
+
export type ChatLogListRow = {
id: number;
username: string;
@@ -39,17 +94,7 @@ export type ChatLogListRow = {
roomId: number;
};
-export async function loadChatLogList({
- searchParams,
- defaultPerPage = 50,
-}: ListParamsInput) {
- await requireLogsView();
-
- const rawParams = await searchParams;
- const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
- const offset = (page - 1) * perPage;
- const like = `%${search}%`;
-
+export function loadChatLogList(input: ListParamsInput) {
type RawRow = {
roomId: number;
message: string;
@@ -57,43 +102,20 @@ export async function loadChatLogList({
username: string | null;
};
- const [rawRowsResult, countRows] = search
- ? await Promise.all([
- rawRows(sql`
- SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username
- FROM chatlogs_room c
- LEFT JOIN users u ON u.id = c.user_from_id
- WHERE c.message LIKE ${like} OR u.username LIKE ${like}
- ORDER BY c.timestamp DESC
- LIMIT ${perPage} OFFSET ${offset}`),
- rawRows<{ count: bigint }>(sql`
- SELECT COUNT(*) AS count
- FROM chatlogs_room c
- LEFT JOIN users u ON u.id = c.user_from_id
- WHERE c.message LIKE ${like} OR u.username LIKE ${like}`),
- ])
- : await Promise.all([
- rawRows(sql`
- SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username
- FROM chatlogs_room c
- LEFT JOIN users u ON u.id = c.user_from_id
- ORDER BY c.timestamp DESC
- LIMIT ${perPage} OFFSET ${offset}`),
- rawRows<{ count: bigint }>(sql`
- SELECT COUNT(*) AS count FROM chatlogs_room`),
- ]);
-
- const total = Number(countRows[0]?.count ?? 0);
- const pagination = calcPagination(total, page, perPage);
- const rows: ChatLogListRow[] = rawRowsResult.map((row, index) => ({
- id: pagination.offset + index + 1,
- username: row.username ?? "-",
- message: row.message,
- timestamp: row.timestamp,
- roomId: row.roomId,
- }));
-
- return { rows, ...pagination };
+ return loadLogList(input, {
+ select: sql`SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username`,
+ from: sql`FROM chatlogs_room c LEFT JOIN users u ON u.id = c.user_from_id`,
+ searchWhere: (like) =>
+ sql`c.message LIKE ${like} OR u.username LIKE ${like}`,
+ orderBy: sql`ORDER BY c.timestamp DESC`,
+ mapRow: (row, index, offset) => ({
+ id: offset + index + 1,
+ username: row.username ?? "-",
+ message: row.message,
+ timestamp: row.timestamp,
+ roomId: row.roomId,
+ }),
+ });
}
export type CommandLogListRow = {
@@ -104,17 +126,7 @@ export type CommandLogListRow = {
timestamp: number;
};
-export async function loadCommandLogList({
- searchParams,
- defaultPerPage = 50,
-}: ListParamsInput) {
- await requireLogsView();
-
- const rawParams = await searchParams;
- const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
- const offset = (page - 1) * perPage;
- const like = `%${search}%`;
-
+export function loadCommandLogList(input: ListParamsInput) {
type RawRow = {
command: string;
params: string;
@@ -122,47 +134,20 @@ export async function loadCommandLogList({
username: string | null;
};
- const [rawRowsResult, countRows] = search
- ? await Promise.all([
- rawRows(sql`
- SELECT c.command, c.params, c.timestamp, u.username AS username
- FROM commandlogs c
- LEFT JOIN users u ON u.id = c.user_id
- WHERE c.command LIKE ${like}
- OR c.params LIKE ${like}
- OR u.username LIKE ${like}
- ORDER BY c.timestamp DESC
- LIMIT ${perPage} OFFSET ${offset}`),
- rawRows<{ count: bigint }>(sql`
- SELECT COUNT(*) AS count
- FROM commandlogs c
- LEFT JOIN users u ON u.id = c.user_id
- WHERE c.command LIKE ${like}
- OR c.params LIKE ${like}
- OR u.username LIKE ${like}`),
- ])
- : await Promise.all([
- rawRows(sql`
- SELECT c.command, c.params, c.timestamp, u.username AS username
- FROM commandlogs c
- LEFT JOIN users u ON u.id = c.user_id
- ORDER BY c.timestamp DESC
- LIMIT ${perPage} OFFSET ${offset}`),
- rawRows<{ count: bigint }>(sql`
- SELECT COUNT(*) AS count FROM commandlogs`),
- ]);
-
- const total = Number(countRows[0]?.count ?? 0);
- const pagination = calcPagination(total, page, perPage);
- const rows: CommandLogListRow[] = rawRowsResult.map((row, index) => ({
- id: pagination.offset + index + 1,
- username: row.username ?? "-",
- command: row.command,
- params: row.params,
- timestamp: row.timestamp,
- }));
-
- return { rows, ...pagination };
+ return loadLogList(input, {
+ select: sql`SELECT c.command, c.params, c.timestamp, u.username AS username`,
+ from: sql`FROM commandlogs c LEFT JOIN users u ON u.id = c.user_id`,
+ searchWhere: (like) =>
+ sql`c.command LIKE ${like} OR c.params LIKE ${like} OR u.username LIKE ${like}`,
+ orderBy: sql`ORDER BY c.timestamp DESC`,
+ mapRow: (row, index, offset) => ({
+ id: offset + index + 1,
+ username: row.username ?? "-",
+ command: row.command,
+ params: row.params,
+ timestamp: row.timestamp,
+ }),
+ });
}
export type TradeLogListRow = {
@@ -173,17 +158,7 @@ export type TradeLogListRow = {
timestamp: number;
};
-export async function loadTradeLogList({
- searchParams,
- defaultPerPage = 50,
-}: ListParamsInput) {
- await requireLogsView();
-
- const rawParams = await searchParams;
- const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
- const offset = (page - 1) * perPage;
- const like = `%${search}%`;
-
+export function loadTradeLogList(input: ListParamsInput) {
type RawRow = {
id: number;
timestamp: number;
@@ -191,47 +166,19 @@ export async function loadTradeLogList({
userTwoName: string | null;
};
- const [rawRowsResult, countRows] = search
- ? await Promise.all([
- rawRows(sql`
- SELECT t.id, t.timestamp,
- u1.username AS userOneName, u2.username AS userTwoName
- FROM room_trade_log t
- LEFT JOIN users u1 ON u1.id = t.user_one_id
- LEFT JOIN users u2 ON u2.id = t.user_two_id
- WHERE u1.username LIKE ${like} OR u2.username LIKE ${like}
- ORDER BY t.timestamp DESC
- LIMIT ${perPage} OFFSET ${offset}`),
- rawRows<{ count: bigint }>(sql`
- SELECT COUNT(*) AS count
- FROM room_trade_log t
- LEFT JOIN users u1 ON u1.id = t.user_one_id
- LEFT JOIN users u2 ON u2.id = t.user_two_id
- WHERE u1.username LIKE ${like} OR u2.username LIKE ${like}`),
- ])
- : await Promise.all([
- rawRows(sql`
- SELECT t.id, t.timestamp,
- u1.username AS userOneName, u2.username AS userTwoName
- FROM room_trade_log t
- LEFT JOIN users u1 ON u1.id = t.user_one_id
- LEFT JOIN users u2 ON u2.id = t.user_two_id
- ORDER BY t.timestamp DESC
- LIMIT ${perPage} OFFSET ${offset}`),
- rawRows<{ count: bigint }>(sql`
- SELECT COUNT(*) AS count FROM room_trade_log`),
- ]);
-
- const total = Number(countRows[0]?.count ?? 0);
- const pagination = calcPagination(total, page, perPage);
- const rows: TradeLogListRow[] = rawRowsResult.map((row) => ({
- id: row.id,
- userOneName: row.userOneName ?? "-",
- userTwoName: row.userTwoName ?? "-",
- // room_trade_log only stores completed trades; no status column exists
- status: "completed",
- timestamp: row.timestamp,
- }));
-
- return { rows, ...pagination };
+ return loadLogList(input, {
+ select: sql`SELECT t.id, t.timestamp, u1.username AS userOneName, u2.username AS userTwoName`,
+ from: sql`FROM room_trade_log t LEFT JOIN users u1 ON u1.id = t.user_one_id LEFT JOIN users u2 ON u2.id = t.user_two_id`,
+ searchWhere: (like) =>
+ sql`u1.username LIKE ${like} OR u2.username LIKE ${like}`,
+ orderBy: sql`ORDER BY t.timestamp DESC`,
+ mapRow: (row) => ({
+ id: row.id,
+ userOneName: row.userOneName ?? "-",
+ userTwoName: row.userTwoName ?? "-",
+ // room_trade_log only stores completed trades; no status column exists
+ status: "completed",
+ timestamp: row.timestamp,
+ }),
+ });
}
diff --git a/src/app/admin/moderation/cfh/[id]/cfh-detail.tsx b/src/app/admin/moderation/cfh/[id]/cfh-detail.tsx
index 0e3fe328..0aadf1b8 100644
--- a/src/app/admin/moderation/cfh/[id]/cfh-detail.tsx
+++ b/src/app/admin/moderation/cfh/[id]/cfh-detail.tsx
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
+import { formatDate } from "@/lib/format-date";
const CFH_STATES: Record = {
0: {
@@ -130,7 +131,7 @@ export function CfhDetail({
Call For Help Report
- {new Date(ticket.timestamp * 1000).toLocaleString()} · Room #
+ {formatDate(new Date(ticket.timestamp * 1000))} · Room #
{ticket.roomId}
@@ -317,7 +318,7 @@ export function CfhDetail({
{ban.reason || "No reason"}
{ban.type} · by {ban.staff} ·{" "}
- {new Date(ban.timestamp * 1000).toLocaleDateString()}
+ {formatDate(new Date(ban.timestamp * 1000), "date")}
))}
@@ -431,9 +432,7 @@ export function CfhDetail({
Created
-
- {new Date(ticket.timestamp * 1000).toLocaleString()}
-
+ {formatDate(new Date(ticket.timestamp * 1000))}
Room
diff --git a/src/app/admin/moderation/team/page.tsx b/src/app/admin/moderation/team/page.tsx
index fb84944e..79c6f8b0 100644
--- a/src/app/admin/moderation/team/page.tsx
+++ b/src/app/admin/moderation/team/page.tsx
@@ -12,6 +12,7 @@ import {
User,
WebsiteTicket,
} from "@/lib/db";
+import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ModTeamPage() {
@@ -169,7 +170,7 @@ export default async function ModTeamPage() {
{member.online === "1"
? "Online"
: member.lastOnline
- ? new Date(member.lastOnline * 1000).toLocaleDateString()
+ ? formatDate(new Date(member.lastOnline * 1000), "date")
: "—"}
diff --git a/src/app/admin/permissions/[id]/rank-edit-client.tsx b/src/app/admin/permissions/[id]/rank-edit-client.tsx
index 125245c2..2fc5aa5f 100644
--- a/src/app/admin/permissions/[id]/rank-edit-client.tsx
+++ b/src/app/admin/permissions/[id]/rank-edit-client.tsx
@@ -6,7 +6,6 @@ import {
Crown,
Loader2,
Save,
- Search,
Settings,
Shield,
ShieldCheck,
@@ -19,6 +18,7 @@ import {
saveRank as saveRankAction,
setCmsPermissions,
} from "@/actions/permissions";
+import { SearchInput } from "@/components/admin/search-input";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -521,15 +521,11 @@ export function RankEditClient({
-
-
- setPermSearch(e.target.value)}
- className="pl-9"
- />
-
+
@@ -606,15 +602,11 @@ export function RankEditClient({
-
-
- setCmsSearch(e.target.value)}
- className="pl-9"
- />
-
+
{filteredCms.map((perm) => {
diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx
index 639a3273..e094025e 100644
--- a/src/app/admin/photos/page.tsx
+++ b/src/app/admin/photos/page.tsx
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { CameraWeb, db, User } from "@/lib/db";
+import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminPhotos({
@@ -133,11 +134,7 @@ export default async function AdminPhotos({
>
{t("byUser", { username })}
{" "}
- ·{" "}
- {new Date(p.timestamp * 1000)
- .toISOString()
- .slice(0, 16)
- .replace("T", " ")}
+ · {formatDate(new Date(p.timestamp * 1000))}
{canEdit ? (
-
-
- setQuery(e.target.value)}
- placeholder="Search keys, values, comments…"
- className="pl-9"
- />
-
+
{canEdit ? (
@@ -428,12 +430,12 @@ export function AdminTicketDetail({
Created
- {new Date(ticket.createdAt).toLocaleString()}
+ {formatDate(new Date(ticket.createdAt))}
{ticket.closedAt && (
Closed
- {new Date(ticket.closedAt).toLocaleString()}
+ {formatDate(new Date(ticket.closedAt))}
)}
diff --git a/src/app/admin/users/[id]/user-activity-log.tsx b/src/app/admin/users/[id]/user-activity-log.tsx
index 26e96b92..f8fda57e 100644
--- a/src/app/admin/users/[id]/user-activity-log.tsx
+++ b/src/app/admin/users/[id]/user-activity-log.tsx
@@ -2,6 +2,7 @@ import { MessageSquare, Terminal } from "lucide-react";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { formatDate } from "@/lib/format-date";
interface ChatMessage {
id: number;
@@ -25,13 +26,7 @@ interface UserActivityLogProps {
function formatTime(ts: number) {
if (!ts) return "-";
- const d = new Date(ts * 1000);
- return d.toLocaleString(undefined, {
- day: "2-digit",
- month: "short",
- hour: "2-digit",
- minute: "2-digit",
- });
+ return formatDate(new Date(ts * 1000));
}
export function UserActivityLog({
diff --git a/src/app/admin/users/[id]/user-ban-section.tsx b/src/app/admin/users/[id]/user-ban-section.tsx
index b4c7676c..f321d85a 100644
--- a/src/app/admin/users/[id]/user-ban-section.tsx
+++ b/src/app/admin/users/[id]/user-ban-section.tsx
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
+import { formatDate } from "@/lib/format-date";
interface BanInfo {
id: number;
@@ -110,7 +111,7 @@ export function UserBanSection({
By {activeBan.staffName} ·{" "}
{activeBan.banExpire === 0
? "Permanent"
- : `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`}
+ : `Expires ${formatDate(new Date(activeBan.banExpire * 1000))}`}
)}
@@ -174,7 +175,7 @@ export function UserBanSection({
- {new Date(ban.timestamp * 1000).toLocaleDateString()}
+ {formatDate(new Date(ban.timestamp * 1000), "date")}
))}
diff --git a/src/app/admin/users/_components/user-account-info.tsx b/src/app/admin/users/_components/user-account-info.tsx
index 1f36020b..27480638 100644
--- a/src/app/admin/users/_components/user-account-info.tsx
+++ b/src/app/admin/users/_components/user-account-info.tsx
@@ -9,6 +9,7 @@ import {
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { formatDate } from "@/lib/format-date";
interface UserAccountInfoProps {
user: {
@@ -36,14 +37,7 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
Registered
- {new Date(user.accountCreated * 1000).toLocaleDateString(
- "it-IT",
- {
- day: "2-digit",
- month: "short",
- year: "numeric",
- },
- )}
+ {formatDate(new Date(user.accountCreated * 1000), "date")}
@@ -51,16 +45,7 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
Last Login
{user.lastLogin
- ? new Date(user.lastLogin * 1000).toLocaleDateString(
- "it-IT",
- {
- day: "2-digit",
- month: "short",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- },
- )
+ ? formatDate(new Date(user.lastLogin * 1000))
: "Never"}
diff --git a/src/app/admin/users/users-table.tsx b/src/app/admin/users/users-table.tsx
index b3188eff..9cb6ccde 100644
--- a/src/app/admin/users/users-table.tsx
+++ b/src/app/admin/users/users-table.tsx
@@ -1,8 +1,18 @@
"use client";
-import { useSearchParams } from "next/navigation";
+import { Eye, Pencil, Wifi, WifiOff } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import {
+ bulkAdjustCurrency,
+ bulkBan,
+ bulkGiveBadge,
+ bulkUnban,
+} from "@/actions/bulk-users";
import { DataTable } from "@/components/admin/data-table";
+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 {
Dialog,
@@ -21,9 +31,161 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import type { PaginatedResult } from "@/types/common";
-import { useUserBulkActions } from "./use-user-bulk-actions";
-import { getUserColumns, type UserRow } from "./user-columns";
+import { formatDate } from "@/lib/format-date";
+import { getAvatarUrl } from "@/lib/imager";
+import type { DataTableColumn, PaginatedResult } from "@/types/common";
+
+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",
+};
+
+function getColumns(canEdit: boolean): 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 (
+
+ {formatDate(new Date(ts * 1000), "date")}
+
+ );
+ },
+ },
+ {
+ key: "actions",
+ label: "",
+ render: (_, row) => (
+
+
+ {canEdit && (
+
+ )}
+
+ ),
+ },
+ ];
+
+ return cols;
+}
interface UsersTableProps {
data: PaginatedResult;
@@ -31,27 +193,144 @@ interface UsersTableProps {
canBan: boolean;
}
+type PendingDialog =
+ | { kind: "ban"; ids: number[] }
+ | { kind: "give-currency"; ids: number[] }
+ | { kind: "give-badge"; ids: number[] }
+ | null;
+
export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
- const searchParams = useSearchParams();
- const {
- pending,
- setPending,
- busy,
- banReason,
- setBanReason,
- banDuration,
- setBanDuration,
- currencyType,
- setCurrencyType,
- currencyAmount,
- setCurrencyAmount,
- badgeCode,
- setBadgeCode,
- handleBulkAction,
- confirmBan,
- confirmGiveCurrency,
- confirmGiveBadge,
- } = useUserBulkActions();
+ const [pending, setPending] = useState(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)[]) {
+ 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);
+ } 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);
+ } else toast.error(result.error);
+ setPending(null);
+ } 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,
+ );
+ } else toast.error(result.error);
+ setPending(null);
+ } 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,
+ );
+ } else toast.error(result.error);
+ setPending(null);
+ } finally {
+ setBusy(false);
+ }
+ }
+
const bulkActions: {
label: string;
value: string;
@@ -70,7 +349,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
<>
!o && !busy && setPending(null)}
+ onOpenChange={(o) => !o && setPending(null)}
>
-
+
Ban {pending?.kind === "ban" ? pending.ids.length : 0} users
@@ -139,7 +418,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
open={pending?.kind === "give-currency"}
onOpenChange={(o) => !o && setPending(null)}
>
-
+
Adjust currency for{" "}
@@ -210,7 +489,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
open={pending?.kind === "give-badge"}
onOpenChange={(o) => !o && setPending(null)}
>
-
+
Give badge to{" "}
diff --git a/src/app/api/admin/analytics/export/route.ts b/src/app/api/admin/analytics/export/route.ts
index 7df399da..61656d34 100644
--- a/src/app/api/admin/analytics/export/route.ts
+++ b/src/app/api/admin/analytics/export/route.ts
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/catalog-export/config/route.ts b/src/app/api/admin/catalog-export/config/route.ts
index 8df270f7..078d31d0 100644
--- a/src/app/api/admin/catalog-export/config/route.ts
+++ b/src/app/api/admin/catalog-export/config/route.ts
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permission-slugs";
import {
catalogStateRoot,
diff --git a/src/app/api/admin/catalog-export/route.ts b/src/app/api/admin/catalog-export/route.ts
index 6239705d..d13d0fbb 100644
--- a/src/app/api/admin/catalog-export/route.ts
+++ b/src/app/api/admin/catalog-export/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { publicCatalogConfig } from "@/lib/services/catalog-git-config";
import {
diff --git a/src/app/api/admin/catalog/icons/route.ts b/src/app/api/admin/catalog/icons/route.ts
index e22c08e8..ac265adb 100644
--- a/src/app/api/admin/catalog/icons/route.ts
+++ b/src/app/api/admin/catalog/icons/route.ts
@@ -1,5 +1,5 @@
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import {
catalogueAssetDirectories,
diff --git a/src/app/api/admin/catalog/images/route.ts b/src/app/api/admin/catalog/images/route.ts
index a371b66d..e4fb26d0 100644
--- a/src/app/api/admin/catalog/images/route.ts
+++ b/src/app/api/admin/catalog/images/route.ts
@@ -1,5 +1,5 @@
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import {
catalogueAssetDirectories,
diff --git a/src/app/api/admin/catalog/items/route.ts b/src/app/api/admin/catalog/items/route.ts
index 90284617..809db02e 100644
--- a/src/app/api/admin/catalog/items/route.ts
+++ b/src/app/api/admin/catalog/items/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
diff --git a/src/app/api/admin/catalog/suggest/route.ts b/src/app/api/admin/catalog/suggest/route.ts
index 6707e1ef..04484d07 100644
--- a/src/app/api/admin/catalog/suggest/route.ts
+++ b/src/app/api/admin/catalog/suggest/route.ts
@@ -1,5 +1,5 @@
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { batchLookupByClassnames } from "@/lib/services/habbofurni";
diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts
index f14ab994..7bc61c82 100644
--- a/src/app/api/admin/catalog/tree/route.ts
+++ b/src/app/api/admin/catalog/tree/route.ts
@@ -1,6 +1,6 @@
import { asc, count, eq, inArray, like, or } from "drizzle-orm";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
diff --git a/src/app/api/admin/csrf/route.ts b/src/app/api/admin/csrf/route.ts
index 0c985add..8d769982 100644
--- a/src/app/api/admin/csrf/route.ts
+++ b/src/app/api/admin/csrf/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { setCsrfCookie } from "@/lib/foundation/security";
export const GET = withAdmin({ requireCsrf: false }, async () => {
diff --git a/src/app/api/admin/furni/search/route.ts b/src/app/api/admin/furni/search/route.ts
index 206c4f0e..91aa449c 100644
--- a/src/app/api/admin/furni/search/route.ts
+++ b/src/app/api/admin/furni/search/route.ts
@@ -1,6 +1,6 @@
import { asc, eq, like, or } from "drizzle-orm";
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/import/badges/edit/route.ts b/src/app/api/admin/import/badges/edit/route.ts
index 8e12953a..f2b3bb9a 100644
--- a/src/app/api/admin/import/badges/edit/route.ts
+++ b/src/app/api/admin/import/badges/edit/route.ts
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
diff --git a/src/app/api/admin/import/badges/route.ts b/src/app/api/admin/import/badges/route.ts
index 89d46dab..54f93534 100644
--- a/src/app/api/admin/import/badges/route.ts
+++ b/src/app/api/admin/import/badges/route.ts
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts
index ba2331b1..d81a0d98 100644
--- a/src/app/api/admin/import/clone/batch/route.ts
+++ b/src/app/api/admin/import/clone/batch/route.ts
@@ -1,5 +1,5 @@
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
diff --git a/src/app/api/admin/import/clone/icon/route.ts b/src/app/api/admin/import/clone/icon/route.ts
index 03db3d3e..68e99f67 100644
--- a/src/app/api/admin/import/clone/icon/route.ts
+++ b/src/app/api/admin/import/clone/icon/route.ts
@@ -1,5 +1,5 @@
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { getSource } from "@/lib/services/clone-sources";
diff --git a/src/app/api/admin/import/clone/route.ts b/src/app/api/admin/import/clone/route.ts
index a082f23e..e0aa48a6 100644
--- a/src/app/api/admin/import/clone/route.ts
+++ b/src/app/api/admin/import/clone/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
diff --git a/src/app/api/admin/import/clothing/batch/route.ts b/src/app/api/admin/import/clothing/batch/route.ts
index 73f9aff1..7920d276 100644
--- a/src/app/api/admin/import/clothing/batch/route.ts
+++ b/src/app/api/admin/import/clothing/batch/route.ts
@@ -1,5 +1,5 @@
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
diff --git a/src/app/api/admin/import/clothing/route.ts b/src/app/api/admin/import/clothing/route.ts
index 52fe7ccc..a19313a8 100644
--- a/src/app/api/admin/import/clothing/route.ts
+++ b/src/app/api/admin/import/clothing/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
diff --git a/src/app/api/admin/import/clothing/sets/batch/route.ts b/src/app/api/admin/import/clothing/sets/batch/route.ts
index 8231d590..7939043b 100644
--- a/src/app/api/admin/import/clothing/sets/batch/route.ts
+++ b/src/app/api/admin/import/clothing/sets/batch/route.ts
@@ -1,5 +1,5 @@
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { importClothingSet } from "@/lib/services/clothing-set-import";
diff --git a/src/app/api/admin/import/clothing/sets/route.ts b/src/app/api/admin/import/clothing/sets/route.ts
index af2453f3..c3383681 100644
--- a/src/app/api/admin/import/clothing/sets/route.ts
+++ b/src/app/api/admin/import/clothing/sets/route.ts
@@ -1,5 +1,5 @@
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import {
getClothingSetList,
diff --git a/src/app/api/admin/import/effects/batch/route.ts b/src/app/api/admin/import/effects/batch/route.ts
index 523bc9be..c007c847 100644
--- a/src/app/api/admin/import/effects/batch/route.ts
+++ b/src/app/api/admin/import/effects/batch/route.ts
@@ -1,5 +1,5 @@
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
diff --git a/src/app/api/admin/import/effects/route.ts b/src/app/api/admin/import/effects/route.ts
index c8fc9921..3c4fe424 100644
--- a/src/app/api/admin/import/effects/route.ts
+++ b/src/app/api/admin/import/effects/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
diff --git a/src/app/api/admin/import/furni/add-size32/route.ts b/src/app/api/admin/import/furni/add-size32/route.ts
index 7c97037e..e1b74c93 100644
--- a/src/app/api/admin/import/furni/add-size32/route.ts
+++ b/src/app/api/admin/import/furni/add-size32/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { addSize32AllNitros } from "@/lib/services/repair-nitros";
diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts
index 2c71d314..7c32ad9c 100644
--- a/src/app/api/admin/import/furni/batch-regen/route.ts
+++ b/src/app/api/admin/import/furni/batch-regen/route.ts
@@ -1,8 +1,8 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts
index 9645e619..1fc0b197 100644
--- a/src/app/api/admin/import/furni/batch/route.ts
+++ b/src/app/api/admin/import/furni/batch/route.ts
@@ -1,5 +1,5 @@
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
diff --git a/src/app/api/admin/import/furni/nitro-editor/route.ts b/src/app/api/admin/import/furni/nitro-editor/route.ts
index 5d4aa5e4..0c6af927 100644
--- a/src/app/api/admin/import/furni/nitro-editor/route.ts
+++ b/src/app/api/admin/import/furni/nitro-editor/route.ts
@@ -2,8 +2,8 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import type { NextRequest } from "next/server";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts
index e29b2b77..a03b79e0 100644
--- a/src/app/api/admin/import/furni/resync/route.ts
+++ b/src/app/api/admin/import/furni/resync/route.ts
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import { asc, inArray, sql } from "drizzle-orm";
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts
index d84cd580..355732eb 100644
--- a/src/app/api/admin/import/furni/route.ts
+++ b/src/app/api/admin/import/furni/route.ts
@@ -1,8 +1,8 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import {
diff --git a/src/app/api/admin/import/furni/upload/route.ts b/src/app/api/admin/import/furni/upload/route.ts
index b3c7247b..abd8c704 100644
--- a/src/app/api/admin/import/furni/upload/route.ts
+++ b/src/app/api/admin/import/furni/upload/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { uploadSingleFurni } from "@/lib/services/upload-import";
diff --git a/src/app/api/admin/import/furni/verify/route.ts b/src/app/api/admin/import/furni/verify/route.ts
index fc6e508a..1240ddf3 100644
--- a/src/app/api/admin/import/furni/verify/route.ts
+++ b/src/app/api/admin/import/furni/verify/route.ts
@@ -1,5 +1,5 @@
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { verifyAndFixInteractionModesCount } from "@/lib/services/furni-import";
diff --git a/src/app/api/admin/import/pets/batch/route.ts b/src/app/api/admin/import/pets/batch/route.ts
index 0e8a3d1d..b3186c7e 100644
--- a/src/app/api/admin/import/pets/batch/route.ts
+++ b/src/app/api/admin/import/pets/batch/route.ts
@@ -1,5 +1,5 @@
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
diff --git a/src/app/api/admin/import/pets/route.ts b/src/app/api/admin/import/pets/route.ts
index b5fdc712..24172c62 100644
--- a/src/app/api/admin/import/pets/route.ts
+++ b/src/app/api/admin/import/pets/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
diff --git a/src/app/api/admin/permissions/assignments/route.ts b/src/app/api/admin/permissions/assignments/route.ts
index c6b989bd..1896e0f0 100644
--- a/src/app/api/admin/permissions/assignments/route.ts
+++ b/src/app/api/admin/permissions/assignments/route.ts
@@ -1,8 +1,8 @@
import { and, eq } from "drizzle-orm";
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { AclModelRole, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/permissions/roles/route.ts b/src/app/api/admin/permissions/roles/route.ts
index 7cac828e..aea4cad3 100644
--- a/src/app/api/admin/permissions/roles/route.ts
+++ b/src/app/api/admin/permissions/roles/route.ts
@@ -2,8 +2,8 @@ import { and, eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
+import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError } from "@/lib/api-response";
import { AclModelPermission, AclModelRole, AclRole, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/prefixes/blacklist/route.ts b/src/app/api/admin/prefixes/blacklist/route.ts
index 6d411fdd..3f16beb4 100644
--- a/src/app/api/admin/prefixes/blacklist/route.ts
+++ b/src/app/api/admin/prefixes/blacklist/route.ts
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/prefixes/route.ts b/src/app/api/admin/prefixes/route.ts
index 1901f693..e54e1e68 100644
--- a/src/app/api/admin/prefixes/route.ts
+++ b/src/app/api/admin/prefixes/route.ts
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/prefixes/settings/route.ts b/src/app/api/admin/prefixes/settings/route.ts
index d95238cd..f081e804 100644
--- a/src/app/api/admin/prefixes/settings/route.ts
+++ b/src/app/api/admin/prefixes/settings/route.ts
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts
index 3b6236fe..7ed25a5d 100644
--- a/src/app/api/admin/search/route.ts
+++ b/src/app/api/admin/search/route.ts
@@ -1,7 +1,7 @@
import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
+import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiOk } from "@/lib/api-response";
import {
db,
Guilds,
diff --git a/src/app/api/admin/sounds/upload/route.ts b/src/app/api/admin/sounds/upload/route.ts
index 61832750..bd636c09 100644
--- a/src/app/api/admin/sounds/upload/route.ts
+++ b/src/app/api/admin/sounds/upload/route.ts
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import { desc, eq } from "drizzle-orm";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { db, Soundtracks } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
diff --git a/src/app/api/admin/studio/import-attachment/route.ts b/src/app/api/admin/studio/import-attachment/route.ts
index ff36971b..ca35518b 100644
--- a/src/app/api/admin/studio/import-attachment/route.ts
+++ b/src/app/api/admin/studio/import-attachment/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { stageFurnitureAttachment } from "@/lib/services/furni-attachment";
diff --git a/src/app/api/admin/studio/import-jobs/route.ts b/src/app/api/admin/studio/import-jobs/route.ts
index 84504cc2..bbd1b230 100644
--- a/src/app/api/admin/studio/import-jobs/route.ts
+++ b/src/app/api/admin/studio/import-jobs/route.ts
@@ -1,7 +1,7 @@
import { after } from "next/server";
import { z } from "zod";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { redis } from "@/lib/redis";
diff --git a/src/app/api/admin/studio/inspect/route.ts b/src/app/api/admin/studio/inspect/route.ts
index 37792b9b..a9e0667c 100644
--- a/src/app/api/admin/studio/inspect/route.ts
+++ b/src/app/api/admin/studio/inspect/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { inspectFurniture } from "@/lib/services/furniture-inspection";
diff --git a/src/app/api/admin/studio/nitro-quality/route.ts b/src/app/api/admin/studio/nitro-quality/route.ts
index cdd20868..10b6926e 100644
--- a/src/app/api/admin/studio/nitro-quality/route.ts
+++ b/src/app/api/admin/studio/nitro-quality/route.ts
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import sharp from "sharp";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import {
inspectNitroScales,
object,
diff --git a/src/app/api/admin/studio/nitro-scale32/route.ts b/src/app/api/admin/studio/nitro-scale32/route.ts
index f06e337b..2ff404ef 100644
--- a/src/app/api/admin/studio/nitro-scale32/route.ts
+++ b/src/app/api/admin/studio/nitro-scale32/route.ts
@@ -1,8 +1,8 @@
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import { generateScale32 } from "@/lib/furni/nitro-scale32";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
diff --git a/src/app/api/admin/studio/source-assets/route.ts b/src/app/api/admin/studio/source-assets/route.ts
index a0e02431..62826705 100644
--- a/src/app/api/admin/studio/source-assets/route.ts
+++ b/src/app/api/admin/studio/source-assets/route.ts
@@ -1,5 +1,5 @@
+import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
-import { apiError, apiOk } from "@/lib/api-response";
import type {
SourceAssetCheck,
SourceAssetItem,
diff --git a/src/app/mod/cfh/page.tsx b/src/app/mod/cfh/page.tsx
index 4c19ad72..34c66c28 100644
--- a/src/app/mod/cfh/page.tsx
+++ b/src/app/mod/cfh/page.tsx
@@ -14,6 +14,7 @@ import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, SupportTickets, User } from "@/lib/db";
+import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
@@ -143,10 +144,7 @@ export default async function ModCfhListPage({
issue: ticket.issue,
roomId: ticket.roomId,
when: ticket.timestamp
- ? new Date(ticket.timestamp * 1000)
- .toISOString()
- .slice(0, 16)
- .replace("T", " ")
+ ? formatDate(new Date(ticket.timestamp * 1000))
: "—",
sender: userMap.get(ticket.senderId) ?? "—",
senderId: ticket.senderId,
diff --git a/src/app/mod/team/page.tsx b/src/app/mod/team/page.tsx
index 6fdc04cc..1d8cb980 100644
--- a/src/app/mod/team/page.tsx
+++ b/src/app/mod/team/page.tsx
@@ -12,6 +12,7 @@ import {
User,
WebsiteTicket,
} from "@/lib/db";
+import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
export default async function ModTeamLitePage() {
@@ -157,7 +158,7 @@ export default async function ModTeamLitePage() {
{member.online === "1"
? "Online"
: member.lastOnline
- ? new Date(member.lastOnline * 1000).toLocaleDateString()
+ ? formatDate(new Date(member.lastOnline * 1000), "date")
: "—"}
diff --git a/src/app/admin/theme/color-field.tsx b/src/components/admin/color-field.tsx
similarity index 67%
rename from src/app/admin/theme/color-field.tsx
rename to src/components/admin/color-field.tsx
index 37a54cbc..3baf2806 100644
--- a/src/app/admin/theme/color-field.tsx
+++ b/src/components/admin/color-field.tsx
@@ -1,59 +1,25 @@
"use client";
import { useState } from "react";
+import { contrastRatio, readableColor } from "@/lib/theme-contrast";
-interface Field {
- key: string;
+type ColorFieldProps = {
+ name: string;
label: string;
- def: string;
- desc: string;
- bgKey?: string;
-}
-
-function hexToRgb(hex: string): readonly [number, number, number] | null {
- const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
- if (!m) return null;
- const n = Number.parseInt(m[1], 16);
- return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
-}
-
-function luminance([r, g, b]: readonly [number, number, number]): number {
- const a = [r, g, b].map((v) => {
- const c = v / 255;
- return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
- });
- return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
-}
-
-function contrastRatio(a: string, b: string): number | null {
- const ra = hexToRgb(a);
- const rb = hexToRgb(b);
- if (!ra || !rb) return null;
- const l1 = luminance(ra);
- const l2 = luminance(rb);
- const hi = Math.max(l1, l2);
- const lo = Math.min(l1, l2);
- return (hi + 0.05) / (lo + 0.05);
-}
-
-function readableOn(bg: string): string {
- const black = contrastRatio("#000000", bg) ?? 0;
- const white = contrastRatio("#ffffff", bg) ?? 0;
- return white >= black ? "#ffffff" : "#000000";
-}
+ description: string;
+ defaultValue: string;
+ /** Background color to check the foreground contrast against. */
+ bgValue?: string;
+};
export function ColorField({
- field,
- value,
- bgValue,
name,
-}: {
- field: Field;
- value: string;
- bgValue?: string;
- name: string;
-}) {
- const [val, setVal] = useState(value);
+ label,
+ description,
+ defaultValue,
+ bgValue,
+}: ColorFieldProps) {
+ const [val, setVal] = useState(defaultValue);
const ratio = bgValue ? contrastRatio(val, bgValue) : null;
const poor = ratio != null && ratio < 4.5;
const ok = ratio != null && ratio >= 4.5;
@@ -78,7 +44,7 @@ export function ColorField({
if (bgValue) {
const nextRatio = contrastRatio(next, bgValue);
if (nextRatio != null && nextRatio < 4.5) {
- setVal(readableOn(bgValue));
+ setVal(readableColor("#ffffff", [bgValue]));
return;
}
}
@@ -113,7 +79,7 @@ export function ColorField({
- {field.label}
+ {label}
{ratio != null ? (
- {field.desc}
+ {description}
{bgValue ? (
setVal(readableOn(bgValue))}
+ onClick={() => setVal(readableColor("#ffffff", [bgValue]))}
style={{
marginTop: 4,
alignSelf: "flex-start",
diff --git a/src/components/admin/import-error-banner.tsx b/src/components/admin/import-error-banner.tsx
index a0ac2fb2..32014fd7 100644
--- a/src/components/admin/import-error-banner.tsx
+++ b/src/components/admin/import-error-banner.tsx
@@ -1,5 +1,6 @@
import { AlertCircle } from "lucide-react";
+/** Shared destructive banner for the import/admin clients. */
export function ImportErrorBanner({ error }: { error: string }) {
return (
"']/.test(url)) return "";
- if (/(data|javascript|vbscript|file):/i.test(url)) return "";
- return url;
-}
+import type { MediaFile } from "@/lib/media-files";
+import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -47,13 +35,7 @@ export function AdminMediaGrid() {
const load = useCallback(async () => {
setLoading(true);
- try {
- const res = await fetch("/api/media");
- const data = await res.json();
- setFiles(data.files ?? []);
- } catch {
- /* ignore */
- }
+ setFiles(await loadMediaFiles());
setLoading(false);
}, []);
diff --git a/src/components/admin/media-picker.tsx b/src/components/admin/media-picker.tsx
index 9c670b31..7f2c3019 100644
--- a/src/components/admin/media-picker.tsx
+++ b/src/components/admin/media-picker.tsx
@@ -1,15 +1,9 @@
"use client";
-import { useState } from "react";
-import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
-import { useMediaLibrary } from "./use-media-library";
-
-function validImageUrl(url: string): string {
- if (!url.startsWith("/")) return "";
- if (/[<>"']/.test(url)) return "";
- if (/(data|javascript|vbscript|file):/i.test(url)) return "";
- return url;
-}
+import { useCallback, useEffect, useRef, useState } from "react";
+import { uploadMediaAndReturn } from "@/actions/admin-media";
+import type { MediaFile } from "@/lib/media-files";
+import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
export function MediaPicker({
onSelect,
@@ -19,8 +13,40 @@ export function MediaPicker({
current?: string;
}) {
const [open, setOpen] = useState(false);
- const { files, loading, uploading, fileRef, error, load, handleUpload } =
- useMediaLibrary(open, setOpen, onSelect);
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const fileRef = useRef(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setFiles(await loadMediaFiles());
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ if (open) load();
+ }, [open, load]);
+
+ async function handleUpload(e: React.ChangeEvent) {
+ const files = e.target.files;
+ if (!files || files.length === 0) return;
+ setUploading(true);
+ const urls: string[] = [];
+ for (const file of Array.from(files)) {
+ const fd = new FormData();
+ fd.set("file", file);
+ const url = await uploadMediaAndReturn(fd);
+ if (url) urls.push(url);
+ }
+ if (urls.length > 0) {
+ await load();
+ onSelect(urls[urls.length - 1]); // select the last one for backward compatibility
+ setOpen(false);
+ }
+ setUploading(false);
+ if (fileRef.current) fileRef.current.value = "";
+ }
return (
<>
@@ -49,107 +75,104 @@ export function MediaPicker({
)}
-