RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Peter Meier
2026-02-16 09:30:30 +01:00
commit aad93d145f
224 changed files with 19225 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQueries, useQuery } from "@tanstack/react-query";
import { fetchCollections, fetchContentList } from "@/lib/api";
export function Sidebar() {
const pathname = usePathname();
const [search, setSearch] = useState("");
const { data, isLoading, error } = useQuery({
queryKey: ["collections"],
queryFn: fetchCollections,
});
const filteredCollections = useMemo(() => {
const list = data?.collections ?? [];
if (!search.trim()) return list;
const q = search.trim().toLowerCase();
return list.filter(
(c) =>
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)
);
}, [data?.collections, search]);
const countQueries = useQueries({
queries: filteredCollections.map((c) => ({
queryKey: ["contentCount", c.name],
queryFn: () =>
fetchContentList(c.name, { _per_page: 1 }).then((r) => r.total),
staleTime: 60_000,
})),
});
const formatCount = (n: number) => (n >= 100 ? "99+" : String(n));
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">
<input
type="search"
placeholder="Search collections…"
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"
/>
</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">
{c.category && (
<span className="rounded bg-gray-200 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>
</nav>
</aside>
);
}