575ca7a1fc
ci/woodpecker/push/woodpecker Pipeline was successful
- Raccourcis clavier : ←/→ (frames), Espace (play/pause), Échap (quitter animation/fermer info), H (basculer heatmap↔flux) - URL partageable : ?period=7&view=flow&city=Paris — état restauré au chargement et mis à jour sans rechargement (history.replaceState) - Sparkline : mini bar-chart SVG dans le StatsPanel montrant l'activité sur la période (données déjà en mémoire, aucune requête) - Recherche identité : champ flottant (⌕) acceptant un nom Ğ1 ou une clé g1…, résout via Subsquid + Cesium+, bascule en vue flux et met la ville en focus Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { ss58ToDuniterKey, SUBSQUID_ENDPOINT } from '../services/adapters/SubsquidAdapter';
|
|
import { resolveGeoByKeys } from '../services/adapters/CesiumAdapter';
|
|
|
|
interface SearchBarProps {
|
|
/** Appelé quand une ville est trouvée — App bascule en vue flux et met la ville en focus. */
|
|
onResult: (city: string) => void;
|
|
}
|
|
|
|
async function resolveQuery(query: string): Promise<{ name: string; city: string } | null> {
|
|
const q = query.trim();
|
|
if (!q) return null;
|
|
|
|
// Clé SS58 Ğ1v2 : commence par "g1" et fait ~50 caractères
|
|
const isKey = /^g1[1-9A-HJ-NP-Za-km-z]{40,}$/.test(q);
|
|
|
|
let duniterKey: string;
|
|
let identityName: string;
|
|
|
|
if (isKey) {
|
|
duniterKey = ss58ToDuniterKey(q);
|
|
identityName = q.slice(0, 10) + '…';
|
|
} else {
|
|
const res = await fetch(SUBSQUID_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
query: `
|
|
query($q: String!) {
|
|
identities(filter: { name: { includesInsensitive: $q } }, first: 1) {
|
|
nodes {
|
|
accountId
|
|
name
|
|
ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) {
|
|
nodes { previousId }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: { q },
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
const node = data?.data?.identities?.nodes?.[0];
|
|
if (!node) return null;
|
|
|
|
const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId;
|
|
duniterKey = ss58ToDuniterKey(genesisKey);
|
|
identityName = node.name as string;
|
|
}
|
|
|
|
const geoMap = await resolveGeoByKeys([duniterKey]);
|
|
const geo = geoMap.get(duniterKey);
|
|
if (!geo) return null;
|
|
|
|
return { name: identityName, city: geo.city.split(',')[0].trim() };
|
|
}
|
|
|
|
export function SearchBar({ onResult }: SearchBarProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [found, setFound] = useState<{ name: string; city: string } | null>(null);
|
|
|
|
const close = () => { setOpen(false); setQuery(''); setError(null); setFound(null); };
|
|
|
|
const handleSubmit = async () => {
|
|
if (!query.trim()) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
setFound(null);
|
|
try {
|
|
const result = await resolveQuery(query);
|
|
if (result) setFound(result);
|
|
else setError('Introuvable dans Cesium+');
|
|
} catch {
|
|
setError('Erreur de connexion');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSelect = () => {
|
|
if (!found) return;
|
|
onResult(found.city);
|
|
close();
|
|
};
|
|
|
|
if (!open) {
|
|
return (
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#6b7280] hover:text-[#d4a843] transition-colors text-sm"
|
|
aria-label="Rechercher une identité Ğ1"
|
|
title="Rechercher une identité (nom ou clé g1…)"
|
|
>
|
|
⌕
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5 bg-[#0a0b0f]/95 backdrop-blur-sm border border-[#2e2f3a] rounded-xl p-2 w-60 shadow-xl">
|
|
<div className="flex gap-1 items-center">
|
|
<input
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleSubmit();
|
|
if (e.key === 'Escape') close();
|
|
}}
|
|
placeholder="Nom ou clé g1…"
|
|
className="flex-1 min-w-0 bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-2 py-1.5 text-xs text-white placeholder-[#4b5563] focus:outline-none focus:border-[#d4a843] transition-colors"
|
|
/>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={loading || !query.trim()}
|
|
className="text-[#d4a843] disabled:text-[#4b5563] text-sm px-1.5 hover:text-white transition-colors shrink-0"
|
|
aria-label="Rechercher"
|
|
>
|
|
{loading ? <span className="animate-spin inline-block">↻</span> : '↵'}
|
|
</button>
|
|
<button
|
|
onClick={close}
|
|
className="text-[#4b5563] hover:text-white transition-colors shrink-0 text-xs"
|
|
aria-label="Fermer"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="text-red-400 text-xs px-1">{error}</p>
|
|
)}
|
|
|
|
{found && (
|
|
<button
|
|
onClick={handleSelect}
|
|
className="text-left px-2 py-2 rounded-lg bg-[#1e1f2a] hover:bg-[#2e2f3a] transition-colors"
|
|
>
|
|
<p className="text-[#d4a843] text-xs font-medium truncate">{found.name}</p>
|
|
<p className="text-[#6b7280] text-xs mt-0.5">📍 {found.city} — cliquer pour zoomer</p>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|