refactor(cms): deduplicate shared admin components and fix studio batch completion
- 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:
1 parent
b12e405b34
commit
8c12fc6c60
93 files changed
+1447
-1005
No files matched your search
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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} · {new Date(event.startsAt).toLocaleString()}
|
{event.type.name} · {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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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} ·{" "}
|
By {activeBan.staffName} ·{" "}
|
||||||
{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>
|
||||||
|
|||||||
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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,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,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,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user