Enhance RustyCMS: Update .gitignore to include demo assets, improve admin UI dependency management, and add new translations for asset management. Implement asset date filtering and enhance content forms with asset previews. Introduce caching mechanisms for improved performance and add support for draft status in content entries.

This commit is contained in:
Peter Meier
2026-03-12 16:03:26 +01:00
parent 7795a238e1
commit 22b4367c47
24 changed files with 900 additions and 131 deletions

View File

@@ -28,7 +28,15 @@
"removeEntry": "Entfernen",
"addEntry": "+ Eintrag hinzufügen",
"keyPlaceholder": "Schlüssel",
"valuePlaceholder": "Wert"
"valuePlaceholder": "Wert",
"pickAsset": "Bild wählen",
"pickFromAssets": "Aus Assets wählen",
"loadingAssets": "Assets werden geladen…",
"noAssets": "Noch keine Assets. Lade Bilder im Bereich Assets hoch.",
"status": "Status",
"statusDraft": "Entwurf",
"statusPublished": "Ver\u00f6ffentlicht",
"statusHint": "Entw\u00fcrfe sind \u00fcber die \u00f6ffentliche API nicht sichtbar."
},
"SearchableSelect": {
"placeholder": "\u2014 Bitte ausw\u00e4hlen \u2014",
@@ -113,6 +121,11 @@
"Dashboard": {
"title": "Dashboard",
"subtitle": "W\u00e4hle eine Sammlung zur Inhaltsverwaltung.",
"newContentType": "Neuer Inhaltstyp",
"searchPlaceholder": "Inhaltstypen suchen…",
"filterByTag": "Tag:",
"tagAll": "Alle",
"noResults": "Kein Inhaltstyp entspricht Suche oder Filter.",
"noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist."
},
"TypesPage": {
@@ -205,6 +218,7 @@
"noEntries": "Keine Einträge.",
"noEntriesCreate": "Noch keine Einträge. Erstellen Sie den ersten.",
"edit": "Bearbeiten",
"draft": "Entwurf",
"searchPlaceholder": "Suchen…",
"loading": "Laden…",
"sortBy": "Sortieren nach {field}",
@@ -223,6 +237,10 @@
"titleAll": "Alle Assets",
"titleRoot": "Root",
"assetCount": "{count} Bild(er)",
"assetCountFiltered": "{count} von {total} Bild(ern)",
"searchPlaceholder": "Nach Dateiname suchen…",
"dateFrom": "Von Datum",
"dateTo": "Bis Datum",
"upload": "Hochladen",
"uploading": "Wird hochgeladen…",
"uploadedCount": "{count} Datei(en) hochgeladen.",
@@ -258,6 +276,16 @@
"copyWithTransformNewName": "Neuer Dateiname",
"copyWithTransformCreate": "Kopie erstellen",
"copyWithTransformDone": "Transformierte Kopie erstellt.",
"transformPresetThumb": "Thumb 300px",
"transformPresetSquare": "Quadrat 1:1",
"transformPresetMedium": "Mittel 800px",
"transformPresetJpeg": "JPEG 1200px",
"transformWidth": "Breite",
"transformHeight": "H\u00f6he",
"transformAspect": "Seitenverh\u00e4ltnis",
"transformFit": "Fit",
"transformFormat": "Format",
"transformQuality": "Qualit\u00e4t (1100)",
"creating": "Wird erstellt…"
}
}

View File

