← back to CelebritySignatures

apps/mobile/screens/GalleryScreen.tsx

372 lines

// GalleryScreen — 2-column signature grid with category filter chips + search.
// Rendered inside App's GlassScene, so this file does NOT wrap itself in one.
// Usage: <GalleryScreen onOpen={(sig) => openDetail(sig)} />
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  ActivityIndicator,
  FlatList,
  Image,
  ListRenderItemInfo,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import * as Haptics from 'expo-haptics';

import { api, qidOf, sigImg, Signature } from '../api';
import { GLASS, GlassPanel, GlassPill, GlassButton } from '../ui/glass';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type LoadState = 'idle' | 'loading' | 'ok' | 'error';

// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------

function CategoryBar({
  categories,
  selected,
  onSelect,
}: {
  categories: string[];
  selected: string;
  onSelect: (cat: string) => void;
}) {
  const all = ['All', ...categories];
  return (
    <ScrollView
      horizontal
      showsHorizontalScrollIndicator={false}
      contentContainerStyle={styles.catRow}
    >
      {all.map((cat) => {
        const active = cat === selected;
        return (
          <Pressable
            key={cat}
            onPress={() => {
              Haptics.selectionAsync();
              onSelect(cat);
            }}
            style={styles.catPressable}
          >
            <GlassPill tintColor={active ? GLASS.accent : undefined}>
              <Text
                style={[styles.catLabel, active && styles.catLabelActive]}
                numberOfLines={1}
              >
                {cat}
              </Text>
            </GlassPill>
          </Pressable>
        );
      })}
    </ScrollView>
  );
}

function SigCard({
  sig,
  onOpen,
}: {
  sig: Signature;
  onOpen: (sig: Signature) => void;
}) {
  return (
    <Pressable
      onPress={() => {
        Haptics.selectionAsync();
        onOpen(sig);
      }}
      style={styles.cardPressable}
    >
      <GlassPanel style={styles.card}>
        {/* solid white image backing — signatures are dark ink on transparent,
            so anything but white renders them invisible */}
        <View style={styles.imgBacking}>
          {sig.signature_image_url ? (
            <Image
              source={{ uri: sigImg(sig.signature_image_url) }}
              style={styles.img}
              resizeMode="contain"
            />
          ) : (
            <View style={styles.imgPlaceholder} />
          )}
        </View>
        <Text style={styles.cardName} numberOfLines={2}>
          {sig.full_name}
        </Text>
        <Text style={styles.cardCat} numberOfLines={1}>
          {sig.category}
        </Text>
      </GlassPanel>
    </Pressable>
  );
}

// ---------------------------------------------------------------------------
// Main screen
// ---------------------------------------------------------------------------

