RustyCMS: file-based headless CMS — API, Admin UI (content, types, assets), Docker/Caddy, image transform; only demo type and demo content in version control

Made-with: Cursor
This commit is contained in:
Peter Meier
2026-03-12 14:21:49 +01:00
parent aad93d145f
commit 7795a238e1
278 changed files with 15551 additions and 4072 deletions

View File

@@ -3,10 +3,22 @@
import { useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Icon } from "@iconify/react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { fetchCollections, fetchContentList } from "@/lib/api";
import { LocaleSwitcher } from "./LocaleSwitcher";
export function Sidebar() {
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;
mobileOpen?: boolean;
onClose?: () => void;
};
export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
const t = useTranslations("Sidebar");
const pathname = usePathname();
const [search, setSearch] = useState("");
const { data, isLoading, error } = useQuery({
@@ -38,88 +50,138 @@ export function Sidebar() {
const formatCount = (n: number) => (n >= 100 ? "99+" : String(n));
const tSidebar = useTranslations("Sidebar");
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)] " +
(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 " +
(mobileOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0")
: "w-56 shrink-0");
return (
<aside className="flex h-full w-56 shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-gray-50 p-4">
<nav className="flex flex-col gap-1">
<Link
href="/"
className={`rounded px-3 py-2 text-sm font-bold ${pathname === "/" ? "bg-gray-200 text-gray-900" : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"}`}
>
Dashboard
</Link>
<hr />
<div className="px-1 py-2">
<aside className={asideClass}>
<nav className="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
href="/"
onClick={onClose}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg py-2 pr-1 text-gray-900 no-underline outline-none focus-visible:ring-2 focus-visible:ring-accent-300"
>
<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>
</Link>
{isDrawer && (
<button
type="button"
onClick={onClose}
className="inline-flex size-10 shrink-0 items-center justify-center rounded-lg text-gray-600 hover:bg-accent-100/80 hover:text-gray-900 md:hidden"
aria-label={tSidebar("closeMenu")}
>
<Icon icon="mdi:close" className="size-6" aria-hidden />
</button>
)}
</div>
<div className="flex shrink-0 flex-col gap-1">
<Link
href="/"
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 />
{t("dashboard")}
</Link>
<Link
href="/admin/types"
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 />
{t("types")}
</Link>
<Link
href="/assets"
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 />
{t("assets")}
</Link>
</div>
<hr className="my-3 shrink-0 border-accent-200/50" />
<div className="shrink-0 px-1 pb-2">
<input
type="search"
placeholder="Search collections…"
placeholder={t("searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-gray-300 focus:outline-none focus:ring-1 focus:ring-gray-300"
aria-label="Search collections"
className="w-full rounded border border-accent-200/60 bg-white/90 px-2 py-1.5 text-sm text-gray-900 placeholder:text-gray-500 focus:border-accent-300 focus:outline-none focus:ring-1 focus:ring-accent-200/80"
aria-label={t("searchAriaLabel")}
/>
</div>
{isLoading && (
<div className="px-3 py-2 text-sm text-gray-400">Loading</div>
)}
{error && (
<div className="px-3 py-2 text-sm text-red-600">
Error loading collections
</div>
)}
{!isLoading && !error && search.trim() && filteredCollections.length === 0 && (
<div className="px-3 py-2 text-sm text-gray-500">
No results for "{search.trim()}"
</div>
)}
{filteredCollections.map((c, i) => {
const href = `/content/${c.name}`;
const active = pathname === href || pathname.startsWith(href + "/");
const hasMeta =
c.description || (c.tags?.length ?? 0) > 0 || c.category;
const countResult = countQueries[i];
const count =
countResult?.data !== undefined ? countResult.data : null;
return (
<Link
key={c.name}
href={href}
title={c.description ?? undefined}
className={`rounded px-3 py-2 text-sm font-medium ${active ? "bg-gray-200 text-gray-900" : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"}`}
>
<span className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate">{c.name}</span>
{count !== null && (
<span className="shrink-0 text-xs font-normal text-gray-500 tabular-nums">
{formatCount(count)}
</span>
)}
</span>
{hasMeta && (
<span className="mt-0.5 block text-xs font-normal text-gray-500">
<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>
)}
{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>
)}
{filteredCollections.map((c, i) => {
const href = `/content/${c.name}`;
const active = pathname === href || pathname.startsWith(href + "/");
const hasMeta =
c.description || (c.tags?.length ?? 0) > 0 || c.category;
const countResult = countQueries[i];
const count =
countResult?.data !== undefined ? countResult.data : null;
return (
<Link
key={c.name}
href={href}
onClick={onClose}
title={c.description ?? undefined}
className={`flex flex-col justify-center rounded-lg px-3 py-2.5 text-sm font-medium min-h-[44px] md:min-h-0 md:py-2 ${active ? "bg-accent-200/70 text-gray-900" : "text-gray-700 hover:bg-accent-100/80 hover:text-gray-900"}`}
>
<span className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate">{c.name}</span>
{count !== null && (
<span className="shrink-0 text-xs font-normal text-gray-500 tabular-nums">
{formatCount(count)}
</span>
)}
</span>
{hasMeta && (
<span className="mt-0.5 block text-xs font-normal text-gray-500">
{c.category && (
<span className="rounded bg-gray-200 px-1">
<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(", ")}
{c.tags.length > 2 ? " …" : ""}
</span>
) : null}
</span>
)}
</Link>
);
})}
<hr className="my-2" />
<Link
href="/admin/new-type"
className="rounded px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
+ Add new type
</Link>
{c.tags?.length ? (
<span className="ml-1">
{c.tags.slice(0, 2).join(", ")}
{c.tags.length > 2 ? " …" : ""}
</span>
) : null}
</span>
)}
</Link>
);
})}
</div>
</nav>
<LocaleSwitcher locale={locale} />
</aside>
);
}