@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.
- package/App.tsx +480 -0
- package/FullPageConstellations.tsx +74 -0
- package/FullPageConstellationsHostShell.tsx +27 -0
- package/README.md +116 -0
- package/components/AppConfirmDialog.tsx +46 -0
- package/components/AppHeader.tsx +73 -0
- package/components/AppNotifications.tsx +21 -0
- package/components/BrowsePeople.tsx +832 -0
- package/components/ControlPanel.tsx +1023 -0
- package/components/Graph.tsx +1525 -0
- package/components/HelpOverlay.tsx +168 -0
- package/components/NodeContextMenu.tsx +160 -0
- package/components/PeopleBrowserSidebar.tsx +690 -0
- package/components/Sidebar.tsx +271 -0
- package/components/TimelineView.tsx +4 -0
- package/hooks/useExpansion.ts +889 -0
- package/hooks/useGraphActions.ts +325 -0
- package/hooks/useGraphState.ts +414 -0
- package/hooks/useKioskMode.ts +47 -0
- package/hooks/useNodeClickHandler.ts +172 -0
- package/hooks/useSearchHandlers.ts +369 -0
- package/host.ts +16 -0
- package/index.css +101 -0
- package/index.tsx +16 -0
- package/kioskDomains.ts +307 -0
- package/package.json +78 -0
- package/services/aiUtils.ts +364 -0
- package/services/cacheService.ts +76 -0
- package/services/crossrefService.ts +107 -0
- package/services/geminiService.ts +1359 -0
- package/services/get-local-graphs.js +5 -0
- package/services/graphUtils.ts +347 -0
- package/services/imageService.ts +39 -0
- package/services/llmClient.ts +194 -0
- package/services/openAlexService.ts +173 -0
- package/services/wikipediaImage.ts +40 -0
- package/services/wikipediaService.ts +1175 -0
- package/sessionHandoff.ts +132 -0
- package/types.ts +99 -0
- package/useFullPageConstellationsHost.ts +116 -0
- package/utils/evidenceUtils.ts +107 -0
- package/utils/graphLogicUtils.ts +32 -0
- package/utils/graphNodeToChannelNotes.ts +71 -0
- 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;
|