151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { AhoCorasick } from '@/lib/aho';
|
|
import { ListSystemsWithRegions, SetDestinationForAll, ListCharacters, StartESILogin } from 'wailsjs/go/main/App';
|
|
import { toast } from '@/hooks/use-toast';
|
|
|
|
interface SearchResult {
|
|
system: string;
|
|
region: string;
|
|
}
|
|
|
|
async function loadAllSystems(): Promise<Array<SearchResult>> {
|
|
const list = await ListSystemsWithRegions();
|
|
const seen = new Set<string>();
|
|
const out: Array<SearchResult> = [];
|
|
for (const item of list) {
|
|
const system = String(item.system);
|
|
if (seen.has(system)) continue;
|
|
seen.add(system);
|
|
out.push({ system, region: String(item.region) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export const SearchDialog: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const [all, setAll] = useState<Array<SearchResult>>([]);
|
|
const [results, setResults] = useState<Array<SearchResult>>([]);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const automaton = useMemo(() => {
|
|
const ac = new AhoCorasick();
|
|
if (query.trim().length > 0) ac.add(query.toLowerCase());
|
|
ac.build();
|
|
return ac;
|
|
}, [query]);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const isCtrlF = (e.key === 'f' || e.key === 'F') && (e.ctrlKey || e.metaKey);
|
|
if (isCtrlF) {
|
|
e.preventDefault();
|
|
setOpen(true);
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
if (all.length === 0) {
|
|
loadAllSystems().then(setAll);
|
|
}
|
|
}, [open, all.length]);
|
|
|
|
useEffect(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (q.length === 0) { setResults([]); return; }
|
|
const scored: Array<{ s: SearchResult; idx: number }> = [];
|
|
for (const r of all) {
|
|
const idx = automaton.searchFirstIndex(r.system.toLowerCase());
|
|
if (idx >= 0) scored.push({ s: r, idx });
|
|
}
|
|
scored.sort((a, b) => {
|
|
if (a.idx !== b.idx) return a.idx - b.idx; // earlier index first
|
|
if (a.s.system.length !== b.s.system.length) return a.s.system.length - b.s.system.length; // shorter name next
|
|
return a.s.system.localeCompare(b.s.system);
|
|
});
|
|
setResults(scored.slice(0, 10).map(x => x.s));
|
|
}, [query, all, automaton]);
|
|
|
|
const onSelect = (r: SearchResult) => {
|
|
setOpen(false);
|
|
setQuery('');
|
|
navigate(`/regions/${encodeURIComponent(r.region)}?focus=${encodeURIComponent(r.system)}`);
|
|
};
|
|
|
|
const ensureAnyLoggedIn = async (): Promise<boolean> => {
|
|
try {
|
|
const list = await ListCharacters();
|
|
if (Array.isArray(list) && list.length > 0) return true;
|
|
await StartESILogin();
|
|
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.' });
|
|
return false;
|
|
} catch (e: any) {
|
|
await StartESILogin();
|
|
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.', variant: 'destructive' });
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleResultClick = async (e: React.MouseEvent, r: SearchResult) => {
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
if (!(await ensureAnyLoggedIn())) return;
|
|
await SetDestinationForAll(r.system, true, false);
|
|
toast({ title: 'Destination set', description: r.system });
|
|
} catch (err: any) {
|
|
toast({ title: 'Failed to set destination', description: String(err), variant: 'destructive' });
|
|
} finally {
|
|
setOpen(false);
|
|
setQuery('');
|
|
}
|
|
return;
|
|
}
|
|
onSelect(r);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="sm:max-w-lg bg-slate-900/95 border border-purple-500/40 text-white">
|
|
<DialogHeader>
|
|
<DialogTitle>Search systems</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-2">
|
|
<Input
|
|
ref={inputRef}
|
|
placeholder="Type system name..."
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-400"
|
|
/>
|
|
<div className="max-h-80 overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
|
|
{results.length === 0 && query && (
|
|
<div className="p-3 text-sm text-slate-300">No results</div>
|
|
)}
|
|
{results.map((r, idx) => (
|
|
<button
|
|
key={`${r.region}-${r.system}-${idx}`}
|
|
className="w-full text-left p-3 hover:bg-purple-500/20"
|
|
onClick={(e) => handleResultClick(e, r)}
|
|
title="Click to open, Shift+Click to set destination"
|
|
>
|
|
<div className="text-sm font-medium">{r.system}</div>
|
|
<div className="text-xs text-slate-300">{r.region}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|