feat: Search Bar Page Navigation

This commit is contained in:
“kirukib” 2026-04-15 10:22:09 +03:00
parent 5adda68494
commit ae7a366e2f
9 changed files with 597 additions and 59 deletions

View File

@ -1,6 +1,10 @@
# Backend API Configuration # Backend API Configuration
VITE_BACKEND_API_URL=http://localhost:3000/api/v1 VITE_BACKEND_API_URL=http://localhost:3000/api/v1
# Local dev: avoid CORS by proxying /api -> API (set VITE_USE_API_PROXY=true and restart dev server)
# VITE_USE_API_PROXY=true
# VITE_PROXY_TARGET=https://api.yaltopiaticket.com
# Environment # Environment
VITE_ENV=development VITE_ENV=development

17
package-lock.json generated
View File

@ -24,6 +24,7 @@
"axios": "^1.13.5", "axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"react": "^19.2.4", "react": "^19.2.4",
@ -4816,6 +4817,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@ -33,6 +33,7 @@
"axios": "^1.13.5", "axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"react": "^19.2.4", "react": "^19.2.4",

View File

@ -0,0 +1,102 @@
import { Fragment, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { DialogTitle } from "@/components/ui/dialog";
import {
ADMIN_SEARCH_ROUTES,
groupAdminSearchRoutes,
} from "@/config/admin-search-routes";
export function AdminQuickSearch() {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const grouped = groupAdminSearchRoutes(ADMIN_SEARCH_ROUTES);
const groups = [...grouped.keys()];
return (
<>
<Button
type="button"
variant="outline"
className="relative h-9 w-64 justify-start text-muted-foreground sm:pr-12"
onClick={() => setOpen(true)}
title="Search and jump to a page (Ctrl+K or ⌘K)"
aria-label="Open quick search to go to any page"
>
<Search className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">Quick search pages</span>
<kbd className="pointer-events-none absolute right-1.5 top-1/2 hidden h-6 -translate-y-1/2 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<DialogTitle className="sr-only">Go to page</DialogTitle>
<CommandInput placeholder="Type a page name or what you want to do…" />
<CommandList>
<CommandEmpty>No page matched. Try another word.</CommandEmpty>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 ? <CommandSeparator className="my-1" /> : null}
<CommandGroup heading={group}>
{grouped.get(group)?.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={item.id}
value={`${item.title} ${item.description} ${item.path} ${item.group}`}
onSelect={() => {
navigate(item.path);
setOpen(false);
}}
>
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium leading-tight">
{item.title}
</span>
<span className="line-clamp-2 text-xs leading-snug text-muted-foreground">
{item.description}
</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
</Fragment>
))}
</CommandList>
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
<span className="font-medium text-foreground">Navigate:</span>{" "}
<kbd className="rounded border bg-muted px-1 font-mono"></kbd>{" "}
<kbd className="rounded border bg-muted px-1 font-mono"></kbd>{" "}
to move ·{" "}
<kbd className="rounded border bg-muted px-1 font-mono"></kbd> to
open · Esc to close
</div>
</CommandDialog>
</>
);
}

View File

@ -0,0 +1,134 @@
import * as React from "react"
import type { ComponentProps } from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: ComponentProps<typeof Dialog>) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg max-w-xl [&>button]:hidden">
<Command className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-3 [&_[cmdk-item]]:py-2.5 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[min(420px,60vh)] overflow-y-auto overflow-x-hidden p-2", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-muted-foreground"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-start gap-3 rounded-sm px-2 py-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
}

View File

