@johndimm/constellations 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/App.tsx +480 -0
  2. package/FullPageConstellations.tsx +74 -0
  3. package/FullPageConstellationsHostShell.tsx +27 -0
  4. package/README.md +116 -0
  5. package/components/AppConfirmDialog.tsx +46 -0
  6. package/components/AppHeader.tsx +73 -0
  7. package/components/AppNotifications.tsx +21 -0
  8. package/components/BrowsePeople.tsx +832 -0
  9. package/components/ControlPanel.tsx +1023 -0
  10. package/components/Graph.tsx +1525 -0
  11. package/components/HelpOverlay.tsx +168 -0
  12. package/components/NodeContextMenu.tsx +160 -0
  13. package/components/PeopleBrowserSidebar.tsx +690 -0
  14. package/components/Sidebar.tsx +271 -0
  15. package/components/TimelineView.tsx +4 -0
  16. package/hooks/useExpansion.ts +889 -0
  17. package/hooks/useGraphActions.ts +325 -0
  18. package/hooks/useGraphState.ts +414 -0
  19. package/hooks/useKioskMode.ts +47 -0
  20. package/hooks/useNodeClickHandler.ts +172 -0
  21. package/hooks/useSearchHandlers.ts +369 -0
  22. package/host.ts +16 -0
  23. package/index.css +101 -0
  24. package/index.tsx +16 -0
  25. package/kioskDomains.ts +307 -0
  26. package/package.json +78 -0
  27. package/services/aiUtils.ts +364 -0
  28. package/services/cacheService.ts +76 -0
  29. package/services/crossrefService.ts +107 -0
  30. package/services/geminiService.ts +1359 -0
  31. package/services/get-local-graphs.js +5 -0
  32. package/services/graphUtils.ts +347 -0
  33. package/services/imageService.ts +39 -0
  34. package/services/llmClient.ts +194 -0
  35. package/services/openAlexService.ts +173 -0
  36. package/services/wikipediaImage.ts +40 -0
  37. package/services/wikipediaService.ts +1175 -0
  38. package/sessionHandoff.ts +132 -0
  39. package/types.ts +99 -0
  40. package/useFullPageConstellationsHost.ts +116 -0
  41. package/utils/evidenceUtils.ts +107 -0
  42. package/utils/graphLogicUtils.ts +32 -0
  43. package/utils/graphNodeToChannelNotes.ts +71 -0
  44. package/utils/wikiUtils.ts +34 -0
