managarten/apps/traces/apps/mobile/components/PhotoImportModal.tsx
Till JS bd1178edf8 feat(traces): integrate traces app into monorepo with NestJS backend and AI city guides
Restructure standalone traces app into monorepo pattern with mobile + backend + shared types.
Add NestJS backend with Drizzle ORM schema for locations, cities, places, POIs, and AI guides.
Add mobile sync layer, cities tab, and guide generation UI. Fix pre-existing type errors across
mobile codebase, matrix-mana-bot (sendDirectMessage), llm-playground, and all web auth stores
(signUp call signature).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:12:42 +01:00

489 lines
12 KiB
TypeScript

import { FontAwesome } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import React, { useState } from 'react';
import {
View,
Text,
Modal,
TouchableOpacity,
ScrollView,
StyleSheet,
ActivityIndicator,
Image,
Alert,
} from 'react-native';
import {
scanPhotosForGPS,
importMultiplePhotos,
PhotoWithGPS,
ScanResult,
} from '../utils/photoImportService';
import { getLocationHistory, LocationData } from '../utils/locationService';
import { useTheme } from '../utils/themeContext';
interface PhotoImportModalProps {
visible: boolean;
onClose: () => void;
onImportComplete: (count: number) => void;
}
export const PhotoImportModal: React.FC<PhotoImportModalProps> = ({
visible,
onClose,
onImportComplete,
}) => {
const { isDarkMode, colors } = useTheme();
const [isScanning, setIsScanning] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
const [importProgress, setImportProgress] = useState({ current: 0, total: 0 });
const handleScan = async () => {
setIsScanning(true);
try {
const result = await scanPhotosForGPS({ limit: 100 });
setScanResult(result);
// Alle Fotos mit GPS standardmäßig auswählen
const allIds = new Set(result.photos.map((p) => p.id));
setSelectedPhotos(allIds);
} catch (error) {
Alert.alert('Fehler', 'Konnte Fotos nicht scannen');
} finally {
setIsScanning(false);
}
};
const handleImport = async () => {
if (!scanResult || selectedPhotos.size === 0) return;
setIsImporting(true);
try {
// Lade bestehende Locations für Duplikat-Check
const existingLocations = await getLocationHistory();
// Filtere ausgewählte Fotos
const photosToImport = scanResult.photos.filter((p) => selectedPhotos.has(p.id));
// Importiere Fotos
const importedLocations = await importMultiplePhotos(
photosToImport,
existingLocations,
(current, total) => {
setImportProgress({ current, total });
}
);
// Speichere in AsyncStorage
if (importedLocations.length > 0) {
const LOCATION_HISTORY_KEY = 'location_history';
const updatedHistory = [...existingLocations, ...importedLocations];
await AsyncStorage.setItem(LOCATION_HISTORY_KEY, JSON.stringify(updatedHistory));
}
// Erfolg
Alert.alert(
'Import erfolgreich',
`${importedLocations.length} Foto-Locations importiert\n${photosToImport.length - importedLocations.length} Duplikate übersprungen`,
[
{
text: 'OK',
onPress: () => {
onImportComplete(importedLocations.length);
onClose();
},
},
]
);
} catch (error) {
Alert.alert('Fehler', 'Import fehlgeschlagen');
} finally {
setIsImporting(false);
setImportProgress({ current: 0, total: 0 });
}
};
const togglePhoto = (photoId: string) => {
const newSelection = new Set(selectedPhotos);
if (newSelection.has(photoId)) {
newSelection.delete(photoId);
} else {
newSelection.add(photoId);
}
setSelectedPhotos(newSelection);
};
const selectAll = () => {
if (!scanResult) return;
const allIds = new Set(scanResult.photos.map((p) => p.id));
setSelectedPhotos(allIds);
};
const deselectAll = () => {
setSelectedPhotos(new Set());
};
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
<View style={[styles.container, isDarkMode && { backgroundColor: '#121212' }]}>
{/* Header */}
<View style={[styles.header, isDarkMode && { borderBottomColor: '#333' }]}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<FontAwesome name="times" size={24} color={isDarkMode ? '#FFF' : '#000'} />
</TouchableOpacity>
<Text style={[styles.title, isDarkMode && { color: '#FFF' }]}>📸 Fotos importieren</Text>
<View style={{ width: 40 }} />
</View>
{/* Content */}
<ScrollView style={styles.content}>
{!scanResult ? (
/* Scan starten */
<View style={styles.startView}>
<FontAwesome name="camera" size={64} color={isDarkMode ? '#666' : '#CCC'} />
<Text style={[styles.startText, isDarkMode && { color: '#AAA' }]}>
Scanne deine Fotos nach GPS-Daten
</Text>
<Text style={[styles.startSubtext, isDarkMode && { color: '#666' }]}>
Die letzten 100 Fotos werden durchsucht
</Text>
<TouchableOpacity
style={[
styles.scanButton,
{ backgroundColor: colors.primary },
isScanning && styles.scanButtonDisabled,
]}
onPress={handleScan}
disabled={isScanning}
>
{isScanning ? (
<ActivityIndicator color="white" />
) : (
<>
<FontAwesome name="search" size={18} color="white" />
<Text style={styles.scanButtonText}>Fotos scannen</Text>
</>
)}
</TouchableOpacity>
</View>
) : (
/* Ergebnisse */
<>
{/* Statistik */}
<View style={[styles.statsContainer, isDarkMode && { backgroundColor: '#1E1E1E' }]}>
<View style={styles.statItem}>
<Text style={[styles.statValue, isDarkMode && { color: '#FFF' }]}>
{scanResult.totalPhotos}
</Text>
<Text style={[styles.statLabel, isDarkMode && { color: '#AAA' }]}>
Fotos gescannt
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: colors.primary }]}>
{scanResult.photosWithGPS}
</Text>
<Text style={[styles.statLabel, isDarkMode && { color: '#AAA' }]}>Mit GPS </Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: '#FF9800' }]}>
{scanResult.photosWithoutGPS}
</Text>
<Text style={[styles.statLabel, isDarkMode && { color: '#AAA' }]}>Ohne GPS</Text>
</View>
</View>
{/* Auswahl-Buttons */}
{scanResult.photosWithGPS > 0 && (
<View style={styles.selectionButtons}>
<TouchableOpacity onPress={selectAll} style={styles.selectionButton}>
<Text
style={[styles.selectionButtonText, isDarkMode && { color: colors.primary }]}
>
Alle auswählen
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={deselectAll} style={styles.selectionButton}>
<Text style={[styles.selectionButtonText, isDarkMode && { color: '#F44336' }]}>
Alle abwählen
</Text>
</TouchableOpacity>
</View>
)}
{/* Foto-Liste */}
{scanResult.photos.map((photo) => {
const isSelected = selectedPhotos.has(photo.id);
return (
<TouchableOpacity
key={photo.id}
style={[
styles.photoItem,
isSelected && styles.photoItemSelected,
isDarkMode && {
backgroundColor: isSelected ? '#1a3a1a' : '#1E1E1E',
borderColor: isSelected ? colors.primary : '#333',
},
]}
onPress={() => togglePhoto(photo.id)}
>
<Image source={{ uri: photo.uri }} style={styles.photoThumbnail} />
<View style={styles.photoInfo}>
<Text
style={[styles.photoFilename, isDarkMode && { color: '#FFF' }]}
numberOfLines={1}
>
{photo.filename}
</Text>
<Text style={[styles.photoDate, isDarkMode && { color: '#AAA' }]}>
📅 {formatDate(photo.creationTime)}
</Text>
<Text style={[styles.photoLocation, isDarkMode && { color: '#AAA' }]}>
📍 {photo.location?.latitude?.toFixed(4) || '0.0000'},{' '}
{photo.location?.longitude?.toFixed(4) || '0.0000'}
</Text>
</View>
{isSelected && (
<FontAwesome name="check-circle" size={24} color={colors.primary} />
)}
</TouchableOpacity>
);
})}
{scanResult.photos.length === 0 && (
<View style={styles.emptyState}>
<FontAwesome name="frown-o" size={48} color={isDarkMode ? '#666' : '#CCC'} />
<Text style={[styles.emptyText, isDarkMode && { color: '#AAA' }]}>
Keine Fotos mit GPS-Daten gefunden
</Text>
</View>
)}
</>
)}
</ScrollView>
{/* Footer */}
{scanResult && scanResult.photosWithGPS > 0 && (
<View
style={[
styles.footer,
isDarkMode && { backgroundColor: '#1E1E1E', borderTopColor: '#333' },
]}
>
{isImporting && (
<Text style={[styles.progressText, isDarkMode && { color: '#AAA' }]}>
Importiere {importProgress.current} / {importProgress.total}...
</Text>
)}
<TouchableOpacity
style={[
styles.importButton,
{ backgroundColor: colors.primary },
(selectedPhotos.size === 0 || isImporting) && styles.importButtonDisabled,
]}
onPress={handleImport}
disabled={selectedPhotos.size === 0 || isImporting}
>
{isImporting ? (
<ActivityIndicator color="white" />
) : (
<>
<FontAwesome name="download" size={18} color="white" />
<Text style={styles.importButtonText}>
{selectedPhotos.size} Foto{selectedPhotos.size !== 1 ? 's' : ''} importieren
</Text>
</>
)}
</TouchableOpacity>
</View>
)}
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFF',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
closeButton: {
padding: 8,
},
title: {
fontSize: 18,
fontWeight: 'bold',
},
content: {
flex: 1,
},
startView: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 80,
paddingHorizontal: 32,
},
startText: {
fontSize: 18,
fontWeight: '600',
marginTop: 24,
textAlign: 'center',
},
startSubtext: {
fontSize: 14,
marginTop: 8,
textAlign: 'center',
color: '#666',
},
scanButton: {
flexDirection: 'row',
alignItems: 'center',
// backgroundColor set dynamically via colors.primary
paddingVertical: 16,
paddingHorizontal: 32,
borderRadius: 12,
marginTop: 32,
minWidth: 200,
justifyContent: 'center',
},
scanButtonDisabled: {
opacity: 0.6,
},
scanButtonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
marginLeft: 8,
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
padding: 20,
backgroundColor: '#F5F5F5',
margin: 16,
borderRadius: 12,
},
statItem: {
alignItems: 'center',
},
statValue: {
fontSize: 28,
fontWeight: 'bold',
},
statLabel: {
fontSize: 12,
color: '#666',
marginTop: 4,
},
selectionButtons: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 16,
paddingBottom: 16,
},
selectionButton: {
padding: 8,
},
selectionButtonText: {
fontSize: 14,
fontWeight: '600',
// color set dynamically via colors.primary
},
photoItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
marginHorizontal: 16,
marginBottom: 8,
borderRadius: 8,
borderWidth: 2,
borderColor: '#E0E0E0',
backgroundColor: 'white',
},
photoItemSelected: {
// borderColor set dynamically via colors.primary
backgroundColor: '#E8F5E9',
},
photoThumbnail: {
width: 60,
height: 60,
borderRadius: 6,
marginRight: 12,
},
photoInfo: {
flex: 1,
},
photoFilename: {
fontSize: 14,
fontWeight: '600',
marginBottom: 4,
},
photoDate: {
fontSize: 12,
color: '#666',
marginBottom: 2,
},
photoLocation: {
fontSize: 11,
color: '#666',
},
emptyState: {
alignItems: 'center',
paddingVertical: 60,
},
emptyText: {
fontSize: 16,
color: '#AAA',
marginTop: 16,
},
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#E0E0E0',
backgroundColor: 'white',
},
progressText: {
textAlign: 'center',
marginBottom: 8,
fontSize: 12,
color: '#666',
},
importButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
// backgroundColor set dynamically via colors.primary
paddingVertical: 16,
borderRadius: 12,
},
importButtonDisabled: {
opacity: 0.5,
},
importButtonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
marginLeft: 8,
},
});