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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user