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

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

No files matched your search

+4 -14
View File
@@ -1,15 +1,5 @@
export default function FriendsLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export default function Loading() {
return <LoadingSpinner />;
}
+4 -14
View File
@@ -1,15 +1,5 @@
export default function GuildForumLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export default function Loading() {
return <LoadingSpinner />;
}
+4 -14
View File
@@ -1,15 +1,5 @@
export default function SiteLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export default function Loading() {
return <LoadingSpinner />;
}
+4 -14
View File
@@ -1,15 +1,5 @@
export default function MessagesLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export default function Loading() {
return <LoadingSpinner />;
}
+4 -14
View File
@@ -1,15 +1,5 @@
export default function NewsLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export default function Loading() {
return <LoadingSpinner />;
}
+4 -14
View File
@@ -1,15 +1,5 @@
export default function ShopLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export default function Loading() {
return <LoadingSpinner />;
}
@@ -30,7 +30,6 @@ import {
RotateCcw,
Rows3,
Save,
Search,
Trash2,
X,
} 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 { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { SearchInput } from "@/components/admin/search-input";
import {
CurrencyIcon,
currencyKindFromPointsType,
@@ -1082,24 +1082,12 @@ export function CatalogItemsTable({
{/* Search bar */}
{items.length > 0 && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
<SearchInput
placeholder="Filter by name, classname, item ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
onChange={setSearchQuery}
onClear={() => setSearchQuery("")}
/>
{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>
<CardContent>
+2 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Card, CardContent } from "@/components/ui/card";
import { db, EmulatorErrors } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function EmulatorErrorsPage({
@@ -55,7 +56,7 @@ export default async function EmulatorErrorsPage({
#{error.id}
</span>
<span className="text-xs text-muted-foreground">
{new Date(error.timestamp * 1000).toLocaleString()}
{formatDate(new Date(error.timestamp * 1000))}
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
+2 -1
View File
@@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { HealthCheckClient } from "./health-check-client";
@@ -168,7 +169,7 @@ export default async function DevOpsPage() {
#{error.id}
</span>
<span className="text-xs text-muted-foreground">
{new Date(error.timestamp * 1000).toLocaleString()}
{formatDate(new Date(error.timestamp * 1000))}
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDate } from "@/lib/format-date";
interface Registration {
id: number;
@@ -36,7 +37,7 @@ export function EventRegistrations({ registrations }: Props) {
>
<span className="text-sm font-medium">{r.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleString()}
{formatDate(new Date(r.createdAt))}
</span>
</div>
))}
+2 -1
View File
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
interface Winner {
id: number;
@@ -93,7 +94,7 @@ export function EventWinnersManager({ eventId, winners }: Props) {
</span>
<span className="font-medium text-sm">{w.username}</span>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(w.createdAt).toLocaleDateString()}
{formatDate(new Date(w.createdAt), "date")}
</span>
</div>
))}
+3 -2
View File
@@ -6,6 +6,7 @@ import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db, User, WebsiteEvent, WebsiteEventType } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EventForm } from "../event-form";
import { EventPrizesManager } from "./event-prizes";
@@ -94,7 +95,7 @@ export default async function EventDetailPage({
</Badge>
</div>
<p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]">
{event.type.name} &middot; {new Date(event.startsAt).toLocaleString()}
{event.type.name} &middot; {formatDate(new Date(event.startsAt))}
</p>
</div>
@@ -156,7 +157,7 @@ export default async function EventDetailPage({
{event.endsAt && (
<p className="text-xs text-muted-foreground">
{t("endsAt", {
date: new Date(event.endsAt).toLocaleString(),
date: formatDate(new Date(event.endsAt)),
})}
</p>
)}
+2 -1
View File
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface EventRow {
@@ -86,7 +87,7 @@ export function EventsTable({ data }: EventsTableProps) {
sortable: true,
render: (value) => (
<span className="text-sm">
{new Date(value as unknown as string).toLocaleString()}
{formatDate(new Date(value as unknown as string))}
</span>
),
},
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
interface ThreadMessage {
key: string;
@@ -162,7 +163,7 @@ export function AdminHelpTicketDetail({
{msg.isStaff ? ` (${t("staffBadge")})` : ""}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleString()}
{formatDate(new Date(msg.createdAt))}
</span>
</div>
<div
@@ -308,11 +309,11 @@ export function AdminHelpTicketDetail({
<CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between gap-2">
<span>{t("colCreated")}</span>
<span>{new Date(ticket.createdAt).toLocaleString()}</span>
<span>{formatDate(new Date(ticket.createdAt))}</span>
</div>
<div className="flex justify-between gap-2">
<span>{t("colUpdated")}</span>
<span>{new Date(ticket.updatedAt).toLocaleString()}</span>
<span>{formatDate(new Date(ticket.updatedAt))}</span>
</div>
<div className="flex justify-between gap-2">
<span>{t("messageCountLabel")}</span>
@@ -1,7 +1,6 @@
"use client";
import {
AlertCircle,
BadgeCheck,
Check,
CheckCircle2,
@@ -15,6 +14,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -348,12 +348,7 @@ export function ImportBadgesClient() {
)}
{/* Error */}
{error && (
<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>
)}
{error && <ImportErrorBanner error={error} />}
{/* Loading skeleton */}
{loading && badges.length === 0 && (
@@ -1,7 +1,6 @@
"use client";
import {
AlertCircle,
Check,
CloudDownload,
Download,
@@ -16,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -782,12 +782,7 @@ function FurniGrid({ source }: FurniGridProps) {
)}
{/* Error */}
{error && (
<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>
)}
{error && <ImportErrorBanner error={error} />}
{/* Empty state */}
{!loading && items.length === 0 && !error && (
@@ -1,7 +1,6 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
@@ -13,6 +12,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -361,12 +361,7 @@ function LibsView() {
</div>
)}
{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>
)}
{error && <ImportErrorBanner error={error} />}
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
@@ -749,12 +744,7 @@ function SetsView() {
</div>
)}
{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>
)}
{error && <ImportErrorBanner error={error} />}
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
@@ -2,7 +2,6 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
@@ -15,6 +14,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -314,12 +314,7 @@ export function ImportEffectsClient() {
)}
{/* Error */}
{error && (
<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>
)}
{error && <ImportErrorBanner error={error} />}
{/* Empty */}
{!loading && visible.length === 0 && !error && (
@@ -1,7 +1,6 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
@@ -13,6 +12,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ImportErrorBanner } from "@/components/admin/import-error-banner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -293,12 +293,7 @@ export function ImportPetsClient() {
</div>
)}
{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>
)}
{error && <ImportErrorBanner error={error} />}
{!loading && pets.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
+4 -14
View File
@@ -1,15 +1,5 @@
export default function AdminLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--admin-accent)",
borderTopColor: "transparent",
}}
/>
</div>
);
import { LoadingSpinner } from "@/components/ui/loading-spinner";
export default function Loading() {
return <LoadingSpinner accentVar="var(--admin-accent)" />;
}
File diff suppressed because it is too large. Load diff
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
const CFH_STATES: Record<number, { label: string; class: string }> = {
0: {
@@ -130,7 +131,7 @@ export function CfhDetail({
</div>
<h1 className="text-xl font-bold">Call For Help Report</h1>
<p className="text-xs text-muted-foreground">
{new Date(ticket.timestamp * 1000).toLocaleString()} · Room #
{formatDate(new Date(ticket.timestamp * 1000))} · Room #
{ticket.roomId}
</p>
</div>
@@ -317,7 +318,7 @@ export function CfhDetail({
<p className="font-medium">{ban.reason || "No reason"}</p>
<p className="text-muted-foreground">
{ban.type} · by {ban.staff} ·{" "}
{new Date(ban.timestamp * 1000).toLocaleDateString()}
{formatDate(new Date(ban.timestamp * 1000), "date")}
</p>
</div>
))}
@@ -431,9 +432,7 @@ export function CfhDetail({
<CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>Created</span>
<span>
{new Date(ticket.timestamp * 1000).toLocaleString()}
</span>
<span>{formatDate(new Date(ticket.timestamp * 1000))}</span>
</div>
<div className="flex justify-between">
<span>Room</span>
+2 -1
View File
@@ -12,6 +12,7 @@ import {
User,
WebsiteTicket,
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ModTeamPage() {
@@ -169,7 +170,7 @@ export default async function ModTeamPage() {
{member.online === "1"
? "Online"
: member.lastOnline
? new Date(member.lastOnline * 1000).toLocaleDateString()
? formatDate(new Date(member.lastOnline * 1000), "date")
: "—"}
</span>
</Link>
@@ -6,7 +6,6 @@ import {
Crown,
Loader2,
Save,
Search,
Settings,
Shield,
ShieldCheck,
@@ -19,6 +18,7 @@ import {
saveRank as saveRankAction,
setCmsPermissions,
} from "@/actions/permissions";
import { SearchInput } from "@/components/admin/search-input";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -521,15 +521,11 @@ export function RankEditClient({
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
<SearchInput
placeholder="Search permissions..."
value={permSearch}
onChange={(e) => setPermSearch(e.target.value)}
className="pl-9"
onChange={setPermSearch}
/>
</div>
<PermSection title="Commands" keys={cats.commands} sectionKey="cmd" />
<PermSection title="Account" keys={cats.account} sectionKey="acc" />
@@ -606,15 +602,11 @@ export function RankEditClient({
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
<SearchInput
placeholder="Search CMS permissions..."
value={cmsSearch}
onChange={(e) => setCmsSearch(e.target.value)}
className="pl-9"
onChange={setCmsSearch}
/>
</div>
<div className="space-y-1">
{filteredCms.map((perm) => {
+2 -5
View File
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminPhotos({
@@ -133,11 +134,7 @@ export default async function AdminPhotos({
>
{t("byUser", { username })}
</Link>{" "}
·{" "}
{new Date(p.timestamp * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ")}
· {formatDate(new Date(p.timestamp * 1000))}
</p>
{canEdit ? (
<form
+2 -1
View File
@@ -9,6 +9,7 @@ import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface PollRow {
@@ -93,7 +94,7 @@ export function PollsTable({ data }: PollsTableProps) {
sortable: true,
render: (value) => (
<span className="text-sm">
{new Date(value as unknown as string).toLocaleDateString()}
{formatDate(new Date(value as unknown as string), "date")}
</span>
),
},
+2 -1
View File
@@ -24,6 +24,7 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import { PrefixDialog } from "./prefix-dialog";
import {
@@ -314,7 +315,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<TableCell>{prefix.active ? "Yes" : "No"}</TableCell>
<TableCell className="text-xs">
{prefix.created_at
? new Date(prefix.created_at).toLocaleDateString()
? formatDate(new Date(prefix.created_at), "date")
: "—"}
</TableCell>
{canEdit && (
@@ -1,12 +1,13 @@
"use client";
import { Search, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import {
createSetting,
deleteSetting,
updateSetting,
} from "@/actions/admin-settings";
import { SearchInput } from "@/components/admin/search-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MANAGED_SETTING_KEYS } from "./cms-settings-config";
@@ -64,15 +65,11 @@ export function AdvancedSettingsPanel({
</label>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
<SearchInput
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={setQuery}
placeholder="Search keys, values, comments…"
className="pl-9"
/>
</div>
{canEdit ? (
<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>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -7,13 +7,13 @@ import {
saveCustomTheme,
saveTheme,
} from "@/actions/admin-theme";
import { ColorField } from "@/components/admin/color-field";
import { RenameTheme } from "@/components/admin/theme/rename-theme";
import { Button } from "@/components/ui/button";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { listCustomThemes } from "@/lib/theme-custom-store";
import { FONTS, PRESETS } from "@/lib/theme-presets";
import { ColorField } from "./color-field";
export const metadata = { title: "Theme" };
@@ -297,13 +297,13 @@ function colorFields(
{fields.map((f) => {
const val = toHex(current[f.key], f.def);
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;
return (
<ColorField
key={f.key}
field={f}
value={val}
label={f.label}
description={f.desc}
defaultValue={val}
bgValue={bgValue || undefined}
name={`${f.key}${suffix}`}
/>
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
import { TICKET_PRIORITIES, TICKET_STATUSES } from "@/lib/validators/ticket";
interface TicketMessage {
@@ -215,7 +216,7 @@ export function AdminTicketDetail({
{msg.isStaff && " (Staff)"}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleString()}
{formatDate(new Date(msg.createdAt))}
</span>
</div>
<div
@@ -276,7 +277,7 @@ export function AdminTicketDetail({
<div className="border-t p-4 text-center text-sm text-muted-foreground">
Ticket closed{" "}
{ticket.closedAt
? `on ${new Date(ticket.closedAt).toLocaleDateString()}`
? `on ${formatDate(new Date(ticket.closedAt), "date")}`
: ""}
</div>
)}
@@ -413,9 +414,10 @@ export function AdminTicketDetail({
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
Registered{" "}
{new Date(
ticket.creator.accountCreated * 1000,
).toLocaleDateString()}
{formatDate(
new Date(ticket.creator.accountCreated * 1000),
"date",
)}
</div>
</CardContent>
</Card>
@@ -428,12 +430,12 @@ export function AdminTicketDetail({
<CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>Created</span>
<span>{new Date(ticket.createdAt).toLocaleString()}</span>
<span>{formatDate(new Date(ticket.createdAt))}</span>
</div>
{ticket.closedAt && (
<div className="flex justify-between">
<span>Closed</span>
<span>{new Date(ticket.closedAt).toLocaleString()}</span>
<span>{formatDate(new Date(ticket.closedAt))}</span>
</div>
)}
<div className="flex justify-between">
@@ -2,6 +2,7 @@ import { MessageSquare, Terminal } from "lucide-react";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDate } from "@/lib/format-date";
interface ChatMessage {
id: number;
@@ -25,13 +26,7 @@ interface UserActivityLogProps {
function formatTime(ts: number) {
if (!ts) return "-";
const d = new Date(ts * 1000);
return d.toLocaleString(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
return formatDate(new Date(ts * 1000));
}
export function UserActivityLog({
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { formatDate } from "@/lib/format-date";
interface BanInfo {
id: number;
@@ -110,7 +111,7 @@ export function UserBanSection({
By {activeBan.staffName} &middot;{" "}
{activeBan.banExpire === 0
? "Permanent"
: `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`}
: `Expires ${formatDate(new Date(activeBan.banExpire * 1000))}`}
</p>
</div>
)}
@@ -174,7 +175,7 @@ export function UserBanSection({
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(ban.timestamp * 1000).toLocaleDateString()}
{formatDate(new Date(ban.timestamp * 1000), "date")}
</span>
</div>
))}
@@ -9,6 +9,7 @@ import {
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDate } from "@/lib/format-date";
interface UserAccountInfoProps {
user: {
@@ -36,14 +37,7 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Registered</span>
<span className="ml-auto font-medium">
{new Date(user.accountCreated * 1000).toLocaleDateString(
"it-IT",
{
day: "2-digit",
month: "short",
year: "numeric",
},
)}
{formatDate(new Date(user.accountCreated * 1000), "date")}
</span>
</div>
<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="ml-auto font-medium">
{user.lastLogin
? new Date(user.lastLogin * 1000).toLocaleDateString(
"it-IT",
{
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)
? formatDate(new Date(user.lastLogin * 1000))
: "Never"}
</span>
</div>
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permission-slugs";
import {
catalogStateRoot,
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { publicCatalogConfig } from "@/lib/services/catalog-git-config";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import {
catalogueAssetDirectories,
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import {
catalogueAssetDirectories,
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { batchLookupByClassnames } from "@/lib/services/habbofurni";
+1 -1
View File
@@ -1,6 +1,6 @@
import { asc, count, eq, inArray, like, or } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { setCsrfCookie } from "@/lib/foundation/security";
export const GET = withAdmin({ requireCsrf: false }, async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { asc, eq, like, or } from "drizzle-orm";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
+1 -1
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { getSource } from "@/lib/services/clone-sources";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { importClothingSet } from "@/lib/services/clothing-set-import";
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import {
getClothingSetList,
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { addSize32AllNitros } from "@/lib/services/repair-nitros";
@@ -1,8 +1,8 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
@@ -2,8 +2,8 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import { asc, inArray, sql } from "drizzle-orm";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
+1 -1
View File
@@ -1,8 +1,8 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import {
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { uploadSingleFurni } from "@/lib/services/upload-import";
@@ -1,5 +1,5 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { verifyAndFixInteractionModesCount } from "@/lib/services/furni-import";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
@@ -1,8 +1,8 @@
import { and, eq } from "drizzle-orm";
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { AclModelRole, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -2,8 +2,8 @@ import { and, eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { AclModelPermission, AclModelRole, AclRole, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
+1 -1
View File
@@ -1,7 +1,7 @@
import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import {
db,
Guilds,
+1 -1
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import { desc, eq } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, Soundtracks } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { stageFurnitureAttachment } from "@/lib/services/furni-attachment";
@@ -1,7 +1,7 @@
import { after } from "next/server";
import { z } from "zod";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { redis } from "@/lib/redis";
+1 -1
View File
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { inspectFurniture } from "@/lib/services/furniture-inspection";
@@ -1,8 +1,8 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import sharp from "sharp";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import {
inspectNitroScales,
object,
@@ -1,8 +1,8 @@
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { generateScale32 } from "@/lib/furni/nitro-scale32";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
@@ -1,5 +1,5 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import type {
SourceAssetCheck,
SourceAssetItem,
+2 -4
View File
@@ -14,6 +14,7 @@ import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, SupportTickets, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
@@ -143,10 +144,7 @@ export default async function ModCfhListPage({
issue: ticket.issue,
roomId: ticket.roomId,
when: ticket.timestamp
? new Date(ticket.timestamp * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ")
? formatDate(new Date(ticket.timestamp * 1000))
: "—",
sender: userMap.get(ticket.senderId) ?? "—",
senderId: ticket.senderId,
+2 -1
View File
@@ -12,6 +12,7 @@ import {
User,
WebsiteTicket,
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
export default async function ModTeamLitePage() {
@@ -157,7 +158,7 @@ export default async function ModTeamLitePage() {
{member.online === "1"
? "Online"
: member.lastOnline
? new Date(member.lastOnline * 1000).toLocaleDateString()
? formatDate(new Date(member.lastOnline * 1000), "date")
: "—"}
</span>
</Link>
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
import { AlertCircle } from "lucide-react";
/** Shared destructive banner for the import/admin clients. */
export function ImportErrorBanner({ error }: { error: string }) {
return (
<div
+3 -21
View File
@@ -5,20 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { formatDate } from "@/lib/format-date";
type MediaFile = {
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;
}
import type { MediaFile } from "@/lib/media-files";
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -47,13 +35,7 @@ export function AdminMediaGrid() {
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/media");
const data = await res.json();
setFiles(data.files ?? []);
} catch {
/* ignore */
}
setFiles(await loadMediaFiles());
setLoading(false);
}, []);
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
type SearchInputProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onClear?: () => void;
};
/** Admin search field with a leading search icon and optional clear button. */
export function SearchInput({
value,
onChange,
placeholder,
onClear,
}: SearchInputProps) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9 h-9"
/>
{value && onClear && (
<button
type="button"
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
type LoadingSpinnerProps = {
className?: string;
accentVar?: string;
};
/** Centered pulsing spinner used across site and admin loading.tsx placeholders. */
export function LoadingSpinner({
className,
accentVar = "var(--color-primary)",
}: LoadingSpinnerProps) {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
className={className}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: accentVar,
borderTopColor: "transparent",
}}
/>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server";
/** Standard success response: { ok: true, ...data } */
export function apiOk(data?: Record<string, unknown>) {
return NextResponse.json({ ok: true, ...data });
}
/** Standard error response: { error: message } with given status */
export function apiError(message: string, status: number = 400) {
return NextResponse.json({ error: message }, { status });
}
+18 -1
View File
@@ -1,5 +1,22 @@
import { describe, expect, it } from "vitest";
import { 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", () => {
it("returns a no-store 503 error without exposing internal details", async () => {
+5
View File
@@ -22,6 +22,11 @@ export function apiJson(data: unknown, init?: ResponseInit): NextResponse {
});
}
/** Standard success envelope: { ok: true, ...data }. */
export function apiOk(data?: Record<string, unknown>): NextResponse {
return apiJson({ ok: true, ...data });
}
/** Standard error envelope. */
export function apiError(message: string, status = 400): NextResponse {
return apiJson({ error: message }, { status });
+25
View File
@@ -0,0 +1,25 @@
export type MediaFile = {
name: string;
url: string;
size: number;
uploaded: number;
};
/** Only allow safe, relative /api/media URLs as image src. */
export function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
/** Load the media library from /api/media, tolerating transient failures. */
export async function loadMediaFiles(): Promise<MediaFile[]> {
try {
const res = await fetch("/api/media");
const data = await res.json();
return Array.isArray(data?.files) ? (data.files as MediaFile[]) : [];
} catch {
return [];
}
}