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 Feature1 from './pages/Feature1';
|
||||||
import Feature2 from './pages/Feature2';
|
import Feature2 from './pages/Feature2';
|
||||||
import Feature3 from './pages/Feature3';
|
import Feature3 from './pages/Feature3';
|
||||||
|
import Search from './pages/Search';
|
||||||
import Notifications from './pages/Notifications';
|
import Notifications from './pages/Notifications';
|
||||||
import APIKeys from './pages/APIKeys';
|
import APIKeys from './pages/APIKeys';
|
||||||
import AdminPanel from './pages/AdminPanel';
|
import AdminPanel from './pages/AdminPanel';
|
||||||
@@ -70,6 +71,7 @@ function AppRoutes() {
|
|||||||
<Route path="/feature1" element={<Feature1 />} />
|
<Route path="/feature1" element={<Feature1 />} />
|
||||||
<Route path="/feature2" element={<Feature2 />} />
|
<Route path="/feature2" element={<Feature2 />} />
|
||||||
<Route path="/feature3" element={<Feature3 />} />
|
<Route path="/feature3" element={<Feature3 />} />
|
||||||
|
<Route path="/search" element={<Search />} />
|
||||||
<Route path="/notifications" element={<Notifications />} />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
<Route path="/api-keys" element={<APIKeys />} />
|
<Route path="/api-keys" element={<APIKeys />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|||||||
@@ -59,6 +59,12 @@
|
|||||||
"feature3": "Feature 3 Integration",
|
"feature3": "Feature 3 Integration",
|
||||||
"comingSoon": "Coming Soon"
|
"comingSoon": "Coming Soon"
|
||||||
},
|
},
|
||||||
|
"searchPage": {
|
||||||
|
"title": "Search",
|
||||||
|
"placeholder": "Search the system",
|
||||||
|
"hint": "Start typing to search",
|
||||||
|
"noResults": "No results"
|
||||||
|
},
|
||||||
"featuresPage": {
|
"featuresPage": {
|
||||||
"title": "Features",
|
"title": "Features",
|
||||||
"subtitle": "Configure application features",
|
"subtitle": "Configure application features",
|
||||||
|
|||||||
@@ -59,6 +59,12 @@
|
|||||||
"feature3": "Integrazione Funzione 3",
|
"feature3": "Integrazione Funzione 3",
|
||||||
"comingSoon": "Prossimamente"
|
"comingSoon": "Prossimamente"
|
||||||
},
|
},
|
||||||
|
"searchPage": {
|
||||||
|
"title": "Cerca",
|
||||||
|
"placeholder": "Cerca nel sistema",
|
||||||
|
"hint": "Inizia a digitare per cercare",
|
||||||
|
"noResults": "Nessun risultato"
|
||||||
|
},
|
||||||
"featuresPage": {
|
"featuresPage": {
|
||||||
"title": "Funzionalità",
|
"title": "Funzionalità",
|
||||||
"subtitle": "Configura le funzionalità dell'applicazione",
|
"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