212 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|