@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,832 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { Search, ChevronLeft, ChevronRight, X, Filter } 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 BrowsePeopleProps {
36
+ baseUrl?: string;
37
+ onSelect?: (personName: string) => void;
38
+ exploreTerm?: string;
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 isLikelyPerson = (title: string) => {
53
+ const lower = title.toLowerCase();
54
+ if (lower.startsWith('category:')) return false;
55
+ if (lower.startsWith('talk:')) return false;
56
+ if (lower.startsWith('list of') || lower.startsWith('lists of')) return false;
57
+ if (lower.includes('(disambiguation)')) return false;
58
+ return true;
59
+ };
60
+
61
+ const pickDisplayCategories = (categories: string[] = []) => {
62
+ // Show the most relevant categories (occupations, nationalities, birth years)
63
+ const prioritized = categories.filter((cat) => {
64
+ const lower = cat.toLowerCase();
65
+ return (
66
+ lower.includes('people') ||
67
+ lower.includes('actors') ||
68
+ lower.includes('actresses') ||
69
+ lower.includes('singers') ||
70
+ lower.includes('musicians') ||
71
+ lower.includes('politicians') ||
72
+ lower.includes('scientists') ||
73
+ lower.includes('engineers') ||
74
+ lower.includes('writers') ||
75
+ lower.includes('artists') ||
76
+ /\d{4}\s+births/.test(lower) ||
77
+ lower.includes('from ')
78
+ );
79
+ });
80
+
81
+ if (prioritized.length > 0) {
82
+ return prioritized.slice(0, 4);
83
+ }
84
+
85
+ return categories.slice(0, 4);
86
+ };
87
+
88
+ const BrowsePeople: React.FC<BrowsePeopleProps> = ({ baseUrl = '', onSelect, exploreTerm }) => {
89
+ const [people, setPeople] = useState<Person[]>([]);
90
+ const [loading, setLoading] = useState(false);
91
+ const [error, setError] = useState<string | null>(null);
92
+ const [searchTerm, setSearchTerm] = useState('');
93
+ const [currentPage, setCurrentPage] = useState(0);
94
+ const [continueParam, setContinueParam] = useState<string | null>(null);
95
+ const [hasMore, setHasMore] = useState(true);
96
+ const [occupation, setOccupation] = useState('');
97
+ const [nationality, setNationality] = useState('');
98
+ const [availableOccupations, setAvailableOccupations] = useState<string[]>([]);
99
+ const [availableNationalities, setAvailableNationalities] = useState<string[]>([]);
100
+ const [sortBy, setSortBy] = useState<'title' | 'length' | 'fame'>('length');
101
+ const [showFilters, setShowFilters] = useState(false);
102
+ const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
103
+ const [seedPeople, setSeedPeople] = useState<Person[] | null>(null);
104
+ const [filteredSeedPeople, setFilteredSeedPeople] = useState<Person[] | null>(null);
105
+ const [seedLoaded, setSeedLoaded] = useState(0);
106
+ const [seedTried, setSeedTried] = useState(false);
107
+ const [continueRaw, setContinueRaw] = useState<string | null>(null);
108
+ const [expandedPersonId, setExpandedPersonId] = useState<number | null>(null);
109
+
110
+ const itemsPerPage = 48;
111
+
112
+ // Basic curated lists for dropdowns (can be replaced by server-driven lists later)
113
+ const defaultOccupations = [
114
+ 'Actor', 'Actress', 'Musician', 'Singer', 'Composer', 'Writer', 'Author', 'Poet',
115
+ 'Scientist', 'Physicist', 'Mathematician', 'Engineer', 'Politician', 'President', 'Prime Minister',
116
+ 'Athlete', 'Footballer', 'Basketball player', 'Tennis player',
117
+ 'Artist', 'Painter', 'Sculptor', 'Photographer', 'Director', 'Producer'
118
+ ];
119
+
120
+ const defaultNationalities = [
121
+ 'American', 'British', 'Canadian', 'Australian', 'French', 'German', 'Italian', 'Spanish', 'Russian',
122
+ 'Chinese', 'Japanese', 'Indian', 'Brazilian', 'Mexican', 'Argentine', 'Irish', 'Scottish', 'Welsh',
123
+ 'Dutch', 'Swedish', 'Norwegian', 'Danish', 'Finnish', 'Greek', 'Turkish', 'Egyptian', 'Nigerian',
124
+ 'South African', 'New Zealander', 'Israeli', 'Saudi Arabian', 'Korean', 'Thai', 'Vietnamese', 'Indonesian'
125
+ ];
126
+
127
+ const buildSearchQuery = useCallback(() => {
128
+ const parts: string[] = [];
129
+
130
+ if (searchTerm.trim()) {
131
+ parts.push(searchTerm.trim());
132
+ }
133
+
134
+ if (occupation.trim()) {
135
+ parts.push(occupation.trim());
136
+ }
137
+
138
+ if (nationality.trim()) {
139
+ parts.push(nationality.trim());
140
+ }
141
+
142
+ if (categoryFilter) {
143
+ const clean = categoryFilter.startsWith('Category:')
144
+ ? categoryFilter
145
+ : `Category:${categoryFilter}`;
146
+ parts.push(`incategory:"${clean}"`);
147
+ }
148
+
149
+ if (parts.length === 0) {
150
+ return '';
151
+ }
152
+
153
+ // Add person/biography context if not already present
154
+ const query = parts.join(' ');
155
+ if (!/\b(person|people|biography|biographical)\b/i.test(query)) {
156
+ return query + ' (person OR biography)';
157
+ }
158
+ return query;
159
+ }, [searchTerm, occupation, nationality, categoryFilter]);
160
+
161
+ const enrichPeople = useCallback(async (batch: Person[]) => {
162
+ if (batch.length === 0) return;
163
+
164
+ // Filter out people who already have both thumbnails and extracts to avoid redundant calls
165
+ const toEnrich = batch.filter(p => !p.thumbnail || !p.extract || !p.categories);
166
+ if (toEnrich.length === 0) return;
167
+
168
+ // Wikipedia's exlimit for extracts is 20. We must batch these calls.
169
+ const batchSize = 20;
170
+ for (let i = 0; i < toEnrich.length; i += batchSize) {
171
+ const currentBatch = toEnrich.slice(i, i + batchSize);
172
+ const titles = currentBatch.map(p => p.title).join('|');
173
+ 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`;
174
+
175
+ try {
176
+ const infoRes = await fetch(infoUrl);
177
+ const infoData = await infoRes.json();
178
+ const pages = infoData.query?.pages || {};
179
+ const pagesByTitle = new Map<string, any>();
180
+
181
+ const titleMap = new Map<string, string>();
182
+ if (infoData.query?.normalized) {
183
+ infoData.query.normalized.forEach((n: any) => titleMap.set(n.to, n.from));
184
+ }
185
+ if (infoData.query?.redirects) {
186
+ infoData.query.redirects.forEach((r: any) => titleMap.set(r.to, r.from));
187
+ }
188
+
189
+ Object.values(pages).forEach((page: any) => {
190
+ if (page.title) {
191
+ pagesByTitle.set(page.title, page);
192
+ const originalTitle = titleMap.get(page.title);
193
+ if (originalTitle) pagesByTitle.set(originalTitle, page);
194
+ }
195
+ });
196
+
197
+ setPeople(prev => prev.map(p => {
198
+ const info = pagesByTitle.get(p.title);
199
+ if (!info) return p;
200
+ return {
201
+ ...p,
202
+ pageid: info.pageid || p.pageid,
203
+ thumbnail: info.thumbnail || p.thumbnail,
204
+ extract: info.extract || p.extract,
205
+ pageviews: sumPageViews(info.pageviews),
206
+ categories: (info.categories || []).map((c: any) => cleanCategoryLabel(c.title || '')).filter(Boolean),
207
+ length: info.length || p.length
208
+ };
209
+ }));
210
+ } catch (e) {
211
+ console.warn("Failed to enrich batch", e);
212
+ }
213
+ }
214
+ }, []);
215
+
216
+ const primeFromSeed = useCallback((all: Person[]) => {
217
+ const initial = all.slice(0, itemsPerPage);
218
+ setPeople(initial);
219
+ setSeedLoaded(initial.length);
220
+ setHasMore(all.length > initial.length);
221
+ setLoading(false);
222
+ setError(null);
223
+ }, [itemsPerPage]);
224
+
225
+ const loadMoreSeed = useCallback(async () => {
226
+ const listToUse = filteredSeedPeople || seedPeople;
227
+ if (!listToUse) return;
228
+ const nextBatchEnd = Math.min(seedLoaded + itemsPerPage, listToUse.length);
229
+ const nextBatch = listToUse.slice(seedLoaded, nextBatchEnd);
230
+
231
+ // Add them immediately as placeholders
232
+ setPeople(prev => [...prev, ...nextBatch]);
233
+ setSeedLoaded(nextBatchEnd);
234
+ setHasMore(nextBatchEnd < listToUse.length);
235
+
236
+ // Use the reusable enrichment function
237
+ enrichPeople(nextBatch);
238
+ }, [itemsPerPage, seedLoaded, seedPeople, filteredSeedPeople, enrichPeople]);
239
+
240
+ const fetchPeople = useCallback(async (search: string, offset: number = 0, continueToken: string | null = null, categoryOverride?: string | null, continueRawToken?: string | null) => {
241
+ setLoading(true);
242
+ setError(null);
243
+
244
+ try {
245
+ let url: string;
246
+ const categoryToUse = categoryOverride !== undefined ? categoryOverride : categoryFilter;
247
+
248
+ if (search.trim()) {
249
+ // Use search API for searching
250
+ url = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(search)}&srlimit=${itemsPerPage}&srprop=size|wordcount|timestamp&sroffset=${offset}&srnamespace=0&origin=*`;
251
+ } else {
252
+ // Use category members API for browsing via generator (single call)
253
+ // Default to a broad people category to ensure results
254
+ const baseCategory = categoryToUse || 'Category:Living people';
255
+ const category = baseCategory.startsWith('Category:') ? baseCategory : `Category:${baseCategory}`;
256
+ const continuePart = continueToken ? `&gcmcontinue=${encodeURIComponent(continueToken)}` : '';
257
+ const continueRawPart = continueRawToken ? `&continue=${encodeURIComponent(continueRawToken)}` : '';
258
+ url = `https://en.wikipedia.org/w/api.php?action=query&format=json&generator=categorymembers&gcmtitle=${encodeURIComponent(category)}&gcmlimit=${itemsPerPage}&gcmtype=page&gcmnamespace=0&prop=info|pageimages|extracts|pageviews|categories&inprop=displaytitle&pithumbsize=150&exintro&explaintext&exlimit=max&exchars=500&cllimit=20&clshow=!hidden&pvipdays=60${continuePart}${continueRawPart}&origin=*`;
259
+ }
260
+
261
+ const response = await fetch(url);
262
+ const data = await response.json();
263
+
264
+ if (data.error) {
265
+ throw new Error(data.error.info || 'Wikipedia API error');
266
+ }
267
+
268
+ let results: Person[] = [];
269
+ let nextContinue: string | null = null;
270
+
271
+ if (search.trim()) {
272
+ // Handle search results
273
+ const searchResults = (data.query?.search || []).filter((item: any) => isLikelyPerson(item.title));
274
+ results = searchResults.map((item: any) => ({
275
+ title: item.title,
276
+ pageid: item.pageid,
277
+ extract: item.snippet,
278
+ pageviews: 0,
279
+ }));
280
+
281
+ // Fetch thumbnails, extracts, pageviews, and categories
282
+ if (results.length > 0) {
283
+ const titles = results.map((item: any) => item.title).join('|');
284
+ 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=*`;
285
+ const infoResponse = await fetch(infoUrl);
286
+ const infoData = await infoResponse.json();
287
+ const pages = infoData.query?.pages || {};
288
+
289
+ const pagesByTitle = new Map<string, any>();
290
+ Object.values(pages).forEach((page: any) => {
291
+ if (page.title) {
292
+ pagesByTitle.set(page.title, page);
293
+ }
294
+ });
295
+
296
+ results = results
297
+ .map((item: any) => {
298
+ const page = pagesByTitle.get(item.title);
299
+ const categories = (page?.categories || []).map((c: any) => cleanCategoryLabel(c.title || '')).filter(Boolean);
300
+ return {
301
+ ...item,
302
+ thumbnail: page?.thumbnail,
303
+ extract: page?.extract || item.extract,
304
+ pageviews: sumPageViews(page?.pageviews),
305
+ categories,
306
+ length: page?.length || 0,
307
+ };
308
+ })
309
+ .filter((item: Person) => isLikelyPerson(item.title));
310
+ }
311
+
312
+ // Check if there are more results
313
+ setHasMore(data.query?.searchinfo?.totalhits > offset + itemsPerPage);
314
+ setContinueParam(null);
315
+ } else {
316
+ // Handle category members (single-call generator)
317
+ const pages = data.query?.pages || {};
318
+ const pageList = Object.values(pages) as any[];
319
+ results = pageList
320
+ .filter((page: any) => page.ns === 0 && isLikelyPerson(page.title))
321
+ .map((page: any) => {
322
+ const categories = (page?.categories || []).map((c: any) => cleanCategoryLabel(c.title || '')).filter(Boolean);
323
+ const extractText = page?.extract || '';
324
+ const trimmedExtract = extractText.length > 200 ? `${extractText.substring(0, 200)}...` : extractText;
325
+ return {
326
+ title: page.title,
327
+ pageid: page.pageid,
328
+ thumbnail: page.thumbnail,
329
+ extract: trimmedExtract,
330
+ pageviews: sumPageViews(page.pageviews),
331
+ categories,
332
+ _index: page.index || 0,
333
+ length: page.length || 0,
334
+ };
335
+ })
336
+ .sort((a: any, b: any) => {
337
+ const lengthDiff = (b.length || 0) - (a.length || 0);
338
+ if (lengthDiff !== 0) return lengthDiff;
339
+ const fame = (b.pageviews || 0) - (a.pageviews || 0);
340
+ if (fame !== 0) return fame;
341
+ const summaryDiff = (b.extract?.length || 0) - (a.extract?.length || 0);
342
+ if (summaryDiff !== 0) return summaryDiff;
343
+ return a.title.localeCompare(b.title);
344
+ });
345
+
346
+ // Set continue parameters for pagination
347
+ nextContinue = data.continue?.gcmcontinue || null;
348
+ const nextRaw = data.continue?.continue || null;
349
+ setHasMore(!!nextContinue);
350
+ setContinueParam(nextContinue);
351
+ setContinueRaw(nextRaw);
352
+ }
353
+
354
+ if (offset === 0) {
355
+ setPeople(results);
356
+ } else {
357
+ setPeople(prev => [...prev, ...results]);
358
+ }
359
+ } catch (err: any) {
360
+ console.error('Error fetching people:', err);
361
+ setError(err.message || 'Failed to fetch people');
362
+ } finally {
363
+ setLoading(false);
364
+ }
365
+ }, [itemsPerPage, categoryFilter]);
366
+
367
+ // Initial load - prefer local top-biographies dataset; fallback to live fetch
368
+ useEffect(() => {
369
+ const loadSeed = async () => {
370
+ if (seedTried) return;
371
+ setSeedTried(true);
372
+ try {
373
+ setLoading(true);
374
+ // Use the new top people list provided by the user
375
+ const res = await fetch('/simplewiki_top_people.json', { cache: 'no-cache' });
376
+ if (!res.ok) throw new Error('seed not found');
377
+ const data = await res.json();
378
+ if (Array.isArray(data) && data.length > 0) {
379
+ // Transform simple list to Person interface, ensuring pageid exists for React keys
380
+ const transformed = data.map((item: any, idx: number) => ({
381
+ ...item,
382
+ pageid: item.pageid || -(idx + 1), // Temporary ID if missing
383
+ extract: item.extract || '',
384
+ originalIndex: idx // Capture importance from JSON order
385
+ }));
386
+ setSeedPeople(transformed);
387
+
388
+ // Extract occupations and nationalities from keywords
389
+ const occMap = new Map<string, number>();
390
+ const natMap = new Map<string, number>();
391
+ transformed.forEach(p => {
392
+ (p.occupation_keywords || []).forEach((o: string) => {
393
+ const formatted = o.charAt(0).toUpperCase() + o.slice(1);
394
+ occMap.set(formatted, (occMap.get(formatted) || 0) + 1);
395
+ });
396
+ (p.nationality_keywords || []).forEach((n: string) => {
397
+ const formatted = n.charAt(0).toUpperCase() + n.slice(1);
398
+ natMap.set(formatted, (natMap.get(formatted) || 0) + 1);
399
+ });
400
+ });
401
+
402
+ // Sort by frequency and take top ones
403
+ setAvailableOccupations(Array.from(occMap.entries())
404
+ .sort((a, b) => b[1] - a[1])
405
+ .map(e => e[0]));
406
+ setAvailableNationalities(Array.from(natMap.entries())
407
+ .sort((a, b) => b[1] - a[1])
408
+ .map(e => e[0]));
409
+
410
+ setFilteredSeedPeople(transformed);
411
+
412
+ // Enrich the first batch immediately
413
+ const firstBatch = transformed.slice(0, itemsPerPage);
414
+ setPeople(firstBatch);
415
+ setSeedLoaded(firstBatch.length);
416
+ setHasMore(transformed.length > firstBatch.length);
417
+
418
+ enrichPeople(firstBatch);
419
+
420
+ setLoading(false);
421
+ setError(null);
422
+ return;
423
+ }
424
+ throw new Error('seed empty');
425
+ } catch (err) {
426
+ // Fallback to live fetch
427
+ fetchPeople('', 0, null);
428
+ }
429
+ };
430
+ loadSeed();
431
+ // eslint-disable-next-line react-hooks/exhaustive-deps
432
+ }, [seedTried, fetchPeople]);
433
+
434
+ // Immediate filtering when inputs change
435
+ useEffect(() => {
436
+ // Only use immediate filtering if we have seed data.
437
+ // Wikipedia API search should still require Enter/Button.
438
+ if (seedPeople) {
439
+ handleSearch();
440
+ }
441
+ }, [occupation, nationality, categoryFilter, sortBy, searchTerm]);
442
+
443
+ const handleSearch = () => {
444
+ setCurrentPage(0);
445
+ setContinueParam(null);
446
+ setHasMore(true);
447
+
448
+ // Use seed if we have it, even if there is a search term
449
+ if (seedPeople) {
450
+ let filtered = [...seedPeople];
451
+
452
+ if (searchTerm.trim()) {
453
+ const lowerSearch = searchTerm.toLowerCase().trim();
454
+ filtered = filtered.filter(p =>
455
+ p.title.toLowerCase().includes(lowerSearch) ||
456
+ (p.occupation_keywords || []).some((o: string) => o.toLowerCase().includes(lowerSearch)) ||
457
+ (p.nationality_keywords || []).some((n: string) => n.toLowerCase().includes(lowerSearch))
458
+ );
459
+ }
460
+
461
+ if (occupation) {
462
+ filtered = filtered.filter(p =>
463
+ (p.occupation_keywords || []).some((o: string) => o.toLowerCase() === occupation.toLowerCase())
464
+ );
465
+ }
466
+
467
+ if (nationality) {
468
+ filtered = filtered.filter(p =>
469
+ (p.nationality_keywords || []).some((n: string) => n.toLowerCase() === nationality.toLowerCase())
470
+ );
471
+ }
472
+
473
+ setFilteredSeedPeople(filtered);
474
+ const initial = filtered.slice(0, itemsPerPage);
475
+ setPeople(initial);
476
+ setSeedLoaded(initial.length);
477
+ setHasMore(filtered.length > initial.length);
478
+
479
+ // Re-trigger enrichment for the filtered set
480
+ enrichPeople(initial);
481
+
482
+ // If we found results in our seed, we're done.
483
+ // If no results in seed and user typed a search term, fall back to Wikipedia search.
484
+ if (filtered.length > 0 || !searchTerm.trim()) {
485
+ return;
486
+ }
487
+ }
488
+
489
+ // Fallback to Wikipedia API
490
+ const query = buildSearchQuery();
491
+ const browsingCategoryOnly = !!categoryFilter && !searchTerm.trim() && !occupation.trim() && !nationality.trim();
492
+ if (browsingCategoryOnly) {
493
+ fetchPeople('', 0, null, categoryFilter);
494
+ } else {
495
+ fetchPeople(query, 0, null);
496
+ }
497
+ };
498
+
499
+ const handleCategoryClick = (category: string) => {
500
+ const normalized = category.startsWith('Category:') ? category : `Category:${category}`;
501
+ setCategoryFilter(normalized);
502
+ setSearchTerm('');
503
+ setOccupation('');
504
+ setNationality('');
505
+ setCurrentPage(0);
506
+ setContinueParam(null);
507
+ setHasMore(true);
508
+ fetchPeople('', 0, null, normalized);
509
+ };
510
+
511
+ const handleLoadMore = () => {
512
+ const nextOffset = (currentPage + 1) * itemsPerPage;
513
+ const query = buildSearchQuery();
514
+ const browsingCategoryOnly = !!categoryFilter && !searchTerm.trim() && !occupation.trim() && !nationality.trim();
515
+ if (seedPeople) {
516
+ loadMoreSeed();
517
+ } else if (browsingCategoryOnly) {
518
+ fetchPeople('', 0, continueParam, categoryFilter, continueRaw);
519
+ } else if (query.trim()) {
520
+ fetchPeople(query, nextOffset, null);
521
+ } else {
522
+ fetchPeople('', 0, continueParam, undefined, continueRaw);
523
+ }
524
+ setCurrentPage(prev => prev + 1);
525
+ };
526
+
527
+ const getAppLink = (personTitle: string) => {
528
+ const params = new URLSearchParams({ q: personTitle });
529
+ return `${baseUrl}?${params.toString()}`;
530
+ };
531
+
532
+ const sortedPeople = [...people].sort((a, b) => {
533
+ if (sortBy === 'title') {
534
+ return a.title.localeCompare(b.title);
535
+ }
536
+ // Default sort: Use importance from seed JSON (originalIndex) if available
537
+ if (sortBy === 'fame' || sortBy === 'length') {
538
+ if (a.originalIndex !== undefined && b.originalIndex !== undefined) {
539
+ return a.originalIndex - b.originalIndex;
540
+ }
541
+ }
542
+ if (sortBy === 'length') {
543
+ const lenDiff = (b.article_length || 0) - (a.article_length || 0);
544
+ if (lenDiff !== 0) return lenDiff;
545
+ }
546
+ const scoreDiff = (b.score || 0) - (a.score || 0);
547
+ if (scoreDiff !== 0) return scoreDiff;
548
+ const textDiff = (b.extract?.length || 0) - (a.extract?.length || 0);
549
+ return textDiff;
550
+ });
551
+
552
+ return (
553
+ <div className="h-full bg-slate-900 text-white overflow-y-auto">
554
+ <main className="max-w-7xl mx-auto p-4 pt-20">
555
+ <div className="flex flex-col gap-4 mb-8">
556
+ <div className="flex items-center justify-between">
557
+ <h1 className="text-2xl font-bold">People</h1>
558
+ <div className="flex gap-2">
559
+ <button
560
+ onClick={() => setShowFilters(!showFilters)}
561
+ className={`px-4 py-2 rounded-lg font-medium border text-sm ${
562
+ showFilters
563
+ ? 'bg-slate-700 border-slate-500 text-white'
564
+ : 'bg-slate-800 border-slate-600 text-slate-300 hover:bg-slate-700'
565
+ }`}
566
+ >
567
+ <Filter size={16} className="inline mr-2" />
568
+ Filters
569
+ </button>
570
+ </div>
571
+ </div>
572
+
573
+ {/* Search Bar */}
574
+ <div className="flex gap-2">
575
+ <div className="flex-1 relative">
576
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" size={20} />
577
+ <input
578
+ type="text"
579
+ value={searchTerm}
580
+ onChange={(e) => setSearchTerm(e.target.value)}
581
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
582
+ placeholder="Search for people..."
583
+ className="w-full bg-slate-800 border border-slate-600 text-white pl-10 pr-4 py-2 rounded-lg focus:ring-2 focus:ring-red-500 outline-none"
584
+ />
585
+ {searchTerm && (
586
+ <button
587
+ onClick={() => {
588
+ setSearchTerm('');
589
+ setOccupation('');
590
+ setNationality('');
591
+ setCurrentPage(0);
592
+ setContinueParam(null);
593
+ if (seedPeople && isPureBrowse()) {
594
+ handleSearch();
595
+ } else {
596
+ fetchPeople('', 0, null);
597
+ }
598
+ }}
599
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-white"
600
+ >
601
+ <X size={18} />
602
+ </button>
603
+ )}
604
+ </div>
605
+ <button
606
+ onClick={handleSearch}
607
+ disabled={loading}
608
+ className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
609
+ >
610
+ Search
611
+ </button>
612
+ </div>
613
+
614
+ {/* Filters Panel */}
615
+ {showFilters && (
616
+ <div className="bg-slate-800/50 border border-slate-700 rounded-lg p-4">
617
+ <div className="flex flex-wrap gap-4 items-end">
618
+ <div className="flex-1 min-w-[200px]">
619
+ <label className="block text-sm font-medium text-slate-300 mb-1">Occupation</label>
620
+ <select
621
+ value={occupation}
622
+ onChange={(e) => setOccupation(e.target.value)}
623
+ className="w-full bg-slate-700 border border-slate-600 text-white px-3 py-2 rounded focus:ring-2 focus:ring-red-500 outline-none"
624
+ >
625
+ <option value="">Any</option>
626
+ {(availableOccupations.length ? availableOccupations : defaultOccupations).map((occ) => (
627
+ <option key={occ} value={occ}>{occ}</option>
628
+ ))}
629
+ </select>
630
+ </div>
631
+ <div className="flex-1 min-w-[200px]">
632
+ <label className="block text-sm font-medium text-slate-300 mb-1">Nationality</label>
633
+ <select
634
+ value={nationality}
635
+ onChange={(e) => setNationality(e.target.value)}
636
+ className="w-full bg-slate-700 border border-slate-600 text-white px-3 py-2 rounded focus:ring-2 focus:ring-red-500 outline-none"
637
+ >
638
+ <option value="">Any</option>
639
+ {(availableNationalities.length ? availableNationalities : defaultNationalities).map((nat) => (
640
+ <option key={nat} value={nat}>{nat}</option>
641
+ ))}
642
+ </select>
643
+ </div>
644
+ <div className="flex flex-col gap-1">
645
+ <label className="block text-sm font-medium text-slate-300 mb-1">Sort By</label>
646
+ <div className="flex gap-2">
647
+ <button
648
+ onClick={() => setSortBy('length')}
649
+ className={`px-3 py-2 rounded ${
650
+ sortBy === 'length'
651
+ ? 'bg-red-600 text-white'
652
+ : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
653
+ }`}
654
+ >
655
+ Importance
656
+ </button>
657
+ <button
658
+ onClick={() => setSortBy('title')}
659
+ className={`px-3 py-2 rounded ${
660
+ sortBy === 'title'
661
+ ? 'bg-red-600 text-white'
662
+ : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
663
+ }`}
664
+ >
665
+ Title A-Z
666
+ </button>
667
+ </div>
668
+ </div>
669
+ </div>
670
+ </div>
671
+ )}
672
+ </div>
673
+ {(categoryFilter || searchTerm || occupation || nationality) && (
674
+ <div className="flex flex-wrap items-center gap-2 mb-4">
675
+ {categoryFilter && (
676
+ <span className="inline-flex items-center gap-2 bg-slate-800 border border-slate-700 text-sm px-3 py-1 rounded-full">
677
+ Category: {cleanCategoryLabel(categoryFilter)}
678
+ <button
679
+ onClick={() => {
680
+ setCategoryFilter(null);
681
+ setCurrentPage(0);
682
+ setContinueParam(null);
683
+ if (seedPeople && isPureBrowse()) {
684
+ handleSearch();
685
+ } else {
686
+ fetchPeople('', 0, null, null);
687
+ }
688
+ }}
689
+ className="text-slate-400 hover:text-white"
690
+ title="Clear category"
691
+ >
692
+ <X size={14} />
693
+ </button>
694
+ </span>
695
+ )}
696
+ </div>
697
+ )}
698
+
699
+ {error && (
700
+ <div className="bg-red-900/50 border border-red-700 text-red-200 p-4 rounded-lg mb-4">
701
+ Error: {error}
702
+ </div>
703
+ )}
704
+
705
+ {loading && people.length === 0 && (
706
+ <div className="text-center py-12 text-slate-400">
707
+ Loading...
708
+ </div>
709
+ )}
710
+
711
+ {!loading && people.length === 0 && (
712
+ <div className="text-center py-12 text-slate-400">
713
+ No people found. Try a different search term.
714
+ </div>
715
+ )}
716
+
717
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
718
+ {sortedPeople.map((person) => (
719
+ <div
720
+ key={person.pageid}
721
+ onClick={() => {
722
+ if (onSelect) {
723
+ onSelect(person.title);
724
+ } else {
725
+ window.location.href = getAppLink(person.title);
726
+ }
727
+ }}
728
+ className="bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg p-4 transition-colors block cursor-pointer"
729
+ >
730
+ <div className="flex gap-4">
731
+ {person.thumbnail && (
732
+ <img
733
+ src={person.thumbnail.source}
734
+ alt={person.title}
735
+ className="w-20 h-20 object-cover rounded flex-shrink-0"
736
+ />
737
+ )}
738
+ <div className="flex-1 min-w-0">
739
+ <div className="flex justify-between items-start gap-2">
740
+ <h3 className="font-semibold text-white mb-1 line-clamp-2">{person.title}</h3>
741
+ <button
742
+ onClick={(e) => {
743
+ e.preventDefault();
744
+ e.stopPropagation();
745
+ setExpandedPersonId(expandedPersonId === person.pageid ? null : person.pageid);
746
+ }}
747
+ className="px-2 py-0.5 text-[10px] bg-slate-700 hover:bg-slate-600 rounded text-slate-300 transition-colors shrink-0"
748
+ >
749
+ {expandedPersonId === person.pageid ? 'HIDE DATA' : 'DATA'}
750
+ </button>
751
+ </div>
752
+ {expandedPersonId === person.pageid && (
753
+ <div className="mt-2 p-3 bg-slate-950 rounded-lg border border-slate-700 text-[11px] font-mono shadow-inner">
754
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1 mb-2">
755
+ <div className="text-slate-500">Score: <span className="text-amber-400">{(person.score || 0).toFixed(2)}</span></div>
756
+ <div className="text-slate-500">Gender: <span className="text-slate-300">{person.gender_guess || 'n/a'}</span></div>
757
+ <div className="text-slate-500">Born: <span className="text-slate-300">{person.birth_year || 'n/a'} ({person.century_birth || 'n/a'})</span></div>
758
+ <div className="text-slate-500">Status: <span className={person.is_living ? 'text-green-400' : 'text-slate-400'}>{person.is_living ? 'Living' : `Died ${person.death_year || 'n/a'}`}</span></div>
759
+ <div className="text-slate-500">Active Est: <span className="text-slate-300">{person.years_active_estimate || 0} years</span></div>
760
+ <div className="text-slate-500">Infobox: <span className="text-slate-300">{person.has_infobox ? 'Yes' : 'No'}</span></div>
761
+ </div>
762
+ <div className="border-t border-slate-800 pt-2 mt-2">
763
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1">
764
+ <div className="text-slate-500">Article Len: <span className="text-slate-300">{(person.article_length || 0).toLocaleString()}</span></div>
765
+ <div className="text-slate-500">Lead Len: <span className="text-slate-300">{(person.lead_paragraph_length || 0).toLocaleString()}</span></div>
766
+ <div className="text-slate-500">Links In: <span className="text-slate-300">{(person.incoming_links || 0).toLocaleString()}</span></div>
767
+ <div className="text-slate-500">Links Out: <span className="text-slate-300">{(person.outgoing_links || 0).toLocaleString()}</span></div>
768
+ <div className="text-slate-500">Density: <span className="text-slate-300">{(person.internal_link_density || 0).toFixed(4)}</span></div>
769
+ </div>
770
+ </div>
771
+ {(person.occupation_keywords?.length || 0) > 0 && (
772
+ <div className="mt-2 text-slate-500">
773
+ Keywords: <span className="text-indigo-300">{person.occupation_keywords?.join(', ')}</span>
774
+ </div>
775
+ )}
776
+ {person.categories && person.categories.length > 0 && (
777
+ <div className="mt-2 pt-2 border-t border-slate-800">
778
+ <div className="text-slate-500 mb-1">Categories:</div>
779
+ <div className="flex flex-wrap gap-2">
780
+ {pickDisplayCategories(person.categories).map((cat) => (
781
+ <button
782
+ key={cat}
783
+ onClick={(e) => {
784
+ e.preventDefault();
785
+ e.stopPropagation();
786
+ handleCategoryClick(cat);
787
+ }}
788
+ className="px-2 py-1 rounded-full bg-slate-800 hover:bg-slate-700 text-[10px] text-slate-300 border border-slate-700"
789
+ >
790
+ {cleanCategoryLabel(cat)}
791
+ </button>
792
+ ))}
793
+ </div>
794
+ </div>
795
+ )}
796
+ </div>
797
+ )}
798
+ {person.extract && (
799
+ <p className="text-sm text-slate-400 line-clamp-3">{person.extract}</p>
800
+ )}
801
+ </div>
802
+ </div>
803
+ </div>
804
+ ))}
805
+ </div>
806
+
807
+ {hasMore && (
808
+ <div className="text-center mt-8">
809
+ <button
810
+ onClick={handleLoadMore}
811
+ disabled={loading}
812
+ className="px-6 py-3 bg-slate-800 hover:bg-slate-700 border border-slate-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
813
+ >
814
+ {loading ? 'Loading...' : 'Load More'}
815
+ </button>
816
+ </div>
817
+ )}
818
+
819
+ {people.length > 0 && (
820
+ <div className="text-center mt-4 text-slate-400 text-sm">
821
+ Showing {people.length} {people.length === 1 ? 'person' : 'people'}
822
+ {(searchTerm || occupation || nationality) && (
823
+ <span> for "{buildSearchQuery().replace(/\s*\(person OR biography\)/i, '')}"</span>
824
+ )}
825
+ </div>
826
+ )}
827
+ </main>
828
+ </div>
829
+ );
830
+ };
831
+
832
+ export default BrowsePeople;