added NAS configuration file; updated MediaViewer to support thumbnail carousel; improved error handling in NASAPI and NASAUTH; refactored various comments for clarity

This commit is contained in:
bobbert committed 2026-02-22 19:19:55 +00:00
1 parent 9519b32d68
commit f8e11fc368
27 files changed
+1036 -558

No files matched your search

+191 -11
View File
@@ -1,45 +1,202 @@
import React from 'react';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import {
Modal, View, Text, TouchableOpacity, Image, StyleSheet,
Dimensions, FlatList,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const THUMB_SIZE = 60;
const THUMB_GAP = 4;
const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) =>
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
const MediaViewer = ({ visible, media, onClose }) => {
/**
* Fullscreen media viewer with thumbnail carousel.
*
* Props:
* visible – boolean to show/hide the modal
* media – currently selected media item
* allMedia – (optional) full array of media items for carousel navigation
* onClose – callback when user dismisses the viewer
*/
const MediaViewer = ({ visible, media, allMedia, onClose }) => {
const { theme } = useTheme();
const mainListRef = useRef(null);
const thumbListRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(0);
const [currentMedia, setCurrentMedia] = useState(media);
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
const hasCarousel = mediaList.length > 1;
// When the modal opens or media prop changes, scroll to the correct item
useEffect(() => {
if (!visible || !media || mediaList.length === 0) return;
const idx = mediaList.findIndex(
(m) => (m.source_id || m.id) === (media.source_id || media.id)
);
const newIndex = idx >= 0 ? idx : 0;
setActiveIndex(newIndex);
setCurrentMedia(mediaList[newIndex]);
// Delay scroll so FlatList has rendered
setTimeout(() => {
if (mainListRef.current && newIndex >= 0) {
mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false });
}
if (thumbListRef.current && newIndex >= 0) {
try {
thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 });
} catch (e) { /* index might be out of range during initial render */ }
}
}, 100);
}, [visible, media]);
const onMainScroll = useCallback(
(event) => {
const offsetX = event.nativeEvent.contentOffset.x;
const idx = Math.round(offsetX / SCREEN_WIDTH);
if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) {
setActiveIndex(idx);
setCurrentMedia(mediaList[idx]);
if (thumbListRef.current) {
try {
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch (e) { /* ignore */ }
}
}
},
[activeIndex, mediaList]
);
const onThumbPress = (index) => {
setActiveIndex(index);
setCurrentMedia(mediaList[index]);
if (mainListRef.current) {
mainListRef.current.scrollToOffset({ offset: index * SCREEN_WIDTH, animated: true });
}
};
if (!media) return null;
const renderMainItem = ({ item }) => (
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
</View>
);
const renderThumb = ({ item, index }) => (
<TouchableOpacity
style={[
styles.thumbItem,
index === activeIndex && { borderColor: '#fff', borderWidth: 2 },
]}
onPress={() => onThumbPress(index)}
activeOpacity={0.8}
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" />
) : (
<View style={styles.thumbPlaceholder}>
<Text style={{ fontSize: 20 }}>📷</Text>
</View>
)}
</TouchableOpacity>
);
const displayMedia = currentMedia || media;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
{/* Close button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{media.local_path ? (
<Image source={{ uri: media.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
{/* Counter */}
{hasCarousel && (
<View style={styles.counter}>
<Text style={styles.counterText}>
{activeIndex + 1} / {mediaList.length}
</Text>
</View>
)}
{/* Main image area – swipeable */}
{hasCarousel ? (
<FlatList
ref={mainListRef}
data={mediaList}
renderItem={renderMainItem}
keyExtractor={(item, i) => item.source_id || item.id?.toString() || i.toString()}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onMainScroll}
getItemLayout={(_, index) => ({
length: SCREEN_WIDTH,
offset: SCREEN_WIDTH * index,
index,
})}
style={styles.mainList}
/>
) : (
// Single image mode (backward compat)
displayMedia.local_path ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)
)}
{/* Thumbnail carousel */}
{hasCarousel && (
<View style={styles.thumbCarousel}>
<FlatList
ref={thumbListRef}
data={mediaList}
renderItem={renderThumb}
keyExtractor={(item, i) => 'thumb_' + (item.source_id || item.id?.toString() || i)}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.thumbContent}
getItemLayout={(_, index) => ({
length: THUMB_SIZE + THUMB_GAP,
offset: (THUMB_SIZE + THUMB_GAP) * index,
index,
})}
/>
</View>
)}
{/* Info panel */}
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
<Text style={styles.title}>{media.title || 'Photo'}</Text>
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
<View style={styles.row}>
<Text style={styles.label}>Source</Text>
<Text style={styles.value}>
{getServiceIcon(media.source_service)} {getServiceName(media.source_service)}
{getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)}
</Text>
</View>
{media.created_date && (
{displayMedia.created_date && (
<View style={styles.row}>
<Text style={styles.label}>Date</Text>
<Text style={styles.value}>
{new Date(media.created_date).toLocaleDateString()}
{new Date(displayMedia.created_date).toLocaleDateString()}
</Text>
</View>
)}
@@ -58,8 +215,31 @@ const styles = StyleSheet.create({
justifyContent: 'center', alignItems: 'center',
},
closeText: { color: '#fff', fontSize: 20, fontWeight: '600' },
counter: {
position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20,
alignItems: 'center',
},
counterText: { color: '#fff', fontSize: 14, fontWeight: '600' },
mainList: { flex: 1 },
fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
// Thumbnail carousel
thumbCarousel: {
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
backgroundColor: 'rgba(0,0,0,0.4)',
paddingVertical: 8,
},
thumbContent: { paddingHorizontal: 12, gap: THUMB_GAP },
thumbItem: {
width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: 6,
overflow: 'hidden', borderWidth: 2, borderColor: 'transparent',
},
thumbImage: { width: '100%', height: '100%' },
thumbPlaceholder: {
width: '100%', height: '100%', backgroundColor: '#333',
justifyContent: 'center', alignItems: 'center',
},
// Info panel
infoPanel: {
padding: 16, paddingBottom: 32,
borderTopLeftRadius: 16, borderTopRightRadius: 16,