Files
rustycms/admin-ui/src/components/Sidebar.tsx

212 lines
8.2 KiB
TypeScript

"use client";
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";
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({
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));
const tSidebar = useTranslations("Sidebar");
const isDrawer = typeof onClose === "function";
const asideClass =
"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: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-full");
return (
<aside className={asideClass}>
<div
className="pointer-events-none absolute inset-0 bg-repeat opacity-10"
aria-hidden
/>
<nav className="relative 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={t("searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
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>
<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-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>
);
})}
</div>
</nav>
<LocaleSwitcher locale={locale} />
</aside>
);
}