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

5
.gitignore vendored
View File

@@ -5,13 +5,16 @@ Cargo.lock
*.db *.db
content.db content.db
# Content: ignore all except one demo entry # Content: ignore all except demo entry and demo asset
content/* content/*
!content/de/ !content/de/
content/de/* content/de/*
!content/de/demo/ !content/de/demo/
content/de/demo/* content/de/demo/*
!content/de/demo/demo-welcome.json5 !content/de/demo/demo-welcome.json5
!content/assets/
content/assets/*
!content/assets/mountains-w300.webp
# Types: ignore all except demo type # Types: ignore all except demo type
types/* types/*

View File

@@ -28,7 +28,15 @@
"removeEntry": "Entfernen", "removeEntry": "Entfernen",
"addEntry": "+ Eintrag hinzufügen", "addEntry": "+ Eintrag hinzufügen",
"keyPlaceholder": "Schlüssel", "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": { "SearchableSelect": {
"placeholder": "\u2014 Bitte ausw\u00e4hlen \u2014", "placeholder": "\u2014 Bitte ausw\u00e4hlen \u2014",
@@ -113,6 +121,11 @@
"Dashboard": { "Dashboard": {
"title": "Dashboard", "title": "Dashboard",
"subtitle": "W\u00e4hle eine Sammlung zur Inhaltsverwaltung.", "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." "noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist."
}, },
"TypesPage": { "TypesPage": {
@@ -205,6 +218,7 @@
"noEntries": "Keine Einträge.", "noEntries": "Keine Einträge.",
"noEntriesCreate": "Noch keine Einträge. Erstellen Sie den ersten.", "noEntriesCreate": "Noch keine Einträge. Erstellen Sie den ersten.",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"draft": "Entwurf",
"searchPlaceholder": "Suchen…", "searchPlaceholder": "Suchen…",
"loading": "Laden…", "loading": "Laden…",
"sortBy": "Sortieren nach {field}", "sortBy": "Sortieren nach {field}",
@@ -223,6 +237,10 @@
"titleAll": "Alle Assets", "titleAll": "Alle Assets",
"titleRoot": "Root", "titleRoot": "Root",
"assetCount": "{count} Bild(er)", "assetCount": "{count} Bild(er)",
"assetCountFiltered": "{count} von {total} Bild(ern)",
"searchPlaceholder": "Nach Dateiname suchen…",
"dateFrom": "Von Datum",
"dateTo": "Bis Datum",
"upload": "Hochladen", "upload": "Hochladen",
"uploading": "Wird hochgeladen…", "uploading": "Wird hochgeladen…",
"uploadedCount": "{count} Datei(en) hochgeladen.", "uploadedCount": "{count} Datei(en) hochgeladen.",
@@ -258,6 +276,16 @@
"copyWithTransformNewName": "Neuer Dateiname", "copyWithTransformNewName": "Neuer Dateiname",
"copyWithTransformCreate": "Kopie erstellen", "copyWithTransformCreate": "Kopie erstellen",
"copyWithTransformDone": "Transformierte Kopie erstellt.", "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…" "creating": "Wird erstellt…"
} }
} }

View File

@@ -28,7 +28,15 @@
"removeEntry": "Remove", "removeEntry": "Remove",
"addEntry": "+ Add entry", "addEntry": "+ Add entry",
"keyPlaceholder": "Key", "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": { "SearchableSelect": {
"placeholder": "— Please select —", "placeholder": "— Please select —",
@@ -113,6 +121,11 @@
"Dashboard": { "Dashboard": {
"title": "Dashboard", "title": "Dashboard",
"subtitle": "Choose a collection to manage content.", "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}." "noCollections": "No collections loaded. Check that the RustyCMS API is running at {url}."
}, },
"TypesPage": { "TypesPage": {
@@ -205,6 +218,7 @@
"noEntries": "No entries.", "noEntries": "No entries.",
"noEntriesCreate": "No entries yet. Create the first one.", "noEntriesCreate": "No entries yet. Create the first one.",
"edit": "Edit", "edit": "Edit",
"draft": "Draft",
"searchPlaceholder": "Search…", "searchPlaceholder": "Search…",
"loading": "Loading…", "loading": "Loading…",
"sortBy": "Sort by {field}", "sortBy": "Sort by {field}",
@@ -223,6 +237,10 @@
"titleAll": "All assets", "titleAll": "All assets",
"titleRoot": "Root", "titleRoot": "Root",
"assetCount": "{count} image(s)", "assetCount": "{count} image(s)",
"assetCountFiltered": "{count} of {total} image(s)",
"searchPlaceholder": "Search by filename…",
"dateFrom": "From date",
"dateTo": "To date",
"upload": "Upload", "upload": "Upload",
"uploading": "Uploading…", "uploading": "Uploading…",
"uploadedCount": "Uploaded {count} file(s).", "uploadedCount": "Uploaded {count} file(s).",
@@ -258,6 +276,16 @@
"copyWithTransformNewName": "New filename", "copyWithTransformNewName": "New filename",
"copyWithTransformCreate": "Create copy", "copyWithTransformCreate": "Create copy",
"copyWithTransformDone": "Transformed copy created.", "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…" "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"; "use client";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -20,6 +21,7 @@ import {
import type { Asset, AssetFolder, TransformParams } from "@/lib/api"; import type { Asset, AssetFolder, TransformParams } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { SearchBar } from "@/components/SearchBar";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -47,6 +49,17 @@ function formatBytes(n: number) {
return `${(n / 1024 / 1024).toFixed(1)} MB`; 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) { function assetPath(asset: Asset) {
return asset.folder ? `${asset.folder}/${asset.filename}` : asset.filename; return asset.folder ? `${asset.folder}/${asset.filename}` : asset.filename;
} }
@@ -54,8 +67,15 @@ function assetPath(asset: Asset) {
export default function AssetsPage() { export default function AssetsPage() {
const t = useTranslations("AssetsPage"); const t = useTranslations("AssetsPage");
const qc = useQueryClient(); const qc = useQueryClient();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const fileInputRef = useRef<HTMLInputElement>(null); 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 // Folder navigation: ALL | ROOT ("") | folder name
const [selected, setSelected] = useState<string>(ALL); const [selected, setSelected] = useState<string>(ALL);
@@ -86,7 +106,7 @@ export default function AssetsPage() {
// Copy with transformation // Copy with transformation
const [copyTransformTarget, setCopyTransformTarget] = useState<Asset | null>(null); 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); const [copyTransformLoading, setCopyTransformLoading] = useState(false);
// Assets query: undefined = all, "" = root, "name" = folder // Assets query: undefined = all, "" = root, "name" = folder
@@ -101,9 +121,28 @@ export default function AssetsPage() {
queryFn: fetchFolders, queryFn: fetchFolders,
}); });
const assets = assetsData?.assets ?? []; const rawAssets = assetsData?.assets ?? [];
const folders = foldersData?.folders ?? []; 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 ────────────────────────────────────────────────────────────── // ── Upload ──────────────────────────────────────────────────────────────
async function handleFiles(files: FileList | null) { 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 ─────────────────────────────────────────────────────────── // ── Upload hint ───────────────────────────────────────────────────────────
const uploadHint = const uploadHint =
@@ -388,13 +436,16 @@ export default function AssetsPage() {
{/* ── Main content ── */} {/* ── Main content ── */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden"> <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Header */} {/* 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="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"> <div className="min-w-0">
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg"> <h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
{selected === ALL ? t("titleAll") : selected === ROOT ? t("titleRoot") : selected} {selected === ALL ? t("titleAll") : selected === ROOT ? t("titleRoot") : selected}
</h1> </h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("assetCount", { count: assetsData?.total ?? 0 })} {assets.length === (assetsData?.total ?? 0) && !q.trim() && !dateFrom && !dateTo
? t("assetCount", { count: assets.length })
: t("assetCountFiltered", { count: assets.length, total: assetsData?.total ?? 0 })}
</p> </p>
</div> </div>
<Button <Button
@@ -407,6 +458,28 @@ export default function AssetsPage() {
{uploading ? t("uploading") : t("upload")} {uploading ? t("uploading") : t("upload")}
</Button> </Button>
</div> </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>
</div>
{/* Hidden file input */} {/* Hidden file input */}
<input <input
@@ -458,7 +531,7 @@ export default function AssetsPage() {
onPreview={() => setPreviewAsset(asset)} onPreview={() => setPreviewAsset(asset)}
onCopy={() => copyUrl(asset)} onCopy={() => copyUrl(asset)}
onRename={() => { setRenameTarget(asset); setRenameFilename(asset.filename); }} onRename={() => { setRenameTarget(asset); setRenameFilename(asset.filename); }}
onCopyTransform={() => { setCopyTransformTarget(asset); setCopyTransformParams({ format: "webp" }); }} onCopyTransform={() => { setCopyTransformTarget(asset); setCopyTransformParams({ format: "webp", quality: 85 }); }}
onDelete={() => setPendingDeleteAsset(asset)} onDelete={() => setPendingDeleteAsset(asset)}
/> />
))} ))}
@@ -506,9 +579,40 @@ export default function AssetsPage() {
</DialogHeader> </DialogHeader>
<form onSubmit={handleCopyWithTransform} className="flex flex-col gap-4"> <form onSubmit={handleCopyWithTransform} className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">{t("copyWithTransformDesc")}</p> <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 className="grid grid-cols-2 gap-3">
<div> <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 <Input
id="tw" id="tw"
type="number" type="number"
@@ -520,7 +624,7 @@ export default function AssetsPage() {
/> />
</div> </div>
<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 <Input
id="th" id="th"
type="number" type="number"
@@ -532,17 +636,17 @@ export default function AssetsPage() {
/> />
</div> </div>
<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 <Input
id="tar" id="tar"
placeholder="" placeholder="1:1, 16:9"
value={copyTransformParams.ar ?? ""} value={copyTransformParams.ar ?? ""}
onChange={(e) => setCopyTransformParams((p) => ({ ...p, ar: e.target.value || undefined }))} onChange={(e) => setCopyTransformParams((p) => ({ ...p, ar: e.target.value || undefined }))}
className="h-8 text-sm" className="h-8 text-sm"
/> />
</div> </div>
<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 <select
id="tfit" id="tfit"
value={copyTransformParams.fit ?? "contain"} value={copyTransformParams.fit ?? "contain"}
@@ -554,8 +658,8 @@ export default function AssetsPage() {
<option value="fill">fill</option> <option value="fill">fill</option>
</select> </select>
</div> </div>
<div className="col-span-2"> <div>
<label htmlFor="tformat" className="mb-1 block text-xs font-medium text-foreground">Format</label> <label htmlFor="tformat" className="mb-1 block text-xs font-medium text-foreground">{t("transformFormat")}</label>
<select <select
id="tformat" id="tformat"
value={copyTransformParams.format ?? "webp"} value={copyTransformParams.format ?? "webp"}
@@ -568,6 +672,20 @@ export default function AssetsPage() {
<option value="avif">AVIF</option> <option value="avif">AVIF</option>
</select> </select>
</div> </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> </div>
{copyTransformTarget && ( {copyTransformTarget && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -612,6 +730,13 @@ export default function AssetsPage() {
{previewAsset.folder} {previewAsset.folder}
</span> </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> </p>
<Button <Button
variant="outline" variant="outline"
@@ -780,6 +905,11 @@ function AssetCard({
)} )}
{formatBytes(asset.size)} {formatBytes(asset.size)}
</p> </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> </div>
{/* Mobile: always-visible action row (touch-friendly) */} {/* Mobile: always-visible action row (touch-friendly) */}

View File

@@ -87,7 +87,7 @@ export default function ContentEditPage() {
{ label: slug }, { 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"> <div className="flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link href={listHref}> <Link href={listHref}>

View File

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

View File

@@ -39,6 +39,18 @@
/* shadcn accent = subtiler Hover-Hintergrund (z.B. SelectItem, CommandItem) */ /* shadcn accent = subtiler Hover-Hintergrund (z.B. SelectItem, CommandItem) */
--shadcn-accent: #fff1f2; --shadcn-accent: #fff1f2;
--shadcn-accent-foreground: #881337; --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: var(--shadcn-accent);
--color-accent-foreground: var(--shadcn-accent-foreground); --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-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);

View File

@@ -1,10 +1,12 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; 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() { export default async function DashboardPage() {
const t = await getTranslations("Dashboard"); const t = await getTranslations("Dashboard");
let collections: { name: string }[] = []; let collections: CollectionMeta[] = [];
try { try {
const res = await fetchCollections(); const res = await fetchCollections();
collections = res.collections ?? []; collections = res.collections ?? [];
@@ -15,28 +17,20 @@ export default async function DashboardPage() {
return ( return (
<div> <div>
<h1 className="mb-6 text-2xl font-semibold text-gray-900">{t("title")}</h1> <h1 className="mb-6 text-2xl font-semibold text-gray-900">{t("title")}</h1>
<p className="mb-6 text-gray-600"> <div className="mb-6 flex flex-wrap items-center gap-3">
<p className="text-gray-600">
{t("subtitle")} {t("subtitle")}
</p> </p>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{collections.map((c) => (
<li key={c.name}>
<Link <Link
href={`/content/${c.name}`} href="/admin/new-type"
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]" 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]"
> >
{c.name} <Icon icon="mdi:plus" className="size-5" aria-hidden />
{t("newContentType")}
</Link> </Link>
</li> </div>
))} <DashboardCollectionList collections={collections} />
</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",
})}
</p>
)}
</div> </div>
); );
} }

View File

@@ -23,12 +23,15 @@ export function AppShell({ locale, children }: AppShellProps) {
aria-label="Close menu" aria-label="Close menu"
/> />
)} )}
{/* 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 <Sidebar
locale={locale} locale={locale}
mobileOpen={sidebarOpen} mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
/> />
<div className="flex min-w-0 flex-1 flex-col"> </div>
<div className="flex min-w-0 flex-1 flex-col w-full">
{/* Mobile menu button */} {/* 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"> <header className="flex shrink-0 items-center gap-3 border-b border-gray-200 bg-white px-4 py-3 md:hidden">
<button <button

View File

@@ -1,13 +1,14 @@
"use client"; "use client";
import { useId, useEffect, useRef } from "react"; import { useId, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import type { SchemaDefinition, FieldDefinition } from "@/lib/api"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -20,6 +21,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ReferenceArrayField } from "./ReferenceArrayField"; import { ReferenceArrayField } from "./ReferenceArrayField";
import { ReferenceField } from "./ReferenceField"; import { ReferenceField } from "./ReferenceField";
import { ReferenceOrInlineField } from "./ReferenceOrInlineField"; 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. */ /** Builds form initial values from API response: references → slug strings, objects recursively. */
function buildDefaultValues( function buildDefaultValues(
schema: SchemaDefinition, schema: SchemaDefinition,
@@ -296,6 +444,9 @@ export function ContentForm({
if (!isEdit && !initialValues?._slug && defaultValues._slug === undefined) { if (!isEdit && !initialValues?._slug && defaultValues._slug === undefined) {
defaultValues._slug = slugPrefixForCollection(collection); defaultValues._slug = slugPrefixForCollection(collection);
} }
if (!isEdit && defaultValues._status === undefined) {
defaultValues._status = "draft";
}
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -452,6 +603,39 @@ export function ContentForm({
slugValue={watch("_slug") as string | undefined} 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 {hasSection
? formItems.map((item) => ? formItems.map((item) =>
item.kind === "object" ? ( item.kind === "object" ? (
@@ -1021,6 +1205,26 @@ function Field({
return String(v); 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 ( return (
<div> <div>
{label} {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 { fetchCollections, fetchContentList } from "@/lib/api";
import { LocaleSwitcher } from "./LocaleSwitcher"; 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 = { type SidebarProps = {
locale: string; locale: string;
@@ -35,7 +36,7 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
c.name.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) ||
(c.category?.toLowerCase().includes(q) ?? false) || (c.category?.toLowerCase().includes(q) ?? false) ||
(c.tags?.some((t) => t.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]); }, [data?.collections, search]);
@@ -54,15 +55,19 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
const isDrawer = typeof onClose === "function"; const isDrawer = typeof onClose === "function";
const asideClass = 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 (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") (mobileOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0")
: "w-56 shrink-0"); : "w-full");
return ( return (
<aside className={asideClass}> <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 */} {/* Brand / logo row */}
<div className="flex shrink-0 items-center gap-2 pb-3"> <div className="flex shrink-0 items-center gap-2 pb-3">
<Link <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"> <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 /> <Icon icon="mdi:cog-outline" className="size-5" aria-hidden />
</span> </span>
<span className="truncate text-lg font-bold tracking-tight">RustyCMS</span> <span className="truncate text-lg font-bold tracking-tight">
RustyCMS
</span>
</Link> </Link>
{isDrawer && ( {isDrawer && (
<button <button
@@ -92,7 +99,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
onClick={onClose} 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"}`} 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")} {t("dashboard")}
</Link> </Link>
<Link <Link
@@ -100,7 +111,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
onClick={onClose} 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"}`} 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")} {t("types")}
</Link> </Link>
<Link <Link
@@ -108,7 +123,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
onClick={onClose} 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"}`} 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")} {t("assets")}
</Link> </Link>
</div> </div>
@@ -125,14 +144,19 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{isLoading && ( {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 && ( {error && (
<div className="px-3 py-2 text-sm text-red-600"> <div className="px-3 py-2 text-sm text-red-600">
{t("errorLoading")} {t("errorLoading")}
</div> </div>
)} )}
{!isLoading && !error && search.trim() && filteredCollections.length === 0 && ( {!isLoading &&
!error &&
search.trim() &&
filteredCollections.length === 0 && (
<div className="px-3 py-2 text-sm text-gray-500"> <div className="px-3 py-2 text-sm text-gray-500">
{t("noResults", { query: search.trim() })} {t("noResults", { query: search.trim() })}
</div> </div>

View File

@@ -3,7 +3,7 @@
* Optional RUSTYCMS_API_KEY for write operations (sent as X-API-Key). * 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"; process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
const getHeaders = (): HeadersInit => { const getHeaders = (): HeadersInit => {
@@ -247,6 +247,10 @@ export type Asset = {
url: string; url: string;
mime_type: string; mime_type: string;
size: number; 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 = { export type AssetsResponse = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -2,4 +2,5 @@
_slug: "demo-welcome", _slug: "demo-welcome",
title: "Welcome", title: "Welcome",
body: "This is the demo content. Replace types and content with your own; only this demo type and entry stay in version control.", body: "This is the demo content. Replace types and content with your own; only this demo type and entry stay in version control.",
image: "/api/assets/mountains-w300.webp",
} }

8
dev.sh
View File

@@ -31,11 +31,9 @@ for i in {1..30}; do
fi fi
done done
# Ensure admin-ui dependencies are installed # Ensure admin-ui dependencies are up to date
if [ ! -d "admin-ui/node_modules" ]; then echo "Installing Admin UI dependencies..."
echo "Installing Admin UI dependencies..." (cd admin-ui && npm install)
(cd admin-ui && npm install)
fi
# Start Admin UI in background # Start Admin UI in background
echo "Starting Admin UI on http://localhost:2001 ..." echo "Starting Admin UI on http://localhost:2001 ..."

View File

@@ -13,9 +13,11 @@
//! DELETE /api/assets/*path delete image (auth required) //! DELETE /api/assets/*path delete image (auth required)
use std::sync::Arc; use std::sync::Arc;
use std::time::UNIX_EPOCH;
use axum::body::Body; use axum::body::Body;
use axum::extract::{Multipart, Path as AxumPath, Query, State}; use chrono::{DateTime, Utc};
use axum::extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, Response, StatusCode}; use axum::http::{header, HeaderMap, Response, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Json; use axum::Json;
@@ -131,18 +133,33 @@ async fn read_images(
if mime_for_ext(&ext).is_none() { if mime_for_ext(&ext).is_none() {
continue; continue;
} }
let size = e.metadata().await.map(|m| m.len()).unwrap_or(0); let meta = e.metadata().await.map_err(|e| ApiError::Internal(e.to_string()))?;
let size = meta.len();
let mime = mime_for_ext(&ext).unwrap_or("application/octet-stream"); let mime = mime_for_ext(&ext).unwrap_or("application/octet-stream");
let url = match folder { let url = match folder {
Some(f) => format!("/api/assets/{}/{}", f, fname), Some(f) => format!("/api/assets/{}/{}", f, fname),
None => format!("/api/assets/{}", fname), None => format!("/api/assets/{}", fname),
}; };
let created_at = meta
.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
entries.push(json!({ entries.push(json!({
"filename": fname, "filename": fname,
"folder": folder, "folder": folder,
"url": url, "url": url,
"mime_type": mime, "mime_type": mime,
"size": size, "size": size,
"created_at": created_at,
"modified_at": modified_at,
})); }));
} }
Ok(entries) Ok(entries)
@@ -340,6 +357,13 @@ pub struct UploadParams {
folder: Option<String>, folder: Option<String>,
} }
/// Max upload size for asset uploads (50 MB).
const MAX_UPLOAD_SIZE: usize = 50 * 1024 * 1024;
pub fn upload_body_limit() -> DefaultBodyLimit {
DefaultBodyLimit::max(MAX_UPLOAD_SIZE)
}
pub async fn upload_asset( pub async fn upload_asset(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
@@ -409,6 +433,19 @@ pub async fn upload_asset(
Some(f) => format!("/api/assets/{}/{}", f, filename), Some(f) => format!("/api/assets/{}/{}", f, filename),
None => format!("/api/assets/{}", filename), None => format!("/api/assets/{}", filename),
}; };
let file_meta = fs::metadata(&dest).await.ok();
let created_at = file_meta
.as_ref()
.and_then(|m| m.created().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
let modified_at = file_meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
@@ -418,6 +455,8 @@ pub async fn upload_asset(
"url": url, "url": url,
"mime_type": mime, "mime_type": mime,
"size": data.len(), "size": data.len(),
"created_at": created_at,
"modified_at": modified_at,
})), })),
) )
.into_response()) .into_response())
@@ -504,6 +543,18 @@ pub async fn rename_asset(
let meta = fs::metadata(&new_path) let meta = fs::metadata(&new_path)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let created_at = meta
.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(json!({ Json(json!({
@@ -512,6 +563,8 @@ pub async fn rename_asset(
"url": url, "url": url,
"mime_type": mime, "mime_type": mime,
"size": meta.len(), "size": meta.len(),
"created_at": created_at,
"modified_at": modified_at,
})), })),
) )
.into_response()) .into_response())

View File

@@ -65,6 +65,12 @@ impl ContentCache {
let mut guard = self.data.write().await; let mut guard = self.data.write().await;
guard.retain(|k, _| !k.starts_with(&prefix_e) && !k.starts_with(&prefix_l)); guard.retain(|k, _| !k.starts_with(&prefix_e) && !k.starts_with(&prefix_l));
} }
/// Clears the entire cache (e.g. after schema hot-reload so content is re-read with new schema).
pub async fn invalidate_all(&self) {
let mut guard = self.data.write().await;
guard.clear();
}
} }
/// Cache key for a single entry (incl. _resolve and optional _locale). /// Cache key for a single entry (incl. _resolve and optional _locale).

View File

@@ -14,7 +14,7 @@ use tokio::sync::RwLock;
use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES}; use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES};
use crate::schema::validator; use crate::schema::validator;
use crate::schema::SchemaRegistry; use crate::schema::SchemaRegistry;
use crate::store::query::QueryParams; use crate::store::query::{QueryParams, StatusFilter, entry_is_draft};
use crate::store::ContentStore; use crate::store::ContentStore;
use crate::store::slug; use crate::store::slug;
@@ -368,6 +368,7 @@ pub async fn list_entries(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(collection): Path<String>, Path(collection): Path<String>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let registry = state.registry.read().await; let registry = state.registry.read().await;
let schema = registry let schema = registry
@@ -411,7 +412,15 @@ pub async fn list_entries(
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?; let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str())); let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str()));
let query = QueryParams::from_map(list_params); // When API key is required but not sent, show only published entries. Otherwise use _status param.
let status_override = if state.api_key.as_ref().is_some()
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
{
Some(StatusFilter::Published)
} else {
None
};
let query = QueryParams::from_map_with_status(list_params, status_override);
let mut result = query.apply(entries); let mut result = query.apply(entries);
for item in result.items.iter_mut() { for item in result.items.iter_mut() {
@@ -461,6 +470,12 @@ pub async fn get_entry(
let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or(""); let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or("");
let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref); let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref);
if let Some(ref cached) = state.cache.get(&cache_key).await { if let Some(ref cached) = state.cache.get(&cache_key).await {
// Don't serve cached draft to unauthenticated requests.
let is_authenticated = state.api_key.as_ref().is_none()
|| auth::token_from_headers(&headers).as_deref() == state.api_key.as_ref().map(String::as_str);
if !is_authenticated && entry_is_draft(cached) {
// Fall through to load from store (will 404 if draft).
} else {
let json_str = serde_json::to_string(cached).unwrap_or_default(); let json_str = serde_json::to_string(cached).unwrap_or_default();
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
json_str.hash(&mut hasher); json_str.hash(&mut hasher);
@@ -485,6 +500,7 @@ pub async fn get_entry(
) )
.into_response()); .into_response());
} }
}
let entry = state let entry = state
.store .store
@@ -495,6 +511,17 @@ pub async fn get_entry(
ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection)) ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection))
})?; })?;
// When no API key is sent, hide draft entries (return 404).
if state.api_key.as_ref().is_some()
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
&& entry_is_draft(&entry)
{
return Err(ApiError::NotFound(format!(
"Entry '{}' not found in '{}'",
slug, collection
)));
}
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str())); let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
let mut formatted = format_references( let mut formatted = format_references(
entry, entry,
@@ -509,10 +536,10 @@ pub async fn get_entry(
expand_asset_urls(&mut formatted, &state.base_url); expand_asset_urls(&mut formatted, &state.base_url);
} }
state // Only cache published entries so unauthenticated requests never see cached drafts.
.cache if !entry_is_draft(&formatted) {
.set(cache_key, formatted.clone()) state.cache.set(cache_key, formatted.clone()).await;
.await; }
let json_str = serde_json::to_string(&formatted).unwrap_or_default(); let json_str = serde_json::to_string(&formatted).unwrap_or_default();
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
@@ -581,9 +608,19 @@ pub async fn create_entry(
slug::validate_slug(&slug_raw).map_err(ApiError::BadRequest)?; slug::validate_slug(&slug_raw).map_err(ApiError::BadRequest)?;
let slug = slug::normalize_slug(&slug_raw); let slug = slug::normalize_slug(&slug_raw);
// Remove _slug from content data // Remove _slug from content data; validate and default _status (publish/draft)
if let Some(obj) = body.as_object_mut() { if let Some(obj) = body.as_object_mut() {
obj.remove("_slug"); obj.remove("_slug");
if let Some(v) = obj.get("_status") {
let s = v.as_str().unwrap_or("");
if s != "draft" && s != "published" {
return Err(ApiError::BadRequest(
"Field '_status' must be \"draft\" or \"published\"".to_string(),
));
}
} else {
obj.insert("_status".to_string(), Value::String("published".to_string()));
}
} }
// Apply defaults and auto-generated values // Apply defaults and auto-generated values
@@ -639,7 +676,7 @@ pub async fn create_entry(
.await .await
.map_err(ApiError::from)? .map_err(ApiError::from)?
.unwrap(); .unwrap();
let mut formatted = format_references( let formatted = format_references(
entry, entry,
&schema, &schema,
state.store.as_ref(), state.store.as_ref(),
@@ -682,9 +719,17 @@ pub async fn update_entry(
))); )));
} }
// Remove _slug if present in body // Remove _slug if present; validate _status when present
if let Some(obj) = body.as_object_mut() { if let Some(obj) = body.as_object_mut() {
obj.remove("_slug"); obj.remove("_slug");
if let Some(v) = obj.get("_status") {
let s = v.as_str().unwrap_or("");
if s != "draft" && s != "published" {
return Err(ApiError::BadRequest(
"Field '_status' must be \"draft\" or \"published\"".to_string(),
));
}
}
} }
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs) // Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
@@ -754,7 +799,7 @@ pub async fn update_entry(
.await .await
.map_err(ApiError::from)? .map_err(ApiError::from)?
.unwrap(); .unwrap();
let mut formatted = format_references( let formatted = format_references(
entry, entry,
&schema, &schema,
state.store.as_ref(), state.store.as_ref(),

View File

@@ -50,7 +50,12 @@ pub fn create_router(state: Arc<AppState>) -> Router {
// Image transformation (external URL → transformed image) // Image transformation (external URL → transformed image)
.route("/api/transform", get(transform::transform_image)) .route("/api/transform", get(transform::transform_image))
// Asset management (images in content/assets/ with folder support) // Asset management (images in content/assets/ with folder support)
.route("/api/assets", get(assets::list_assets).post(assets::upload_asset)) .route(
"/api/assets",
get(assets::list_assets)
.post(assets::upload_asset)
.layer(assets::upload_body_limit()),
)
.route( .route(
"/api/assets/folders", "/api/assets/folders",
get(assets::list_folders).post(assets::create_folder), get(assets::list_folders).post(assets::create_folder),

View File

@@ -11,6 +11,7 @@ use tower_http::trace::{DefaultOnResponse, TraceLayer};
use tracing::{info_span, Level}; use tracing::{info_span, Level};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use rustycms::api::cache::ContentCache;
use rustycms::api::handlers::AppState; use rustycms::api::handlers::AppState;
use rustycms::schema::SchemaRegistry; use rustycms::schema::SchemaRegistry;
use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore}; use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
@@ -35,16 +36,50 @@ struct Cli {
host: String, host: String,
} }
/// Auto-detect locale subdirectories in the content dir.
/// Matches 2-3 letter directory names (e.g. "de", "en", "fra") that contain at
/// least one subdirectory themselves, ignoring known non-locale dirs like "assets".
fn detect_locales(content_dir: &std::path::Path) -> Option<Vec<String>> {
let entries = std::fs::read_dir(content_dir).ok()?;
let mut locales: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name == "assets" || name.starts_with('.') {
return None;
}
let is_locale = (2..=3).contains(&name.len()) && name.chars().all(|c| c.is_ascii_lowercase());
if !is_locale {
return None;
}
let has_subdirs = std::fs::read_dir(e.path())
.ok()
.map(|rd| rd.filter_map(|e| e.ok()).any(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)))
.unwrap_or(false);
if has_subdirs { Some(name) } else { None }
})
.collect();
if locales.is_empty() {
return None;
}
locales.sort();
tracing::info!("Auto-detected locales from content directory: {:?}", locales);
Some(locales)
}
fn reload_schemas( fn reload_schemas(
types_dir: &PathBuf, types_dir: &PathBuf,
server_url: &str, server_url: &str,
registry: &Arc<RwLock<SchemaRegistry>>, registry: &Arc<RwLock<SchemaRegistry>>,
openapi_spec: &Arc<RwLock<serde_json::Value>>, openapi_spec: &Arc<RwLock<serde_json::Value>>,
cache: &Arc<ContentCache>,
) { ) {
let types_dir = types_dir.clone(); let types_dir = types_dir.clone();
let server_url = server_url.to_string(); let server_url = server_url.to_string();
let registry = Arc::clone(registry); let registry = Arc::clone(registry);
let openapi_spec = Arc::clone(openapi_spec); let openapi_spec = Arc::clone(openapi_spec);
let cache = Arc::clone(cache);
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Handle::current(); let rt = tokio::runtime::Handle::current();
rt.block_on(async move { rt.block_on(async move {
@@ -53,7 +88,8 @@ fn reload_schemas(
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url); let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
*registry.write().await = new_registry; *registry.write().await = new_registry;
*openapi_spec.write().await = spec; *openapi_spec.write().await = spec;
tracing::info!("Hot-reload: schemas and OpenAPI spec updated"); cache.invalidate_all().await;
tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared");
} }
Err(e) => { Err(e) => {
tracing::error!("Hot-reload failed: {}", e); tracing::error!("Hot-reload failed: {}", e);
@@ -129,7 +165,8 @@ async fn main() -> anyhow::Result<()> {
let locales: Option<Vec<String>> = std::env::var("RUSTYCMS_LOCALES") let locales: Option<Vec<String>> = std::env::var("RUSTYCMS_LOCALES")
.ok() .ok()
.map(|s| s.split(',').map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()) .map(|s| s.split(',').map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
.filter(|v: &Vec<String>| !v.is_empty()); .filter(|v: &Vec<String>| !v.is_empty())
.or_else(|| detect_locales(&cli.content_dir));
if let Some(ref locs) = locales { if let Some(ref locs) = locales {
tracing::info!("Multilingual: locales {:?} (default: {})", locs, &locs[0]); tracing::info!("Multilingual: locales {:?} (default: {})", locs, &locs[0]);
} }
@@ -147,7 +184,7 @@ async fn main() -> anyhow::Result<()> {
openapi_spec: Arc::clone(&openapi_spec), openapi_spec: Arc::clone(&openapi_spec),
types_dir: cli.types_dir.clone(), types_dir: cli.types_dir.clone(),
api_key, api_key,
cache, cache: Arc::clone(&cache),
transform_cache, transform_cache,
http_client, http_client,
locales, locales,
@@ -187,9 +224,9 @@ async fn main() -> anyhow::Result<()> {
let _watcher = watcher; let _watcher = watcher;
while rx.recv().is_ok() { while rx.recv().is_ok() {
// Debounce: wait for editor to finish writing, drain extra events, then reload once // Debounce: wait for editor to finish writing, drain extra events, then reload once
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(800));
while rx.try_recv().is_ok() {} while rx.try_recv().is_ok() {}
reload_schemas(&types_dir_watch, &server_url_watch, &registry, &openapi_spec); reload_schemas(&types_dir_watch, &server_url_watch, &registry, &openapi_spec, &cache);
} }
}); });
tracing::info!("Hot-reload: watching {}", cli.types_dir.display()); tracing::info!("Hot-reload: watching {}", cli.types_dir.display());

View File

@@ -18,10 +18,18 @@ pub enum FilterOp {
Max(String), Max(String),
} }
/// Publish/draft filter for list: only published, only draft, or all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusFilter {
Published,
Draft,
All,
}
/// Parsed query parameters for list endpoints. /// Parsed query parameters for list endpoints.
/// ///
/// Special parameters (prefixed with `_`): /// Special parameters (prefixed with `_`):
/// - `_sort`, `_order`, `_page`, `_per_page` /// - `_sort`, `_order`, `_page`, `_per_page`, `_status` (draft | published | all)
/// ///
/// Field filters support suffixes: `field`, `field_prefix`, `field_contains`, `field_min`, `field_max`. /// Field filters support suffixes: `field`, `field_prefix`, `field_contains`, `field_min`, `field_max`.
pub struct QueryParams { pub struct QueryParams {
@@ -29,20 +37,38 @@ pub struct QueryParams {
pub order: Option<String>, pub order: Option<String>,
pub page: Option<usize>, pub page: Option<usize>,
pub per_page: Option<usize>, pub per_page: Option<usize>,
/// Publish/draft filter from _status param.
pub status_filter: StatusFilter,
/// (field_name, filter_op) /// (field_name, filter_op)
pub filters: Vec<(String, FilterOp)>, pub filters: Vec<(String, FilterOp)>,
/// Full-text search: case-insensitive contains across all string values (_q param). /// Full-text search: case-insensitive contains across all string values (_q param).
pub full_text_query: Option<String>, pub full_text_query: Option<String>,
} }
/// Entry is draft if _status == "draft". Missing or "published" = published.
pub fn entry_is_draft(value: &Value) -> bool {
value.get("_status").and_then(|v| v.as_str()) == Some("draft")
}
impl QueryParams { impl QueryParams {
/// Parse query parameters. Extracts system params and builds list of (field, FilterOp). /// Parse query parameters. Extracts system params and builds list of (field, FilterOp).
pub fn from_map(mut map: HashMap<String, String>) -> Self { /// Pass `status_override` to force status filter (e.g. Published when unauthenticated).
pub fn from_map_with_status(
mut map: HashMap<String, String>,
status_override: Option<StatusFilter>,
) -> Self {
let sort = map.remove("_sort"); let sort = map.remove("_sort");
let order = map.remove("_order"); let order = map.remove("_order");
let page = map.remove("_page").and_then(|v| v.parse().ok()); let page = map.remove("_page").and_then(|v| v.parse().ok());
let per_page = map.remove("_per_page").and_then(|v| v.parse().ok()); let per_page = map.remove("_per_page").and_then(|v| v.parse().ok());
let full_text_query = map.remove("_q").filter(|s| !s.trim().is_empty()); let full_text_query = map.remove("_q").filter(|s| !s.trim().is_empty());
let status_filter = status_override.unwrap_or_else(|| {
match map.remove("_status").as_deref().map(str::trim) {
Some("draft") => StatusFilter::Draft,
Some("published") => StatusFilter::Published,
_ => StatusFilter::All,
}
});
map.retain(|k, _| !k.starts_with('_')); map.retain(|k, _| !k.starts_with('_'));
let mut filters = Vec::new(); let mut filters = Vec::new();
@@ -70,11 +96,17 @@ impl QueryParams {
order, order,
page, page,
per_page, per_page,
status_filter,
filters, filters,
full_text_query, full_text_query,
} }
} }
/// Parse without status override (uses _status from params or All).
pub fn from_map(map: HashMap<String, String>) -> Self {
Self::from_map_with_status(map, None)
}
/// Apply filters, sorting, and pagination to a list of entries. /// Apply filters, sorting, and pagination to a list of entries.
pub fn apply(&self, mut entries: Vec<(String, Value)>) -> QueryResult { pub fn apply(&self, mut entries: Vec<(String, Value)>) -> QueryResult {
entries.retain(|(_, value)| self.matches_filters(value)); entries.retain(|(_, value)| self.matches_filters(value));
@@ -125,6 +157,11 @@ impl QueryParams {
} }
fn matches_filters(&self, value: &Value) -> bool { fn matches_filters(&self, value: &Value) -> bool {
match self.status_filter {
StatusFilter::Published if entry_is_draft(value) => return false,
StatusFilter::Draft if !entry_is_draft(value) => return false,
_ => {}
}
if let Some(ref q) = self.full_text_query { if let Some(ref q) = self.full_text_query {
if !value_contains_text(value, q) { if !value_contains_text(value, q) {
return false; return false;

View File

@@ -13,5 +13,9 @@
type: "string", type: "string",
description: "Optional body text", description: "Optional body text",
}, },
image: {
type: "string",
description: "Image URL (asset path)",
},
}, },
} }