@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,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;
|