← back to CelebritySignatures
apps/mobile/screens/SignatureDetail.tsx
489 lines
// SignatureDetail — full detail view for one signature.
// The App wraps this in a Modal with its own GlassBar top chrome;
// this component is just the scrollable body content.
// Usage: <SignatureDetail sig={sig} evolution={ev} onOrderMural={(sig) => …} />
import React, { useCallback, useEffect, useState } from 'react';
import {
Image,
Linking,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import * as Haptics from 'expo-haptics';
import { qidOf, sigImg, Signature, Evolution } from '../api';
import { GLASS, GlassPanel, GlassButton, GlassPill } from '../ui/glass';
// ---------------------------------------------------------------------------
// Works loader — tapping a signature opens this detail, which surfaces the
// person's ARTWORK (Artists — free open-museum APIs, tried in order until we
// have 6: The Met → Art Institute of Chicago → Cleveland → Wikidata/Commons)
// or their BOOKS (everyone else — Open Library, keyless). Mirrors the web
// popup's loader in public/index.html.
// ---------------------------------------------------------------------------
type Work = { img: string; title: string; date: string; url: string; src: string };
const WORKS_CACHE: Record<string, Work[]> = {};
const MAX_WORKS = 6;
async function loadArtworks(qid: string | null, name: string): Promise<Work[]> {
const works: Work[] = [];
const last = name.toLowerCase().split(' ').pop() || '';
// 1) The Met
try {
const MET = 'https://collectionapi.metmuseum.org/public/collection/v1';
const s = await (await fetch(`${MET}/search?hasImages=true&artistOrCulture=true&q=${encodeURIComponent(name)}`)).json();
for (const id of (s.objectIDs || []).slice(0, 14)) {
if (works.length >= MAX_WORKS) break;
try {
const o = await (await fetch(`${MET}/objects/${id}`)).json();
if (!o.primaryImageSmall) continue;
if (!(o.artistDisplayName || '').toLowerCase().includes(last)) continue;
works.push({
img: o.primaryImageSmall, title: o.title || 'Untitled', date: o.objectDate || '',
url: o.objectURL || `https://www.metmuseum.org/art/collection/search/${id}`, src: 'The Met',
});
} catch { /* skip object */ }
}
} catch { /* Met unavailable */ }
// 2) Art Institute of Chicago
if (works.length < MAX_WORKS) try {
const j = await (await fetch(
`https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(name)}` +
'&query[term][is_public_domain]=true&fields=id,title,image_id,artist_title,date_display&limit=12',
)).json();
for (const a of (j.data || [])) {
if (works.length >= MAX_WORKS) break;
if (!a.image_id || !(a.artist_title || '').toLowerCase().includes(last)) continue;
works.push({
img: `https://www.artic.edu/iiif/2/${a.image_id}/full/400,/0/default.jpg`,
title: a.title || 'Untitled', date: a.date_display || '',
url: `https://www.artic.edu/artworks/${a.id}`, src: 'Art Institute of Chicago',
});
}
} catch { /* AIC unavailable */ }
// 3) Cleveland Museum of Art
if (works.length < MAX_WORKS) try {
const j = await (await fetch(
`https://openaccess-api.clevelandart.org/api/artworks/?artists=${encodeURIComponent(name)}&has_image=1&limit=10`,
)).json();
for (const a of (j.data || [])) {
if (works.length >= MAX_WORKS) break;
const img = a.images?.web?.url;
if (!img) continue;
if (!(a.creators || []).some((c: any) => (c.description || '').toLowerCase().includes(last))) continue;
works.push({
img, title: a.title || 'Untitled', date: a.creation_date || '',
url: a.url || 'https://www.clevelandart.org/art/collection/search', src: 'Cleveland Museum of Art',
});
}
} catch { /* Cleveland unavailable */ }
// 4) Wikidata works-by-QID (P170 creator + P18 image) — QID-exact fallback
if (qid && works.length < MAX_WORKS) try {
const q = `SELECT ?w ?wLabel ?img ?date WHERE { ?w wdt:P170 wd:${qid} ; wdt:P18 ?img . OPTIONAL { ?w wdt:P571 ?date } SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } } LIMIT ${MAX_WORKS * 2}`;
const j = await (await fetch(
`https://query.wikidata.org/sparql?format=json&query=${encodeURIComponent(q)}`,
{ headers: { Accept: 'application/sparql-results+json' } },
)).json();
for (const b of (j.results?.bindings || [])) {
if (works.length >= MAX_WORKS) break;
const m = (b.img?.value || '').match(/Special:FilePath\/(.+)$/);
if (!m) continue;
works.push({
img: `https://commons.wikimedia.org/wiki/Special:FilePath/${m[1]}?width=400`,
title: b.wLabel?.value || 'Untitled', date: (b.date?.value || '').slice(0, 4),
url: b.w?.value || '', src: 'Wikimedia Commons',
});
}
} catch { /* WDQS unavailable */ }
return works;
}
async function loadBooks(name: string): Promise<Work[]> {
const works: Work[] = [];
try {
const j = await (await fetch(
`https://openlibrary.org/search.json?author=${encodeURIComponent(name)}&sort=editions&limit=10&fields=title,cover_i,first_publish_year,key`,
)).json();
for (const d of (j.docs || [])) {
if (works.length >= MAX_WORKS) break;
if (!d.cover_i) continue;
works.push({
img: `https://covers.openlibrary.org/b/id/${d.cover_i}-M.jpg`,
title: d.title || 'Untitled',
date: d.first_publish_year ? String(d.first_publish_year) : '',
url: d.key ? `https://openlibrary.org${d.key}` : 'https://openlibrary.org',
src: 'Open Library',
});
}
} catch { /* Open Library unavailable */ }
return works;
}
async function loadWorks(sig: Signature): Promise<Work[]> {
const key = qidOf(sig) || sig.full_name;
if (WORKS_CACHE[key]) return WORKS_CACHE[key];
const out = sig.category === 'Artists'
? await loadArtworks(qidOf(sig), sig.full_name)
: await loadBooks(sig.full_name);
WORKS_CACHE[key] = out;
return out;
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function MuseumChips({ museums }: { museums: string[] }) {
return (
<View style={styles.chipWrap}>
{museums.map((m) => (
<GlassPill key={m} style={styles.museumPill}>
<Text style={styles.museumLabel}>{m}</Text>
</GlassPill>
))}
</View>
);
}
function EvolutionReel({
sigs,
}: {
sigs: NonNullable<Evolution[string]>['sigs'];
}) {
// Only render entries that have a URL; year may be null (skip caption then)
const items = sigs.filter((s) => Boolean(s.url));
if (items.length === 0) return null;
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Signature over time</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.reelRow}
>
{items.map((entry, i) => (
<View key={entry.url ?? i} style={styles.reelItem}>
<View style={styles.reelImgBacking}>
<Image
source={{ uri: entry.url }}
style={styles.reelImg}
resizeMode="contain"
/>
</View>
{entry.year != null && (
<Text style={styles.reelYear}>{String(entry.year)}</Text>
)}
</View>
))}
</ScrollView>
</View>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function SignatureDetail({
sig,
evolution,
onOrderMural,
}: {
sig: Signature;
evolution: Evolution | null;
onOrderMural: (sig: Signature) => void;
}) {
const qid = qidOf(sig);
const evEntry = qid && evolution ? evolution[qid] ?? null : null;
// works (artwork or books) — loaded per signature; null = still loading
const [works, setWorks] = useState<Work[] | null>(null);
useEffect(() => {
let cancelled = false;
setWorks(null);
loadWorks(sig).then((w) => { if (!cancelled) setWorks(w); }).catch(() => { if (!cancelled) setWorks([]); });
return () => { cancelled = true; };
}, [sig]);
const handleOrder = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onOrderMural(sig);
}, [sig, onOrderMural]);
return (
<ScrollView
style={styles.root}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Hero signature image on parchment backing */}
<GlassPanel style={styles.heroPanel}>
<View style={styles.heroBacking}>
{sig.signature_image_url ? (
<Image
source={{ uri: sigImg(sig.signature_image_url) }}
style={styles.heroImg}
resizeMode="contain"
/>
) : (
<View style={styles.heroImgPlaceholder}>
<Text style={styles.placeholderText}>No image available</Text>
</View>
)}
</View>
</GlassPanel>
{/* Name + category + death date */}
<View style={styles.nameBlock}>
<Text style={styles.fullName}>{sig.full_name}</Text>
<Text style={styles.subCaption}>
{sig.category}
{sig.death_date ? ` · d. ${sig.death_date}` : ''}
</Text>
</View>
{/* Reason for ranking */}
{Boolean(sig.reason_for_ranking) && (
<View style={styles.section}>
<Text style={styles.reasonText}>{sig.reason_for_ranking}</Text>
</View>
)}
{/* Museums */}
{sig.museums && sig.museums.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>In museum collections</Text>
<MuseumChips museums={sig.museums} />
</View>
)}
{/* Signature evolution reel */}
{evEntry && evEntry.sigs && evEntry.sigs.length > 0 && (
<EvolutionReel sigs={evEntry.sigs} />
)}
{/* Works — the person's artwork (Artists) or books (everyone else) */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{sig.category === 'Artists' ? 'Works — open museum collections' : 'Books'}
</Text>
{works === null ? (
<Text style={styles.creditText}>loading…</Text>
) : works.length === 0 ? (
<Text style={styles.creditText}>
{sig.category === 'Artists'
? 'No openly-licensed works found in the open museum APIs.'
: 'No books found on Open Library.'}
</Text>
) : (
<View style={styles.worksGrid}>
{works.map((w) => (
<Pressable
key={w.url + w.title}
style={styles.workTile}
onPress={() => { Haptics.selectionAsync(); Linking.openURL(w.url).catch(() => {}); }}
>
<Image source={{ uri: w.img }} style={styles.workImg} resizeMode="cover" />
<Text style={styles.workTitle} numberOfLines={2}>
{w.title}{w.date ? ` · ${w.date}` : ''}
</Text>
<Text style={styles.workSrc} numberOfLines={1}>{w.src}</Text>
</Pressable>
))}
</View>
)}
</View>
{/* Image credit */}
{(sig.image_author || sig.image_license) && (
<View style={styles.section}>
<Text style={styles.creditText}>
{[sig.image_author, sig.image_license].filter(Boolean).join(' · ')}
</Text>
</View>
)}
{/* CTA */}
<View style={styles.ctaWrap}>
<GlassButton
label="Order a mural featuring this signature"
onPress={handleOrder}
solid
/>
</View>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
root: {
flex: 1,
},
content: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 48,
gap: 20,
},
// Hero
heroPanel: {
// GlassPanel adds borderRadius + panelBody padding
},
heroBacking: {
backgroundColor: '#ffffff',
borderRadius: 12,
height: 200,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
heroImg: {
width: '100%',
height: '100%',
},
heroImgPlaceholder: {
flex: 1,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
},
placeholderText: {
color: GLASS.textDim,
fontSize: 14,
},
// Name block
nameBlock: {
gap: 6,
},
fullName: {
fontSize: 28,
fontWeight: '800',
color: GLASS.accentSoft,
letterSpacing: 0.1,
lineHeight: 34,
},
subCaption: {
fontSize: 14,
color: GLASS.textDim,
letterSpacing: 0.2,
},
// Sections
section: {
gap: 10,
},
sectionTitle: {
fontSize: 13,
fontWeight: '700',
color: GLASS.accent,
letterSpacing: 0.6,
textTransform: 'uppercase',
},
reasonText: {
fontSize: 15,
lineHeight: 23,
color: GLASS.textDim,
},
// Museum chips
chipWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
museumPill: {
// GlassPill handles its own clip/border
},
museumLabel: {
fontSize: 13,
fontWeight: '600',
color: GLASS.accentSoft,
},
// Evolution reel
reelRow: {
gap: 12,
paddingVertical: 4,
},
reelItem: {
alignItems: 'center',
gap: 6,
},
reelImgBacking: {
width: 120,
height: 80,
backgroundColor: '#ffffff',
borderRadius: 10,
overflow: 'hidden',
borderWidth: StyleSheet.hairlineWidth,
borderColor: GLASS.edgeFaint,
},
reelImg: {
width: '100%',
height: '100%',
},
reelYear: {
fontSize: 12,
color: GLASS.textDim,
letterSpacing: 0.2,
},
// Credit
creditText: {
fontSize: 11,
color: GLASS.textDim,
opacity: 0.7,
letterSpacing: 0.15,
},
// Works grid (artworks / book covers)
worksGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
workTile: {
width: '48%',
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#ffffff',
borderWidth: StyleSheet.hairlineWidth,
borderColor: GLASS.edgeFaint,
paddingBottom: 8,
},
workImg: {
width: '100%',
height: 150,
backgroundColor: 'rgba(0,0,0,0.04)',
},
workTitle: {
fontSize: 12,
fontWeight: '600',
color: GLASS.accentSoft,
lineHeight: 16,
paddingHorizontal: 8,
paddingTop: 6,
},
workSrc: {
fontSize: 10,
color: GLASS.textDim,
paddingHorizontal: 8,
paddingTop: 2,
},
// CTA
ctaWrap: {
marginTop: 4,
},
});