diff --git a/.gitignore b/.gitignore index 8e26f93..1ab21f0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,13 +5,16 @@ Cargo.lock *.db content.db -# Content: ignore all except one demo entry +# Content: ignore all except demo entry and demo asset content/* !content/de/ content/de/* !content/de/demo/ content/de/demo/* !content/de/demo/demo-welcome.json5 +!content/assets/ +content/assets/* +!content/assets/mountains-w300.webp # Types: ignore all except demo type types/* diff --git a/admin-ui/messages/de.json b/admin-ui/messages/de.json index a9ee23d..9e1467d 100644 --- a/admin-ui/messages/de.json +++ b/admin-ui/messages/de.json @@ -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…" } } diff --git a/admin-ui/messages/en.json b/admin-ui/messages/en.json index 216f25b..5f16154 100644 --- a/admin-ui/messages/en.json +++ b/admin-ui/messages/en.json @@ -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…" } } diff --git a/admin-ui/public/rusty.jpg b/admin-ui/public/rusty.jpg new file mode 100644 index 0000000..0d4fe33 Binary files /dev/null and b/admin-ui/public/rusty.jpg differ diff --git a/admin-ui/src/app/assets/page.tsx b/admin-ui/src/app/assets/page.tsx index 364d646..6db4f2e 100644 --- a/admin-ui/src/app/assets/page.tsx +++ b/admin-ui/src/app/assets/page.tsx @@ -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(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(ALL); @@ -86,7 +106,7 @@ export default function AssetsPage() { // Copy with transformation const [copyTransformTarget, setCopyTransformTarget] = useState(null); - const [copyTransformParams, setCopyTransformParams] = useState({ format: "webp" }); + const [copyTransformParams, setCopyTransformParams] = useState({ 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 ── */}
{/* Header */} -
-
-

- {selected === ALL ? t("titleAll") : selected === ROOT ? t("titleRoot") : selected} -

-

- {t("assetCount", { count: assetsData?.total ?? 0 })} -

+
+
+
+

+ {selected === ALL ? t("titleAll") : selected === ROOT ? t("titleRoot") : selected} +

+

+ {assets.length === (assetsData?.total ?? 0) && !q.trim() && !dateFrom && !dateTo + ? t("assetCount", { count: assets.length }) + : t("assetCountFiltered", { count: assets.length, total: assetsData?.total ?? 0 })} +

+
+ +
+
+ +
+ + 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")} + /> + + 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")} + /> +
-
{/* 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() {

{t("copyWithTransformDesc")}

+ {/* Presets */} +
+ + + + +
- +
- +
- + setCopyTransformParams((p) => ({ ...p, ar: e.target.value || undefined }))} className="h-8 text-sm" />
- +
-
- +
+
+ {(copyTransformParams.format === "jpeg" || copyTransformParams.format === "webp") && ( +
+ + setCopyTransformParams((p) => ({ ...p, quality: e.target.value ? Number(e.target.value) : 85 }))} + className="h-8 text-sm" + /> +
+ )}
{copyTransformTarget && (

@@ -612,6 +730,13 @@ export default function AssetsPage() { {previewAsset.folder} )} + {(previewAsset.modified_at || previewAsset.created_at) && ( + + {previewAsset.modified_at + ? formatAssetDate(previewAsset.modified_at) + : formatAssetDate(previewAsset.created_at ?? undefined)} + + )}

{/* Mobile: always-visible action row (touch-friendly) */} diff --git a/admin-ui/src/app/content/[collection]/[slug]/page.tsx b/admin-ui/src/app/content/[collection]/[slug]/page.tsx index 056b91d..39169f7 100644 --- a/admin-ui/src/app/content/[collection]/[slug]/page.tsx +++ b/admin-ui/src/app/content/[collection]/[slug]/page.tsx @@ -87,7 +87,7 @@ export default function ContentEditPage() { { label: slug }, ]} /> -
+
+ ); + })} +
+ )} +
+ ); +} + +/** 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 ( +
+ {label} + {previewUrl && ( +
+ +
+ )} +
+ onChange(e.target.value)} + placeholder="/api/assets/…" + readOnly={readonly} + className="flex-1 min-w-[200px] font-mono text-sm" + /> + {!readonly && ( + + + + + + + {t("pickAsset")} + + { + onChange(url); + setPickerOpen(false); + }} + /> + + + )} +
+ {fieldError ? ( +

+ {String((fieldError as { message?: string })?.message)} +

+ ) : null} +
+ ); +} + /** 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} /> )} +
+ + { + const value = (field.value as string) ?? "draft"; + return ( +
+ + +
+ ); + }} + /> +

{t("statusHint")}

+
{hasSection ? formItems.map((item) => item.kind === "object" ? ( @@ -1021,6 +1205,26 @@ function Field({ return String(v); }; + if (type === "string" && isImageUrlField(def, name)) { + return ( + ( + + )} + /> + ); + } + return (
{label} diff --git a/admin-ui/src/components/DashboardCollectionList.tsx b/admin-ui/src/components/DashboardCollectionList.tsx new file mode 100644 index 0000000..8c7802c --- /dev/null +++ b/admin-ui/src/components/DashboardCollectionList.tsx @@ -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(null); + + const allTags = useMemo(() => { + const set = new Set(); + 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 ( +
+
+
+ setSearch(e.target.value)} + placeholder={t("searchPlaceholder")} + aria-label={t("searchPlaceholder")} + className="w-full max-w-md" + /> +
+ {allTags.length > 0 && ( +
+ {t("filterByTag")} + + {allTags.map((tag) => ( + + ))} +
+ )} +
+ + {collections.length === 0 ? ( +

+ {t("noCollections", { + url: process.env.NEXT_PUBLIC_RUSTYCMS_API_URL ?? "http://127.0.0.1:3000", + })} +

+ ) : filtered.length === 0 ? ( +

{t("noResults")}

+ ) : ( +
    + {filtered.map((c) => ( +
  • + + {c.name} + {c.description && ( + + {c.description} + + )} + {c.tags && c.tags.length > 0 && ( + + {c.tags.map((tag) => ( + + {tag} + + ))} + + )} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/admin-ui/src/components/Sidebar.tsx b/admin-ui/src/components/Sidebar.tsx index 98fb8ba..4d134db 100644 --- a/admin-ui/src/components/Sidebar.tsx +++ b/admin-ui/src/components/Sidebar.tsx @@ -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 (