@ -0,0 +1,276 @@
import type { LucideIcon } from "lucide-react"
import {
LayoutDashboard,
Bell,
Receipt,
FileSearch,
ClipboardList,
CreditCard,
FileClock,
Users,
FileText,
Settings,
Wrench,
Megaphone,
Activity,
Shield,
BarChart3,
Heart,
AlertTriangle,
Key,
Gauge,
DollarSign,
HardDrive,
} from "lucide-react"
export interface AdminSearchRoute {
id: string
path: string
title: string
/** Sub-heading style line shown under the title */
description: string
group: string
icon: LucideIcon
}
export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
{
id: "dashboard",
path: "/admin/dashboard",
title: "Dashboard",
description:
"High-level metrics, recent invoices, and platform status at a glance.",
group: "Overview",
icon: LayoutDashboard,
},
{
id: "notifications",
path: "/notifications",
title: "Notifications",
description:
"System alerts and messages for your account; mark read or filter.",
group: "Overview",
icon: Bell,
},
{
id: "invoices",
path: "/admin/invoices",
title: "Invoices",
description: "Browse, search, and manage issued invoices.",
group: "Commerce",
icon: Receipt,
},
{
id: "proforma",
path: "/admin/proforma",
title: "Proforma",
description: "View and manage proforma invoices and drafts.",
group: "Commerce",
icon: FileSearch,
},
{
id: "proforma-requests",
path: "/admin/proforma-requests",
title: "Proforma requests",
description: "Review and process incoming proforma requests.",
group: "Commerce",
icon: ClipboardList,
},
{
id: "payments",
path: "/admin/payments",
title: "Payments",
description: "Recorded payments and transaction history.",
group: "Commerce",
icon: CreditCard,
},
{
id: "payment-requests",
path: "/admin/payment-requests",
title: "Payment requests",
description: "Pending and processed payment requests.",
group: "Commerce",
icon: FileClock,
},
{
id: "users",
path: "/admin/users",
title: "Users",
description: "Search, create, import, and manage user accounts.",
group: "People & activity",
icon: Users,
},
{
id: "logs",
path: "/admin/logs",
title: "Activity log",
description: "User activity and system events across the platform.",
group: "People & activity",
icon: FileText,
},
{
id: "settings",
path: "/admin/settings",
title: "Settings",
description: "System configuration, keys, and admin preferences.",
group: "Operations",
icon: Settings,
},
{
id: "maintenance",
path: "/admin/maintenance",
title: "Maintenance",
description: "Enable maintenance mode and set user-facing messages.",
group: "Operations",
icon: Wrench,
},
{
id: "announcements",
path: "/admin/announcements",
title: "Announcements",
description: "Create and broadcast announcements to users.",
group: "Operations",
icon: Megaphone,
},
{
id: "audit",
path: "/admin/audit",
title: "Audit",
description: "Immutable audit trail of sensitive admin actions.",
group: "Operations",
icon: Activity,
},
{
id: "security-hub",
path: "/admin/security",
title: "Security",
description: "Hub for sessions, API keys, failed logins, and limits.",
group: "Security",
icon: Shield,
},
{
id: "failed-logins",
path: "/admin/security/failed-logins",
title: "Failed logins",
description: "View and manage failed login attempts.",
group: "Security",
icon: AlertTriangle,
},
{
id: "suspicious",
path: "/admin/security/suspicious",
title: "Suspicious activity",
description: "Monitor suspicious IPs and emails.",
group: "Security",
icon: Shield,
},
{
id: "api-keys",
path: "/admin/security/api-keys",
title: "API keys",
description: "Manage API keys and tokens.",
group: "Security",
icon: Key,
},
{
id: "rate-limits",
path: "/admin/security/rate-limits",
title: "Rate limits",
description: "View rate limit violations and abuse patterns.",
group: "Security",
icon: Gauge,
},
{
id: "sessions",
path: "/admin/security/sessions",
title: "Active sessions",
description: "Manage active user sessions across devices.",
group: "Security",
icon: Users,
},
{
id: "analytics-hub",
path: "/admin/analytics",
title: "Analytics",
description: "Hub for charts, growth, revenue, and API usage.",
group: "Analytics",
icon: BarChart3,
},
{
id: "analytics-overview",
path: "/admin/analytics/overview",
title: "Performance overview",
description: "Platform analytics overview and KPIs.",
group: "Analytics",
icon: BarChart3,
},
{
id: "analytics-users",
path: "/admin/analytics/users",
title: "User dynamics",
description: "User growth and statistics over time.",
group: "Analytics",
icon: Users,
},
{
id: "analytics-revenue",
path: "/admin/analytics/revenue",
title: "Revenue streams",
description: "Revenue trends and breakdowns.",
group: "Analytics",
icon: DollarSign,
},
{
id: "analytics-storage",
path: "/admin/analytics/storage",
title: "Resource allocation",
description: "Storage usage and breakdown by user or resource.",
group: "Analytics",
icon: HardDrive,
},
{
id: "analytics-api",
path: "/admin/analytics/api",
title: "API operations",
description: "API endpoint usage and traffic statistics.",
group: "Analytics",
icon: Activity,
},
{
id: "health",
path: "/admin/health",
title: "System health",
description: "Service health checks, version, and diagnostics.",
group: "Operations",
icon: Heart,
},
]
const GROUP_ORDER = [
"Overview",
"Commerce",
"People & activity",
"Operations",
"Security",
"Analytics",
]
export function groupAdminSearchRoutes(
routes: AdminSearchRoute[],
): Map<string, AdminSearchRoute[]> {
const map = new Map<string, AdminSearchRoute[]>()
for (const r of routes) {
const list = map.get(r.group) ?? []
list.push(r)
map.set(r.group, list)
}
const ordered = new Map<string, AdminSearchRoute[]>()
for (const g of GROUP_ORDER) {
const list = map.get(g)
if (list?.length) ordered.set(g, list)
}
for (const [g, list] of map) {
if (!ordered.has(g)) ordered.set(g, list)
}
return ordered
}