@@ -0,0 +1,690 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { Search, X, Filter, ChevronRight } from 'lucide-react';
3
+
4
+ interface Person {
5
+ title: string;
6
+ pageid: number;
7
+ thumbnail?: {
8
+ source: string;
9
+ width: number;
10
+ height: number;
11
+ };
12
+ extract?: string;
13
+ pageviews?: number;
14
+ categories?: string[];
15
+ article_length?: number;
16
+ originalIndex?: number;
17
+ birth_year?: number | null;
18
+ death_year?: number | null;
19
+ is_living?: boolean;
20
+ century_birth?: string | null;
21
+ century_death?: string | null;
22
+ years_active_estimate?: number | null;
23
+ lead_paragraph_length?: number | null;
24
+ internal_link_density?: number | null;
25
+ gender_guess?: string | null;
26
+ occupation_keywords?: string[];
27
+ nationality_keywords?: string[];
28
+ has_infobox?: boolean;
29
+ score?: number;
30
+ outgoing_links?: number;
31
+ incoming_links?: number;
32
+ [key: string]: any;
33
+ }
34
+
35
+ interface PeopleBrowserSidebarProps {
36
+ isOpen: boolean;
37
+ onClose: () => void;
38
+ onSelectPerson: (personName: string) => void;
39
+ }
40
+
41
+ const sumPageViews = (pageviews: Record<string, number> | undefined) => {
42
+ if (!pageviews) return 0;
43
+ return Object.values(pageviews).reduce((total, value) => {
44
+ if (typeof value !== 'number') return total;
45
+ return total + value;
46
+ }, 0);
47
+ };
48
+
49
+ const cleanCategoryLabel = (category: string) =>
50
+ category.replace(/^Category:/i, '').replace(/_/g, ' ');
51
+
52
+ const PeopleBrowserSidebar: React.FC<PeopleBrowserSidebarProps> = ({ isOpen, onClose, onSelectPerson }) => {
53
+ const [people, setPeople] = useState<Person[]>([]);
54
+ const [loading, setLoading] = useState(false);
55
+ const [error, setError] = useState<string | null>(null);
56
+ const [searchTerm, setSearchTerm] = useState('');
57
+ const [currentPage, setCurrentPage] = useState(0);
58
+ const [continueParam, setContinueParam] = useState<string | null>(null);
59
+ const [hasMore, setHasMore] = useState(true);
60
+ const [occupation, setOccupation] = useState('');
61
+ const [nationality, setNationality] = useState('');
62
+ const [showFilters, setShowFilters] = useState(false);
63
+ const [isCollapsed, setIsCollapsed] = useState(false);
64
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
65
+ const [seedPeople, setSeedPeople] = useState<Person[] | null>(null);
66
+ const [filteredSeedPeople, setFilteredSeedPeople] = useState<Person[] | null>(null);
67
+ const [seedLoaded, setSeedLoaded] = useState(0);
68
+ const [seedTried, setSeedTried] = useState(false);
69
+ const [expandedPersonId, setExpandedPersonId] = useState<number | null>(null);
70
+ const [availableOccupations, setAvailableOccupations] = useState<string[]>([]);
71
+ const [availableNationalities, setAvailableNationalities] = useState<string[]>([]);
72
+
73
+ const defaultOccupations = [
74
+ 'Actor', 'Actress', 'Musician', 'Singer', 'Composer', 'Writer', 'Author', 'Poet',
75
+ 'Scientist', 'Physicist', 'Mathematician', 'Engineer', 'Politician', 'President', 'Prime Minister',
76
+ 'Athlete', 'Footballer', 'Basketball player', 'Tennis player',
77
+ 'Artist', 'Painter', 'Sculptor', 'Photographer', 'Director', 'Producer'
78
+ ];
79
+
80
+ const defaultNationalities = [
81
+ 'American', 'British', 'Canadian', 'Australian', 'French', 'German', 'Italian', 'Spanish', 'Russian',
82
+ 'Chinese', 'Japanese', 'Indian', 'Brazilian', 'Mexican', 'Argentine', 'Irish', 'Scottish', 'Welsh',
83
+ 'Dutch', 'Swedish', 'Norwegian', 'Danish', 'Finnish', 'Greek', 'Turkish', 'Egyptian', 'Nigerian',
84
+ 'South African', 'New Zealander', 'Israeli', 'Saudi Arabian', 'Korean', 'Thai', 'Vietnamese', 'Indonesian'
85
+ ];
86
+
87
+ useEffect(() => {
88
+ const handleResize = () => setIsMobile(window.innerWidth < 768);
89
+ window.addEventListener('resize', handleResize);
90
+ return () => window.removeEventListener('resize', handleResize);
91
+ }, []);
92
+
93
+ const itemsPerPage = 50;
94
+
95
+ const isPureBrowse = useCallback(() => !searchTerm.trim() && !occupation && !nationality, [searchTerm, occupation, nationality]);
96
+
97
+ const buildSearchQuery = useCallback(() => {
98
+ const parts: string[] = [];
99
+
100
+ if (searchTerm.trim()) {
101
+ parts.push(searchTerm.trim());
102
+ }
103
+
104
+ if (occupation.trim()) {
105
+ parts.push(occupation.trim());
106
+ }
107
+
108
+ if (nationality.trim()) {
109
+ parts.push(nationality.trim());
110
+ }
111
+
112
+ if (parts.length === 0) {
113
+ return '';
114
+ }
115
+
116
+ const query = parts.join(' ');
117
+ if (!/\b(person|people|biography|biographical)\b/i.test(query)) {
118
+ return query + ' (person OR biography)';
119
+ }
120
+ return query;
121
+ }, [searchTerm, occupation, nationality]);
122
+
123
+ const enrichPeople = useCallback(async (batch: Person[]) => {
124
+ if (batch.length === 0) return;
125
+
126
+ // Only enrich those that lack thumbnails, extracts, or categories
127
+ const toEnrich = batch.filter(p => !p.thumbnail || !p.extract || !p.categories);
128
+ if (toEnrich.length === 0) return;
129
+
130
+ // Wikipedia's exlimit for extracts is 20. We must batch these calls.
131
+ const batchSize = 20;
132
+ for (let i = 0; i < toEnrich.length; i += batchSize) {
133
+ const currentBatch = toEnrich.slice(i, i + batchSize);
134
+ const titles = currentBatch.map(p => p.title).join('|');
135
+ const infoUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=info|pageimages|extracts|pageviews|categories&inprop=displaytitle&titles=${encodeURIComponent(titles)}&pithumbsize=150&exintro&explaintext&exchars=500&exlimit=max&cllimit=20&clshow=!hidden&pvipdays=60&origin=*&redirects=1`;
136
+
137
+ try {
138
+ const infoRes = await fetch(infoUrl);
139
+ const infoData = await infoRes.json();
140
+ const pages = infoData.query?.pages || {};
141
+ const pagesByTitle = new Map<string, any>();
142
+
143
+ const titleMap = new Map<string, string>();
144
+ if (infoData.query?.normalized) {
145
+ infoData.query.normalized.forEach((n: any) => titleMap.set(n.to, n.from));
146
+ }
147
+ if (infoData.query?.redirects) {
148
+ infoData.query.redirects.forEach((r: any) => titleMap.set(r.to, r.from));
149
+ }
150
+
151
+ Object.values(pages).forEach((page: any) => {
152
+ if (page.title) {
153
+ pagesByTitle.set(page.title, page);
154
+ const originalTitle = titleMap.get(page.title);
155
+ if (originalTitle) pagesByTitle.set(originalTitle, page);
156
+ }
157
+ });
158
+
159
+ setPeople(prev => prev.map(p => {
160
+ const info = pagesByTitle.get(p.title);
161
+ if (!info) return p;
162
+ return {
163
+ ...p,
164
+ pageid: info.pageid || p.pageid,
165
+ thumbnail: info.thumbnail || p.thumbnail,
166
+ extract: info.extract || p.extract,
167
+ pageviews: sumPageViews(info.pageviews),
168
+ categories: (info.categories || []).map((c: any) => cleanCategoryLabel(c.title || '')).filter(Boolean),
169
+ length: info.length || p.length
170
+ };
171
+ }));
172
+ } catch (e) {
173
+ console.warn("Failed to enrich sidebar batch", e);
174
+ }
175
+ }
176
+ }, []);
177
+
178
+ const primeFromSeed = useCallback((all: Person[]) => {
179
+ const initial = all.slice(0, itemsPerPage);
180
+ setPeople(initial);
181
+ setSeedLoaded(initial.length);
182
+ setHasMore(all.length > initial.length);
183
+ setLoading(false);
184
+ setError(null);
185
+ }, [itemsPerPage]);
186
+
187
+ const loadMoreSeed = useCallback(async () => {
188
+ const listToUse = filteredSeedPeople || seedPeople;
189
+ if (!listToUse) return;
190
+ const nextBatchEnd = Math.min(seedLoaded + itemsPerPage, listToUse.length);
191
+ const nextBatch = listToUse.slice(seedLoaded, nextBatchEnd);
192
+
193
+ // Add them immediately as placeholders
194
+ setPeople(prev => [...prev, ...nextBatch]);
195
+ setSeedLoaded(nextBatchEnd);
196
+ setHasMore(nextBatchEnd < listToUse.length);
197
+
198
+ enrichPeople(nextBatch);
199
+ }, [itemsPerPage, seedLoaded, seedPeople, filteredSeedPeople, enrichPeople]);
200
+
201
+ const isLikelyPerson = (title: string) => {
202
+ const lower = title.toLowerCase();
203
+ if (lower.startsWith('category:')) return false;
204
+ if (lower.startsWith('talk:')) return false;
205
+ if (lower.startsWith('list of') || lower.startsWith('lists of')) return false;
206
+ if (lower.startsWith('dictionary of')) return false;
207
+ if (lower.includes('(disambiguation)')) return false;
208
+ if (lower.includes('(film)') || lower.includes('(movie)')) return false;
209
+ // Filter out years, decades, centuries
210
+ if (/^\d{4}$/.test(title.trim())) return false; // e.g., "1904"
211
+ if (/^\d{4}s$/.test(title.trim())) return false; // e.g., "1900s"
212
+ // Filter out common non-person titles
213
+ const nonPersonTitles = ['biographical film', 'biography', 'autobiography'];
214
+ if (nonPersonTitles.includes(lower)) return false;
215
+ return true;
216
+ };
217
+
218
+ const fetchPeople = useCallback(async (search: string, offset: number = 0, continueToken: string | null = null) => {
219
+ setLoading(true);
220
+ setError(null);
221
+
222
+ try {
223
+ let url: string;
224
+ let results: Person[] = [];
225
+
226
+ // Always use search API - use a more specific query to find biographies
227
+ // Search for articles in biographical categories or with biographical terms
228
+ const searchQuery = search.trim() || 'insource:"born" (biography OR "was a" OR "is a" OR "was an" OR "is an")';
229
+ url = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(searchQuery)}&srlimit=${itemsPerPage}&srprop=size|wordcount|timestamp&sroffset=${offset}&origin=*`;
230
+
231
+ const response = await fetch(url);
232
+ const data = await response.json();
233
+
234
+ if (data.error) {
235
+ throw new Error(data.error.info || 'Wikipedia API error');
236
+ }
237
+
238
+ // Handle search results
239
+ const searchResults = (data.query?.search || []).filter((item: any) => isLikelyPerson(item.title));
240
+ results = searchResults.map((item: any) => ({
241
+ title: item.title,
242
+ pageid: item.pageid,
243
+ extract: item.snippet,
244
+ wordcount: item.wordcount,
245
+ size: item.size,
246
+ }));
247
+
248
+ // Fetch thumbnails and extracts for search results
249
+ if (results.length > 0) {
250
+ const titles = results.map((item: any) => item.title).join('|');
251
+ const infoUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=info|pageimages|extracts|pageviews|categories&inprop=displaytitle&titles=${encodeURIComponent(titles)}&pithumbsize=150&exintro&explaintext&exchars=500&exlimit=max&cllimit=20&clshow=!hidden&pvipdays=60&origin=*`;
252
+ const infoResponse = await fetch(infoUrl);
253
+ const infoData = await infoResponse.json();
254
+ const pages = infoData.query?.pages || {};
255
+
256
+ const pagesByTitle = new Map<string, any>();
257
+ Object.values(pages).forEach((page: any) => {
258
+ if (page.title) {
259
+ pagesByTitle.set(page.title, page);
260
+ }
261
+ });
262
+
263
+ results = results.map((item: any) => {
264
+ const page = pagesByTitle.get(item.title);
265
+ return {
266
+ ...item,
267
+ thumbnail: page?.thumbnail,
268
+ extract: page?.extract || item.extract,
269
+ pageviews: sumPageViews(page?.pageviews),
270
+ article_length: page?.length || 0,
271
+ };
272
+ }).sort((a: any, b: any) => {
273
+ // Sort by importance: article length, then pageviews, then extract length
274
+ const lengthDiff = (b.article_length || 0) - (a.article_length || 0);
275
+ if (lengthDiff !== 0) return lengthDiff;
276
+ const fame = (b.pageviews || 0) - (a.pageviews || 0);
277
+ if (fame !== 0) return fame;
278
+ const summaryDiff = (b.extract?.length || 0) - (a.extract?.length || 0);
279
+ if (summaryDiff !== 0) return summaryDiff;
280
+ return a.title.localeCompare(b.title);
281
+ });
282
+ }
283
+
284
+ setHasMore(data.query?.searchinfo?.totalhits > offset + itemsPerPage);
285
+
286
+ if (offset === 0) {
287
+ setPeople(results);
288
+ } else {
289
+ setPeople(prev => [...prev, ...results]);
290
+ }
291
+ } catch (err: any) {
292
+ console.error('Error fetching people:', err);
293
+ setError(err.message || 'Failed to fetch people');
294
+ } finally {
295
+ setLoading(false);
296
+ }
297
+ }, [itemsPerPage]);
298
+
299
+ const handleFilterClick = (type: string, value: string) => {
300
+ if (type === 'occupation') setOccupation(value);
301
+ if (type === 'nationality') setNationality(value);
302
+ setSearchTerm('');
303
+ setCurrentPage(0);
304
+ setContinueParam(null);
305
+ };
306
+
307
+ const extractAspects = (person: Person) => {
308
+ const aspects: { type: string; value: string }[] = [];
309
+ if (person.occupation_keywords?.length) {
310
+ person.occupation_keywords.slice(0, 2).forEach(v => aspects.push({ type: 'occupation', value: v }));
311
+ }
312
+ if (person.nationality_keywords?.length) {
313
+ person.nationality_keywords.slice(0, 1).forEach(v => aspects.push({ type: 'nationality', value: v }));
314
+ }
315
+ return aspects;
316
+ };
317
+
318
+ // Initial load when sidebar opens - prefer local top-biographies dataset; fallback to live fetch
319
+ useEffect(() => {
320
+ const loadSeed = async () => {
321
+ if (!isOpen || seedTried) return;
322
+ setSeedTried(true);
323
+ try {
324
+ setLoading(true);
325
+ // Use the new top people list provided by the user
326
+ const res = await fetch('/simplewiki_top_people.json', { cache: 'no-cache' });
327
+ if (!res.ok) throw new Error('seed not found');
328
+ const data = await res.json();
329
+ if (Array.isArray(data) && data.length > 0) {
330
+ // Transform simple list to Person interface
331
+ const transformed = data.map((item: any, idx: number) => ({
332
+ ...item,
333
+ pageid: item.pageid || -(idx + 1),
334
+ extract: item.extract || '',
335
+ originalIndex: idx
336
+ }));
337
+ setSeedPeople(transformed);
338
+
339
+ // Extract occupations and nationalities from keywords
340
+ const occMap = new Map<string, number>();
341
+ const natMap = new Map<string, number>();
342
+ transformed.forEach(p => {
343
+ (p.occupation_keywords || []).forEach((o: string) => {
344
+ const formatted = o.charAt(0).toUpperCase() + o.slice(1);
345
+ occMap.set(formatted, (occMap.get(formatted) || 0) + 1);
346
+ });
347
+ (p.nationality_keywords || []).forEach((n: string) => {
348
+ const formatted = n.charAt(0).toUpperCase() + n.slice(1);
349
+ natMap.set(formatted, (natMap.get(formatted) || 0) + 1);
350
+ });
351
+ });
352
+
353
+ setAvailableOccupations(Array.from(occMap.entries())
354
+ .sort((a, b) => b[1] - a[1])
355
+ .map(e => e[0]));
356
+ setAvailableNationalities(Array.from(natMap.entries())
357
+ .sort((a, b) => b[1] - a[1])
358
+ .map(e => e[0]));
359
+
360
+ setFilteredSeedPeople(transformed);
361
+
362
+ // Enrich the first batch immediately
363
+ const firstBatch = transformed.slice(0, itemsPerPage);
364
+ setPeople(firstBatch);
365
+ setSeedLoaded(firstBatch.length);
366
+ setHasMore(transformed.length > firstBatch.length);
367
+
368
+ enrichPeople(firstBatch);
369
+
370
+ setLoading(false);
371
+ setError(null);
372
+ return;
373
+ }
374
+ throw new Error('seed empty');
375
+ } catch (err) {
376
+ // Fall back to Wikipedia API for browsing
377
+ fetchPeople('', 0, null);
378
+ }
379
+ };
380
+ loadSeed();
381
+ // eslint-disable-next-line react-hooks/exhaustive-deps
382
+ }, [isOpen, seedTried, fetchPeople]);
383
+
384
+ // Immediate filtering when inputs change
385
+ useEffect(() => {
386
+ if (seedPeople) {
387
+ handleSearch();
388
+ }
389
+ }, [occupation, nationality, seedPeople, searchTerm]);
390
+
391
+ const handleSearch = () => {
392
+ setCurrentPage(0);
393
+ setContinueParam(null);
394
+ setHasMore(true);
395
+
396
+ if (seedPeople) {
397
+ let filtered = [...seedPeople];
398
+
399
+ if (searchTerm.trim()) {
400
+ const lowerSearch = searchTerm.toLowerCase().trim();
401
+ filtered = filtered.filter(p =>
402
+ p.title.toLowerCase().includes(lowerSearch) ||
403
+ (p.occupation_keywords || []).some((o: string) => o.toLowerCase().includes(lowerSearch)) ||
404
+ (p.nationality_keywords || []).some((n: string) => n.toLowerCase().includes(lowerSearch))
405
+ );
406
+ }
407
+
408
+ if (occupation) {
409
+ filtered = filtered.filter(p =>
410
+ (p.occupation_keywords || []).some((o: string) => o.toLowerCase() === occupation.toLowerCase())
411
+ );
412
+ }
413
+
414
+ if (nationality) {
415
+ filtered = filtered.filter(p =>
416
+ (p.nationality_keywords || []).some((n: string) => n.toLowerCase() === nationality.toLowerCase())
417
+ );
418
+ }
419
+
420
+ setFilteredSeedPeople(filtered);
421
+ const initial = filtered.slice(0, itemsPerPage);
422
+ setPeople(initial);
423
+ setSeedLoaded(initial.length);
424
+ setHasMore(filtered.length > initial.length);
425
+
426
+ enrichPeople(initial);
427
+
428
+ if (filtered.length > 0 || !searchTerm.trim()) {
429
+ return;
430
+ }
431
+ }
432
+
433
+ const query = buildSearchQuery();
434
+ fetchPeople(query, 0, null);
435
+ };
436
+
437
+ const handleSelect = (personTitle: string) => {
438
+ onSelectPerson(personTitle);
439
+ };
440
+
441
+ const handleLoadMore = () => {
442
+ if (seedPeople) {
443
+ loadMoreSeed();
444
+ return;
445
+ }
446
+
447
+ // Always use search API with offset for pagination
448
+ const nextOffset = (currentPage + 1) * itemsPerPage;
449
+ const query = buildSearchQuery() || 'insource:"born" (biography OR "was a" OR "is a" OR "was an" OR "is an")';
450
+ fetchPeople(query, nextOffset, null);
451
+ setCurrentPage(prev => prev + 1);
452
+ };
453
+
454
+ // No need to sort - Wikipedia's search API already returns results sorted by relevance
455
+ // The most famous/important people come first
456
+
457
+ if (!isOpen) return null;
458
+
459
+ // Position at the same location as regular sidebar, but with higher z-index when open
460
+ const panelClasses = `fixed top-16 right-3 sm:right-4 z-[60] transition-transform duration-300 ease-in-out ${isCollapsed ? 'translate-x-[calc(100%+2rem)]' : 'translate-x-0'}`;
461
+ const panelStyle = isMobile
462
+ ? { width: 'calc(100% - 1.5rem)', maxWidth: '28rem' }
463
+ : { width: '28rem' };
464
+
465
+ return (
466
+ <div className={panelClasses} style={panelStyle}>
467
+ <div className="bg-slate-900/95 backdrop-blur-xl rounded-xl border border-slate-700 shadow-2xl relative pointer-events-auto flex flex-col max-h-[calc(100vh-2rem)]">
468
+ {/* Header */}
469
+ <div className="p-4 border-b border-slate-700 flex-shrink-0">
470
+ <div className="flex items-center justify-between mb-3">
471
+ <h2 className="text-lg font-bold text-white">Browse People</h2>
472
+ <div className="flex items-center gap-2">
473
+ <button
474
+ onClick={() => setIsCollapsed(!isCollapsed)}
475
+ className="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors"
476
+ title={isCollapsed ? "Expand" : "Collapse"}
477
+ >
478
+ <ChevronRight size={18} className={isCollapsed ? 'rotate-180' : ''} />
479
+ </button>
480
+ <button
481
+ onClick={onClose}
482
+ className="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors"
483
+ title="Close"
484
+ >
485
+ <X size={18} />
486
+ </button>
487
+ </div>
488
+ </div>
489
+
490
+ {/* Search Bar */}
491
+ <div className="flex gap-2 mb-2">
492
+ <div className="flex-1 relative">
493
+ <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-slate-400" size={16} />
494
+ <input
495
+ type="text"
496
+ value={searchTerm}
497
+ onChange={(e) => setSearchTerm(e.target.value)}
498
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
499
+ placeholder="Search..."
500
+ className="w-full bg-slate-800 border border-slate-600 text-white pl-8 pr-8 py-1.5 text-sm rounded-lg focus:ring-2 focus:ring-red-500 outline-none"
501
+ />
502
+ {searchTerm && (
503
+ <button
504
+ onClick={() => {
505
+ setSearchTerm('');
506
+ setOccupation('');
507
+ setNationality('');
508
+ setCurrentPage(0);
509
+ setContinueParam(null);
510
+ if (seedPeople && isPureBrowse()) {
511
+ handleSearch();
512
+ } else {
513
+ fetchPeople('', 0, null);
514
+ }
515
+ }}
516
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-white"
517
+ >
518
+ <X size={14} />
519
+ </button>
520
+ )}
521
+ </div>
522
+ <button
523
+ onClick={() => setShowFilters(!showFilters)}
524
+ className={`px-2 py-1.5 rounded-lg text-sm border ${showFilters
525
+ ? 'bg-slate-700 border-slate-500 text-white'
526
+ : 'bg-slate-800 border-slate-600 text-slate-300 hover:bg-slate-700'
527
+ }`}
528
+ title="Filters"
529
+ >
530
+ <Filter size={14} />
531
+ </button>
532
+ </div>
533
+
534
+ {/* Filters Panel */}
535
+ {showFilters && (
536
+ <div className="bg-slate-800/50 border border-slate-700 rounded-lg p-3 mb-2 text-xs">
537
+ <div className="grid grid-cols-2 gap-3">
538
+ <div>
539
+ <label className="block text-[10px] font-medium text-slate-400 mb-1 uppercase tracking-wider">Occupation</label>
540
+ <select
541
+ value={occupation}
542
+ onChange={(e) => setOccupation(e.target.value)}
543
+ className="w-full bg-slate-700 border border-slate-600 text-white px-2 py-1.5 rounded text-xs focus:ring-1 focus:ring-red-500 outline-none"
544
+ >
545
+ <option value="">Any</option>
546
+ {(availableOccupations.length ? availableOccupations : defaultOccupations).map((occ) => (
547
+ <option key={occ} value={occ}>{occ}</option>
548
+ ))}
549
+ </select>
550
+ </div>
551
+ <div>
552
+ <label className="block text-[10px] font-medium text-slate-400 mb-1 uppercase tracking-wider">Nationality</label>
553
+ <select
554
+ value={nationality}
555
+ onChange={(e) => setNationality(e.target.value)}
556
+ className="w-full bg-slate-700 border border-slate-600 text-white px-2 py-1.5 rounded text-xs focus:ring-1 focus:ring-red-500 outline-none"
557
+ >
558
+ <option value="">Any</option>
559
+ {(availableNationalities.length ? availableNationalities : defaultNationalities).map((nat) => (
560
+ <option key={nat} value={nat}>{nat}</option>
561
+ ))}
562
+ </select>
563
+ </div>
564
+ </div>
565
+ </div>
566
+ )}
567
+ </div>
568
+
569
+ {/* Content - Scrollable */}
570
+ <div className="flex-1 overflow-y-auto p-4">
571
+ {error && (
572
+ <div className="bg-red-900/50 border border-red-700 text-red-200 p-2 rounded-lg mb-3 text-xs">
573
+ {error}
574
+ </div>
575
+ )}
576
+
577
+ {loading && people.length === 0 && (
578
+ <div className="text-center py-8 text-slate-400 text-sm">
579
+ Loading...
580
+ </div>
581
+ )}
582
+
583
+ {!loading && people.length === 0 && (
584
+ <div className="text-center py-8 text-slate-400 text-sm">
585
+ No people found.
586
+ </div>
587
+ )}
588
+
589
+ <div className="space-y-2">
590
+ {people.map((person) => {
591
+ const aspects = extractAspects(person);
592
+ return (
593
+ <div
594
+ key={person.pageid}
595
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg p-3"
596
+ >
597
+ <button
598
+ onClick={() => handleSelect(person.title)}
599
+ className="w-full text-left"
600
+ >
601
+ <div className="flex gap-3">
602
+ {person.thumbnail && (
603
+ <img
604
+ src={person.thumbnail.source}
605
+ alt={person.title}
606
+ className="w-12 h-12 object-cover rounded flex-shrink-0"
607
+ />
608
+ )}
609
+ <div className="flex-1 min-w-0">
610
+ <div className="flex justify-between items-start gap-2">
611
+ <h3 className="font-semibold text-white text-sm mb-1 line-clamp-1">{person.title}</h3>
612
+ <button
613
+ onClick={(e) => {
614
+ e.preventDefault();
615
+ e.stopPropagation();
616
+ setExpandedPersonId(expandedPersonId === person.pageid ? null : person.pageid);
617
+ }}
618
+ className="px-1.5 py-0.5 text-[9px] bg-slate-700 hover:bg-slate-600 rounded text-slate-300 transition-colors shrink-0"
619
+ >
620
+ {expandedPersonId === person.pageid ? 'HIDE' : 'DATA'}
621
+ </button>
622
+ </div>
623
+ {expandedPersonId === person.pageid && (
624
+ <div className="mt-2 p-2 bg-slate-950 rounded border border-slate-700 text-[10px] font-mono shadow-inner">
625
+ <div className="grid grid-cols-2 gap-x-2 gap-y-1 mb-1">
626
+ <div className="text-slate-500">Score: <span className="text-amber-400">{(person.score || 0).toFixed(1)}</span></div>
627
+ <div className="text-slate-500">Gender: <span className="text-slate-300">{person.gender_guess || 'n/a'}</span></div>
628
+ <div className="text-slate-500">Born: <span className="text-slate-300">{person.birth_year || 'n/a'}</span></div>
629
+ <div className="text-slate-500">Status: <span className={person.is_living ? 'text-green-400' : 'text-slate-400'}>{person.is_living ? 'Living' : 'Deceased'}</span></div>
630
+ </div>
631
+ <div className="border-t border-slate-800 pt-1 mt-1">
632
+ <div className="grid grid-cols-2 gap-x-2 gap-y-1 text-[9px]">
633
+ <div className="text-slate-500">Art. Len: <span className="text-slate-300">{(person.article_length || 0).toLocaleString()}</span></div>
634
+ <div className="text-slate-500">Links: <span className="text-slate-300">{(person.incoming_links || 0).toLocaleString()} in</span></div>
635
+ </div>
636
+ </div>
637
+ {aspects.length > 0 && (
638
+ <div className="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-slate-800">
639
+ {aspects.map((aspect, idx) => (
640
+ <button
641
+ key={idx}
642
+ onClick={(e) => {
643
+ e.stopPropagation();
644
+ handleFilterClick(aspect.type, aspect.value);
645
+ }}
646
+ className="px-2 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white rounded text-[9px] border border-slate-700 transition-colors"
647
+ title={`Filter by ${aspect.type}: ${aspect.value}`}
648
+ >
649
+ {aspect.value}
650
+ </button>
651
+ ))}
652
+ </div>
653
+ )}
654
+ </div>
655
+ )}
656
+ {person.extract && (
657
+ <p className="text-xs text-slate-400 line-clamp-2">{person.extract}</p>
658
+ )}
659
+ </div>
660
+ </div>
661
+ </button>
662
+ </div>
663
+ );
664
+ })}
665
+ </div>
666
+
667
+ {hasMore && (
668
+ <div className="text-center mt-4">
669
+ <button
670
+ onClick={handleLoadMore}
671
+ disabled={loading}
672
+ className="px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
673
+ >
674
+ {loading ? 'Loading...' : 'Load More'}
675
+ </button>
676
+ </div>
677
+ )}
678
+
679
+ {people.length > 0 && (
680
+ <div className="text-center mt-3 text-slate-400 text-xs">
681
+ Showing {people.length} {people.length === 1 ? 'person' : 'people'}
682
+ </div>
683
+ )}
684
+ </div>
685
+ </div>
686
+ </div>
687
+ );
688
+ };
689
+
690
+ export default PeopleBrowserSidebar;