@@ -28,7 +28,15 @@
"removeEntry": "Remove",
"addEntry": "+ Add entry",
"keyPlaceholder": "Key",
"valuePlaceholder": "Value"
"valuePlaceholder": "Value",
"pickAsset": "Pick image",
"pickFromAssets": "Pick from assets",
"loadingAssets": "Loading assets…",
"noAssets": "No assets yet. Upload images in the Assets section.",
"status": "Status",
"statusDraft": "Draft",
"statusPublished": "Published",
"statusHint": "Draft entries are not visible in the public API."
},
"SearchableSelect": {
"placeholder": "— Please select —",
@@ -113,6 +121,11 @@
"Dashboard": {
"title": "Dashboard",
"subtitle": "Choose a collection to manage content.",
"newContentType": "New content type",
"searchPlaceholder": "Search content types…",
"filterByTag": "Tag:",
"tagAll": "All",
"noResults": "No content types match your search or filter.",
"noCollections": "No collections loaded. Check that the RustyCMS API is running at {url}."
},
"TypesPage": {
@@ -205,6 +218,7 @@
"noEntries": "No entries.",
"noEntriesCreate": "No entries yet. Create the first one.",
"edit": "Edit",
"draft": "Draft",
"searchPlaceholder": "Search…",
"loading": "Loading…",
"sortBy": "Sort by {field}",
@@ -223,6 +237,10 @@
"titleAll": "All assets",
"titleRoot": "Root",
"assetCount": "{count} image(s)",
"assetCountFiltered": "{count} of {total} image(s)",
"searchPlaceholder": "Search by filename…",
"dateFrom": "From date",
"dateTo": "To date",
"upload": "Upload",
"uploading": "Uploading…",
"uploadedCount": "Uploaded {count} file(s).",
@@ -258,6 +276,16 @@
"copyWithTransformNewName": "New filename",
"copyWithTransformCreate": "Create copy",
"copyWithTransformDone": "Transformed copy created.",
"transformPresetThumb": "Thumb 300px",
"transformPresetSquare": "Square 1:1",
"transformPresetMedium": "Medium 800px",
"transformPresetJpeg": "JPEG 1200px",
"transformWidth": "Width",
"transformHeight": "Height",
"transformAspect": "Aspect ratio",
"transformFit": "Fit",
"transformFormat": "Format",
"transformQuality": "Quality (1100)",
"creating": "Creating…"
}
}

