RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
125
admin-ui/src/components/Sidebar.tsx
Normal file
125
admin-ui/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user