/ 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;