export default function GalleryScreen({ onOpen }: { onOpen: (sig: Signature) => void }) {
  const [loadState, setLoadState] = useState<LoadState>('idle');
  const [signatures, setSignatures] = useState<Signature[]>([]);
  const [selectedCat, setSelectedCat] = useState<string>('All');
  const [query, setQuery] = useState<string>('');

  const load = useCallback(async () => {
    setLoadState('loading');
    try {
      const sigs = await api.signatures();
      setSignatures(sigs);
      setLoadState('ok');
    } catch {
      setLoadState('error');
    }
  }, []);

  useEffect(() => { load(); }, [load]);

  // Distinct ordered category list
  const categories = useMemo<string[]>(() => {
    const seen = new Set<string>();
    const out: string[] = [];
    for (const s of signatures) {
      if (!seen.has(s.category)) { seen.add(s.category); out.push(s.category); }
    }
    return out.sort();
  }, [signatures]);

  // Filtered list
  const filtered = useMemo<Signature[]>(() => {
    let list = selectedCat === 'All'
      ? signatures
      : signatures.filter((s) => s.category === selectedCat);

    const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
    if (terms.length > 0) {
      list = list.filter((s) => {
        const hay = s.full_name.toLowerCase();
        return terms.every((t) => hay.includes(t));
      });
    }
    return list;
  }, [signatures, selectedCat, query]);

  // Stable keyExtractor
  const keyExtractor = useCallback(
    (item: Signature, index: number) => qidOf(item) ?? `${item.full_name}-${index}`,
    [],
  );

  // getItemLayout for fixed-height cards (card = imgBacking 110 + name ~40 + cat ~20 + padding)
  // GlassPanel panelBody padding=18 top+bottom=36; imgBacking=110; name=40; cat=20 → ~206 + card margins
  const CARD_HEIGHT = 216;
  const CARD_MARGIN = 6;
  const ITEM_HEIGHT = CARD_HEIGHT + CARD_MARGIN * 2;
  const getItemLayout = useCallback(
    (_: ArrayLike<Signature> | null | undefined, index: number) => ({
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * Math.floor(index / 2),
      index,
    }),
    [ITEM_HEIGHT],
  );

  const renderItem = useCallback(
    ({ item }: ListRenderItemInfo<Signature>) => <SigCard sig={item} onOpen={onOpen} />,
    [onOpen],
  );

  // ---------------------------------------------------------------------------
  // Render states
  // ---------------------------------------------------------------------------

  if (loadState === 'loading' || loadState === 'idle') {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" color={GLASS.accent} />
        <Text style={styles.loadingText}>Loading signatures…</Text>
      </View>
    );
  }

  if (loadState === 'error') {
    return (
      <View style={styles.center}>
        <Text style={styles.errorText}>Could not load signatures.</Text>
        <View style={styles.retryBtn}>
          <GlassButton label="Retry" onPress={load} />
        </View>
      </View>
    );
  }

  return (
    <View style={styles.root}>
      {/* Search bar */}
      <View style={styles.searchWrap}>
        <TextInput
          style={styles.searchInput}
          placeholder="Search a name…"
          placeholderTextColor={GLASS.textDim}
          value={query}
          onChangeText={setQuery}
          clearButtonMode="while-editing"
          autoCorrect={false}
          autoCapitalize="none"
          returnKeyType="search"
        />
      </View>

      {/* Category chips */}
      <CategoryBar
        categories={categories}
        selected={selectedCat}
        onSelect={setSelectedCat}
      />

      {/* Signature grid */}
      <FlatList<Signature>
        data={filtered}
        keyExtractor={keyExtractor}
        renderItem={renderItem}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.listContent}
        initialNumToRender={10}
        windowSize={7}
        removeClippedSubviews
        getItemLayout={getItemLayout}
        showsVerticalScrollIndicator={false}
        ListEmptyComponent={
          <View style={styles.center}>
            <Text style={styles.emptyText}>No signatures match your filter.</Text>
          </View>
        }
      />
    </View>
  );
}

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

const styles = StyleSheet.create({
  root: {
    flex: 1,
  },
  center: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 32,
  },
  loadingText: {
    marginTop: 16,
    color: GLASS.textDim,
    fontSize: 15,
  },
  errorText: {
    color: GLASS.accentSoft,
    fontSize: 16,
    marginBottom: 20,
    textAlign: 'center',
  },
  retryBtn: {
    width: 160,
  },
  emptyText: {
    color: GLASS.textDim,
    fontSize: 15,
    textAlign: 'center',
  },
  searchWrap: {
    marginHorizontal: 14,
    marginTop: 12,
    marginBottom: 8,
    borderRadius: 12,
    overflow: 'hidden',
    backgroundColor: '#ffffff',
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: GLASS.edgeFaint,
  },
  searchInput: {
    paddingHorizontal: 14,
    paddingVertical: 11,
    fontSize: 16,
    color: GLASS.accentSoft,
  },
  catRow: {
    paddingHorizontal: 14,
    paddingBottom: 10,
    gap: 8,
    flexDirection: 'row',
    alignItems: 'center',
  },
  catPressable: {
    // pill handles its own sizing
  },
  catLabel: {
    fontSize: 13,
    fontWeight: '600',
    color: GLASS.textDim,
    letterSpacing: 0.2,
  },
  catLabelActive: {
    color: GLASS.ink,
  },
  listContent: {
    paddingHorizontal: 10,
    paddingBottom: 32,
  },
  row: {
    justifyContent: 'space-between',
  },
  cardPressable: {
    flex: 1,
    marginHorizontal: 4,
    marginVertical: 6,
  },
  card: {
    // GlassPanel handles borderRadius + overflow
  },
  imgBacking: {
    backgroundColor: '#ffffff',
    borderRadius: 10,
    height: 110,
    alignItems: 'center',
    justifyContent: 'center',
    marginBottom: 10,
    overflow: 'hidden',
  },
  img: {
    width: '100%',
    height: '100%',
  },
  imgPlaceholder: {
    width: '100%',
    height: '100%',
    backgroundColor: 'rgba(0,0,0,0.03)',
  },
  cardName: {
    fontSize: 14,
    fontWeight: '700',
    color: GLASS.accentSoft,
    marginBottom: 4,
    lineHeight: 19,
  },
  cardCat: {
    fontSize: 12,
    color: GLASS.textDim,
    letterSpacing: 0.15,
  },
});