← 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,
  },
});