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:
@@ -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 (1–100)",
|
||||
"creating": "Wird erstellt…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (1–100)",
|
||||
"creating": "Creating…"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
admin-ui/public/rusty.jpg
Normal file
BIN
admin-ui/public/rusty.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
@@ -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) */}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
132
admin-ui/src/components/DashboardCollectionList.tsx
Normal file
132
admin-ui/src/components/DashboardCollectionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(", ")}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user