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

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

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { contrastRatio, readableColor } from "@/lib/theme-contrast";
type ColorFieldProps = {
name: string;
label: string;
description: string;
defaultValue: string;
/** Background color to check the foreground contrast against. */
bgValue?: string;
};
export function ColorField({
name,
label,
description,
defaultValue,
bgValue,
}: ColorFieldProps) {
const [val, setVal] = useState(defaultValue);
const ratio = bgValue ? contrastRatio(val, bgValue) : null;
const poor = ratio != null && ratio < 4.5;
const ok = ratio != null && ratio >= 4.5;
return (
<div
style={{
display: "flex",
gap: "0.6rem",
padding: "0.5rem",
borderRadius: 8,
border: poor ? "1px solid #ef4444" : "1px solid transparent",
background: poor ? "rgba(239,68,68,0.08)" : "transparent",
}}
>
<input
type="color"
name={name}
value={val}
onChange={(e) => {
const next = e.target.value;
if (bgValue) {
const nextRatio = contrastRatio(next, bgValue);
if (nextRatio != null && nextRatio < 4.5) {
setVal(readableColor("#ffffff", [bgValue]));
return;
}
}
setVal(next);
}}
style={{
width: 42,
height: 36,
padding: 0,
border: "none",
background: "none",
cursor: "pointer",
flexShrink: 0,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
minWidth: 0,
flex: 1,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: "0.5rem",
}}
>
<span
style={{ fontSize: "0.82rem", fontWeight: 600, lineHeight: 1.2 }}
>
{label}
</span>
{ratio != null ? (
<span
style={{
fontSize: "0.7rem",
fontWeight: 700,
color: ok ? "#22c55e" : "#ef4444",
whiteSpace: "nowrap",
}}
>
{ratio.toFixed(1)}:1 {ok ? "✓" : "⚠"}
</span>
) : null}
</div>
<span
style={{
fontSize: "0.7rem",
color: "var(--admin-text-muted)",
lineHeight: 1.3,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{description}
</span>
{bgValue ? (
<div
style={{
marginTop: 4,
padding: "2px 8px",
borderRadius: 4,
background: bgValue,
color: val,
fontSize: "0.75rem",
fontWeight: 600,
}}
>
Aa Text preview
</div>
) : null}
{poor && bgValue ? (
<button
type="button"
onClick={() => setVal(readableColor("#ffffff", [bgValue]))}
style={{
marginTop: 4,
alignSelf: "flex-start",
fontSize: "0.7rem",
fontWeight: 600,
color: "var(--admin-accent)",
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
}}
>
Use readable color →
</button>
) : null}
</div>
</div>
);
}
@@ -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);
}, []);
+129 -106
View File
@@ -1,15 +1,9 @@
"use client";
import { useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useMediaLibrary } from "./use-media-library";
function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
import { useCallback, useEffect, useRef, useState } from "react";
import { uploadMediaAndReturn } from "@/actions/admin-media";
import type { MediaFile } from "@/lib/media-files";
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
export function MediaPicker({
onSelect,
@@ -19,8 +13,40 @@ export function MediaPicker({
current?: string;
}) {
const [open, setOpen] = useState(false);
const { files, loading, uploading, fileRef, error, load, handleUpload } =
useMediaLibrary(open, setOpen, onSelect);
const [files, setFiles] = useState<MediaFile[]>([]);
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 (
<>
@@ -49,107 +75,104 @@ export function MediaPicker({
)}
</div>
<Dialog
open={open}
onOpenChange={(next) => {
if (!uploading) setOpen(next);
}}
>
<DialogContent
showCloseButton={false}
className="flex flex-col p-0 gap-0 max-h-[80dvh] sm:max-w-[680px] overflow-hidden"
{open && (
// biome-ignore lint/a11y/useSemanticElements: backdrop overlay uses role=button for click-to-close
<div
role="button"
tabIndex={0}
aria-label="Close media picker"
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50"
onClick={(e) => {
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">
<DialogTitle className="text-base">Media Library</DialogTitle>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
className="hidden"
disabled={uploading}
multiple
/>
</label>
<button
type="button"
disabled={uploading}
onClick={() => setOpen(false)}
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>
<div
role="dialog"
aria-modal="true"
className="bg-[var(--admin-surface)] text-[var(--admin-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
<strong className="text-base">Media Library</strong>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
className="hidden"
multiple
/>
</label>
<button
type="button"
className="btn btn-outline"
disabled={loading || uploading}
onClick={() => void load()}
onClick={() => setOpen(false)}
className="btn text-sm"
>
Retry loading
Close
</button>
</div>
)}
{loading ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
Loading…
</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
disabled={uploading}
onClick={() => {
onSelect(f.url);
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"
style={{
borderColor:
f.url === current
? "var(--admin-accent)"
: "transparent",
}}
onMouseEnter={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor =
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
}}
onMouseLeave={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
Loading…
</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => {
onSelect(f.url);
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"
style={{
borderColor:
f.url === current
? "var(--admin-accent)"
: "transparent",
}}
onMouseEnter={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor =
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
}}
onMouseLeave={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
</>
);
}
+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>
);
}