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