feat: add new search panel components and update existing ones with headers and close buttons

This commit is contained in:
Jaime Idolpx 2026-06-15 00:47:22 -04:00
parent 48f75a1acc
commit e5c2a7fca5
10 changed files with 110 additions and 81 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

View File

@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useState } from 'react';
import { Cpu, Wifi, Network, HardDrive, Activity, Search, Wrench, User, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import { Toaster, toast } from 'sonner';
import StatusPage from './components/StatusPage';
import DevicesPage from './components/DevicesPage';
@ -54,8 +55,7 @@ type AppId =
export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('status');
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
const [showSearch, setShowSearch] = useState(false);
const [searchInitialTab, setSearchInitialTab] = useState<0 | 1 | 2 | 3 | undefined>(undefined);
const [searchPanel, setSearchPanel] = useState<'local' | 'assembly64' | 'commoserve' | 'csdb' | 'last' | null>(null);
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
@ -118,9 +118,9 @@ export default function App() {
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<AppCard icon={<Folder className="w-7 h-7" />} label="Media Manager" onClick={() => { setFileManagerInitialPath(undefined); setCurrentPage('file-manager'); }} />
<AppCard icon={<Database className="w-7 h-7" />} label="Assembly64" onClick={() => { setSearchInitialTab(1); setShowSearch(true); }} />
<AppCard icon={<Database className="w-7 h-7" />} label="CommoServe" onClick={() => { setSearchInitialTab(2); setShowSearch(true); }} />
<AppCard icon={<Database className="w-7 h-7" />} label="CSDb" onClick={() => { setSearchInitialTab(3); setShowSearch(true); }} />
<AppCard icon={<Database className="w-7 h-7" />} label="Assembly64" onClick={() => setSearchPanel('assembly64')} />
<AppCard icon={<Database className="w-7 h-7" />} label="CommoServe" onClick={() => setSearchPanel('commoserve')} />
<AppCard icon={<Database className="w-7 h-7" />} label="CSDb" onClick={() => setSearchPanel('csdb')} />
<AppCard icon={<Printer className="w-7 h-7" />} label="Print Manager" onClick={() => setCurrentPage('print-manager')} />
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
@ -262,7 +262,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
</button>
<button
onClick={() => { setSearchInitialTab(undefined); setShowSearch(true); }}
onClick={() => setSearchPanel('last')}
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
>
<Search className="w-5 h-5 text-white" />
@ -316,20 +316,18 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
</div>
</nav>
{showSearch && (
<SearchPane
config={config}
setConfig={setConfig}
initialTab={searchInitialTab}
onClose={() => setShowSearch(false)}
onOpenFolder={(path: string) => {
setFileManagerInitialPath(path);
setFileManagerReturnPage('status');
setShowSearch(false);
setCurrentPage('file-manager');
}}
/>
)}
<AnimatePresence>
{searchPanel && (
<SearchPane
key="search"
config={config}
setConfig={setConfig}
initialTab={({ local: 0, assembly64: 1, commoserve: 2, csdb: 3, last: undefined } as const)[searchPanel]}
onClose={() => setSearchPanel(null)}
onOpenFolder={(path: string) => { setSearchPanel(null); setFileManagerInitialPath(path); setFileManagerReturnPage('status'); setCurrentPage('file-manager'); }}
/>
)}
</AnimatePresence>
</div>
</WsProvider>
);

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle } from 'lucide-react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X } from 'lucide-react';
import { toast } from 'sonner';
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
@ -153,7 +153,7 @@ const AQL_TERMS = [
// ─── Component ────────────────────────────────────────────────────────────────
export default function SearchAssembly64({ config, setConfig, onClose: _onClose }: SearchAssembly64Props) {
export default function SearchAssembly64({ config, setConfig, onClose }: SearchAssembly64Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [showAqlHelp, setShowAqlHelp] = useState(false);
@ -322,6 +322,16 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<img src={`${import.meta.env.BASE_URL}assets/favicon.a64.png`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">Assembly64</span>
</div>
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
</button>
</div>
{/* Header */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
{/* Search input */}

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight } from 'lucide-react';
import { Search, Loader2, HardDrive, Download, ChevronRight, X, Archive } from 'lucide-react';
import { toast } from 'sonner';
import { basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
@ -68,7 +68,7 @@ function typeLabel(tags: string): string {
// ─── Component ────────────────────────────────────────────────────────────────
export default function SearchCSDbNG({ config, setConfig, onClose: _onClose }: SearchCSDbNGProps) {
export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbNGProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(() => _store.query);
@ -201,6 +201,16 @@ export default function SearchCSDbNG({ config, setConfig, onClose: _onClose }: S
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<Archive className="w-5 h-5 flex-shrink-0 text-neutral-500" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">CSDb-ng</span>
</div>
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
</button>
</div>
{/* Header */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
<div className="flex gap-2 mb-2">
@ -211,7 +221,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose: _onClose }: S
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isSearching && doSearch(query)}
placeholder="Search CSDb…"
placeholder="Search CSDb-ng…"
className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
disabled={isSearching}
/>

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle } from 'lucide-react';
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle, X } from 'lucide-react';
import { toast } from 'sonner';
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
@ -145,7 +145,7 @@ const AQL_TERMS = [
// ─── Component ────────────────────────────────────────────────────────────────
export default function SearchCommoServe({ config, setConfig, onClose: _onClose }: SearchCommoServeProps) {
export default function SearchCommoServe({ config, setConfig, onClose }: SearchCommoServeProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [showAqlHelp, setShowAqlHelp] = useState(false);
@ -307,6 +307,16 @@ export default function SearchCommoServe({ config, setConfig, onClose: _onClose
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<img src={`${import.meta.env.BASE_URL}assets/favicon.cbm.png`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">CommoServe</span>
</div>
<button onClick={onClose} className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
</button>
</div>
{/* Header */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-200/70">
<div className="flex gap-2 mb-2">

View File

@ -339,32 +339,23 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
return (
<>
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
<div className="flex items-center justify-end mb-3 gap-1">
{/* Panel header */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-4">
<div className="flex items-center gap-2 flex-1 py-3 min-w-0">
<img src={`${import.meta.env.BASE_URL}favicon.ico`} className="w-5 h-5 flex-shrink-0 object-contain" alt="" aria-hidden="true" />
<span className="text-sm font-semibold text-neutral-700">Local</span>
</div>
<div className="flex items-center gap-0.5">
{dbPhase === 'ready' && !busy && (
<button
onClick={handleRefreshDb}
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 transition-colors"
title="Reload database"
>
<button onClick={handleRefreshDb} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 transition-colors" title="Reload database">
<RefreshCw className="w-4 h-4" />
</button>
)}
<button
onClick={() => setShowScanConfirm(true)}
disabled={busy}
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors"
title="Scan /sd and rebuild database"
>
<button onClick={() => setShowScanConfirm(true)} disabled={busy} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors" title="Scan /sd and rebuild database">
<FolderSearch className="w-4 h-4" />
</button>
{hasSearched && (
<button
onClick={() => setShowFilter(v => !v)}
className={`relative p-1.5 rounded-lg transition-colors ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
title="Filter & sort"
>
<button onClick={() => setShowFilter(v => !v)} className={`relative p-1.5 rounded-lg transition-colors ${showFilter ? 'bg-blue-100 text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`} title="Filter & sort">
<SlidersHorizontal className="w-4 h-4" />
{activeFilters > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full bg-blue-600 text-white text-[9px] flex items-center justify-center font-bold">
@ -373,9 +364,14 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
)}
</button>
)}
<button onClick={onClose} className="p-1.5 ml-1 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors" aria-label="Close search">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Search input */}
{/* Search input */}
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
@ -439,7 +435,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
{/* Body */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
className="flex-1 overflow-y-auto overflow-x-hidden"
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
>
{isScanning && scanPhase && (
@ -482,7 +478,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
selected={mountedPaths.has(result.path)}
nameSlot={
<>
<div className="flex items-center gap-1.5 flex-wrap">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-neutral-900 text-sm">{result.name}</span>
<TypeBadge type={result.type} isDir={result.isDir} />
{result.system && <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-mono">{result.system}</span>}

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { X, Loader2, Database } from 'lucide-react';
import { Loader2, Database } from 'lucide-react';
import { motion } from 'motion/react';
import SearchLocal from './SearchLocal';
import SearchAssembly64 from './SearchAssembly64';
@ -21,15 +21,15 @@ interface SearchPaneProps {
onOpenFolder: (path: string) => void;
}
const TABS = ['Local', 'Assembly64', 'CommoServe', 'CSDb'] as const;
let _lastTab: 0 | 1 | 2 | 3 = (() => {
const v = parseInt(localStorage.getItem('search.tab') ?? '0', 10);
return (v >= 0 && v <= 3 ? v : 0) as 0 | 1 | 2 | 3;
})();
const PANEL_LABELS = ['Local', 'Assembly64', 'CommoServe', 'CSDb-ng'] as const;
export default function SearchPane({ config, setConfig, initialTab, onClose, onOpenFolder }: SearchPaneProps) {
const panelRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<0 | 1 | 2 | 3>(initialTab ?? _lastTab);
// Subscribe to the locate-database load pipeline so the user can see the
@ -61,9 +61,7 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const scrollToTab = (idx: 0 | 1 | 2 | 3) => {
const el = panelRef.current;
if (!el) return;
el.scrollTo({ left: idx * el.clientWidth, behavior: 'smooth' });
panelRef.current?.scrollTo({ left: idx * (panelRef.current.clientWidth), behavior: 'smooth' });
};
const handleScroll = () => {
@ -93,31 +91,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
>
{/* Tab bar */}
<div className="flex-shrink-0 flex items-center border-b border-neutral-200/70 px-1">
{TABS.map((label, i) => (
<button
key={label}
onClick={() => scrollToTab(i as 0 | 1 | 2 | 3)}
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === i
? 'text-blue-600 border-blue-600'
: 'text-neutral-500 border-transparent hover:text-neutral-700'
}`}
>
{label}
</button>
))}
<div className="flex-1" />
<button
onClick={onClose}
className="p-1.5 mr-2 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors"
aria-label="Close search"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Locate-database load bar. Visible only while a transfer is in
flight; collapses to zero height otherwise so it doesn't take
up space when there's nothing to show. */}
@ -200,6 +173,22 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
/>
</div>
</div>
{/* Swipe indicator dots */}
<div className="flex-shrink-0 flex items-center justify-center gap-2.5 py-2 border-t border-neutral-200/50">
{([0, 1, 2, 3] as const).map(i => (
<button
key={i}
onClick={() => scrollToTab(i)}
aria-label={PANEL_LABELS[i]}
className={`rounded-full transition-all duration-200 ${
activeTab === i
? 'w-4 h-2 bg-blue-500'
: 'w-2 h-2 bg-neutral-300 hover:bg-neutral-400'
}`}
/>
))}
</div>
</motion.div>
</div>
);

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true
},
"include": ["src"]
}