Add search page with popup UI
This commit is contained in:
@@ -14,6 +14,7 @@ import Dashboard from './pages/Dashboard';
|
||||
import Feature1 from './pages/Feature1';
|
||||
import Feature2 from './pages/Feature2';
|
||||
import Feature3 from './pages/Feature3';
|
||||
import Search from './pages/Search';
|
||||
import Notifications from './pages/Notifications';
|
||||
import APIKeys from './pages/APIKeys';
|
||||
import AdminPanel from './pages/AdminPanel';
|
||||
@@ -70,6 +71,7 @@ function AppRoutes() {
|
||||
<Route path="/feature1" element={<Feature1 />} />
|
||||
<Route path="/feature2" element={<Feature2 />} />
|
||||
<Route path="/feature3" element={<Feature3 />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
<Route path="/api-keys" element={<APIKeys />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
@@ -59,6 +59,12 @@
|
||||
"feature3": "Feature 3 Integration",
|
||||
"comingSoon": "Coming Soon"
|
||||
},
|
||||
"searchPage": {
|
||||
"title": "Search",
|
||||
"placeholder": "Search the system",
|
||||
"hint": "Start typing to search",
|
||||
"noResults": "No results"
|
||||
},
|
||||
"featuresPage": {
|
||||
"title": "Features",
|
||||
"subtitle": "Configure application features",
|
||||
|
||||
@@ -59,6 +59,12 @@
|
||||
"feature3": "Integrazione Funzione 3",
|
||||
"comingSoon": "Prossimamente"
|
||||
},
|
||||
"searchPage": {
|
||||
"title": "Cerca",
|
||||
"placeholder": "Cerca nel sistema",
|
||||
"hint": "Inizia a digitare per cercare",
|
||||
"noResults": "Nessun risultato"
|
||||
},
|
||||
"featuresPage": {
|
||||
"title": "Funzionalità",
|
||||
"subtitle": "Configura le funzionalità dell'applicazione",
|
||||
|
||||
140
frontend/src/pages/Search.tsx
Normal file
140
frontend/src/pages/Search.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import '../styles/Search.css';
|
||||
|
||||
type SearchResult = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const PANEL_ESTIMATED_HEIGHT = 260;
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [direction, setDirection] = useState<'up' | 'down'>('down');
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const results = useMemo<SearchResult[]>(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return [];
|
||||
return [];
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const updateDirection = () => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!rect || typeof window === 'undefined') return;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const shouldOpenUp = spaceBelow < PANEL_ESTIMATED_HEIGHT && spaceAbove > spaceBelow;
|
||||
setDirection(shouldOpenUp ? 'up' : 'down');
|
||||
};
|
||||
updateDirection();
|
||||
window.addEventListener('resize', updateDirection);
|
||||
return () => window.removeEventListener('resize', updateDirection);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const nextTarget = event.relatedTarget as Node | null;
|
||||
if (nextTarget && wrapperRef.current?.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const showResults = query.trim().length > 0;
|
||||
const showPopover = isOpen;
|
||||
const hintText = showResults
|
||||
? t.searchPage.noResults
|
||||
: t.searchPage.hint;
|
||||
|
||||
return (
|
||||
<main className="main-content search-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.searchPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="content-narrow search-panel">
|
||||
<div className="search-input-wrapper" ref={wrapperRef}>
|
||||
<div className="search-input-group">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t.searchPage.placeholder}
|
||||
value={query}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`search-popover ${showPopover ? 'open' : ''}`}
|
||||
data-direction={direction}
|
||||
aria-hidden={!showPopover}
|
||||
>
|
||||
{results.length > 0 ? (
|
||||
<div className="search-results">
|
||||
{results.map((result) => (
|
||||
<button key={result.id} className="search-result" type="button">
|
||||
<span className="search-result-icon material-symbols-outlined">
|
||||
{result.icon || 'search'}
|
||||
</span>
|
||||
<span className="search-result-text">
|
||||
<span className="search-result-title">{result.title}</span>
|
||||
{result.subtitle && (
|
||||
<span className="search-result-subtitle">{result.subtitle}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="search-empty">
|
||||
<span className="material-symbols-outlined">manage_search</span>
|
||||
<span>{hintText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
130
frontend/src/styles/Search.css
Normal file
130
frontend/src/styles/Search.css
Normal file
@@ -0,0 +1,130 @@
|
||||
.search-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap);
|
||||
padding: var(--input-padding);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-group:focus-within {
|
||||
border-color: rgba(var(--color-accent-rgb), 0.35);
|
||||
box-shadow: var(--shadow-ring);
|
||||
}
|
||||
|
||||
.search-input-group .material-symbols-outlined {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.search-input-group input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 0.5rem);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.search-popover[data-direction='up'] {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.search-popover.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
}
|
||||
|
||||
.search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.search-result-icon {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.search-result-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-result-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.search-empty .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
Reference in New Issue
Block a user