View File

@ -1,5 +1,5 @@
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
@ -11,7 +11,6 @@ import {
BarChart3, BarChart3,
Activity, Activity,
Heart, Heart,
Search,
Bell, Bell,
LogOut, LogOut,
CreditCard, CreditCard,
@ -21,7 +20,7 @@ import {
ClipboardList, ClipboardList,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { AdminQuickSearch } from "@/components/admin-quick-search";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { import {
DropdownMenu, DropdownMenu,
@ -33,7 +32,6 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { authService } from "@/services"; import { authService } from "@/services";
import { toast } from "sonner";
interface User { interface User {
email: string; email: string;
@ -71,7 +69,6 @@ const adminNavigationItems = [
export function AppShell() { export function AppShell() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
// Initialize user from localStorage // Initialize user from localStorage
const [user] = useState<User | null>(() => { const [user] = useState<User | null>(() => {
@ -98,19 +95,6 @@ export function AppShell() {
navigate("/login", { replace: true }); navigate("/login", { replace: true });
}; };
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
const currentPath = location.pathname;
navigate(`${currentPath}?search=${encodeURIComponent(searchQuery)}`);
toast.success(`Searching for: ${searchQuery}`);
}
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const handleNotificationClick = () => { const handleNotificationClick = () => {
navigate("/notifications"); navigate("/notifications");
}; };
@ -205,15 +189,7 @@ export function AppShell() {
<header className="h-16 border-b bg-background flex items-center justify-between px-6"> <header className="h-16 border-b bg-background flex items-center justify-between px-6">
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<form onSubmit={handleSearch} className="relative"> <AdminQuickSearch />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Quick Search..."
className="pl-10 w-64"
value={searchQuery}
onChange={handleSearchChange}
/>
</form>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@ -4,8 +4,12 @@ import axios, {
type InternalAxiosRequestConfig, type InternalAxiosRequestConfig,
} from "axios"; } from "axios";
const API_BASE_URL = const useDevApiProxy =
import.meta.env.VITE_BACKEND_API_URL || "http://localhost:3001"; import.meta.env.DEV && import.meta.env.VITE_USE_API_PROXY === "true";
const API_BASE_URL = useDevApiProxy
? "/api"
: import.meta.env.VITE_BACKEND_API_URL || "http://localhost:3001";
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig { interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean; _retry?: boolean;

View File

@ -1,37 +1,61 @@
import { defineConfig } from "vite" import { defineConfig, loadEnv } from "vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import path from "node:path" import path from "node:path"
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], const env = loadEnv(mode, process.cwd(), "")
resolve: { const useApiProxy = env.VITE_USE_API_PROXY === "true"
alias: { const proxyTarget =
"@": path.resolve(__dirname, "./src"), env.VITE_PROXY_TARGET ||
}, env.VITE_BACKEND_API_URL ||
}, "http://localhost:3001"
build: {
sourcemap: false, return {
rollupOptions: { plugins: [react()],
output: { resolve: {
manualChunks: { alias: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'], "@": path.resolve(__dirname, "./src"),
'ui-vendor': ['@radix-ui/react-avatar', '@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-select'],
'chart-vendor': ['recharts'],
'query-vendor': ['@tanstack/react-query'],
},
}, },
}, },
chunkSizeWarningLimit: 1000, build: {
}, sourcemap: false,
server: { rollupOptions: {
port: 5173, output: {
strictPort: false, manualChunks: {
host: true, "react-vendor": ["react", "react-dom", "react-router-dom"],
}, "ui-vendor": [
preview: { "@radix-ui/react-avatar",
port: 4173, "@radix-ui/react-dialog",
strictPort: false, "@radix-ui/react-dropdown-menu",
host: true, "@radix-ui/react-select",
}, ],
"chart-vendor": ["recharts"],
"query-vendor": ["@tanstack/react-query"],
},
},
},
chunkSizeWarningLimit: 1000,
},
server: {
port: 5173,
strictPort: false,
host: true,
...(useApiProxy && {
proxy: {
"/api": {
target: proxyTarget.replace(/\/$/, ""),
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(/^\/api/, "") || "/",
},
},
}),
},
preview: {
port: 4173,
strictPort: false,
host: true,
},
}
}) })