/ components / NavigationHistoryPanel.tsx
NavigationHistoryPanel.tsx
  1  import React, { useState, useRef } from 'react';
  2  import {
  3    View,
  4    Text,
  5    TouchableOpacity,
  6    StyleSheet,
  7    ScrollView,
  8    useColorScheme,
  9    Animated,
 10    PanResponder,
 11    Dimensions,
 12    Modal,
 13  } from 'react-native';
 14  import { Ionicons } from '@expo/vector-icons';
 15  import { BlurView } from 'expo-blur';
 16  import { Colors, ColorScheme } from '@/constants/Colors';
 17  import { NavigationEntry } from '@/types/navigation';
 18  import { useNavigationHistory } from '@/hooks/useNavigationHistory';
 19  
 20  const { height: screenHeight } = Dimensions.get('window');
 21  
 22  interface NavigationHistoryPanelProps {
 23    visible: boolean;
 24    onClose: () => void;
 25    maxItems?: number;
 26  }
 27  
 28  const NavigationHistoryPanel: React.FC<NavigationHistoryPanelProps> = ({
 29    visible,
 30    onClose,
 31    maxItems = 20,
 32  }) => {
 33    const colorScheme = useColorScheme() as ColorScheme;
 34    const { navigationState, navigateTo, clearHistory } = useNavigationHistory();
 35    const [selectedEntry, setSelectedEntry] = useState<NavigationEntry | null>(
 36      null
 37    );
 38  
 39    const colors = Colors[colorScheme];
 40    const slideAnim = useRef(new Animated.Value(screenHeight)).current;
 41  
 42    React.useEffect(() => {
 43      if (visible) {
 44        Animated.spring(slideAnim, {
 45          toValue: 0,
 46          useNativeDriver: true,
 47          tension: 100,
 48          friction: 8,
 49        }).start();
 50      } else {
 51        Animated.timing(slideAnim, {
 52          toValue: screenHeight,
 53          duration: 300,
 54          useNativeDriver: true,
 55        }).start();
 56      }
 57    }, [visible, slideAnim]);
 58  
 59    const panResponder = useRef(
 60      PanResponder.create({
 61        onMoveShouldSetPanResponder: (_, gestureState) => {
 62          return Math.abs(gestureState.dy) > 5;
 63        },
 64        onPanResponderMove: (_, gestureState) => {
 65          if (gestureState.dy > 0) {
 66            slideAnim.setValue(gestureState.dy);
 67          }
 68        },
 69        onPanResponderRelease: (_, gestureState) => {
 70          if (gestureState.dy > 100 || gestureState.vy > 0.5) {
 71            onClose();
 72          } else {
 73            Animated.spring(slideAnim, {
 74              toValue: 0,
 75              useNativeDriver: true,
 76            }).start();
 77          }
 78        },
 79      })
 80    ).current;
 81  
 82    const handleEntryPress = async (entry: NavigationEntry) => {
 83      await navigateTo(entry.path, { replace: true });
 84      onClose();
 85    };
 86  
 87    const handleClearHistory = async () => {
 88      await clearHistory();
 89      onClose();
 90    };
 91  
 92    const formatTimestamp = (timestamp: number) => {
 93      const now = Date.now();
 94      const diff = now - timestamp;
 95  
 96      if (diff < 60000) return 'Just now';
 97      if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
 98      if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
 99      return `${Math.floor(diff / 86400000)}d ago`;
100    };
101  
102    const getContextIcon = (context: string): keyof typeof Ionicons.glyphMap => {
103      const contextIcons: Record<string, keyof typeof Ionicons.glyphMap> = {
104        browse: 'compass',
105        reading: 'book',
106        search: 'search',
107        settings: 'settings',
108      };
109      return contextIcons[context] || 'document';
110    };
111  
112    const groupedHistory = navigationState.contextHistory
113      .slice(-maxItems)
114      .reverse()
115      .reduce(
116        (groups, entry) => {
117          const date = new Date(entry.timestamp).toDateString();
118          if (!groups[date]) {
119            groups[date] = [];
120          }
121          groups[date].push(entry);
122          return groups;
123        },
124        {} as Record<string, NavigationEntry[]>
125      );
126  
127    return (
128      <Modal
129        visible={visible}
130        transparent
131        animationType="none"
132        onRequestClose={onClose}
133      >
134        <View style={styles.overlay}>
135          <TouchableOpacity
136            style={styles.backdrop}
137            activeOpacity={1}
138            onPress={onClose}
139          />
140  
141          <Animated.View
142            style={[
143              styles.panel,
144              {
145                transform: [{ translateY: slideAnim }],
146              },
147            ]}
148            {...panResponder.panHandlers}
149          >
150            <BlurView
151              intensity={100}
152              tint={colorScheme === 'dark' ? 'dark' : 'light'}
153              style={styles.blurContainer}
154            >
155              <View style={styles.handle} />
156  
157              <View style={styles.header}>
158                <Text style={[styles.title, { color: colors.text }]}>
159                  Navigation History
160                </Text>
161                <View style={styles.headerActions}>
162                  <TouchableOpacity
163                    style={[styles.clearButton, { borderColor: colors.border }]}
164                    onPress={handleClearHistory}
165                  >
166                    <Ionicons
167                      name="trash"
168                      size={16}
169                      color={colors.tabIconDefault}
170                    />
171                    <Text
172                      style={[
173                        styles.clearButtonText,
174                        { color: colors.tabIconDefault },
175                      ]}
176                    >
177                      Clear
178                    </Text>
179                  </TouchableOpacity>
180                  <TouchableOpacity style={styles.closeButton} onPress={onClose}>
181                    <Ionicons name="close" size={24} color={colors.text} />
182                  </TouchableOpacity>
183                </View>
184              </View>
185  
186              <ScrollView
187                style={styles.content}
188                showsVerticalScrollIndicator={false}
189              >
190                {Object.entries(groupedHistory).map(([date, entries]) => (
191                  <View key={date} style={styles.dateGroup}>
192                    <Text
193                      style={[styles.dateHeader, { color: colors.tabIconDefault }]}
194                    >
195                      {date === new Date().toDateString() ? 'Today' : date}
196                    </Text>
197  
198                    {entries.map((entry, index) => (
199                      <TouchableOpacity
200                        key={`${entry.path}-${entry.timestamp}-${index}`}
201                        style={[
202                          styles.historyItem,
203                          { backgroundColor: colors.card },
204                          selectedEntry === entry && {
205                            backgroundColor: colors.primary + '20',
206                          },
207                        ]}
208                        onPress={() => handleEntryPress(entry)}
209                        onLongPress={() => setSelectedEntry(entry)}
210                        activeOpacity={0.7}
211                      >
212                        <View style={styles.historyItemContent}>
213                          <View style={styles.historyItemHeader}>
214                            <View style={styles.titleContainer}>
215                              <Ionicons
216                                name={getContextIcon(entry.context)}
217                                size={16}
218                                color={colors.primary}
219                                style={styles.contextIcon}
220                              />
221                              <Text
222                                style={[
223                                  styles.historyItemTitle,
224                                  { color: colors.text },
225                                ]}
226                                numberOfLines={1}
227                              >
228                                {entry.title}
229                              </Text>
230                            </View>
231                            <Text
232                              style={[
233                                styles.timestamp,
234                                { color: colors.tabIconDefault },
235                              ]}
236                            >
237                              {formatTimestamp(entry.timestamp)}
238                            </Text>
239                          </View>
240  
241                          <Text
242                            style={[
243                              styles.historyItemPath,
244                              { color: colors.tabIconDefault },
245                            ]}
246                            numberOfLines={1}
247                          >
248                            {entry.path}
249                          </Text>
250  
251                          {entry.metadata?.mangaId && (
252                            <View style={styles.metadata}>
253                              <Text
254                                style={[
255                                  styles.metadataText,
256                                  { color: colors.tabIconDefault },
257                                ]}
258                              >
259                                Manga ID: {entry.metadata.mangaId}
260                              </Text>
261                              {entry.metadata.chapterNumber && (
262                                <Text
263                                  style={[
264                                    styles.metadataText,
265                                    { color: colors.tabIconDefault },
266                                  ]}
267                                >
268                                  • Chapter {entry.metadata.chapterNumber}
269                                </Text>
270                              )}
271                            </View>
272                          )}
273                        </View>
274                      </TouchableOpacity>
275                    ))}
276                  </View>
277                ))}
278  
279                {Object.keys(groupedHistory).length === 0 && (
280                  <View style={styles.emptyState}>
281                    <Ionicons
282                      name="time"
283                      size={48}
284                      color={colors.tabIconDefault}
285                    />
286                    <Text
287                      style={[styles.emptyText, { color: colors.tabIconDefault }]}
288                    >
289                      No navigation history yet
290                    </Text>
291                  </View>
292                )}
293              </ScrollView>
294            </BlurView>
295          </Animated.View>
296        </View>
297      </Modal>
298    );
299  };
300  
301  const styles = StyleSheet.create({
302    overlay: {
303      flex: 1,
304      backgroundColor: 'rgba(0, 0, 0, 0.5)',
305    },
306    backdrop: {
307      flex: 1,
308    },
309    panel: {
310      position: 'absolute',
311      bottom: 0,
312      left: 0,
313      right: 0,
314      height: screenHeight * 0.7,
315      borderTopLeftRadius: 20,
316      borderTopRightRadius: 20,
317      overflow: 'hidden',
318    },
319    blurContainer: {
320      flex: 1,
321    },
322    handle: {
323      width: 40,
324      height: 4,
325      backgroundColor: 'rgba(128, 128, 128, 0.5)',
326      borderRadius: 2,
327      alignSelf: 'center',
328      marginTop: 8,
329      marginBottom: 16,
330    },
331    header: {
332      flexDirection: 'row',
333      justifyContent: 'space-between',
334      alignItems: 'center',
335      paddingHorizontal: 20,
336      paddingBottom: 16,
337    },
338    title: {
339      fontSize: 20,
340      fontWeight: '600',
341    },
342    headerActions: {
343      flexDirection: 'row',
344      alignItems: 'center',
345      gap: 12,
346    },
347    clearButton: {
348      flexDirection: 'row',
349      alignItems: 'center',
350      paddingHorizontal: 12,
351      paddingVertical: 6,
352      borderRadius: 16,
353      borderWidth: 1,
354      gap: 4,
355    },
356    clearButtonText: {
357      fontSize: 12,
358      fontWeight: '500',
359    },
360    closeButton: {
361      padding: 4,
362    },
363    content: {
364      flex: 1,
365      paddingHorizontal: 20,
366    },
367    dateGroup: {
368      marginBottom: 24,
369    },
370    dateHeader: {
371      fontSize: 14,
372      fontWeight: '600',
373      marginBottom: 8,
374      textTransform: 'uppercase',
375      letterSpacing: 0.5,
376    },
377    historyItem: {
378      borderRadius: 12,
379      marginBottom: 8,
380      overflow: 'hidden',
381    },
382    historyItemContent: {
383      padding: 16,
384    },
385    historyItemHeader: {
386      flexDirection: 'row',
387      justifyContent: 'space-between',
388      alignItems: 'flex-start',
389      marginBottom: 4,
390    },
391    titleContainer: {
392      flexDirection: 'row',
393      alignItems: 'center',
394      flex: 1,
395      marginRight: 8,
396    },
397    contextIcon: {
398      marginRight: 8,
399    },
400    historyItemTitle: {
401      fontSize: 16,
402      fontWeight: '500',
403      flex: 1,
404    },
405    timestamp: {
406      fontSize: 12,
407      fontWeight: '400',
408    },
409    historyItemPath: {
410      fontSize: 14,
411      marginBottom: 4,
412    },
413    metadata: {
414      flexDirection: 'row',
415      flexWrap: 'wrap',
416      gap: 8,
417    },
418    metadataText: {
419      fontSize: 12,
420    },
421    emptyState: {
422      alignItems: 'center',
423      justifyContent: 'center',
424      paddingVertical: 60,
425    },
426    emptyText: {
427      fontSize: 16,
428      marginTop: 16,
429      textAlign: 'center',
430    },
431  });
432  
433  export default NavigationHistoryPanel;