refactor(cms): deduplicate shared admin components and fix studio batch completion
CI / check (push) Failing after 1m35s
CI / deploy (push) Skipped
CI / e2e (push) Skipped

- fix(studio): stop markBatchDone infinite recursion so batches complete
- refactor(api): merge api-response into api and drop the duplicate module
- refactor(media): extract shared media loader and URL validator
- refactor(ui): extract shared LoadingSpinner for site and admin groups
- refactor(dates): consolidate raw date formatting into formatDate util
- refactor(logs): share a single generic log-list loader across tables
- refactor(theme): merge both ColorField components and reuse contrast helpers
- refactor(import): extract shared ImportErrorBanner and SearchInput
- chore(): remove dead theme-editor-tabs after inlining tab components
This commit is contained in:
openhands committed 2026-09-05 20:58:42 +02:00
1 parent b12e405b34
commit 8c12fc6c60
93 files changed
+1447 -1005

No files matched your search

+4 -14
View File
@@ -1,15 +1,5 @@
export default function FriendsLoading() { import { LoadingSpinner } from "@/components/ui/loading-spinner";
return (
<div export default function Loading() {
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }} return <LoadingSpinner />;
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
} }
+4 -14
View File
@@ -1,15 +1,5 @@
export default function GuildForumLoading() { import { LoadingSpinner } from "@/components/ui/loading-spinner";
return (
<div export default function Loading() {
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }} return <LoadingSpinner />;
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
} }
+4 -14
View File
@@ -1,15 +1,5 @@
export default function SiteLoading() { import { LoadingSpinner } from "@/components/ui/loading-spinner";
return (
<div export default function Loading() {
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }} return <LoadingSpinner />;
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
} }
+4 -14
View File
@@ -1,15 +1,5 @@
export default function MessagesLoading() { import { LoadingSpinner } from "@/components/ui/loading-spinner";
return (
<div export default function Loading() {
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }} return <LoadingSpinner />;
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
} }
+4 -14
View File
@@ -1,15 +1,5 @@
export default function NewsLoading() { import { LoadingSpinner } from "@/components/ui/loading-spinner";
return (
<div export default function Loading() {
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }} return <LoadingSpinner />;
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
} }
+4 -14
View File
@@ -1,15 +1,5 @@
export default function ShopLoading() { import { LoadingSpinner } from "@/components/ui/loading-spinner";
return (
<div export default function Loading() {
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }} return <LoadingSpinner />;
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
} }
@@ -30,7 +30,6 @@ import {
RotateCcw, RotateCcw,
Rows3, Rows3,
Save, Save,
Search,
Trash2, Trash2,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -48,6 +47,7 @@ import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview"; import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni"; import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { SearchInput } from "@/components/admin/search-input";
import { import {
CurrencyIcon, CurrencyIcon,
currencyKindFromPointsType, currencyKindFromPointsType,
@@ -1082,24 +1082,12 @@ export function CatalogItemsTable({
{/* Search bar */} {/* Search bar */}
{items.length > 0 && ( {items.length > 0 && (
<div className="relative"> <SearchInput
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> placeholder="Filter by name, classname, item ID..."
<Input value={searchQuery}
placeholder="Filter by name, classname, item ID..." onChange={setSearchQuery}
value={searchQuery} onClear={() => setSearchQuery("")}
onChange={(e) => setSearchQuery(e.target.value)} />
className="pl-9 h-9"
/>
{searchQuery && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+2 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { db, EmulatorErrors } from "@/lib/db"; import { db, EmulatorErrors } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function EmulatorErrorsPage({ export default async function EmulatorErrorsPage({
@@ -55,7 +56,7 @@ export default async function EmulatorErrorsPage({
#{error.id} #{error.id}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(error.timestamp * 1000).toLocaleString()} {formatDate(new Date(error.timestamp * 1000))}
</span> </span>
</div> </div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded"> <pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
+2 -1
View File
@@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fetchOpsHealth } from "@/lib/admin/ops-health"; import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db"; import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { HealthCheckClient } from "./health-check-client"; import { HealthCheckClient } from "./health-check-client";
@@ -168,7 +169,7 @@ export default async function DevOpsPage() {
#{error.id} #{error.id}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(error.timestamp * 1000).toLocaleString()} {formatDate(new Date(error.timestamp * 1000))}
</span> </span>
</div> </div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive"> <pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDate } from "@/lib/format-date";
interface Registration { interface Registration {
id: number; id: number;
@@ -36,7 +37,7 @@ export function EventRegistrations({ registrations }: Props) {
> >
<span className="text-sm font-medium">{r.username}</span> <span className="text-sm font-medium">{r.username}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleString()} {formatDate(new Date(r.createdAt))}
</span> </span>
</div> </div>
))} ))}
+2 -1
View File
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
interface Winner { interface Winner {
id: number; id: number;
@@ -93,7 +94,7 @@ export function EventWinnersManager({ eventId, winners }: Props) {
</span> </span>
<span className="font-medium text-sm">{w.username}</span> <span className="font-medium text-sm">{w.username}</span>
<span className="text-xs text-muted-foreground ml-auto"> <span className="text-xs text-muted-foreground ml-auto">
{new Date(w.createdAt).toLocaleDateString()} {formatDate(new Date(w.createdAt), "date")}
</span> </span>
</div> </div>
))} ))}
+3 -2
View File
@@ -6,6 +6,7 @@ import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db, User, WebsiteEvent, WebsiteEventType } from "@/lib/db"; import { db, User, WebsiteEvent, WebsiteEventType } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EventForm } from "../event-form"; import { EventForm } from "../event-form";
import { EventPrizesManager } from "./event-prizes"; import { EventPrizesManager } from "./event-prizes";
@@ -94,7 +95,7 @@ export default async function EventDetailPage({
</Badge> </Badge>
</div> </div>
<p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]"> <p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]">
{event.type.name} &middot; {new Date(event.startsAt).toLocaleString()} {event.type.name} &middot; {formatDate(new Date(event.startsAt))}
</p> </p>
</div> </div>
@@ -156,7 +157,7 @@ export default async function EventDetailPage({
{event.endsAt && ( {event.endsAt && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("endsAt", { {t("endsAt", {
date: new Date(event.endsAt).toLocaleString(), date: formatDate(new Date(event.endsAt)),
})} })}
</p> </p>
)} )}
+2 -1
View File
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import type { DataTableColumn, PaginatedResult } from "@/types/common"; import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface EventRow { interface EventRow {
@@ -86,7 +87,7 @@ export function EventsTable({ data }: EventsTableProps) {
sortable: true, sortable: true,
render: (value) => ( render: (value) => (
<span className="text-sm"> <span className="text-sm">
{new Date(value as unknown as string).toLocaleString()} {formatDate(new Date(value as unknown as string))}
</span> </span>
), ),
}, },
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
interface ThreadMessage { interface ThreadMessage {
key: string; key: string;
@@ -162,7 +163,7 @@ export function AdminHelpTicketDetail({
{msg.isStaff ? ` (${t("staffBadge")})` : ""} {msg.isStaff ? ` (${t("staffBadge")})` : ""}
</span> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleString()} {formatDate(new Date(msg.createdAt))}
</span> </span>
</div> </div>
<div <div
@@ -308,11 +309,11 @@ export function AdminHelpTicketDetail({
<CardContent className="space-y-2 text-xs text-muted-foreground"> <CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>{t("colCreated")}</span> <span>{t("colCreated")}</span>
<span>{new Date(ticket.createdAt).toLocaleString()}</span> <span>{formatDate(new Date(ticket.createdAt))}</span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>{t("colUpdated")}</span> <span>{t("colUpdated")}</span>
<span>{new Date(ticket.updatedAt).toLocaleString()}</span> <span>{formatDate(new Date(ticket.updatedAt))}</span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>{t("messageCountLabel")}</span> <span>{t("messageCountLabel")}</span>
@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
AlertCircle,
BadgeCheck, BadgeCheck,
Check, Check,
CheckCircle2, CheckCircle2,
@@ -15,6 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -348,12 +348,7 @@ export function ImportBadgesClient() {
)} )}
{/* Error */} {/* Error */}
{error && ( {error && <ImportErrorBanner error={error} />}
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Loading skeleton */} {/* Loading skeleton */}
{loading && badges.length === 0 && ( {loading && badges.length === 0 && (
@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
AlertCircle,
Check, Check,
CloudDownload, CloudDownload,
Download, Download,
@@ -16,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -782,12 +782,7 @@ function FurniGrid({ source }: FurniGridProps) {
)} )}
{/* Error */} {/* Error */}
{error && ( {error && <ImportErrorBanner error={error} />}
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Empty state */} {/* Empty state */}
{!loading && items.length === 0 && !error && ( {!loading && items.length === 0 && !error && (
@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
AlertCircle,
Check, Check,
Download, Download,
Loader2, Loader2,
@@ -13,6 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -361,12 +361,7 @@ function LibsView() {
</div> </div>
)} )}
{error && ( {error && <ImportErrorBanner error={error} />}
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && items.length === 0 && !error && ( {!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
@@ -749,12 +744,7 @@ function SetsView() {
</div> </div>
)} )}
{error && ( {error && <ImportErrorBanner error={error} />}
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && items.length === 0 && !error && ( {!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
@@ -2,7 +2,6 @@
"use client"; "use client";
import { import {
AlertCircle,
Check, Check,
Download, Download,
Loader2, Loader2,
@@ -15,6 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -314,12 +314,7 @@ export function ImportEffectsClient() {
)} )}
{/* Error */} {/* Error */}
{error && ( {error && <ImportErrorBanner error={error} />}
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Empty */} {/* Empty */}
{!loading && visible.length === 0 && !error && ( {!loading && visible.length === 0 && !error && (
@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
AlertCircle,
Check, Check,
Download, Download,
Loader2, Loader2,
@@ -13,6 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -293,12 +293,7 @@ export function ImportPetsClient() {
</div> </div>
)} )}
{error && ( {error && <ImportErrorBanner error={error} />}
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && pets.length === 0 && !error && ( {!loading && pets.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
+4 -14
View File
@@ -1,15 +1,5 @@
export default function AdminLoading() { import { LoadingSpinner } from "@/components/ui/loading-spinner";
return (
<div export default function Loading() {
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }} return <LoadingSpinner accentVar="var(--admin-accent)" />;
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--admin-accent)",
borderTopColor: "transparent",
}}
/>
</div>
);
} }
+102 -155
View File
@@ -9,6 +9,16 @@ type ListParamsInput = {
defaultPerPage?: number; defaultPerPage?: number;
}; };
type SqlFragment = ReturnType<typeof sql>;
type LogListSpec<RawRow, Row> = {
select: SqlFragment;
from: SqlFragment;
searchWhere: (like: string) => SqlFragment;
orderBy: SqlFragment;
mapRow: (raw: RawRow, index: number, offset: number) => Row;
};
async function requireLogsView() { async function requireLogsView() {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) { if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
@@ -26,11 +36,56 @@ function parsePageParams(
return { search: parsed.search.trim(), page: parsed.page, perPage }; return { search: parsed.search.trim(), page: parsed.page, perPage };
} }
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> { async function rawRows<T>(query: SqlFragment): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? []; 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<RawRow, Row>(
input: ListParamsInput,
spec: LogListSpec<RawRow, Row>,
): 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<RawRow>(
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 = { export type ChatLogListRow = {
id: number; id: number;
username: string; username: string;
@@ -39,17 +94,7 @@ export type ChatLogListRow = {
roomId: number; roomId: number;
}; };
export async function loadChatLogList({ export function loadChatLogList(input: ListParamsInput) {
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}%`;
type RawRow = { type RawRow = {
roomId: number; roomId: number;
message: string; message: string;
@@ -57,43 +102,20 @@ export async function loadChatLogList({
username: string | null; username: string | null;
}; };
const [rawRowsResult, countRows] = search return loadLogList<RawRow, ChatLogListRow>(input, {
? await Promise.all([ select: sql`SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username`,
rawRows<RawRow>(sql` from: sql`FROM chatlogs_room c LEFT JOIN users u ON u.id = c.user_from_id`,
SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username searchWhere: (like) =>
FROM chatlogs_room c sql`c.message LIKE ${like} OR u.username LIKE ${like}`,
LEFT JOIN users u ON u.id = c.user_from_id orderBy: sql`ORDER BY c.timestamp DESC`,
WHERE c.message LIKE ${like} OR u.username LIKE ${like} mapRow: (row, index, offset) => ({
ORDER BY c.timestamp DESC id: offset + index + 1,
LIMIT ${perPage} OFFSET ${offset}`), username: row.username ?? "-",
rawRows<{ count: bigint }>(sql` message: row.message,
SELECT COUNT(*) AS count timestamp: row.timestamp,
FROM chatlogs_room c roomId: row.roomId,
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<RawRow>(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 };
} }
export type CommandLogListRow = { export type CommandLogListRow = {
@@ -104,17 +126,7 @@ export type CommandLogListRow = {
timestamp: number; timestamp: number;
}; };
export async function loadCommandLogList({ export function loadCommandLogList(input: ListParamsInput) {
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}%`;
type RawRow = { type RawRow = {
command: string; command: string;
params: string; params: string;
@@ -122,47 +134,20 @@ export async function loadCommandLogList({
username: string | null; username: string | null;
}; };
const [rawRowsResult, countRows] = search return loadLogList<RawRow, CommandLogListRow>(input, {
? await Promise.all([ select: sql`SELECT c.command, c.params, c.timestamp, u.username AS username`,
rawRows<RawRow>(sql` from: sql`FROM commandlogs c LEFT JOIN users u ON u.id = c.user_id`,
SELECT c.command, c.params, c.timestamp, u.username AS username searchWhere: (like) =>
FROM commandlogs c sql`c.command LIKE ${like} OR c.params LIKE ${like} OR u.username LIKE ${like}`,
LEFT JOIN users u ON u.id = c.user_id orderBy: sql`ORDER BY c.timestamp DESC`,
WHERE c.command LIKE ${like} mapRow: (row, index, offset) => ({
OR c.params LIKE ${like} id: offset + index + 1,
OR u.username LIKE ${like} username: row.username ?? "-",
ORDER BY c.timestamp DESC command: row.command,
LIMIT ${perPage} OFFSET ${offset}`), params: row.params,
rawRows<{ count: bigint }>(sql` timestamp: row.timestamp,
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<RawRow>(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 };
} }
export type TradeLogListRow = { export type TradeLogListRow = {
@@ -173,17 +158,7 @@ export type TradeLogListRow = {
timestamp: number; timestamp: number;
}; };
export async function loadTradeLogList({ export function loadTradeLogList(input: ListParamsInput) {
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}%`;
type RawRow = { type RawRow = {
id: number; id: number;
timestamp: number; timestamp: number;
@@ -191,47 +166,19 @@ export async function loadTradeLogList({
userTwoName: string | null; userTwoName: string | null;
}; };
const [rawRowsResult, countRows] = search return loadLogList<RawRow, TradeLogListRow>(input, {
? await Promise.all([ select: sql`SELECT t.id, t.timestamp, u1.username AS userOneName, u2.username AS userTwoName`,
rawRows<RawRow>(sql` 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`,
SELECT t.id, t.timestamp, searchWhere: (like) =>
u1.username AS userOneName, u2.username AS userTwoName sql`u1.username LIKE ${like} OR u2.username LIKE ${like}`,
FROM room_trade_log t orderBy: sql`ORDER BY t.timestamp DESC`,
LEFT JOIN users u1 ON u1.id = t.user_one_id mapRow: (row) => ({
LEFT JOIN users u2 ON u2.id = t.user_two_id id: row.id,
WHERE u1.username LIKE ${like} OR u2.username LIKE ${like} userOneName: row.userOneName ?? "-",
ORDER BY t.timestamp DESC userTwoName: row.userTwoName ?? "-",
LIMIT ${perPage} OFFSET ${offset}`), // room_trade_log only stores completed trades; no status column exists
rawRows<{ count: bigint }>(sql` status: "completed",
SELECT COUNT(*) AS count timestamp: row.timestamp,
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<RawRow>(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 };
} }
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
const CFH_STATES: Record<number, { label: string; class: string }> = { const CFH_STATES: Record<number, { label: string; class: string }> = {
0: { 0: {
@@ -130,7 +131,7 @@ export function CfhDetail({
</div> </div>
<h1 className="text-xl font-bold">Call For Help Report</h1> <h1 className="text-xl font-bold">Call For Help Report</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{new Date(ticket.timestamp * 1000).toLocaleString()} · Room # {formatDate(new Date(ticket.timestamp * 1000))} · Room #
{ticket.roomId} {ticket.roomId}
</p> </p>
</div> </div>
@@ -317,7 +318,7 @@ export function CfhDetail({
<p className="font-medium">{ban.reason || "No reason"}</p> <p className="font-medium">{ban.reason || "No reason"}</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{ban.type} · by {ban.staff} ·{" "} {ban.type} · by {ban.staff} ·{" "}
{new Date(ban.timestamp * 1000).toLocaleDateString()} {formatDate(new Date(ban.timestamp * 1000), "date")}
</p> </p>
</div> </div>
))} ))}
@@ -431,9 +432,7 @@ export function CfhDetail({
<CardContent className="space-y-2 text-xs text-muted-foreground"> <CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between"> <div className="flex justify-between">
<span>Created</span> <span>Created</span>
<span> <span>{formatDate(new Date(ticket.timestamp * 1000))}</span>
{new Date(ticket.timestamp * 1000).toLocaleString()}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Room</span> <span>Room</span>
+2 -1
View File
@@ -12,6 +12,7 @@ import {
User, User,
WebsiteTicket, WebsiteTicket,
} from "@/lib/db"; } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ModTeamPage() { export default async function ModTeamPage() {
@@ -169,7 +170,7 @@ export default async function ModTeamPage() {
{member.online === "1" {member.online === "1"
? "Online" ? "Online"
: member.lastOnline : member.lastOnline
? new Date(member.lastOnline * 1000).toLocaleDateString() ? formatDate(new Date(member.lastOnline * 1000), "date")
: "—"} : "—"}
</span> </span>
</Link> </Link>
@@ -6,7 +6,6 @@ import {
Crown, Crown,
Loader2, Loader2,
Save, Save,
Search,
Settings, Settings,
Shield, Shield,
ShieldCheck, ShieldCheck,
@@ -19,6 +18,7 @@ import {
saveRank as saveRankAction, saveRank as saveRankAction,
setCmsPermissions, setCmsPermissions,
} from "@/actions/permissions"; } from "@/actions/permissions";
import { SearchInput } from "@/components/admin/search-input";
import Link from "@/components/link"; import Link from "@/components/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -521,15 +521,11 @@ export function RankEditClient({
</div> </div>
</div> </div>
<div className="relative"> <SearchInput
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> placeholder="Search permissions..."
<Input value={permSearch}
placeholder="Search permissions..." onChange={setPermSearch}
value={permSearch} />
onChange={(e) => setPermSearch(e.target.value)}
className="pl-9"
/>
</div>
<PermSection title="Commands" keys={cats.commands} sectionKey="cmd" /> <PermSection title="Commands" keys={cats.commands} sectionKey="cmd" />
<PermSection title="Account" keys={cats.account} sectionKey="acc" /> <PermSection title="Account" keys={cats.account} sectionKey="acc" />
@@ -606,15 +602,11 @@ export function RankEditClient({
</div> </div>
</div> </div>
<div className="relative"> <SearchInput
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> placeholder="Search CMS permissions..."
<Input value={cmsSearch}
placeholder="Search CMS permissions..." onChange={setCmsSearch}
value={cmsSearch} />
onChange={(e) => setCmsSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
{filteredCms.map((perm) => { {filteredCms.map((perm) => {
+2 -5
View File
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { CameraWeb, db, User } from "@/lib/db"; import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminPhotos({ export default async function AdminPhotos({
@@ -133,11 +134,7 @@ export default async function AdminPhotos({
> >
{t("byUser", { username })} {t("byUser", { username })}
</Link>{" "} </Link>{" "}
·{" "} · {formatDate(new Date(p.timestamp * 1000))}
{new Date(p.timestamp * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ")}
</p> </p>
{canEdit ? ( {canEdit ? (
<form <form
+2 -1
View File
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import type { DataTableColumn, PaginatedResult } from "@/types/common"; import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface PollRow { interface PollRow {
@@ -93,7 +94,7 @@ export function PollsTable({ data }: PollsTableProps) {
sortable: true, sortable: true,
render: (value) => ( render: (value) => (
<span className="text-sm"> <span className="text-sm">
{new Date(value as unknown as string).toLocaleDateString()} {formatDate(new Date(value as unknown as string), "date")}
</span> </span>
), ),
}, },
+2 -1
View File
@@ -24,6 +24,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import { PrefixDialog } from "./prefix-dialog"; import { PrefixDialog } from "./prefix-dialog";
import { import {
@@ -314,7 +315,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<TableCell>{prefix.active ? "Yes" : "No"}</TableCell> <TableCell>{prefix.active ? "Yes" : "No"}</TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
{prefix.created_at {prefix.created_at
? new Date(prefix.created_at).toLocaleDateString() ? formatDate(new Date(prefix.created_at), "date")
: "—"} : "—"}
</TableCell> </TableCell>
{canEdit && ( {canEdit && (
@@ -1,12 +1,13 @@
"use client"; "use client";
import { Search, Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
createSetting, createSetting,
deleteSetting, deleteSetting,
updateSetting, updateSetting,
} from "@/actions/admin-settings"; } from "@/actions/admin-settings";
import { SearchInput } from "@/components/admin/search-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { MANAGED_SETTING_KEYS } from "./cms-settings-config"; import { MANAGED_SETTING_KEYS } from "./cms-settings-config";
@@ -64,15 +65,11 @@ export function AdvancedSettingsPanel({
</label> </label>
</div> </div>
<div className="relative"> <SearchInput
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> value={query}
<Input onChange={setQuery}
value={query} placeholder="Search keys, values, comments…"
onChange={(e) => setQuery(e.target.value)} />
placeholder="Search keys, values, comments…"
className="pl-9"
/>
</div>
{canEdit ? ( {canEdit ? (
<form <form
@@ -1,42 +0,0 @@
"use client";
import { useState } from "react";
export function ColorField({
name,
label,
description,
defaultValue,
}: {
name: string;
label: string;
description: string;
defaultValue: string;
backgroundColorKey?: string;
}) {
const [val, setVal] = useState(defaultValue);
return (
<div className="rounded-lg p-2">
<div className="flex items-start gap-2">
<input
type="color"
name={name}
value={val}
onChange={(e) => setVal(e.target.value)}
className="w-[42px] h-[36px] p-0 border-none bg-none cursor-pointer flex-shrink-0 rounded"
/>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-[var(--admin-text)] leading-tight">
{label}
</span>
</div>
<span className="text-xs text-[var(--admin-text-muted)] leading-snug line-clamp-2">
{description}
</span>
</div>
</div>
</div>
);
}
@@ -1,273 +0,0 @@
"use client";
import { BLOCK_KEY_PREFIX, getBlocksByCategory } from "@/lib/theme-blocks";
import { ColorField } from "./color-field";
import {
COLOR_FIELDS,
EFFECT_FIELDS,
getVal,
LAYOUT_FIELDS,
MEDIA_FIELDS,
type ScopeValues,
} from "./theme-editor-fields";
// ─── Colors Tab ──────────────────────────────────────────────────────────────
export function ColorsTab({
values,
mode,
}: {
values: ScopeValues;
mode: "light" | "dark";
}) {
return (
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Colors ({mode} mode)
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Only values you set here override the parent scope. Leave empty to
inherit.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{COLOR_FIELDS.map((field) => (
<ColorField
key={`${field.key}-${mode}`}
name={`${field.key}_${mode}`}
label={field.label}
description={field.desc}
defaultValue={getVal(values, field.key, mode) || field.def}
backgroundColorKey={
field.bgKey ? `${field.bgKey}_${mode}` : undefined
}
/>
))}
</div>
</div>
);
}
// ─── Blocks Tab ──────────────────────────────────────────────────────────────
export function BlocksTab({ values }: { values: ScopeValues }) {
const grouped = getBlocksByCategory();
const categoryLabels: Record<string, string> = {
chrome: "Site Chrome",
layout: "Layout",
components: "Components",
effects: "Effects",
};
return (
<div className="space-y-4">
{Object.entries(grouped).map(([category, blocks]) => (
<div key={category} className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-1">
{categoryLabels[category] ?? category}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Toggle visibility of blocks in this scope. Hidden blocks use{" "}
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
display: none
</code>
.
</p>
<div className="space-y-2">
{blocks.map((block) => {
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
const currentVal = values.blocks[key];
const isChecked =
currentVal === "true" ||
(currentVal === undefined && block.defaultVisible);
return (
<label
key={block.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--admin-surface-elevated)] cursor-pointer"
>
<input
type="checkbox"
name={key}
defaultChecked={isChecked}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-[var(--admin-text)]">
{block.label}
</span>
<span className="text-xs text-[var(--admin-text-muted)] ml-2">
{block.description}
</span>
</div>
<code className="text-[10px] text-[var(--admin-text-muted)] font-mono">
{block.selector}
</code>
</label>
);
})}
</div>
</div>
))}
</div>
);
}
// ─── Layout Tab ──────────────────────────────────────────────────────────────
export function LayoutTab({ values }: { values: ScopeValues }) {
return (
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Layout Settings
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Control spacing, width, and grid settings for this scope. Values are
applied as CSS custom properties.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{LAYOUT_FIELDS.map((field) => (
<div key={field.key} className="rounded-lg p-2">
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
{field.label}
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
{field.desc}
</p>
<input
type={field.type || "text"}
name={field.key}
defaultValue={getVal(values, field.key, "light") || field.def}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
</div>
))}
</div>
</div>
);
}
// ─── Effects Tab ─────────────────────────────────────────────────────────────
export function EffectsTab({ values }: { values: ScopeValues }) {
return (
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Effects & Transitions
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Control shadows, borders, and animation timing for this scope.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{EFFECT_FIELDS.map((field) => (
<div key={field.key} className="rounded-lg p-2">
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
{field.label}
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
{field.desc}
</p>
<input
type={field.type || "text"}
name={field.key}
defaultValue={getVal(values, field.key, "light") || field.def}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
</div>
))}
</div>
</div>
);
}
// ─── Media Tab ───────────────────────────────────────────────────────────────
export function MediaTab({ values }: { values: ScopeValues }) {
return (
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Media & Backgrounds
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Set custom logos, background images, and background properties for this
scope.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{MEDIA_FIELDS.map((field) => (
<div key={field.key} className="rounded-lg p-2">
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
{field.label}
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
{field.desc}
</p>
<input
type={field.type || "text"}
name={field.key}
defaultValue={getVal(values, field.key, "light") || field.def}
placeholder={field.def}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
</div>
))}
</div>
</div>
);
}
// ─── Custom Tab ──────────────────────────────────────────────────────────────
export function CustomTab({ values }: { values: ScopeValues }) {
return (
<div className="space-y-4">
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Custom CSS
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Add custom CSS that applies within this scope. Use{" "}
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
&{" "}
</code>{" "}
as a placeholder for the scope selector (e.g.{" "}
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
& .content-card {"{"} border: 2px solid red; {"}"}
</code>
).
</p>
<textarea
name="custom:css"
defaultValue={getVal(values, "custom:css", "light")}
rows={8}
placeholder={".content-card { border-radius: 20px; }"}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
style={{ borderColor: "var(--admin-border)" }}
/>
</div>
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Custom HTML
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Add custom HTML that injects into the page for this scope. Use for
custom banners, scripts, or content blocks.
</p>
<textarea
name="custom:html"
defaultValue={getVal(values, "custom:html", "light")}
rows={6}
placeholder='<div class="my-banner">Welcome!</div>'
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
style={{ borderColor: "var(--admin-border)" }}
/>
</div>
</div>
);
}
+612 -18
View File
@@ -1,22 +1,361 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useFormStatus } from "react-dom";
import { import {
deleteScope, deleteScope,
duplicateScope, duplicateScope,
saveScopeValues, saveScopeValues,
} from "@/actions/theme-builder"; } from "@/actions/theme-builder";
import { ColorField } from "@/components/admin/color-field";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { ScopeInfo, ScopeValues } from "./theme-editor-fields";
import { import {
BlocksTab, BLOCK_KEY_PREFIX,
ColorsTab, type EffectKey,
CustomTab, getBlocksByCategory,
EffectsTab, type LayoutKey,
LayoutTab, type MediaKey,
MediaTab, } from "@/lib/theme-blocks";
} from "./theme-editor-tabs"; import type { ThemeColorKey } from "@/lib/theme-presets";
interface ScopeInfo {
id: number;
name: string;
type: string;
parentId: number | null;
siteDomain: string | null;
routePath: string | null;
moduleId: string | null;
isActive: boolean;
}
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 ────────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────
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 ────────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────────────
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] ?? "";
}
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
global: "🌍 Global", global: "🌍 Global",
@@ -110,7 +449,6 @@ export function ThemeEditor({
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden"> <div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
<button <button
type="button" type="button"
aria-pressed={mode === "light"}
onClick={() => setMode("light")} onClick={() => setMode("light")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
mode === "light" mode === "light"
@@ -122,7 +460,6 @@ export function ThemeEditor({
</button> </button>
<button <button
type="button" type="button"
aria-pressed={mode === "dark"}
onClick={() => setMode("dark")} onClick={() => setMode("dark")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
mode === "dark" mode === "dark"
@@ -142,7 +479,6 @@ export function ThemeEditor({
<button <button
key={t.id} key={t.id}
type="button" type="button"
aria-pressed={tab === t.id}
onClick={() => setTab(t.id)} onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${ className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
tab === t.id tab === t.id
@@ -168,18 +504,276 @@ export function ThemeEditor({
{tab === "custom" && <CustomTab values={values} />} {tab === "custom" && <CustomTab values={values} />}
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 mt-4">
<ThemeSaveButton /> <Button type="submit" variant="default">
Save Theme Values
</Button>
</div> </div>
</form> </form>
</div> </div>
); );
} }
function ThemeSaveButton() { // ─── Colors Tab ──────────────────────────────────────────────────────────────
const { pending } = useFormStatus();
function ColorsTab({
values,
mode,
}: {
values: ScopeValues;
mode: "light" | "dark";
}) {
return ( return (
<Button type="submit" disabled={pending} aria-busy={pending}> <div className="admin-card p-4">
{pending ? "Saving theme…" : "Save Theme Values"} <h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
</Button> Colors ({mode} mode)
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Only values you set here override the parent scope. Leave empty to
inherit.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{COLOR_FIELDS.map((field) => (
<ColorField
key={`${field.key}-${mode}`}
name={`${field.key}_${mode}`}
label={field.label}
description={field.desc}
defaultValue={getVal(values, field.key, mode) || field.def}
bgValue={
field.bgKey
? getVal(values, field.bgKey, mode) || undefined
: undefined
}
/>
))}
</div>
</div>
);
}
// ─── Blocks Tab ──────────────────────────────────────────────────────────────
function BlocksTab({ values }: { values: ScopeValues }) {
const grouped = getBlocksByCategory();
const categoryLabels: Record<string, string> = {
chrome: "Site Chrome",
layout: "Layout",
components: "Components",
effects: "Effects",
};
return (
<div className="space-y-4">
{Object.entries(grouped).map(([category, blocks]) => (
<div key={category} className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-1">
{categoryLabels[category] ?? category}
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Toggle visibility of blocks in this scope. Hidden blocks use{" "}
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
display: none
</code>
.
</p>
<div className="space-y-2">
{blocks.map((block) => {
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
const currentVal = values.blocks[key];
const isChecked =
currentVal === "true" ||
(currentVal === undefined && block.defaultVisible);
return (
<label
key={block.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--admin-surface-elevated)] cursor-pointer"
>
<input
type="checkbox"
name={key}
defaultChecked={isChecked}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-[var(--admin-text)]">
{block.label}
</span>
<span className="text-xs text-[var(--admin-text-muted)] ml-2">
{block.description}
</span>
</div>
<code className="text-[10px] text-[var(--admin-text-muted)] font-mono">
{block.selector}
</code>
</label>
);
})}
</div>
</div>
))}
</div>
);
}
// ─── Layout Tab ──────────────────────────────────────────────────────────────
function LayoutTab({ values }: { values: ScopeValues }) {
return (
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Layout Settings
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Control spacing, width, and grid settings for this scope. Values are
applied as CSS custom properties.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{LAYOUT_FIELDS.map((field) => (
<div key={field.key} className="rounded-lg p-2">
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
{field.label}
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
{field.desc}
</p>
<input
type={field.type || "text"}
name={field.key}
defaultValue={getVal(values, field.key, "light") || field.def}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
</div>
))}
</div>
</div>
);
}
// ─── Effects Tab ─────────────────────────────────────────────────────────────
function EffectsTab({ values }: { values: ScopeValues }) {
return (
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Effects & Transitions
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Control shadows, borders, and animation timing for this scope.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{EFFECT_FIELDS.map((field) => (
<div key={field.key} className="rounded-lg p-2">
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
{field.label}
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
{field.desc}
</p>
<input
type={field.type || "text"}
name={field.key}
defaultValue={getVal(values, field.key, "light") || field.def}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
</div>
))}
</div>
</div>
);
}
// ─── Media Tab ───────────────────────────────────────────────────────────────
function MediaTab({ values }: { values: ScopeValues }) {
return (
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Media & Backgrounds
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Set custom logos, background images, and background properties for this
scope.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{MEDIA_FIELDS.map((field) => (
<div key={field.key} className="rounded-lg p-2">
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
{field.label}
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
{field.desc}
</p>
<input
type={field.type || "text"}
name={field.key}
defaultValue={getVal(values, field.key, "light") || field.def}
placeholder={field.def}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
</div>
))}
</div>
</div>
);
}
// ─── Custom Tab ──────────────────────────────────────────────────────────────
function CustomTab({ values }: { values: ScopeValues }) {
return (
<div className="space-y-4">
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Custom CSS
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Add custom CSS that applies within this scope. Use{" "}
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
&{" "}
</code>{" "}
as a placeholder for the scope selector (e.g.{" "}
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
& .content-card {"{"} border: 2px solid red; {"}"}
</code>
).
</p>
<textarea
name="custom:css"
defaultValue={getVal(values, "custom:css", "light")}
rows={8}
placeholder={".content-card { border-radius: 20px; }"}
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
style={{ borderColor: "var(--admin-border)" }}
/>
</div>
<div className="admin-card p-4">
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
Custom HTML
</h3>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
Add custom HTML that injects into the page for this scope. Use for
custom banners, scripts, or content blocks.
</p>
<textarea
name="custom:html"
defaultValue={getVal(values, "custom:html", "light")}
rows={6}
placeholder='<div class="my-banner">Welcome!</div>'
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
style={{ borderColor: "var(--admin-border)" }}
/>
</div>
</div>
); );
} }
+4 -4
View File
@@ -7,13 +7,13 @@ import {
saveCustomTheme, saveCustomTheme,
saveTheme, saveTheme,
} from "@/actions/admin-theme"; } from "@/actions/admin-theme";
import { ColorField } from "@/components/admin/color-field";
import { RenameTheme } from "@/components/admin/theme/rename-theme"; import { RenameTheme } from "@/components/admin/theme/rename-theme";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { listCustomThemes } from "@/lib/theme-custom-store"; import { listCustomThemes } from "@/lib/theme-custom-store";
import { FONTS, PRESETS } from "@/lib/theme-presets"; import { FONTS, PRESETS } from "@/lib/theme-presets";
import { ColorField } from "./color-field";
export const metadata = { title: "Theme" }; export const metadata = { title: "Theme" };
@@ -297,13 +297,13 @@ function colorFields(
{fields.map((f) => { {fields.map((f) => {
const val = toHex(current[f.key], f.def); const val = toHex(current[f.key], f.def);
const bgKey = f.bgKey ? `${f.bgKey}${suffix}` : undefined; const bgKey = f.bgKey ? `${f.bgKey}${suffix}` : undefined;
// eslint-disable-next-line security/detect-object-injection -- bgKey derived from hardcoded field list
const bgValue = bgKey ? toHex(current[bgKey], "") : undefined; const bgValue = bgKey ? toHex(current[bgKey], "") : undefined;
return ( return (
<ColorField <ColorField
key={f.key} key={f.key}
field={f} label={f.label}
value={val} description={f.desc}
defaultValue={val}
bgValue={bgValue || undefined} bgValue={bgValue || undefined}
name={`${f.key}${suffix}`} name={`${f.key}${suffix}`}
/> />
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import { TICKET_PRIORITIES, TICKET_STATUSES } from "@/lib/validators/ticket"; import { TICKET_PRIORITIES, TICKET_STATUSES } from "@/lib/validators/ticket";
interface TicketMessage { interface TicketMessage {
@@ -215,7 +216,7 @@ export function AdminTicketDetail({
{msg.isStaff && " (Staff)"} {msg.isStaff && " (Staff)"}
</span> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleString()} {formatDate(new Date(msg.createdAt))}
</span> </span>
</div> </div>
<div <div
@@ -276,7 +277,7 @@ export function AdminTicketDetail({
<div className="border-t p-4 text-center text-sm text-muted-foreground"> <div className="border-t p-4 text-center text-sm text-muted-foreground">
Ticket closed{" "} Ticket closed{" "}
{ticket.closedAt {ticket.closedAt
? `on ${new Date(ticket.closedAt).toLocaleDateString()}` ? `on ${formatDate(new Date(ticket.closedAt), "date")}`
: ""} : ""}
</div> </div>
)} )}
@@ -413,9 +414,10 @@ export function AdminTicketDetail({
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
Registered{" "} Registered{" "}
{new Date( {formatDate(
ticket.creator.accountCreated * 1000, new Date(ticket.creator.accountCreated * 1000),
).toLocaleDateString()} "date",
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -428,12 +430,12 @@ export function AdminTicketDetail({
<CardContent className="space-y-2 text-xs text-muted-foreground"> <CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between"> <div className="flex justify-between">
<span>Created</span> <span>Created</span>
<span>{new Date(ticket.createdAt).toLocaleString()}</span> <span>{formatDate(new Date(ticket.createdAt))}</span>
</div> </div>
{ticket.closedAt && ( {ticket.closedAt && (
<div className="flex justify-between"> <div className="flex justify-between">
<span>Closed</span> <span>Closed</span>
<span>{new Date(ticket.closedAt).toLocaleString()}</span> <span>{formatDate(new Date(ticket.closedAt))}</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
@@ -2,6 +2,7 @@ import { MessageSquare, Terminal } from "lucide-react";
import Link from "@/components/link"; import Link from "@/components/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDate } from "@/lib/format-date";
interface ChatMessage { interface ChatMessage {
id: number; id: number;
@@ -25,13 +26,7 @@ interface UserActivityLogProps {
function formatTime(ts: number) { function formatTime(ts: number) {
if (!ts) return "-"; if (!ts) return "-";
const d = new Date(ts * 1000); return formatDate(new Date(ts * 1000));
return d.toLocaleString(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
} }
export function UserActivityLog({ export function UserActivityLog({
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
interface BanInfo { interface BanInfo {
id: number; id: number;
@@ -110,7 +111,7 @@ export function UserBanSection({
By {activeBan.staffName} &middot;{" "} By {activeBan.staffName} &middot;{" "}
{activeBan.banExpire === 0 {activeBan.banExpire === 0
? "Permanent" ? "Permanent"
: `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`} : `Expires ${formatDate(new Date(activeBan.banExpire * 1000))}`}
</p> </p>
</div> </div>
)} )}
@@ -174,7 +175,7 @@ export function UserBanSection({
</span> </span>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(ban.timestamp * 1000).toLocaleDateString()} {formatDate(new Date(ban.timestamp * 1000), "date")}
</span> </span>
</div> </div>
))} ))}
@@ -9,6 +9,7 @@ import {
import Link from "@/components/link"; import Link from "@/components/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDate } from "@/lib/format-date";
interface UserAccountInfoProps { interface UserAccountInfoProps {
user: { user: {
@@ -36,14 +37,7 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" /> <Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Registered</span> <span className="text-muted-foreground">Registered</span>
<span className="ml-auto font-medium"> <span className="ml-auto font-medium">
{new Date(user.accountCreated * 1000).toLocaleDateString( {formatDate(new Date(user.accountCreated * 1000), "date")}
"it-IT",
{
day: "2-digit",
month: "short",
year: "numeric",
},
)}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
@@ -51,16 +45,7 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
<span className="text-muted-foreground">Last Login</span> <span className="text-muted-foreground">Last Login</span>
<span className="ml-auto font-medium"> <span className="ml-auto font-medium">
{user.lastLogin {user.lastLogin
? new Date(user.lastLogin * 1000).toLocaleDateString( ? formatDate(new Date(user.lastLogin * 1000))
"it-IT",
{
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)
: "Never"} : "Never"}
</span> </span>
</div> </div>
+308 -29
View File
@@ -1,8 +1,18 @@
"use client"; "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 { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { CurrencyIcon } from "@/components/shared/currency-icon"; import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -21,9 +31,161 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { PaginatedResult } from "@/types/common"; import { formatDate } from "@/lib/format-date";
import { useUserBulkActions } from "./use-user-bulk-actions"; import { getAvatarUrl } from "@/lib/imager";
import { getUserColumns, type UserRow } from "./user-columns"; 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<number, string> = {
1: "User",
2: "Bronze",
3: "Silver",
4: "Gold",
5: "Moderator",
6: "Admin",
7: "Super Admin",
};
function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
const cols: DataTableColumn<UserRow>[] = [
{ key: "id", label: "ID", sortable: true },
{
key: "username",
label: "User",
sortable: true,
render: (_, row) => (
<Link
href={`/admin/users/show/${row.id}`}
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">
{formatDate(new Date(ts * 1000), "date")}
</span>
);
},
},
{
key: "actions",
label: "",
render: (_, row) => (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" asChild title="View">
<Link href={`/admin/users/show/${row.id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
{canEdit && (
<Button variant="ghost" size="icon" asChild title="Edit">
<Link href={`/admin/users/edit/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
)}
</div>
),
},
];
return cols;
}
interface UsersTableProps { interface UsersTableProps {
data: PaginatedResult<UserRow>; data: PaginatedResult<UserRow>;
@@ -31,27 +193,144 @@ interface UsersTableProps {
canBan: boolean; 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) { export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
const searchParams = useSearchParams(); const [pending, setPending] = useState<PendingDialog>(null);
const { const [busy, setBusy] = useState(false);
pending, const [banReason, setBanReason] = useState("Bulk ban");
setPending, const [banDuration, setBanDuration] = useState("-1");
busy, const [currencyType, setCurrencyType] = useState<
banReason, "credits" | "pixels" | "points"
setBanReason, >("credits");
banDuration, const [currencyAmount, setCurrencyAmount] = useState("100");
setBanDuration, const [badgeCode, setBadgeCode] = useState("");
currencyType,
setCurrencyType, function reportResult(
currencyAmount, label: string,
setCurrencyAmount, ok: number,
badgeCode, total: number,
setBadgeCode, failures = 0,
handleBulkAction, ) {
confirmBan, if (ok > 0)
confirmGiveCurrency, toast.success(
confirmGiveBadge, `${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`,
} = useUserBulkActions(); );
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: { const bulkActions: {
label: string; label: string;
value: string; value: string;
@@ -70,7 +349,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
<> <>
<DataTable <DataTable
data={data} data={data}
columns={getUserColumns(canEdit, searchParams.toString())} columns={getColumns(canEdit)}
searchPlaceholder="Search by username, email or IP..." searchPlaceholder="Search by username, email or IP..."
presets={[ presets={[
{ label: "Online now", params: { filter_online: "1" } }, { label: "Online now", params: { filter_online: "1" } },
@@ -84,9 +363,9 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
<Dialog <Dialog
open={pending?.kind === "ban"} open={pending?.kind === "ban"}
onOpenChange={(o) => !o && !busy && setPending(null)} onOpenChange={(o) => !o && setPending(null)}
> >
<DialogContent className="max-h-[90dvh] overflow-y-auto"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Ban {pending?.kind === "ban" ? pending.ids.length : 0} users 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"} open={pending?.kind === "give-currency"}
onOpenChange={(o) => !o && setPending(null)} onOpenChange={(o) => !o && setPending(null)}
> >
<DialogContent className="max-h-[90dvh] overflow-y-auto"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Adjust currency for{" "} Adjust currency for{" "}
@@ -210,7 +489,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
open={pending?.kind === "give-badge"} open={pending?.kind === "give-badge"}
onOpenChange={(o) => !o && setPending(null)} onOpenChange={(o) => !o && setPending(null)}
> >
<DialogContent className="max-h-[90dvh] overflow-y-auto"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Give badge to{" "} Give badge to{" "}
+1 -1
View File
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { hostname } from "node:os"; import { hostname } from "node:os";
import path from "node:path"; import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { import {
catalogStateRoot, catalogStateRoot,
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { publicCatalogConfig } from "@/lib/services/catalog-git-config"; import { publicCatalogConfig } from "@/lib/services/catalog-git-config";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
catalogueAssetDirectories, catalogueAssetDirectories,
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
catalogueAssetDirectories, catalogueAssetDirectories,
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader"; import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { batchLookupByClassnames } from "@/lib/services/habbofurni"; import { batchLookupByClassnames } from "@/lib/services/habbofurni";
+1 -1
View File
@@ -1,6 +1,6 @@
import { asc, count, eq, inArray, like, or } from "drizzle-orm"; import { asc, count, eq, inArray, like, or } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db"; import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { setCsrfCookie } from "@/lib/foundation/security"; import { setCsrfCookie } from "@/lib/foundation/security";
export const GET = withAdmin({ requireCsrf: false }, async () => { export const GET = withAdmin({ requireCsrf: false }, async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { asc, eq, like, or } from "drizzle-orm"; import { asc, eq, like, or } from "drizzle-orm";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs"; import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
+1 -1
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs"; import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { getSource } from "@/lib/services/clone-sources"; import { getSource } from "@/lib/services/clone-sources";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { importClothingSet } from "@/lib/services/clothing-set-import"; import { importClothingSet } from "@/lib/services/clothing-set-import";
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
getClothingSetList, getClothingSetList,
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { addSize32AllNitros } from "@/lib/services/repair-nitros"; import { addSize32AllNitros } from "@/lib/services/repair-nitros";
@@ -1,8 +1,8 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources"; import { getSource } from "@/lib/services/clone-sources";
@@ -2,8 +2,8 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { asc, inArray, sql } from "drizzle-orm"; import { asc, inArray, sql } from "drizzle-orm";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
+1 -1
View File
@@ -1,8 +1,8 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname"; import { normalizeClassname } from "@/lib/furni/classname";
import { import {
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { uploadSingleFurni } from "@/lib/services/upload-import"; import { uploadSingleFurni } from "@/lib/services/upload-import";
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { verifyAndFixInteractionModesCount } from "@/lib/services/furni-import"; import { verifyAndFixInteractionModesCount } from "@/lib/services/furni-import";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { runSseBatch } from "@/lib/services/import/core/sse-batch"; import { runSseBatch } from "@/lib/services/import/core/sse-batch";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
@@ -1,8 +1,8 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { AclModelRole, db } from "@/lib/db"; import { AclModelRole, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -2,8 +2,8 @@ import { and, eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2"; import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { AclModelPermission, AclModelRole, AclRole, db } from "@/lib/db"; import { AclModelPermission, AclModelRole, AclRole, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -1,7 +1,7 @@
import { eq, like, or } from "drizzle-orm"; import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { import {
db, db,
Guilds, Guilds,
+1 -1
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, Soundtracks } from "@/lib/db"; import { db, Soundtracks } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection"; import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { stageFurnitureAttachment } from "@/lib/services/furni-attachment"; import { stageFurnitureAttachment } from "@/lib/services/furni-attachment";
@@ -1,7 +1,7 @@
import { after } from "next/server"; import { after } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection"; import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection"; import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { inspectFurniture } from "@/lib/services/furniture-inspection"; import { inspectFurniture } from "@/lib/services/furniture-inspection";
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { import {
inspectNitroScales, inspectNitroScales,
object, object,
@@ -1,8 +1,8 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { generateScale32 } from "@/lib/furni/nitro-scale32"; import { generateScale32 } from "@/lib/furni/nitro-scale32";
import { validateClassnames } from "@/lib/furni/studio-inspection"; import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import type { import type {
SourceAssetCheck, SourceAssetCheck,
SourceAssetItem, SourceAssetItem,
+2 -4
View File
@@ -14,6 +14,7 @@ import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
import { requireModPermission } from "@/lib/admin/guard"; import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, SupportTickets, User } from "@/lib/db"; import { db, SupportTickets, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
@@ -143,10 +144,7 @@ export default async function ModCfhListPage({
issue: ticket.issue, issue: ticket.issue,
roomId: ticket.roomId, roomId: ticket.roomId,
when: ticket.timestamp when: ticket.timestamp
? new Date(ticket.timestamp * 1000) ? formatDate(new Date(ticket.timestamp * 1000))
.toISOString()
.slice(0, 16)
.replace("T", " ")
: "—", : "—",
sender: userMap.get(ticket.senderId) ?? "—", sender: userMap.get(ticket.senderId) ?? "—",
senderId: ticket.senderId, senderId: ticket.senderId,
+2 -1
View File
@@ -12,6 +12,7 @@ import {
User, User,
WebsiteTicket, WebsiteTicket,
} from "@/lib/db"; } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
export default async function ModTeamLitePage() { export default async function ModTeamLitePage() {
@@ -157,7 +158,7 @@ export default async function ModTeamLitePage() {
{member.online === "1" {member.online === "1"
? "Online" ? "Online"
: member.lastOnline : member.lastOnline
? new Date(member.lastOnline * 1000).toLocaleDateString() ? formatDate(new Date(member.lastOnline * 1000), "date")
: "—"} : "—"}
</span> </span>
</Link> </Link>
@@ -1,59 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { contrastRatio, readableColor } from "@/lib/theme-contrast";
interface Field { type ColorFieldProps = {
key: string; name: string;
label: string; label: string;
def: string; description: string;
desc: string; defaultValue: string;
bgKey?: string; /** Background color to check the foreground contrast against. */
} bgValue?: 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";
}
export function ColorField({ export function ColorField({
field,
value,
bgValue,
name, name,
}: { label,
field: Field; description,
value: string; defaultValue,
bgValue?: string; bgValue,
name: string; }: ColorFieldProps) {
}) { const [val, setVal] = useState(defaultValue);
const [val, setVal] = useState(value);
const ratio = bgValue ? contrastRatio(val, bgValue) : null; const ratio = bgValue ? contrastRatio(val, bgValue) : null;
const poor = ratio != null && ratio < 4.5; const poor = ratio != null && ratio < 4.5;
const ok = ratio != null && ratio >= 4.5; const ok = ratio != null && ratio >= 4.5;
@@ -78,7 +44,7 @@ export function ColorField({
if (bgValue) { if (bgValue) {
const nextRatio = contrastRatio(next, bgValue); const nextRatio = contrastRatio(next, bgValue);
if (nextRatio != null && nextRatio < 4.5) { if (nextRatio != null && nextRatio < 4.5) {
setVal(readableOn(bgValue)); setVal(readableColor("#ffffff", [bgValue]));
return; return;
} }
} }
@@ -113,7 +79,7 @@ export function ColorField({
<span <span
style={{ fontSize: "0.82rem", fontWeight: 600, lineHeight: 1.2 }} style={{ fontSize: "0.82rem", fontWeight: 600, lineHeight: 1.2 }}
> >
{field.label} {label}
</span> </span>
{ratio != null ? ( {ratio != null ? (
<span <span
@@ -139,7 +105,7 @@ export function ColorField({
overflow: "hidden", overflow: "hidden",
}} }}
> >
{field.desc} {description}
</span> </span>
{bgValue ? ( {bgValue ? (
<div <div
@@ -159,7 +125,7 @@ export function ColorField({
{poor && bgValue ? ( {poor && bgValue ? (
<button <button
type="button" type="button"
onClick={() => setVal(readableOn(bgValue))} onClick={() => setVal(readableColor("#ffffff", [bgValue]))}
style={{ style={{
marginTop: 4, marginTop: 4,
alignSelf: "flex-start", alignSelf: "flex-start",
@@ -1,5 +1,6 @@
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
/** Shared destructive banner for the import/admin clients. */
export function ImportErrorBanner({ error }: { error: string }) { export function ImportErrorBanner({ error }: { error: string }) {
return ( return (
<div <div
+3 -21
View File
@@ -5,20 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { deleteMedia, uploadMedia } from "@/actions/admin-media"; import { deleteMedia, uploadMedia } from "@/actions/admin-media";
import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import type { MediaFile } from "@/lib/media-files";
type MediaFile = { import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
name: string;
url: string;
size: number;
uploaded: number;
};
function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -47,13 +35,7 @@ export function AdminMediaGrid() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { setFiles(await loadMediaFiles());
const res = await fetch("/api/media");
const data = await res.json();
setFiles(data.files ?? []);
} catch {
/* ignore */
}
setLoading(false); setLoading(false);
}, []); }, []);
+129 -106
View File
@@ -1,15 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { uploadMediaAndReturn } from "@/actions/admin-media";
import { useMediaLibrary } from "./use-media-library"; import type { MediaFile } from "@/lib/media-files";
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
export function MediaPicker({ export function MediaPicker({
onSelect, onSelect,
@@ -19,8 +13,40 @@ export function MediaPicker({
current?: string; current?: string;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { files, loading, uploading, fileRef, error, load, handleUpload } = const [files, setFiles] = useState<MediaFile[]>([]);
useMediaLibrary(open, setOpen, onSelect); const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setLoading(true);
setFiles(await loadMediaFiles());
setLoading(false);
}, []);
useEffect(() => {
if (open) load();
}, [open, load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
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 ( return (
<> <>
@@ -49,107 +75,104 @@ export function MediaPicker({
)} )}
</div> </div>
<Dialog {open && (
open={open} // biome-ignore lint/a11y/useSemanticElements: backdrop overlay uses role=button for click-to-close
onOpenChange={(next) => { <div
if (!uploading) setOpen(next); role="button"
}} tabIndex={0}
> aria-label="Close media picker"
<DialogContent className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50"
showCloseButton={false} onClick={(e) => {
className="flex flex-col p-0 gap-0 max-h-[80dvh] sm:max-w-[680px] overflow-hidden" if (e.target === e.currentTarget) setOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(false);
}
}}
> >
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14"> <div
<DialogTitle className="text-base">Media Library</DialogTitle> role="dialog"
<div className="flex gap-2 items-center"> aria-modal="true"
<label className="btn btn-primary text-sm cursor-pointer"> className="bg-[var(--admin-surface)] text-[var(--admin-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
{uploading ? "Uploading…" : "Upload"} >
<input <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
ref={fileRef} <strong className="text-base">Media Library</strong>
type="file" <div className="flex gap-2 items-center">
accept="image/png,image/jpeg,image/gif,image/webp" <label className="btn btn-primary text-sm cursor-pointer">
onChange={handleUpload} {uploading ? "Uploading…" : "Upload"}
className="hidden" <input
disabled={uploading} ref={fileRef}
multiple type="file"
/> accept="image/png,image/jpeg,image/gif,image/webp"
</label> onChange={handleUpload}
<button className="hidden"
type="button" multiple
disabled={uploading} />
onClick={() => setOpen(false)} </label>
className="btn text-sm"
>
Close
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
{error && (
<div role="alert" className="p-2 text-sm">
<p>{error}</p>
<button <button
type="button" type="button"
className="btn btn-outline" onClick={() => setOpen(false)}
disabled={loading || uploading} className="btn text-sm"
onClick={() => void load()}
> >
Retry loading Close
</button> </button>
</div> </div>
)} </div>
{loading ? ( <div className="flex-1 overflow-auto p-3">
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8"> {loading ? (
Loading… <p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
</p> Loading…
) : files.length === 0 ? ( </p>
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8"> ) : files.length === 0 ? (
No images yet. Upload one above. <p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
</p> No images yet. Upload one above.
) : ( </p>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5"> ) : (
{files.map((f) => ( <div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
<button {files.map((f) => (
type="button" <button
key={f.name} type="button"
disabled={uploading} key={f.name}
onClick={() => { onClick={() => {
onSelect(f.url); onSelect(f.url);
setOpen(false); setOpen(false);
}} }}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150" className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150"
style={{ style={{
borderColor: borderColor:
f.url === current f.url === current
? "var(--admin-accent)" ? "var(--admin-accent)"
: "transparent", : "transparent",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (f.url !== current) if (f.url !== current)
e.currentTarget.style.borderColor = e.currentTarget.style.borderColor =
"color-mix(in srgb, var(--admin-accent) 40%, transparent)"; "color-mix(in srgb, var(--admin-accent) 40%, transparent)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (f.url !== current) if (f.url !== current)
e.currentTarget.style.borderColor = "transparent"; e.currentTarget.style.borderColor = "transparent";
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={validImageUrl(f.url)} src={validImageUrl(f.url)}
alt={f.name} alt={f.name}
className="w-full h-[100px] object-cover block" className="w-full h-[100px] object-cover block"
/> />
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap"> <span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name} {f.name}
</span> </span>
</button> </button>
))} ))}
</div> </div>
)} )}
</div>
</div> </div>
</DialogContent> </div>
</Dialog> )}
</> </>
); );
} }
+39
View File
@@ -0,0 +1,39 @@
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
type SearchInputProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onClear?: () => void;
};
/** Admin search field with a leading search icon and optional clear button. */
export function SearchInput({
value,
onChange,
placeholder,
onClear,
}: SearchInputProps) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9 h-9"
/>
{value && onClear && (
<button
type="button"
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
type LoadingSpinnerProps = {
className?: string;
accentVar?: string;
};
/** Centered pulsing spinner used across site and admin loading.tsx placeholders. */
export function LoadingSpinner({
className,
accentVar = "var(--color-primary)",
}: LoadingSpinnerProps) {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
className={className}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: accentVar,
borderTopColor: "transparent",
}}
/>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server";
/** Standard success response: { ok: true, ...data } */
export function apiOk(data?: Record<string, unknown>) {
return NextResponse.json({ ok: true, ...data });
}
/** Standard error response: { error: message } with given status */
export function apiError(message: string, status: number = 400) {
return NextResponse.json({ error: message }, { status });
}
+18 -1
View File
@@ -1,5 +1,22 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { apiUnavailable, pagination, positiveBigInt } from "@/lib/api"; import { apiOk, apiUnavailable, pagination, positiveBigInt } from "@/lib/api";
describe("apiOk", () => {
it("returns a BigInt-safe success envelope with ok: true", async () => {
const response = apiOk({ id: 9007199254740993n });
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
await expect(response.json()).resolves.toEqual({
ok: true,
id: "9007199254740993",
});
});
it("allows empty payloads", async () => {
await expect(apiOk().json()).resolves.toEqual({ ok: true });
});
});
describe("apiUnavailable", () => { describe("apiUnavailable", () => {
it("returns a no-store 503 error without exposing internal details", async () => { it("returns a no-store 503 error without exposing internal details", async () => {
+5
View File
@@ -22,6 +22,11 @@ export function apiJson(data: unknown, init?: ResponseInit): NextResponse {
}); });
} }
/** Standard success envelope: { ok: true, ...data }. */
export function apiOk(data?: Record<string, unknown>): NextResponse {
return apiJson({ ok: true, ...data });
}
/** Standard error envelope. */ /** Standard error envelope. */
export function apiError(message: string, status = 400): NextResponse { export function apiError(message: string, status = 400): NextResponse {
return apiJson({ error: message }, { status }); return apiJson({ error: message }, { status });
+25
View File
@@ -0,0 +1,25 @@
export type MediaFile = {
name: string;
url: string;
size: number;
uploaded: number;
};
/** Only allow safe, relative /api/media URLs as image src. */
export function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
/** Load the media library from /api/media, tolerating transient failures. */
export async function loadMediaFiles(): Promise<MediaFile[]> {
try {
const res = await fetch("/api/media");
const data = await res.json();
return Array.isArray(data?.files) ? (data.files as MediaFile[]) : [];
} catch {
return [];
}
}