mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-24 16:43:41 +00:00
Added a global search bar
This commit is contained in:
247
backend/src/routes/searchRoutes.js
Normal file
247
backend/src/routes/searchRoutes.js
Normal file
@@ -0,0 +1,247 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { createPrismaClient } = require("../config/database");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
/**
|
||||
* Global search endpoint
|
||||
* Searches across hosts, packages, repositories, and users
|
||||
* Returns categorized results
|
||||
*/
|
||||
router.get("/", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || q.trim().length === 0) {
|
||||
return res.json({
|
||||
hosts: [],
|
||||
packages: [],
|
||||
repositories: [],
|
||||
users: [],
|
||||
});
|
||||
}
|
||||
|
||||
const searchTerm = q.trim();
|
||||
|
||||
// Prepare results object
|
||||
const results = {
|
||||
hosts: [],
|
||||
packages: [],
|
||||
repositories: [],
|
||||
users: [],
|
||||
};
|
||||
|
||||
// Get user permissions from database
|
||||
let userPermissions = null;
|
||||
try {
|
||||
userPermissions = await prisma.role_permissions.findUnique({
|
||||
where: { role: req.user.role },
|
||||
});
|
||||
|
||||
// If no specific permissions found, default to admin permissions
|
||||
if (!userPermissions) {
|
||||
console.warn(
|
||||
`No permissions found for role: ${req.user.role}, defaulting to admin access`,
|
||||
);
|
||||
userPermissions = {
|
||||
can_view_hosts: true,
|
||||
can_view_packages: true,
|
||||
can_view_users: true,
|
||||
};
|
||||
}
|
||||
} catch (permError) {
|
||||
console.error("Error fetching permissions:", permError);
|
||||
// Default to restrictive permissions on error
|
||||
userPermissions = {
|
||||
can_view_hosts: false,
|
||||
can_view_packages: false,
|
||||
can_view_users: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Search hosts if user has permission
|
||||
if (userPermissions.can_view_hosts) {
|
||||
try {
|
||||
const hosts = await prisma.hosts.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ hostname: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ friendly_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ ip: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
friendly_name: true,
|
||||
ip: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
status: true,
|
||||
last_update: true,
|
||||
},
|
||||
take: 10, // Limit results
|
||||
orderBy: {
|
||||
last_update: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
results.hosts = hosts.map((host) => ({
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
friendly_name: host.friendly_name,
|
||||
ip: host.ip,
|
||||
os_type: host.os_type,
|
||||
os_version: host.os_version,
|
||||
status: host.status,
|
||||
last_update: host.last_update,
|
||||
type: "host",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching hosts:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Search packages if user has permission
|
||||
if (userPermissions.can_view_packages) {
|
||||
try {
|
||||
const packages = await prisma.packages.findMany({
|
||||
where: {
|
||||
name: { contains: searchTerm, mode: "insensitive" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
latest_version: true,
|
||||
_count: {
|
||||
select: {
|
||||
host_packages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
results.packages = packages.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
category: pkg.category,
|
||||
latest_version: pkg.latest_version,
|
||||
host_count: pkg._count.host_packages,
|
||||
type: "package",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching packages:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Search repositories if user has permission (usually same as hosts)
|
||||
if (userPermissions.can_view_hosts) {
|
||||
try {
|
||||
const repositories = await prisma.repositories.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ url: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ description: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
distribution: true,
|
||||
repo_type: true,
|
||||
is_active: true,
|
||||
description: true,
|
||||
_count: {
|
||||
select: {
|
||||
host_repositories: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
results.repositories = repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
url: repo.url,
|
||||
distribution: repo.distribution,
|
||||
repo_type: repo.repo_type,
|
||||
is_active: repo.is_active,
|
||||
description: repo.description,
|
||||
host_count: repo._count.host_repositories,
|
||||
type: "repository",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching repositories:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Search users if user has permission
|
||||
if (userPermissions.can_view_users) {
|
||||
try {
|
||||
const users = await prisma.users.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ email: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ first_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ last_name: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
role: true,
|
||||
is_active: true,
|
||||
last_login: true,
|
||||
},
|
||||
take: 10,
|
||||
orderBy: {
|
||||
username: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
results.users = users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
last_login: user.last_login,
|
||||
type: "user",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error("Global search error:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to perform search",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
const repositoryRoutes = require("./routes/repositoryRoutes");
|
||||
const versionRoutes = require("./routes/versionRoutes");
|
||||
const tfaRoutes = require("./routes/tfaRoutes");
|
||||
const searchRoutes = require("./routes/searchRoutes");
|
||||
const updateScheduler = require("./services/updateScheduler");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { cleanup_expired_sessions } = require("./utils/session_manager");
|
||||
@@ -378,6 +379,7 @@ app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
||||
app.use(`/api/${apiVersion}/version`, versionRoutes);
|
||||
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
|
||||
app.use(`/api/${apiVersion}/search`, searchRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, _req, res, _next) => {
|
||||
|
||||
428
frontend/src/components/GlobalSearch.jsx
Normal file
428
frontend/src/components/GlobalSearch.jsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { GitBranch, Package, Search, Server, User, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { searchAPI } from "../utils/api";
|
||||
|
||||
const GlobalSearch = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const searchRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Debounce search
|
||||
const debounceTimerRef = useRef(null);
|
||||
|
||||
const performSearch = useCallback(async (searchQuery) => {
|
||||
if (!searchQuery || searchQuery.trim().length === 0) {
|
||||
setResults(null);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await searchAPI.global(searchQuery);
|
||||
setResults(response.data);
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
setResults(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
|
||||
// Clear previous timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
performSearch(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
// Clear debounce timer to prevent any pending searches
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
setQuery("");
|
||||
setResults(null);
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleResultClick = (result) => {
|
||||
// Navigate based on result type
|
||||
switch (result.type) {
|
||||
case "host":
|
||||
navigate(`/hosts/${result.id}`);
|
||||
break;
|
||||
case "package":
|
||||
navigate(`/packages/${result.id}`);
|
||||
break;
|
||||
case "repository":
|
||||
navigate(`/repositories/${result.id}`);
|
||||
break;
|
||||
case "user":
|
||||
// Users don't have detail pages, so navigate to settings
|
||||
navigate("/settings/users");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Close dropdown and clear
|
||||
handleClear();
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation
|
||||
const flattenedResults = [];
|
||||
if (results) {
|
||||
if (results.hosts?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Hosts" });
|
||||
flattenedResults.push(...results.hosts);
|
||||
}
|
||||
if (results.packages?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Packages" });
|
||||
flattenedResults.push(...results.packages);
|
||||
}
|
||||
if (results.repositories?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Repositories" });
|
||||
flattenedResults.push(...results.repositories);
|
||||
}
|
||||
if (results.users?.length > 0) {
|
||||
flattenedResults.push({ type: "header", label: "Users" });
|
||||
flattenedResults.push(...results.users);
|
||||
}
|
||||
}
|
||||
|
||||
const navigableResults = flattenedResults.filter((r) => r.type !== "header");
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!isOpen || !results) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < navigableResults.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && navigableResults[selectedIndex]) {
|
||||
handleResultClick(navigableResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon for result type
|
||||
const getResultIcon = (type) => {
|
||||
switch (type) {
|
||||
case "host":
|
||||
return <Server className="h-4 w-4 text-blue-500" />;
|
||||
case "package":
|
||||
return <Package className="h-4 w-4 text-green-500" />;
|
||||
case "repository":
|
||||
return <GitBranch className="h-4 w-4 text-purple-500" />;
|
||||
case "user":
|
||||
return <User className="h-4 w-4 text-orange-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get display text for result
|
||||
const getResultDisplay = (result) => {
|
||||
switch (result.type) {
|
||||
case "host":
|
||||
return {
|
||||
primary: result.friendly_name || result.hostname,
|
||||
secondary: result.ip || result.hostname,
|
||||
};
|
||||
case "package":
|
||||
return {
|
||||
primary: result.name,
|
||||
secondary: result.description || result.category,
|
||||
};
|
||||
case "repository":
|
||||
return {
|
||||
primary: result.name,
|
||||
secondary: result.distribution,
|
||||
};
|
||||
case "user":
|
||||
return {
|
||||
primary: result.username,
|
||||
secondary: result.email,
|
||||
};
|
||||
default:
|
||||
return { primary: "", secondary: "" };
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults =
|
||||
results &&
|
||||
(results.hosts?.length > 0 ||
|
||||
results.packages?.length > 0 ||
|
||||
results.repositories?.length > 0 ||
|
||||
results.users?.length > 0);
|
||||
|
||||
return (
|
||||
<div ref={searchRef} className="relative w-full max-w-sm">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Search className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
|
||||
placeholder="Search hosts, packages, repos, users..."
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (query && results) setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown Results */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||
Searching...
|
||||
</div>
|
||||
) : hasResults ? (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{/* Hosts */}
|
||||
{results.hosts?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Hosts
|
||||
</div>
|
||||
{results.hosts.map((host, idx) => {
|
||||
const display = getResultDisplay(host);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === host.id && r.type === "host",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={host.id}
|
||||
onClick={() => handleResultClick(host)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("host")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{host.os_type}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{results.packages?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Packages
|
||||
</div>
|
||||
{results.packages.map((pkg, idx) => {
|
||||
const display = getResultDisplay(pkg);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === pkg.id && r.type === "package",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={pkg.id}
|
||||
onClick={() => handleResultClick(pkg)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("package")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
{display.secondary && (
|
||||
<>
|
||||
<span className="text-xs text-secondary-400">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{pkg.host_count} hosts
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repositories */}
|
||||
{results.repositories?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Repositories
|
||||
</div>
|
||||
{results.repositories.map((repo, idx) => {
|
||||
const display = getResultDisplay(repo);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === repo.id && r.type === "repository",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={repo.id}
|
||||
onClick={() => handleResultClick(repo)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("repository")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{repo.host_count} hosts
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users */}
|
||||
{results.users?.length > 0 && (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||
Users
|
||||
</div>
|
||||
{results.users.map((user, idx) => {
|
||||
const display = getResultDisplay(user);
|
||||
const globalIdx = navigableResults.findIndex(
|
||||
(r) => r.id === user.id && r.type === "user",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={user.id}
|
||||
onClick={() => handleResultClick(user)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||
globalIdx === selectedIndex
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
{getResultIcon("user")}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{display.primary}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">•</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||
{display.secondary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||
{user.role}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : query.trim() ? (
|
||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||
No results found for "{query}"
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearch;
|
||||
@@ -29,6 +29,7 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||
import { dashboardAPI, versionAPI } from "../utils/api";
|
||||
import GlobalSearch from "./GlobalSearch";
|
||||
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
@@ -866,12 +867,18 @@ const Layout = ({ children }) => {
|
||||
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<div className="relative flex flex-1 items-center">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
<div className="relative flex items-center">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 whitespace-nowrap">
|
||||
{getPageTitle()}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||
|
||||
{/* Global Search Bar */}
|
||||
<div className="hidden md:flex items-center max-w-sm">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
|
||||
{/* External Links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
|
||||
@@ -259,4 +259,9 @@ export const formatRelativeTime = (date) => {
|
||||
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
|
||||
};
|
||||
|
||||
// Search API
|
||||
export const searchAPI = {
|
||||
global: (query) => api.get("/search", { params: { q: query } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Reference in New Issue
Block a user