BIN
admin-ui/public/rusty.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -1,7 +1,8 @@
"use client";
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
@@ -20,6 +21,7 @@ import {
import type { Asset, AssetFolder, TransformParams } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SearchBar } from "@/components/SearchBar";
import {
AlertDialog,
AlertDialogAction,
@@ -47,6 +49,17 @@ function formatBytes(n: number) {
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
function formatAssetDate(iso: string | null | undefined): string {
if (!iso) return "";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
} catch {
return "";
}
}
function assetPath(asset: Asset) {
return asset.folder ? `${asset.folder}/${asset.filename}` : asset.filename;
}
@@ -54,8 +67,15 @@ function assetPath(asset: Asset) {
export default function AssetsPage() {
const t = useTranslations("AssetsPage");
const qc = useQueryClient();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const fileInputRef = useRef<HTMLInputElement>(null);
const q = searchParams.get("_q") ?? "";
const dateFrom = searchParams.get("_from") ?? "";
const dateTo = searchParams.get("_to") ?? "";
// Folder navigation: ALL | ROOT ("") | folder name
const [selected, setSelected] = useState<string>(ALL);
@@ -86,7 +106,7 @@ export default function AssetsPage() {
// Copy with transformation
const [copyTransformTarget, setCopyTransformTarget] = useState<Asset | null>(null);
const [copyTransformParams, setCopyTransformParams] = useState<TransformParams>({ format: "webp" });
const [copyTransformParams, setCopyTransformParams] = useState<TransformParams>({ format: "webp", quality: 85 });
const [copyTransformLoading, setCopyTransformLoading] = useState(false);
// Assets query: undefined = all, "" = root, "name" = folder
@@ -101,9 +121,28 @@ export default function AssetsPage() {
queryFn: fetchFolders,
});
const assets = assetsData?.assets ?? [];
const rawAssets = assetsData?.assets ?? [];
const folders = foldersData?.folders ?? [];
const assets = useMemo(() => {
let list = rawAssets;
if (q.trim()) {
const lower = q.trim().toLowerCase();
list = list.filter((a) => a.filename.toLowerCase().includes(lower));
}
if (dateFrom || dateTo) {
list = list.filter((a) => {
const iso = a.modified_at ?? a.created_at ?? "";
const assetDate = iso.slice(0, 10);
if (!assetDate) return false;
if (dateFrom && assetDate < dateFrom) return false;
if (dateTo && assetDate > dateTo) return false;
return true;
});
}
return list;
}, [rawAssets, q, dateFrom, dateTo]);
// ── Upload ──────────────────────────────────────────────────────────────
async function handleFiles(files: FileList | null) {
@@ -233,6 +272,15 @@ export default function AssetsPage() {
}
}
function setDateFilter(from: string, to: string) {
const sp = new URLSearchParams(searchParams.toString());
if (from) sp.set("_from", from);
else sp.delete("_from");
if (to) sp.set("_to", to);
else sp.delete("_to");
router.push(`${pathname}?${sp.toString()}`);
}
// ── Upload hint ───────────────────────────────────────────────────────────
const uploadHint =
@@ -388,24 +436,49 @@ export default function AssetsPage() {
{/* ── Main content ── */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between md:px-6">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
{selected === ALL ? t("titleAll") : selected === ROOT ? t("titleRoot") : selected}
</h1>
<p className="text-xs text-muted-foreground">
{t("assetCount", { count: assetsData?.total ?? 0 })}
</p>
<div className="flex flex-col gap-3 border-b border-border px-4 py-3 md:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
{selected === ALL ? t("titleAll") : selected === ROOT ? t("titleRoot") : selected}
</h1>
<p className="text-xs text-muted-foreground">
{assets.length === (assetsData?.total ?? 0) && !q.trim() && !dateFrom && !dateTo
? t("assetCount", { count: assets.length })
: t("assetCountFiltered", { count: assets.length, total: assetsData?.total ?? 0 })}
</p>
</div>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
size="sm"
className="min-h-[44px] w-full shrink-0 sm:w-auto"
>
<Icon icon="mdi:upload" className="size-4" aria-hidden />
{uploading ? t("uploading") : t("upload")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<SearchBar placeholder={t("searchPlaceholder")} paramName="_q" />
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs font-medium text-muted-foreground">{t("dateFrom")}</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFilter(e.target.value, dateTo)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground touch-manipulation"
aria-label={t("dateFrom")}
/>
<label className="text-xs font-medium text-muted-foreground">{t("dateTo")}</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateFilter(dateFrom, e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground touch-manipulation"
aria-label={t("dateTo")}
/>
</div>
</div>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
size="sm"
className="min-h-[44px] w-full shrink-0 sm:w-auto"
>
<Icon icon="mdi:upload" className="size-4" aria-hidden />
{uploading ? t("uploading") : t("upload")}
</Button>
</div>
{/* Hidden file input */}
@@ -458,7 +531,7 @@ export default function AssetsPage() {
onPreview={() => setPreviewAsset(asset)}
onCopy={() => copyUrl(asset)}
onRename={() => { setRenameTarget(asset); setRenameFilename(asset.filename); }}
onCopyTransform={() => { setCopyTransformTarget(asset); setCopyTransformParams({ format: "webp" }); }}
onCopyTransform={() => { setCopyTransformTarget(asset); setCopyTransformParams({ format: "webp", quality: 85 }); }}
onDelete={() => setPendingDeleteAsset(asset)}
/>
))}
@@ -506,9 +579,40 @@ export default function AssetsPage() {
</DialogHeader>
<form onSubmit={handleCopyWithTransform} className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">{t("copyWithTransformDesc")}</p>
{/* Presets */}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCopyTransformParams({ format: "webp", w: 300, fit: "contain", quality: 85 })}
className="rounded-md border border-input bg-muted/50 px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted"
>
{t("transformPresetThumb")}
</button>
<button
type="button"
onClick={() => setCopyTransformParams({ format: "webp", ar: "1:1", fit: "cover", quality: 85 })}
className="rounded-md border border-input bg-muted/50 px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted"
>
{t("transformPresetSquare")}
</button>
<button
type="button"
onClick={() => setCopyTransformParams({ format: "webp", w: 800, fit: "contain", quality: 80 })}
className="rounded-md border border-input bg-muted/50 px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted"
>
{t("transformPresetMedium")}
</button>
<button
type="button"
onClick={() => setCopyTransformParams({ format: "jpeg", w: 1200, fit: "contain", quality: 88 })}
className="rounded-md border border-input bg-muted/50 px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted"
>
{t("transformPresetJpeg")}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="tw" className="mb-1 block text-xs font-medium text-foreground">Width</label>
<label htmlFor="tw" className="mb-1 block text-xs font-medium text-foreground">{t("transformWidth")}</label>
<Input
id="tw"
type="number"
@@ -520,7 +624,7 @@ export default function AssetsPage() {
/>
</div>
<div>
<label htmlFor="th" className="mb-1 block text-xs font-medium text-foreground">Height</label>
<label htmlFor="th" className="mb-1 block text-xs font-medium text-foreground">{t("transformHeight")}</label>
<Input
id="th"
type="number"
@@ -532,17 +636,17 @@ export default function AssetsPage() {
/>
</div>
<div>
<label htmlFor="tar" className="mb-1 block text-xs font-medium text-foreground">Aspect (e.g. 1:1)</label>
<label htmlFor="tar" className="mb-1 block text-xs font-medium text-foreground">{t("transformAspect")}</label>
<Input
id="tar"
placeholder=""
placeholder="1:1, 16:9"
value={copyTransformParams.ar ?? ""}
onChange={(e) => setCopyTransformParams((p) => ({ ...p, ar: e.target.value || undefined }))}
className="h-8 text-sm"
/>
</div>
<div>
<label htmlFor="tfit" className="mb-1 block text-xs font-medium text-foreground">Fit</label>
<label htmlFor="tfit" className="mb-1 block text-xs font-medium text-foreground">{t("transformFit")}</label>
<select
id="tfit"
value={copyTransformParams.fit ?? "contain"}
@@ -554,8 +658,8 @@ export default function AssetsPage() {
<option value="fill">fill</option>
</select>
</div>
<div className="col-span-2">
<label htmlFor="tformat" className="mb-1 block text-xs font-medium text-foreground">Format</label>
<div>
<label htmlFor="tformat" className="mb-1 block text-xs font-medium text-foreground">{t("transformFormat")}</label>
<select
id="tformat"
value={copyTransformParams.format ?? "webp"}
@@ -568,6 +672,20 @@ export default function AssetsPage() {
<option value="avif">AVIF</option>
</select>
</div>
{(copyTransformParams.format === "jpeg" || copyTransformParams.format === "webp") && (
<div>
<label htmlFor="tquality" className="mb-1 block text-xs font-medium text-foreground">{t("transformQuality")}</label>
<Input
id="tquality"
type="number"
min={1}
max={100}
value={copyTransformParams.quality ?? 85}
onChange={(e) => setCopyTransformParams((p) => ({ ...p, quality: e.target.value ? Number(e.target.value) : 85 }))}
className="h-8 text-sm"
/>
</div>
)}
</div>
{copyTransformTarget && (
<p className="text-xs text-muted-foreground">
@@ -612,6 +730,13 @@ export default function AssetsPage() {
{previewAsset.folder}
</span>
)}
{(previewAsset.modified_at || previewAsset.created_at) && (
<span className="ml-2">
{previewAsset.modified_at
? formatAssetDate(previewAsset.modified_at)
: formatAssetDate(previewAsset.created_at ?? undefined)}
</span>
)}
</p>
<Button
variant="outline"
@@ -780,6 +905,11 @@ function AssetCard({
)}
{formatBytes(asset.size)}
</p>
{(asset.modified_at || asset.created_at) && (
<p className="text-[11px] text-muted-foreground/80" title={asset.modified_at ?? asset.created_at ?? undefined}>
{asset.modified_at ? formatAssetDate(asset.modified_at) : formatAssetDate(asset.created_at ?? undefined)}
</p>
)}
</div>
{/* Mobile: always-visible action row (touch-friendly) */}

View File

@@ -87,7 +87,7 @@ export default function ContentEditPage() {
{ label: slug },
]}
/>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="mb-4 flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" asChild>
<Link href={listHref}>

View File

@@ -46,6 +46,7 @@ export default function ContentListPage() {
const listParams = {
_page: page,
_per_page: PER_PAGE,
_status: "all" as const,
...(sort ? { _sort: sort, _order: order } : {}),
...(q?.trim() ? { _q: q.trim() } : {}),
...(locale ? { _locale: locale } : {}),
@@ -198,10 +199,18 @@ export default function ContentListPage() {
{items.map((entry: Record<string, unknown>) => {
const slug = entry._slug as string | undefined;
if (slug == null) return null;
const isDraft = entry._status === "draft";
const editHref = `/content/${collection}/${encodeURIComponent(slug)}${localeQ ? `?${localeQ}` : ""}`;
return (
<TableRow key={slug}>
<TableCell className="font-mono text-sm">{slug}</TableCell>
<TableCell className="font-mono text-sm">
{slug}
{isDraft && (
<span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-800">
{t("draft")}
</span>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" asChild className="min-h-[44px] sm:min-h-0">
<Link href={editHref}>

View File

@@ -39,6 +39,18 @@
/* shadcn accent = subtiler Hover-Hintergrund (z.B. SelectItem, CommandItem) */
--shadcn-accent: #fff1f2;
--shadcn-accent-foreground: #881337;
/* Success/Publish-Grün (Dashboard „Neuer Inhaltstyp“, Status „Veröffentlicht“) */
--success-50: #f0fdf4;
--success-100: #dcfce7;
--success-200: #bbf7d0;
--success-300: #86efac;
--success-400: #4ade80;
--success-500: #22c55e;
--success-600: #16a34a;
--success-700: #15803d;
--success-800: #166534;
--success-900: #14532d;
}
@@ -79,6 +91,18 @@
--color-accent: var(--shadcn-accent);
--color-accent-foreground: var(--shadcn-accent-foreground);
/* Success-Grün (bg-success-50, text-success-600, border-success-300, …) */
--color-success-50: var(--success-50);
--color-success-100: var(--success-100);
--color-success-200: var(--success-200);
--color-success-300: var(--success-300);
--color-success-400: var(--success-400);
--color-success-500: var(--success-500);
--color-success-600: var(--success-600);
--color-success-700: var(--success-700);
--color-success-800: var(--success-800);
--color-success-900: var(--success-900);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);

View File

@@ -1,10 +1,12 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { fetchCollections } from "@/lib/api";
import { Icon } from "@iconify/react";
import { fetchCollections, type CollectionMeta } from "@/lib/api";
import { DashboardCollectionList } from "@/components/DashboardCollectionList";
export default async function DashboardPage() {
const t = await getTranslations("Dashboard");
let collections: { name: string }[] = [];
let collections: CollectionMeta[] = [];
try {
const res = await fetchCollections();
collections = res.collections ?? [];
@@ -15,28 +17,20 @@ export default async function DashboardPage() {
return (
<div>
<h1 className="mb-6 text-2xl font-semibold text-gray-900">{t("title")}</h1>
<p className="mb-6 text-gray-600">
{t("subtitle")}
</p>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{collections.map((c) => (
<li key={c.name}>
<Link
href={`/content/${c.name}`}
className="block min-h-[48px] rounded-lg border border-accent-200 bg-accent-50/50 px-4 py-3 font-medium text-gray-900 hover:border-accent-300 hover:bg-accent-100/80 active:bg-accent-200/60 [touch-action:manipulation]"
>
{c.name}
</Link>
</li>
))}
</ul>
{collections.length === 0 && (
<p className="text-gray-500">
{t("noCollections", {
url: process.env.NEXT_PUBLIC_RUSTYCMS_API_URL ?? "http://127.0.0.1:3000",
})}
<div className="mb-6 flex flex-wrap items-center gap-3">
<p className="text-gray-600">
{t("subtitle")}
</p>
)}
<Link
href="/admin/new-type"
data-slot="button"
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg border border-success-300 bg-success-50 px-4 py-2.5 font-medium text-success-600 no-underline hover:border-success-400 hover:bg-success-100 hover:text-success-700 active:bg-success-200/80 [touch-action:manipulation]"
>
<Icon icon="mdi:plus" className="size-5" aria-hidden />
{t("newContentType")}
</Link>
</div>
<DashboardCollectionList collections={collections} />
</div>
);
}

View File

@@ -23,12 +23,15 @@ export function AppShell({ locale, children }: AppShellProps) {
aria-label="Close menu"
/>
)}
<Sidebar
locale={locale}
mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="flex min-w-0 flex-1 flex-col">
{/* Wrapper takes no flex space on mobile so main content is full width; on md it reserves sidebar width */}
<div className="w-0 shrink-0 overflow-visible md:w-56">
<Sidebar
locale={locale}
mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col w-full">
{/* Mobile menu button */}
<header className="flex shrink-0 items-center gap-3 border-b border-gray-200 bg-white px-4 py-3 md:hidden">
<button

View File

@@ -1,13 +1,14 @@
"use client";
import { useId, useEffect, useRef } from "react";
import { useId, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import type { SchemaDefinition, FieldDefinition } from "@/lib/api";
import { checkSlug } from "@/lib/api";
import { checkSlug, getBaseUrl, fetchAssets } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -20,6 +21,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ReferenceArrayField } from "./ReferenceArrayField";
import { ReferenceField } from "./ReferenceField";
import { ReferenceOrInlineField } from "./ReferenceOrInlineField";
@@ -173,6 +181,146 @@ function SlugField({
);
}
/** True if this string field is used for image/asset URLs (by description or name). */
function isImageUrlField(def: FieldDefinition, name: string): boolean {
const desc = def.description && String(def.description).toLowerCase();
if (desc && (desc.includes("image") || desc.includes("asset"))) return true;
if (name === "image" || name === "imageUrl" || name === "asset") return true;
return false;
}
function assetPreviewUrl(value: string | undefined): string | null {
if (!value || typeof value !== "string") return null;
const v = value.trim();
if (!v) return null;
if (v.startsWith("http://") || v.startsWith("https://")) return v;
if (v.startsWith("/api/assets/")) return getBaseUrl() + v;
if (/\.(jpg|jpeg|png|webp|gif|avif|svg)(\?|$)/i.test(v)) return getBaseUrl() + (v.startsWith("/") ? v : "/" + v);
return null;
}
/** Asset picker content: grid of assets, click to select URL. Use inside DialogContent. */
function AssetPickerContent({ onSelect }: { onSelect: (url: string) => void }) {
const t = useTranslations("ContentForm");
const { data, isLoading, error } = useQuery({
queryKey: ["assets", "all"],
queryFn: () => fetchAssets(),
});
const assets = data?.assets ?? [];
return (
<div className="min-h-0 flex-1 overflow-auto">
{isLoading && (
<p className="text-muted-foreground text-sm">{t("loadingAssets")}</p>
)}
{error && (
<p className="text-destructive text-sm">{String((error as Error).message)}</p>
)}
{!isLoading && !error && assets.length === 0 && (
<p className="text-muted-foreground text-sm">{t("noAssets")}</p>
)}
{!isLoading && assets.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{assets.map((asset) => {
const src = getBaseUrl() + asset.url;
return (
<button
key={asset.url}
type="button"
onClick={() => onSelect(asset.url)}
className="flex flex-col items-center rounded-lg border border-input bg-muted/30 p-2 hover:bg-muted/60 focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<span className="aspect-square w-full overflow-hidden rounded border bg-background flex items-center justify-center">
{/\.(svg)$/i.test(asset.filename) ? (
<img src={src} alt="" className="max-h-full max-w-full object-contain" />
) : (
<img src={src} alt="" className="h-full w-full object-cover" />
)}
</span>
<span className="mt-1 truncate w-full text-center text-xs text-muted-foreground">
{asset.filename}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
/** String field for image/asset URL: preview + input + asset picker button. */
function ImageUrlField({
value,
onChange,
label,
fieldError,
required,
readonly,
}: {
value: string;
onChange: (v: string) => void;
label: React.ReactNode;
fieldError: unknown;
required: boolean;
readonly: boolean;
}) {
const t = useTranslations("ContentForm");
const [pickerOpen, setPickerOpen] = useState(false);
const previewUrl = assetPreviewUrl(value);
return (
<div>
{label}
{previewUrl && (
<div className="mt-2 rounded-lg border border-input overflow-hidden bg-muted/20 inline-block max-w-[200px]">
<img
src={previewUrl}
alt=""
className="max-h-40 w-full object-contain"
/>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/api/assets/…"
readOnly={readonly}
className="flex-1 min-w-[200px] font-mono text-sm"
/>
{!readonly && (
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Icon icon="mdi:image-multiple" className="mr-1 size-4" />
{t("pickFromAssets")}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t("pickAsset")}</DialogTitle>
</DialogHeader>
<AssetPickerContent
onSelect={(url) => {
onChange(url);
setPickerOpen(false);
}}
/>
</DialogContent>
</Dialog>
)}
</div>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
/** Builds form initial values from API response: references → slug strings, objects recursively. */
function buildDefaultValues(
schema: SchemaDefinition,
@@ -296,6 +444,9 @@ export function ContentForm({
if (!isEdit && !initialValues?._slug && defaultValues._slug === undefined) {
defaultValues._slug = slugPrefixForCollection(collection);
}
if (!isEdit && defaultValues._status === undefined) {
defaultValues._status = "draft";
}
const {
register,
handleSubmit,
@@ -452,6 +603,39 @@ export function ContentForm({
slugValue={watch("_slug") as string | undefined}
/>
)}
<div>
<Label className="mb-1 block font-medium">{t("status")}</Label>
<Controller
name="_status"
control={control!}
render={({ field }) => {
const value = (field.value as string) ?? "draft";
return (
<div className="flex flex-wrap gap-2" role="group" aria-label={t("status")}>
<Button
type="button"
variant={value === "draft" ? "default" : "outline"}
size="sm"
onClick={() => field.onChange("draft")}
className="min-w-[100px]"
>
{t("statusDraft")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => field.onChange("published")}
className={`min-w-[100px] ${value === "published" ? "border-success-300 bg-success-50 text-success-700 hover:bg-success-100 hover:text-success-800 hover:border-success-400" : ""}`}
>
{t("statusPublished")}
</Button>
</div>
);
}}
/>
<p className="mt-0.5 text-xs text-muted-foreground">{t("statusHint")}</p>
</div>
{hasSection
? formItems.map((item) =>
item.kind === "object" ? (
@@ -1021,6 +1205,26 @@ function Field({
return String(v);
};
if (type === "string" && isImageUrlField(def, name)) {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<ImageUrlField
value={safeStringValue(field.value)}
onChange={field.onChange}
label={label}
fieldError={fieldError}
required={required}
readonly={readonly}
/>
)}
/>
);
}
return (
<div>
{label}

View File

@@ -0,0 +1,132 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import type { CollectionMeta } from "@/lib/api";
import { Input } from "@/components/ui/input";
type Props = {
collections: CollectionMeta[];
};
export function DashboardCollectionList({ collections }: Props) {
const t = useTranslations("Dashboard");
const [search, setSearch] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const allTags = useMemo(() => {
const set = new Set<string>();
for (const c of collections) {
for (const tag of c.tags ?? []) {
set.add(tag);
}
}
return Array.from(set).sort();
}, [collections]);
const filtered = useMemo(() => {
let list = collections;
const q = search.trim().toLowerCase();
if (q) {
list = list.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.description ?? "").toLowerCase().includes(q) ||
(c.tags ?? []).some((tag) => tag.toLowerCase().includes(q)),
);
}
if (selectedTag) {
list = list.filter((c) => (c.tags ?? []).includes(selectedTag));
}
return list;
}, [collections, search, selectedTag]);
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="relative flex-1">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchPlaceholder")}
className="w-full max-w-md"
/>
</div>
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-600">{t("filterByTag")}</span>
<button
type="button"
onClick={() => setSelectedTag(null)}
className={`rounded px-2.5 py-1 text-sm transition-colors ${
selectedTag === null
? "bg-accent-200 font-medium text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80"
}`}
>
{t("tagAll")}
</button>
{allTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded px-2.5 py-1 text-sm transition-colors ${
selectedTag === tag
? "bg-accent-200 font-medium text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80"
}`}
>
{tag}
</button>
))}
</div>
)}
</div>
{collections.length === 0 ? (
<p className="text-gray-500">
{t("noCollections", {
url: process.env.NEXT_PUBLIC_RUSTYCMS_API_URL ?? "http://127.0.0.1:3000",
})}
</p>
) : filtered.length === 0 ? (
<p className="text-gray-500">{t("noResults")}</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((c) => (
<li key={c.name}>
<Link
href={`/content/${c.name}`}
data-slot="button"
className="flex min-h-[48px] flex-col items-stretch justify-center gap-1 rounded-lg border border-accent-200 bg-accent-50/50 px-4 py-3 font-medium text-gray-900 no-underline hover:border-accent-300 hover:bg-accent-100/80 active:bg-accent-200/60 touch-manipulation"
>
<span className="font-medium">{c.name}</span>
{c.description && (
<span className="text-sm font-normal text-gray-600 line-clamp-2">
{c.description}
</span>
)}
{c.tags && c.tags.length > 0 && (
<span className="flex flex-wrap gap-1 pt-0.5">
{c.tags.map((tag) => (
<span
key={tag}
className="inline rounded bg-accent-200/80 px-1.5 py-0.5 text-xs text-gray-700"
>
{tag}
</span>
))}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -9,7 +9,8 @@ import { useTranslations } from "next-intl";
import { fetchCollections, fetchContentList } from "@/lib/api";
import { LocaleSwitcher } from "./LocaleSwitcher";
const navLinkClass = "inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium min-h-[44px] md:min-h-0 md:py-2";
const navLinkClass =
"inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium min-h-[44px] md:min-h-0 md:py-2";
type SidebarProps = {
locale: string;
@@ -35,7 +36,7 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
c.name.toLowerCase().includes(q) ||
(c.category?.toLowerCase().includes(q) ?? false) ||
(c.tags?.some((t) => t.toLowerCase().includes(q)) ?? false) ||
(c.description?.toLowerCase().includes(q) ?? false)
(c.description?.toLowerCase().includes(q) ?? false),
);
}, [data?.collections, search]);
@@ -54,15 +55,19 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
const isDrawer = typeof onClose === "function";
const asideClass =
"flex h-screen flex-col overflow-hidden border-r border-accent-200/50 bg-gradient-to-b from-violet-50/95 via-accent-50/90 to-amber-50/85 shadow-[2px_0_16px_-2px_rgba(225,29,72,0.06)] " +
"relative flex h-screen flex-col overflow-hidden border-r border-accent-200/50 bg-gradient-to-b from-violet-50/95 via-accent-50/90 to-amber-50/85 shadow-[2px_0_16px_-2px_rgba(225,29,72,0.06)] " +
(isDrawer
? "fixed left-0 top-0 z-40 w-72 max-w-[85vw] transition-transform duration-200 ease-out md:relative md:z-auto md:w-56 md:shrink-0 md:translate-x-0 " +
? "fixed left-0 top-0 z-40 w-72 max-w-[85vw] transition-transform duration-200 ease-out md:relative md:left-auto md:top-auto md:z-auto md:h-full md:w-full md:max-w-none md:translate-x-0 " +
(mobileOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0")
: "w-56 shrink-0");
: "w-full");
return (
<aside className={asideClass}>
<nav className="flex flex-1 min-h-0 flex-col p-4">
<div
className="pointer-events-none absolute inset-0 bg-repeat opacity-10"
aria-hidden
/>
<nav className="relative flex flex-1 min-h-0 flex-col p-4">
{/* Brand / logo row */}
<div className="flex shrink-0 items-center gap-2 pb-3">
<Link
@@ -73,7 +78,9 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-accent-200/80 text-accent-800">
<Icon icon="mdi:cog-outline" className="size-5" aria-hidden />
</span>
<span className="truncate text-lg font-bold tracking-tight">RustyCMS</span>
<span className="truncate text-lg font-bold tracking-tight">
RustyCMS
</span>
</Link>
{isDrawer && (
<button
@@ -92,7 +99,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
onClick={onClose}
className={`${navLinkClass} font-bold ${pathname === "/" ? "bg-accent-200/70 text-gray-900" : "text-gray-700 hover:bg-accent-100/80 hover:text-gray-900"}`}
>
<Icon icon="mdi:view-dashboard-outline" className="size-5 shrink-0" aria-hidden />
<Icon
icon="mdi:view-dashboard-outline"
className="size-5 shrink-0"
aria-hidden
/>
{t("dashboard")}
</Link>
<Link
@@ -100,7 +111,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
onClick={onClose}
className={`${navLinkClass} ${pathname?.startsWith("/admin/types") ? "bg-accent-200/70 text-gray-900" : "text-gray-700 hover:bg-accent-100/80 hover:text-gray-900"}`}
>
<Icon icon="mdi:shape-outline" className="size-5 shrink-0" aria-hidden />
<Icon
icon="mdi:shape-outline"
className="size-5 shrink-0"
aria-hidden
/>
{t("types")}
</Link>
<Link
@@ -108,7 +123,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
onClick={onClose}
className={`${navLinkClass} ${pathname?.startsWith("/assets") ? "bg-accent-200/70 text-gray-900" : "text-gray-700 hover:bg-accent-100/80 hover:text-gray-900"}`}
>
<Icon icon="mdi:image-multiple-outline" className="size-5 shrink-0" aria-hidden />
<Icon
icon="mdi:image-multiple-outline"
className="size-5 shrink-0"
aria-hidden
/>
{t("assets")}
</Link>
</div>
@@ -125,18 +144,23 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{isLoading && (
<div className="px-3 py-2 text-sm text-gray-400">{t("loading")}</div>
<div className="px-3 py-2 text-sm text-gray-400">
{t("loading")}
</div>
)}
{error && (
<div className="px-3 py-2 text-sm text-red-600">
{t("errorLoading")}
</div>
)}
{!isLoading && !error && search.trim() && filteredCollections.length === 0 && (
<div className="px-3 py-2 text-sm text-gray-500">
{t("noResults", { query: search.trim() })}
</div>
)}
{!isLoading &&
!error &&
search.trim() &&
filteredCollections.length === 0 && (
<div className="px-3 py-2 text-sm text-gray-500">
{t("noResults", { query: search.trim() })}
</div>
)}
{filteredCollections.map((c, i) => {
const href = `/content/${c.name}`;
const active = pathname === href || pathname.startsWith(href + "/");
@@ -163,11 +187,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
</span>
{hasMeta && (
<span className="mt-0.5 block text-xs font-normal text-gray-500">
{c.category && (
<span className="rounded bg-accent-200/50 px-1">
{c.category}
</span>
)}
{c.category && (
<span className="rounded bg-accent-200/50 px-1">
{c.category}
</span>
)}
{c.tags?.length ? (
<span className="ml-1">
{c.tags.slice(0, 2).join(", ")}

View File

@@ -3,7 +3,7 @@
* Optional RUSTYCMS_API_KEY for write operations (sent as X-API-Key).
*/
const getBaseUrl = () =>
export const getBaseUrl = () =>
process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
const getHeaders = (): HeadersInit => {
@@ -247,6 +247,10 @@ export type Asset = {
url: string;
mime_type: string;
size: number;
/** ISO8601 when file was created (if available). */
created_at?: string | null;
/** ISO8601 when file was last modified. */
modified_at?: string | null;
};
export type AssetsResponse = {