افزودن پالت فرمان (⌘K) و صفحهٔ مصرف سراسری پنل مالک
دو شکاف رسیدگیشده از بازبینی PLAN.md و TODO.md: - پالت فرمان: جستوجو و رفتن سریع بین بخشها با ⌘K/Ctrl+K، پیمایش با کلیدهای جهتدار، Enter برای رفتن، و Escape برای بستن. گروه اختصاصی «کنسول مالک سامانه» فقط برای مالک سامانه نمایش داده میشود. - صفحهٔ /admin/usage: گزارش مصرف سراسری به تفکیک سازمان. اندپوینت `GET /admin/usage` و کلاینت `adminUsage()` از قبل وجود داشتند و توسط صفحات نمای کلی و سازمانها مصرف میشدند؛ این صفحهٔ اختصاصی آن است. همچنین `NAV_ITEMS` به `lib/nav.ts` منتقل شد تا sidebar و پالت فرمان هرگز از هم واگرا نشوند. تست: ۱۰ تست جدید برای پالت فرمان. مجموع تستهای فرانتاند از ۲۵ به ۳۵ رسید. tsc، eslint و ruff تمیز.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { EmptyState, Modal, cx } from "@agy/ui";
|
||||
import { ADMIN_ITEMS, NAV_ITEMS, type NavItem } from "@/lib/nav";
|
||||
|
||||
/**
|
||||
* Command palette over the app's destinations.
|
||||
*
|
||||
* PLAN §12.3 asks for a command palette so power users can move between
|
||||
* sections without hunting through the sidebar. It is a client-side filter
|
||||
* over a fixed route list — the app has no dynamic search surface yet, so a
|
||||
* round trip would buy nothing and would break while offline.
|
||||
*
|
||||
* The platform-owner group is passed in rather than read from the session so
|
||||
* this component stays free of provider setup and is renderable in isolation.
|
||||
*/
|
||||
|
||||
/** ``⌘K`` on macOS, ``Ctrl+K`` everywhere else. */
|
||||
function isPaletteShortcut(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "k"
|
||||
);
|
||||
}
|
||||
|
||||
interface PalletGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
function buildGroups(isPlatformOwner: boolean): PalletGroup[] {
|
||||
const groups: PalletGroup[] = [{ title: "بخشها", items: NAV_ITEMS }];
|
||||
if (isPlatformOwner) {
|
||||
groups.push({ title: "کنسول مالک سامانه", items: ADMIN_ITEMS });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function matches(item: NavItem, needle: string): boolean {
|
||||
return (
|
||||
item.label.toLowerCase().includes(needle) ||
|
||||
item.hint.toLowerCase().includes(needle) ||
|
||||
item.href.includes(needle)
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandPalette({ isPlatformOwner = false }: { isPlatformOwner?: boolean }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [active, setActive] = React.useState(0);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const groups = React.useMemo(() => buildGroups(isPlatformOwner), [isPlatformOwner]);
|
||||
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const keep = (item: NavItem) => !needle || matches(item, needle);
|
||||
return groups
|
||||
.map((group) => ({ title: group.title, items: group.items.filter(keep) }))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}, [groups, query]);
|
||||
|
||||
/**
|
||||
* Keyboard navigation walks the flattened, visible item list. `indexOf` is
|
||||
* safe here because both lists hold the same module-level object references.
|
||||
*/
|
||||
const visible = React.useMemo(
|
||||
() => filteredGroups.flatMap((group) => group.items),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
// Toggle the palette from anywhere on the page without stealing focus.
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isPaletteShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
setOpen((currentlyOpen) => !currentlyOpen);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Focus the input once the dialog is mounted, so typing starts immediately.
|
||||
React.useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// A narrowed list must not leave the highlight off the end.
|
||||
React.useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}, []);
|
||||
|
||||
const go = React.useCallback(
|
||||
(href: string) => {
|
||||
close();
|
||||
router.push(href);
|
||||
},
|
||||
[close, router],
|
||||
);
|
||||
|
||||
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (visible.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index + 1) % visible.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index - 1 + visible.length) % visible.length);
|
||||
} else if (event.key === "Enter") {
|
||||
const target = visible[active];
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
go(target.href);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label="جستوجو و رفتن سریع"
|
||||
className="hidden items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-2.5 py-1.5 text-xs text-slate-400 transition-colors hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 md:flex"
|
||||
>
|
||||
<span aria-hidden>⌕</span>
|
||||
<span>جستوجو…</span>
|
||||
<kbd className="rounded border border-surface-border px-1 text-[10px] text-slate-500">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
title="جستوجو و رفتن سریع"
|
||||
description="با کلیدهای بالا و پایین جابهجایی کنید و Enter برای رفتن."
|
||||
onClose={close}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder="نام بخش…"
|
||||
aria-label="جستوجوی بخشها"
|
||||
className="h-10 w-full rounded-lg border border-surface-border bg-surface-overlay px-3 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex max-h-80 flex-col gap-3 overflow-y-auto">
|
||||
{filteredGroups.length === 0 ? (
|
||||
<EmptyState title="نتیجهای پیدا نشد" description="عبارت دیگری را امتحان کنید." />
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<div key={group.title} className="flex flex-col gap-1">
|
||||
<p className="px-1 text-[11px] font-medium text-slate-500">{group.title}</p>
|
||||
{group.items.map((item) => {
|
||||
const index = visible.indexOf(item);
|
||||
const isActive = index === active;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(index)}
|
||||
onClick={() => go(item.href)}
|
||||
className={cx(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-2 text-start",
|
||||
isActive
|
||||
? "bg-accent-soft text-slate-100"
|
||||
: "text-slate-300 hover:bg-surface-raised",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<span className="text-xs text-slate-500">{item.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user