feat: add media renaming functionality; implement rename feature in MediaViewer and NASAPI

This commit is contained in:
bobbert committed 2026-04-15 15:13:09 +01:00
1 parent ac9e4aee43
commit bb1eb375ad
16 files changed
+509 -181

No files matched your search

+33 -18
View File
@@ -6,8 +6,10 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native'; import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
import { registerRootComponent } from 'expo'; import { registerRootComponent } from 'expo';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemeProvider, useTheme } from './src/services/ThemeContext'; import { ThemeProvider, useTheme } from './src/services/ThemeContext';
import DatabaseService from './src/services/DatabaseService'; import DatabaseService from './src/services/DatabaseService';
import { getStoredUserId } from './src/services/AuthSession';
import LoginScreen from './src/screens/LoginScreen'; import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen'; import RegisterScreen from './src/screens/RegisterScreen';
import OnboardingScreen from './src/screens/OnboardingScreen'; import OnboardingScreen from './src/screens/OnboardingScreen';
@@ -15,6 +17,9 @@ import HomeScreen from './src/screens/HomeScreen';
import SearchScreen from './src/screens/SearchScreen'; import SearchScreen from './src/screens/SearchScreen';
import CollectionsScreen from './src/screens/AlbumsScreen'; import CollectionsScreen from './src/screens/AlbumsScreen';
import SettingsScreen from './src/screens/SettingsScreen'; import SettingsScreen from './src/screens/SettingsScreen';
import FolderIcon from './assets/icons/folder.svg';
import LibraryGridIcon from './assets/icons/library-grid.svg';
import SearchIcon from './assets/icons/search.svg';
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
@@ -22,6 +27,8 @@ const Tab = createBottomTabNavigator();
function MainTabs({ route }) { function MainTabs({ route }) {
const { userId } = route.params || {}; const { userId } = route.params || {};
const { theme } = useTheme(); const { theme } = useTheme();
const insets = useSafeAreaInsets();
const bottomInset = Math.max(insets.bottom, 8);
return ( return (
<Tab.Navigator <Tab.Navigator
@@ -33,8 +40,8 @@ function MainTabs({ route }) {
backgroundColor: theme.tabBar, backgroundColor: theme.tabBar,
borderTopColor: theme.tabBarBorder, borderTopColor: theme.tabBarBorder,
borderTopWidth: 1, borderTopWidth: 1,
height: 56, height: 56 + bottomInset,
paddingBottom: 6, paddingBottom: bottomInset,
paddingTop: 6, paddingTop: 6,
}, },
tabBarLabelStyle: { fontSize: 11, fontWeight: '500' }, tabBarLabelStyle: { fontSize: 11, fontWeight: '500' },
@@ -46,7 +53,7 @@ function MainTabs({ route }) {
initialParams={{ userId }} initialParams={{ userId }}
options={{ options={{
tabBarLabel: 'Library', tabBarLabel: 'Library',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🖼️</Text>, tabBarIcon: ({ color }) => <LibraryGridIcon width={20} height={20} color={color} />,
}} }}
/> />
<Tab.Screen <Tab.Screen
@@ -55,7 +62,7 @@ function MainTabs({ route }) {
initialParams={{ userId }} initialParams={{ userId }}
options={{ options={{
tabBarLabel: 'Search', tabBarLabel: 'Search',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🔍</Text>, tabBarIcon: ({ color }) => <SearchIcon width={20} height={20} color={color} />,
}} }}
/> />
<Tab.Screen <Tab.Screen
@@ -64,28 +71,20 @@ function MainTabs({ route }) {
initialParams={{ userId }} initialParams={{ userId }}
options={{ options={{
tabBarLabel: 'Collections', tabBarLabel: 'Collections',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>📁</Text>, tabBarIcon: ({ color }) => <FolderIcon width={20} height={20} color={color} />,
}} }}
/> />
</Tab.Navigator> </Tab.Navigator>
); );
} }
function AppNavigator() { // App navigation is initialized inside AppInner.
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
<Stack.Screen name="Onboarding" component={OnboardingScreen} />
<Stack.Screen name="MainTabs" component={MainTabs} />
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>
);
}
function AppInner() { function AppInner() {
const { theme } = useTheme(); const { theme } = useTheme();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [bootUserId, setBootUserId] = useState(null);
const [bootChecked, setBootChecked] = useState(false);
useEffect(() => { initializeApp(); }, []); useEffect(() => { initializeApp(); }, []);
@@ -93,6 +92,9 @@ function AppInner() {
try { try {
setError(null); setError(null);
await DatabaseService.initDatabase(); await DatabaseService.initDatabase();
const stored = await getStoredUserId();
setBootUserId(stored);
setBootChecked(true);
setIsReady(true); setIsReady(true);
} catch (err) { } catch (err) {
console.error('Database initialization failed:', err); console.error('Database initialization failed:', err);
@@ -101,7 +103,7 @@ function AppInner() {
} }
}; };
if (!isReady && !error) { if ((!isReady || !bootChecked) && !error) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: theme.background }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: theme.background }}>
<ActivityIndicator size="large" color={theme.primary} /> <ActivityIndicator size="large" color={theme.primary} />
@@ -126,7 +128,20 @@ function AppInner() {
return ( return (
<> <>
<NavigationContainer> <NavigationContainer>
<AppNavigator /> <Stack.Navigator
screenOptions={{ headerShown: false }}
initialRouteName={bootUserId ? 'MainTabs' : 'Login'}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
<Stack.Screen name="Onboarding" component={OnboardingScreen} />
<Stack.Screen
name="MainTabs"
component={MainTabs}
initialParams={bootUserId ? { userId: bootUserId } : undefined}
/>
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>
</NavigationContainer> </NavigationContainer>
<StatusBar style={theme.statusBarStyle} backgroundColor={theme.background} /> <StatusBar style={theme.statusBarStyle} backgroundColor={theme.background} />
</> </>
+1 -1
View File
@@ -48,7 +48,7 @@ The method of obtaining the Google photos picker's api key is via:
## Running the Project ## Running the Project
Useful scripts: Useful scripts:
```bash ```bash
expo run:android npx expo run:android
``` ```
+3
View File
@@ -28,12 +28,15 @@
"expo-status-bar": "~55.0.5", "expo-status-bar": "~55.0.5",
"expo-system-ui": "^55.0.15", "expo-system-ui": "^55.0.15",
"expo-web-browser": "~55.0.14", "expo-web-browser": "~55.0.14",
"metro-react-native-babel-transformer": "^0.77.0",
"react": "19.2.0", "react": "19.2.0",
"react-native": "0.83.4", "react-native": "0.83.4",
"react-native-gesture-handler": "~2.30.0", "react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1", "react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-svg": "^15.15.4",
"react-native-svg-transformer": "^1.5.3",
"react-native-worklets": "0.7.2" "react-native-worklets": "0.7.2"
}, },
"devDependencies": { "devDependencies": {
+5
View File
@@ -228,6 +228,11 @@ class NASAPI {
return this.deleteMedia(mediaId); return this.deleteMedia(mediaId);
} }
async renameMedia(mediaId, newFilename) {
if (!mediaId || !newFilename) throw new Error('mediaId and newFilename are required');
return this.updateMedia(mediaId, { filename: newFilename });
}
async disconnect() { async disconnect() {
if (this.connectionToken) { if (this.connectionToken) {
try { try {
+5
View File
@@ -106,6 +106,11 @@ class NASAUTH extends AuthServiceBase {
this.ensureConnected(); this.ensureConnected();
return await this.nasAPI.deleteFile(remotePath); return await this.nasAPI.deleteFile(remotePath);
} }
async renameMedia(mediaId, newFilename) {
this.ensureConnected();
return await this.nasAPI.renameMedia(mediaId, newFilename);
}
} }
export default NASAUTH; export default NASAUTH;
+61 -15
View File
@@ -5,18 +5,29 @@ import {
} from 'react-native'; } from 'react-native';
import { useTheme } from '../services/ThemeContext'; import { useTheme } from '../services/ThemeContext';
import MediaDetailDrawer from './MediaDetailDrawer'; import MediaDetailDrawer from './MediaDetailDrawer';
import RenameMediaModal from './RenameMediaModal';
import CameraIcon from '../../assets/icons/camera.svg';
const DRAWER_PEEK = 124; const DRAWER_PEEK = 124;
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const THUMB_SIZE = 60; const THUMB_SIZE = 60;
const THUMB_GAP = 4; const THUMB_GAP = 4;
const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata }) => { const MediaViewer = ({
visible,
media,
allMedia,
onClose,
resolveNasMetadata,
onRenameMedia,
renaming,
}) => {
const { theme } = useTheme(); const { theme } = useTheme();
const mainListRef = useRef(null); const mainListRef = useRef(null);
const thumbListRef = useRef(null); const thumbListRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [currentMedia, setCurrentMedia] = useState(media); const [currentMedia, setCurrentMedia] = useState(media);
const [renameVisible, setRenameVisible] = useState(false);
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []); const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
const hasCarousel = mediaList.length > 1; const hasCarousel = mediaList.length > 1;
@@ -43,6 +54,10 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
}, 100); }, 100);
}, [visible, media]); }, [visible, media]);
useEffect(() => {
if (!visible) setRenameVisible(false);
}, [visible]);
const activeIndexRef = useRef(0); const activeIndexRef = useRef(0);
const onMainScroll = (event) => { const onMainScroll = (event) => {
@@ -74,7 +89,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
} }
return ( return (
<View style={styles.fullPlaceholder}> <View style={styles.fullPlaceholder}>
<Text style={{ fontSize: placeholderSize }}>📷</Text> <CameraIcon width={placeholderSize} height={placeholderSize} color="#7C7F85" />
</View> </View>
); );
}; };
@@ -98,31 +113,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
<Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" /> <Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" />
) : ( ) : (
<View style={styles.thumbPlaceholder}> <View style={styles.thumbPlaceholder}>
<Text style={{ fontSize: 20 }}>📷</Text> <CameraIcon width={20} height={20} color="#7C7F85" />
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
const displayMedia = currentMedia || media; const displayMedia = currentMedia || media;
const canRename = !!displayMedia && typeof onRenameMedia === 'function';
if (!media) return null; if (!media) return null;
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}> <View style={[styles.container, { backgroundColor: theme.modalBg }]}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{hasCarousel && (
<View style={styles.counter}>
<Text style={styles.counterText}>
{activeIndex + 1} / {mediaList.length}
</Text>
</View>
)}
{hasCarousel ? ( {hasCarousel ? (
<FlatList <FlatList
ref={mainListRef} ref={mainListRef}
@@ -172,6 +176,36 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
resolveNasMetadata={resolveNasMetadata} resolveNasMetadata={resolveNasMetadata}
/> />
</View> </View>
<View style={styles.topControls} pointerEvents="box-none">
{canRename && (
<TouchableOpacity style={styles.renameButton} onPress={() => setRenameVisible(true)}>
<Text style={styles.renameText}>Rename</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{hasCarousel && (
<View style={styles.counter}>
<Text style={styles.counterText}>
{activeIndex + 1} / {mediaList.length}
</Text>
</View>
)}
</View>
<RenameMediaModal
visible={renameVisible}
initialName={displayMedia?.title || ''}
saving={!!renaming}
onClose={() => setRenameVisible(false)}
onSave={async (nextName) => {
const ok = await onRenameMedia?.(displayMedia, nextName);
if (ok) setRenameVisible(false);
}}
/>
</View> </View>
</Modal> </Modal>
); );
@@ -185,12 +219,24 @@ const styles = StyleSheet.create({
elevation: 50, elevation: 50,
justifyContent: 'flex-end', justifyContent: 'flex-end',
}, },
topControls: {
...StyleSheet.absoluteFillObject,
zIndex: 120,
elevation: 120,
},
closeButton: { closeButton: {
position: 'absolute', top: 44, right: 16, zIndex: 20, position: 'absolute', top: 44, right: 16, zIndex: 20,
width: 36, height: 36, borderRadius: 18, width: 36, height: 36, borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.2)', backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center', alignItems: 'center', justifyContent: 'center', alignItems: 'center',
}, },
renameButton: {
position: 'absolute', top: 44, right: 62, zIndex: 20,
height: 36, borderRadius: 18, paddingHorizontal: 12,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center', alignItems: 'center',
},
renameText: { color: '#fff', fontSize: 12, fontWeight: '600' },
closeText: { color: '#fff', fontSize: 20, fontWeight: '600' }, closeText: { color: '#fff', fontSize: 20, fontWeight: '600' },
counter: { counter: {
position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20, position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20,
+9 -4
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '../services/ThemeContext'; import { useTheme } from '../services/ThemeContext';
import NasIcon from '../../assets/icons/nas.svg';
const NASSetupInstructions = () => { const NASSetupInstructions = () => {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -13,11 +14,14 @@ const NASSetupInstructions = () => {
onPress={() => setExpanded(!expanded)} onPress={() => setExpanded(!expanded)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.headerText, { color: theme.text }]}> <View style={styles.headerLabel}>
📋 How to set up a NAS for photo storage <NasIcon width={16} height={16} />
</Text> <Text style={[styles.headerText, { color: theme.text }]}>
How to set up a NAS for photo storage
</Text>
</View>
<Text style={[styles.arrow, { color: theme.textSecondary }]}> <Text style={[styles.arrow, { color: theme.textSecondary }]}>
{expanded ? '▲' : '▼'} {expanded ? '^' : 'v'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -71,6 +75,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
padding: 14, padding: 14,
}, },
headerLabel: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1, marginRight: 8 },
headerText: { fontSize: 13, fontWeight: '600', flex: 1, marginRight: 8 }, headerText: { fontSize: 13, fontWeight: '600', flex: 1, marginRight: 8 },
arrow: { fontSize: 12 }, arrow: { fontSize: 12 },
body: { paddingHorizontal: 14, paddingBottom: 14 }, body: { paddingHorizontal: 14, paddingBottom: 14 },
+19 -9
View File
@@ -4,10 +4,22 @@ import {
} from 'react-native'; } from 'react-native';
import { useTheme } from '../services/ThemeContext'; import { useTheme } from '../services/ThemeContext';
import MediaMetadataService from '../services/MediaMetadataService'; import MediaMetadataService from '../services/MediaMetadataService';
import CameraIcon from '../../assets/icons/camera.svg';
import CheckIcon from '../../assets/icons/check.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const GRID_GAP = 2; const GRID_GAP = 2;
const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
const ServiceGlyph = ({ serviceType, size }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const PhotoGrid = ({ const PhotoGrid = ({
data, data,
@@ -28,9 +40,6 @@ const PhotoGrid = ({
const { theme } = useTheme(); const { theme } = useTheme();
const itemSize = (width - GRID_GAP * (columns + 1)) / columns; const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
const getServiceIcon = (type) =>
(SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE);
const renderSourceBadges = (item) => { const renderSourceBadges = (item) => {
const sources = item.sources && item.sources.length > 0 const sources = item.sources && item.sources.length > 0
? item.sources ? item.sources
@@ -39,7 +48,7 @@ const PhotoGrid = ({
if (sources.length === 1) { if (sources.length === 1) {
return ( return (
<View style={styles.sourceBadge}> <View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text> <ServiceGlyph serviceType={sources[0]} size={10} />
</View> </View>
); );
} }
@@ -47,7 +56,9 @@ const PhotoGrid = ({
return ( return (
<View style={styles.multiSourceBadge}> <View style={styles.multiSourceBadge}>
{sources.map((src, i) => ( {sources.map((src, i) => (
<Text key={src + i} style={{ fontSize: 9 }}>{getServiceIcon(src)}</Text> <View key={src + i}>
<ServiceGlyph serviceType={src} size={9} />
</View>
))} ))}
</View> </View>
); );
@@ -98,7 +109,7 @@ const PhotoGrid = ({
/> />
) : ( ) : (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}> <View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 28 }}>📷</Text> <CameraIcon width={28} height={28} color="#7C7F85" />
</View> </View>
)} )}
{item.media_type === 'video' && ( {item.media_type === 'video' && (
@@ -109,7 +120,7 @@ const PhotoGrid = ({
{renderSourceBadges(item)} {renderSourceBadges(item)}
{selected && ( {selected && (
<View style={[styles.checkBadge, { borderColor: theme.primary }]}> <View style={[styles.checkBadge, { borderColor: theme.primary }]}>
<Text style={[styles.checkMark, { color: theme.primary }]}>✓</Text> <CheckIcon width={11} height={11} color={theme.primary} />
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -215,7 +226,6 @@ const styles = StyleSheet.create({
borderWidth: 2, borderWidth: 2,
justifyContent: 'center', alignItems: 'center', justifyContent: 'center', alignItems: 'center',
}, },
checkMark: { fontSize: 14, fontWeight: '800' },
}); });
export default PhotoGrid; export default PhotoGrid;
+2 -2
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '../services/ThemeContext'; import { useTheme } from '../services/ThemeContext';
import SearchIcon from '../../assets/icons/search.svg';
const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => { const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -12,7 +13,7 @@ const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) =>
style={[styles.searchPill, { backgroundColor: theme.searchBg }]} style={[styles.searchPill, { backgroundColor: theme.searchBg }]}
onPress={onSearchPress} onPress={onSearchPress}
> >
<Text style={styles.searchIcon}>🔍</Text> <SearchIcon width={16} height={16} color={theme.textSecondary} />
<Text style={[styles.searchText, { color: theme.textSecondary }]}>Search your photos</Text> <Text style={[styles.searchText, { color: theme.textSecondary }]}>Search your photos</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -46,7 +47,6 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
gap: 8, gap: 8,
}, },
searchIcon: { fontSize: 16 },
searchText: { fontSize: 14 }, searchText: { fontSize: 14 },
profileButton: { profileButton: {
width: 32, width: 32,
+51 -15
View File
@@ -8,13 +8,38 @@ import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import MediaMetadataService from '../services/MediaMetadataService'; import MediaMetadataService from '../services/MediaMetadataService';
import MediaViewer from '../components/MediaViewer'; import MediaViewer from '../components/MediaViewer';
import CameraIcon from '../../assets/icons/camera.svg';
import EmptyPhotoIcon from '../../assets/icons/empty-photo.svg';
import FolderIcon from '../../assets/icons/folder.svg';
import FolderOpenIcon from '../../assets/icons/folder-open.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
import HiddenIcon from '../../assets/icons/hidden.svg';
import DuplicatesIcon from '../../assets/icons/duplicates.svg';
import TrashIcon from '../../assets/icons/trash.svg';
import EmptyMailIcon from '../../assets/icons/empty-mail.svg';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const ALBUM_COLUMNS = 2; const ALBUM_COLUMNS = 2;
const ALBUM_GAP = 12; const ALBUM_GAP = 12;
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS; const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }; const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
const ServiceGlyph = ({ serviceType, size = 20 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const UtilityGlyph = ({ name, size = 20, color = '#8B949E' }) => {
if (name === 'hidden') return <HiddenIcon width={size} height={size} color={color} />;
if (name === 'duplicates') return <DuplicatesIcon width={size} height={size} color={color} />;
if (name === 'trash') return <TrashIcon width={size} height={size} color={color} />;
if (name === 'emptyMail') return <EmptyMailIcon width={size} height={size} color={color} />;
return <FolderIcon width={size} height={size} color={color} />;
};
const CollectionsScreen = ({ route }) => { const CollectionsScreen = ({ route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
@@ -33,7 +58,6 @@ const CollectionsScreen = ({ route }) => {
}, [userId]) }, [userId])
); );
const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
const getServiceName = (type) => SERVICE_NAMES[type] || type; const getServiceName = (type) => SERVICE_NAMES[type] || type;
const refreshCollections = () => { const refreshCollections = () => {
@@ -54,7 +78,7 @@ const CollectionsScreen = ({ route }) => {
const resolved = MediaMetadataService.resolveAlbumEntriesToMediaItems(entries, allMedia); const resolved = MediaMetadataService.resolveAlbumEntriesToMediaItems(entries, allMedia);
myAlbums.push({ myAlbums.push({
name: al.name, name: al.name,
icon: '📁', iconType: 'folder',
count: resolved.length, count: resolved.length,
coverImage: resolved[0]?.local_path || null, coverImage: resolved[0]?.local_path || null,
media: resolved, media: resolved,
@@ -64,16 +88,16 @@ const CollectionsScreen = ({ route }) => {
if (myAlbums.length > 0) { if (myAlbums.length > 0) {
sectionList.push({ sectionList.push({
title: 'My albums', title: 'My albums',
icon: '📂', iconType: 'folderOpen',
data: [{ albums: myAlbums }], data: [{ albums: myAlbums }],
}); });
} }
const utilityAlbums = [ const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' }, { name: 'All Photos', iconType: 'allPhotos', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' }, { name: 'Hidden', iconType: 'hidden', count: 0, media: [], type: 'utility' },
{ name: 'Duplicates', icon: '📋', count: 0, media: [], type: 'utility' }, { name: 'Duplicates', iconType: 'duplicates', count: 0, media: [], type: 'utility' },
{ name: 'Recently Deleted', icon: '🗑️', count: 0, media: [], type: 'utility' }, { name: 'Recently Deleted', iconType: 'trash', count: 0, media: [], type: 'utility' },
]; ];
sectionList.push({ title: '', data: [{ albums: utilityAlbums }] }); sectionList.push({ title: '', data: [{ albums: utilityAlbums }] });
@@ -82,7 +106,7 @@ const CollectionsScreen = ({ route }) => {
const serviceAlbums = [ const serviceAlbums = [
{ {
name: `All ${getServiceName(acc.service_type)}`, name: `All ${getServiceName(acc.service_type)}`,
icon: getServiceIcon(acc.service_type), serviceType: acc.service_type,
count: media.length, count: media.length,
coverImage: media.length > 0 ? media[0].local_path : null, coverImage: media.length > 0 ? media[0].local_path : null,
media, media,
@@ -91,7 +115,7 @@ const CollectionsScreen = ({ route }) => {
]; ];
sectionList.push({ sectionList.push({
title: getServiceName(acc.service_type), title: getServiceName(acc.service_type),
icon: getServiceIcon(acc.service_type), serviceType: acc.service_type,
data: [{ albums: serviceAlbums }], data: [{ albums: serviceAlbums }],
}); });
} }
@@ -122,7 +146,7 @@ const CollectionsScreen = ({ route }) => {
</View> </View>
{albumMedia.length === 0 ? ( {albumMedia.length === 0 ? (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📭</Text> <UtilityGlyph name="emptyMail" size={48} />
<Text style={[styles.emptyTitle, { color: theme.text }]}>No items in this collection</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No items in this collection</Text>
</View> </View>
) : ( ) : (
@@ -138,7 +162,7 @@ const CollectionsScreen = ({ route }) => {
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" /> <Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
) : ( ) : (
<View style={[styles.gridPlaceholder, { backgroundColor: theme.borderLight }]}> <View style={[styles.gridPlaceholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 24 }}>📷</Text> <CameraIcon width={24} height={24} color="#7C7F85" />
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -176,7 +200,15 @@ const CollectionsScreen = ({ route }) => {
<Image source={{ uri: album.coverImage }} style={styles.albumCoverImage} resizeMode="cover" /> <Image source={{ uri: album.coverImage }} style={styles.albumCoverImage} resizeMode="cover" />
) : ( ) : (
<View style={[styles.albumCoverPlaceholder, { backgroundColor: theme.borderLight }]}> <View style={[styles.albumCoverPlaceholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 36 }}>{album.icon}</Text> {album.serviceType ? (
<ServiceGlyph serviceType={album.serviceType} size={34} />
) : album.iconType === 'folder' ? (
<FolderIcon width={34} height={34} color="#8B949E" />
) : album.iconType === 'allPhotos' ? (
<EmptyPhotoIcon width={34} height={34} color="#9CA3AF" />
) : (
<UtilityGlyph name={album.iconType} size={34} />
)}
</View> </View>
)} )}
</View> </View>
@@ -193,7 +225,11 @@ const CollectionsScreen = ({ route }) => {
if (!section.title) return null; if (!section.title) return null;
return ( return (
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionIcon}>{section.icon}</Text> {section.serviceType ? (
<ServiceGlyph serviceType={section.serviceType} size={20} />
) : (
<FolderOpenIcon width={20} height={20} color="#8B949E" />
)}
<Text style={[styles.sectionTitle, { color: theme.text }]}>{section.title}</Text> <Text style={[styles.sectionTitle, { color: theme.text }]}>{section.title}</Text>
</View> </View>
); );
@@ -208,7 +244,7 @@ const CollectionsScreen = ({ route }) => {
{sections.length === 0 {sections.length === 0
|| !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? ( || !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text> <FolderIcon width={48} height={48} color="#8B949E" />
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Collections from your connected services will appear here Collections from your connected services will appear here
+88 -13
View File
@@ -14,12 +14,22 @@ import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer'; import MediaViewer from '../components/MediaViewer';
import SelectionToolbar from '../components/SelectionToolbar'; import SelectionToolbar from '../components/SelectionToolbar';
import AlbumPickerModal from '../components/AlbumPickerModal'; import AlbumPickerModal from '../components/AlbumPickerModal';
import MediaSortControls from '../components/MediaSortControls';
import MediaMetadataService from '../services/MediaMetadataService'; import MediaMetadataService from '../services/MediaMetadataService';
import { logError } from '../services/ErrorHandler'; import { logError } from '../services/ErrorHandler';
import CameraIcon from '../../assets/icons/camera.svg';
import EmptyPhotoIcon from '../../assets/icons/empty-photo.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }; const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' };
const ServiceGlyph = ({ serviceType, size = 14 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
/** Keeps device library timestamps and aligns `created_date` with resolved capture time after merges. */ /** Keeps device library timestamps and aligns `created_date` with resolved capture time after merges. */
const mergeCaptureFields = (target, donor) => { const mergeCaptureFields = (target, donor) => {
@@ -98,6 +108,29 @@ const mergeMediaAcrossServices = (items) => {
}); });
}; };
const buildSourceKey = (item) => `${item?.source_service || ''}:${item?.source_id || ''}`;
const applyPersistedTitleOverrides = (items, persistedRows) => {
const bySource = new Map();
for (const row of persistedRows || []) {
if (row?.source_id && row?.source_service && row?.title) {
bySource.set(buildSourceKey(row), row.title);
}
}
return (items || []).map((item) => {
const override = bySource.get(buildSourceKey(item));
return override ? { ...item, title: override } : item;
});
};
const preserveFileExtensionIfMissing = (oldName, nextName) => {
const trimmed = (nextName || '').trim();
if (!trimmed) return '';
const oldExt = oldName && oldName.includes('.') ? oldName.slice(oldName.lastIndexOf('.')) : '';
const hasNewExt = trimmed.includes('.');
return !hasNewExt && oldExt ? `${trimmed}${oldExt}` : trimmed;
};
const buildGoogleAuth = (accountData = {}) => { const buildGoogleAuth = (accountData = {}) => {
const googleAuth = new GoogleAUTH(); const googleAuth = new GoogleAUTH();
googleAuth.accessToken = accountData.accessToken || null; googleAuth.accessToken = accountData.accessToken || null;
@@ -138,6 +171,7 @@ const HomeScreen = ({ navigation, route }) => {
const [googleSessionId, setGoogleSessionId] = useState(null); const [googleSessionId, setGoogleSessionId] = useState(null);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [renaming, setRenaming] = useState(false);
const [sortMode, setSortMode] = useState('date_taken'); const [sortMode, setSortMode] = useState('date_taken');
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const [selectedKeys, setSelectedKeys] = useState(() => new Set()); const [selectedKeys, setSelectedKeys] = useState(() => new Set());
@@ -225,7 +259,8 @@ const HomeScreen = ({ navigation, route }) => {
console.log('iCloud: placeholder — would fetch photos here'); console.log('iCloud: placeholder — would fetch photos here');
} }
const merged = mergeMediaAcrossServices(newMedia); const persistedRows = await DatabaseService.getMediaByUser(userId);
const merged = mergeMediaAcrossServices(applyPersistedTitleOverrides(newMedia, persistedRows));
setAllMedia(merged); setAllMedia(merged);
} catch (err) { } catch (err) {
console.error('Error loading media:', err); console.error('Error loading media:', err);
@@ -426,7 +461,48 @@ const HomeScreen = ({ navigation, route }) => {
} }
}; };
const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE; const handleRenameMedia = useCallback(async (item, nextTitle) => {
if (!item || !nextTitle?.trim()) return false;
const titleForStore = preserveFileExtensionIfMissing(item.title, nextTitle);
if (!titleForStore) return false;
setRenaming(true);
try {
if (item.source_service === 'NAS') {
const nasId = item.source_id || item.remote_path;
const acc = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasId && acc?.account_data) {
const nas = new NASAUTH();
const d = acc.account_data;
await nas.authenticate(d.host, d.port, d.username, d.password, d.photoFolder);
await nas.renameMedia(nasId, titleForStore);
await nas.revokeAccess();
}
}
let ok = await DatabaseService.renameMediaTitle(userId, item, titleForStore);
if (!ok) {
await DatabaseService.saveMediaItem(userId, { ...item, title: titleForStore });
ok = await DatabaseService.renameMediaTitle(userId, item, titleForStore);
}
if (!ok) {
Alert.alert('Rename failed', 'Could not persist the new title.');
return false;
}
const sourceKey = buildSourceKey(item);
setAllMedia((prev) => prev.map((m) => (buildSourceKey(m) === sourceKey ? { ...m, title: titleForStore } : m)));
setSelectedMedia((prev) => (prev && buildSourceKey(prev) === sourceKey ? { ...prev, title: titleForStore } : prev));
return true;
} catch (err) {
logError('Rename media', err);
Alert.alert('Rename failed', err?.message || 'Unexpected error while renaming');
return false;
} finally {
setRenaming(false);
}
}, [linkedAccounts, userId]);
const getServiceName = (type) => SERVICE_NAMES[type] || type; const getServiceName = (type) => SERVICE_NAMES[type] || type;
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS'); const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
@@ -441,10 +517,9 @@ const HomeScreen = ({ navigation, route }) => {
} }
const sourceFilters = [ const sourceFilters = [
{ key: 'DEVICE', icon: '📱', name: 'On Device' }, { key: 'DEVICE', name: 'On Device' },
...linkedAccounts.map((acc) => ({ ...linkedAccounts.map((acc) => ({
key: acc.service_type, key: acc.service_type,
icon: getServiceIcon(acc.service_type),
name: getServiceName(acc.service_type), name: getServiceName(acc.service_type),
})), })),
]; ];
@@ -463,7 +538,7 @@ const HomeScreen = ({ navigation, route }) => {
style={[styles.chip, { backgroundColor: selectedFilter === f.key ? theme.chipActiveBg : theme.chipBg }]} style={[styles.chip, { backgroundColor: selectedFilter === f.key ? theme.chipActiveBg : theme.chipBg }]}
onPress={() => setSelectedFilter(f.key)} onPress={() => setSelectedFilter(f.key)}
> >
<Text style={styles.chipIcon}>{f.icon}</Text> <ServiceGlyph serviceType={f.key} size={13} />
<Text style={[styles.chipText, selectedFilter === f.key && { color: theme.primary, fontWeight: '600' }]}> <Text style={[styles.chipText, selectedFilter === f.key && { color: theme.primary, fontWeight: '600' }]}>
{f.name} {f.name}
</Text> </Text>
@@ -483,8 +558,6 @@ const HomeScreen = ({ navigation, route }) => {
<FilterChips /> <FilterChips />
<MediaSortControls value={sortMode} onChange={setSortMode} />
<SelectionToolbar <SelectionToolbar
visible={selectionMode} visible={selectionMode}
selectedCount={selectedKeys.size} selectedCount={selectedKeys.size}
@@ -503,7 +576,7 @@ const HomeScreen = ({ navigation, route }) => {
<ActivityIndicator size="small" color="#fff" /> <ActivityIndicator size="small" color="#fff" />
) : ( ) : (
<> <>
<Text style={styles.importIcon}>📷</Text> <CameraIcon width={16} height={16} color="#FFFFFF" />
<Text style={styles.importText}>Import from Google Photos</Text> <Text style={styles.importText}>Import from Google Photos</Text>
</> </>
)} )}
@@ -512,7 +585,9 @@ const HomeScreen = ({ navigation, route }) => {
{filteredMedia.length === 0 ? ( {filteredMedia.length === 0 ? (
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text> <View style={{ marginBottom: 16 }}>
<EmptyPhotoIcon width={56} height={56} color="#9CA3AF" />
</View>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>
{hasGoogleAccount {hasGoogleAccount
@@ -561,6 +636,8 @@ const HomeScreen = ({ navigation, route }) => {
allMedia={filteredMedia} allMedia={filteredMedia}
onClose={() => setViewerVisible(false)} onClose={() => setViewerVisible(false)}
resolveNasMetadata={resolveNasMetadata} resolveNasMetadata={resolveNasMetadata}
onRenameMedia={handleRenameMedia}
renaming={renaming}
/> />
</View> </View>
); );
@@ -576,14 +653,12 @@ const styles = StyleSheet.create({
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 16, gap: 4, height: 28, borderRadius: 16, gap: 4, height: 28,
}, },
chipIcon: { fontSize: 11 },
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' }, chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
importButton: { importButton: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16, marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
borderRadius: 12, gap: 8, minHeight: 42, borderRadius: 12, gap: 8, minHeight: 42,
}, },
importIcon: { fontSize: 16 },
importText: { color: '#fff', fontSize: 14, fontWeight: '600' }, importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 }, emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 }, emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
+2
View File
@@ -5,6 +5,7 @@ import {
} from 'react-native'; } from 'react-native';
import { useTheme } from '../services/ThemeContext'; import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import { setStoredUserId } from '../services/AuthSession';
const APP_ICON = require('../../assets/icon.png'); const APP_ICON = require('../../assets/icon.png');
const isBlank = (value) => !value?.trim(); const isBlank = (value) => !value?.trim();
@@ -25,6 +26,7 @@ const LoginScreen = ({ navigation }) => {
try { try {
const user = await DatabaseService.loginUser(identifier.trim(), password); const user = await DatabaseService.loginUser(identifier.trim(), password);
if (user) { if (user) {
await setStoredUserId(user.id);
const accounts = await DatabaseService.getLinkedAccounts(user.id); const accounts = await DatabaseService.getLinkedAccounts(user.id);
if (accounts.length > 0) { if (accounts.length > 0) {
navigation.replace('MainTabs', { userId: user.id }); navigation.replace('MainTabs', { userId: user.id });
+57 -21
View File
@@ -9,17 +9,28 @@ import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH'; import NASAUTH from '../AUTH/NASAUTH';
import NASSetupInstructions from '../components/NASSetupInstructions'; import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator'; import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import CheckIcon from '../../assets/icons/check.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const SERVICES = { const SERVICES = {
GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' }, GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS' },
ICLOUD_PHOTOS: { name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' }, ICLOUD_PHOTOS: { name: 'iCloud Photos', type: 'ICLOUD_PHOTOS' },
NAS: { name: 'NAS Storage', type: 'NAS', icon: '💾' }, NAS: { name: 'NAS Storage', type: 'NAS' },
}; };
const ONBOARDING_STEPS = [ const ONBOARDING_STEPS = [
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' }, { title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
{ title: 'Connect Services', description: 'Set up each selected service' }, { title: 'Connect Services', description: 'Set up each selected service' },
{ title: 'Automatic Backup', description: 'Choose where new photos are backed up' }, { title: 'Automatic Backup', description: 'Choose where new photos are backed up' },
]; ];
const ServiceGlyph = ({ serviceType, size = 20 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const OnboardingScreen = ({ navigation, route }) => { const OnboardingScreen = ({ navigation, route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
@@ -196,13 +207,13 @@ const OnboardingScreen = ({ navigation, route }) => {
{currentStep === 0 && ( {currentStep === 0 && (
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
<ServiceToggleCard theme={theme} icon="📷" name="Google Photos" <ServiceToggleCard theme={theme} serviceType="GOOGLE_PHOTOS" name="Google Photos"
description="Access your Google Photos library" description="Access your Google Photos library"
enabled={googleEnabled} onToggle={() => setGoogleEnabled(!googleEnabled)} /> enabled={googleEnabled} onToggle={() => setGoogleEnabled(!googleEnabled)} />
<ServiceToggleCard theme={theme} icon="🍎" name="iCloud Photos" <ServiceToggleCard theme={theme} serviceType="ICLOUD_PHOTOS" name="iCloud Photos"
description="Access your iCloud photo library (placeholder)" description="Access your iCloud photo library (placeholder)"
enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} /> enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} />
<ServiceToggleCard theme={theme} icon="💾" name="NAS Storage" <ServiceToggleCard theme={theme} serviceType="NAS" name="NAS Storage"
description="Connect to your Ubuntu server via SFTP" description="Connect to your Ubuntu server via SFTP"
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} /> enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
@@ -216,9 +227,16 @@ const OnboardingScreen = ({ navigation, route }) => {
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}> <View style={styles.configHeader}>
<ServiceHealthIndicator status={googleHealth} size={10} /> <ServiceHealthIndicator status={googleHealth} size={10} />
<Text style={styles.configIcon}>📷</Text> <View style={styles.configIcon}>
<ServiceGlyph serviceType="GOOGLE_PHOTOS" size={24} />
</View>
<Text style={[styles.configTitle, { color: theme.text }]}>Google Photos</Text> <Text style={[styles.configTitle, { color: theme.text }]}>Google Photos</Text>
{googleConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>} {googleConnected && (
<View style={styles.connectedRow}>
<CheckIcon width={12} height={12} color={theme.accent} />
<Text style={[styles.connectedBadge, { color: theme.accent }]}>Connected</Text>
</View>
)}
</View> </View>
{!googleConnected && ( {!googleConnected && (
<TouchableOpacity <TouchableOpacity
@@ -235,9 +253,16 @@ const OnboardingScreen = ({ navigation, route }) => {
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}> <View style={styles.configHeader}>
<ServiceHealthIndicator status={iosHealth} size={10} /> <ServiceHealthIndicator status={iosHealth} size={10} />
<Text style={styles.configIcon}>🍎</Text> <View style={styles.configIcon}>
<ServiceGlyph serviceType="ICLOUD_PHOTOS" size={24} />
</View>
<Text style={[styles.configTitle, { color: theme.text }]}>iCloud Photos</Text> <Text style={[styles.configTitle, { color: theme.text }]}>iCloud Photos</Text>
{iosConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>} {iosConnected && (
<View style={styles.connectedRow}>
<CheckIcon width={12} height={12} color={theme.accent} />
<Text style={[styles.connectedBadge, { color: theme.accent }]}>Connected</Text>
</View>
)}
</View> </View>
{!iosConnected && ( {!iosConnected && (
<TouchableOpacity <TouchableOpacity
@@ -254,9 +279,16 @@ const OnboardingScreen = ({ navigation, route }) => {
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}> <View style={styles.configHeader}>
<ServiceHealthIndicator status={nasHealth} size={10} /> <ServiceHealthIndicator status={nasHealth} size={10} />
<Text style={styles.configIcon}>💾</Text> <View style={styles.configIcon}>
<ServiceGlyph serviceType="NAS" size={24} />
</View>
<Text style={[styles.configTitle, { color: theme.text }]}>NAS Storage</Text> <Text style={[styles.configTitle, { color: theme.text }]}>NAS Storage</Text>
{nasConnected && <Text style={[styles.connectedBadge, { color: theme.accent }]}>✓ Connected</Text>} {nasConnected && (
<View style={styles.connectedRow}>
<CheckIcon width={12} height={12} color={theme.accent} />
<Text style={[styles.connectedBadge, { color: theme.accent }]}>Connected</Text>
</View>
)}
</View> </View>
{!nasConnected && ( {!nasConnected && (
<> <>
@@ -312,9 +344,11 @@ const OnboardingScreen = ({ navigation, route }) => {
{ borderColor: theme.primary }, { borderColor: theme.primary },
autoBackupServices.includes(svc.type) && { backgroundColor: theme.primary }, autoBackupServices.includes(svc.type) && { backgroundColor: theme.primary },
]}> ]}>
{autoBackupServices.includes(svc.type) && <Text style={styles.checkmark}>✓</Text>} {autoBackupServices.includes(svc.type) && <CheckIcon width={12} height={12} color="#fff" />}
</View>
<View style={styles.checkIcon}>
<ServiceGlyph serviceType={svc.type} size={18} />
</View> </View>
<Text style={styles.checkIcon}>{svc.icon}</Text>
<Text style={[styles.checkLabel, { color: theme.text }]}>{svc.name}</Text> <Text style={[styles.checkLabel, { color: theme.text }]}>{svc.name}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@@ -329,7 +363,7 @@ const OnboardingScreen = ({ navigation, route }) => {
{ borderColor: theme.border }, { borderColor: theme.border },
autoBackupServices.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary }, autoBackupServices.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary },
]}> ]}>
{autoBackupServices.length === 0 && <Text style={styles.checkmark}>✓</Text>} {autoBackupServices.length === 0 && <CheckIcon width={12} height={12} color="#fff" />}
</View> </View>
<Text style={[styles.checkLabel, { color: theme.text }]}>Don't automatically back up</Text> <Text style={[styles.checkLabel, { color: theme.text }]}>Don't automatically back up</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -362,10 +396,12 @@ const OnboardingScreen = ({ navigation, route }) => {
); );
}; };
const ServiceToggleCard = ({ theme, icon, name, description, enabled, onToggle }) => ( const ServiceToggleCard = ({ theme, serviceType, name, description, enabled, onToggle }) => (
<View style={[styles.toggleCard, { backgroundColor: theme.card, borderColor: enabled ? theme.primary : theme.border }]}> <View style={[styles.toggleCard, { backgroundColor: theme.card, borderColor: enabled ? theme.primary : theme.border }]}>
<View style={styles.toggleCardContent}> <View style={styles.toggleCardContent}>
<Text style={styles.toggleIcon}>{icon}</Text> <View style={styles.toggleIcon}>
<ServiceGlyph serviceType={serviceType} size={28} />
</View>
<View style={styles.toggleInfo}> <View style={styles.toggleInfo}>
<Text style={[styles.toggleName, { color: theme.text }]}>{name}</Text> <Text style={[styles.toggleName, { color: theme.text }]}>{name}</Text>
<Text style={[styles.toggleDescription, { color: theme.textSecondary }]}>{description}</Text> <Text style={[styles.toggleDescription, { color: theme.textSecondary }]}>{description}</Text>
@@ -398,15 +434,16 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5, justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
}, },
toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' }, toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' },
toggleIcon: { fontSize: 28, marginRight: 14 }, toggleIcon: { marginRight: 14 },
toggleInfo: { flex: 1 }, toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 }, toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12 }, toggleDescription: { fontSize: 12 },
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 }, configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 }, configIcon: { marginRight: 10 },
configTitle: { fontSize: 16, fontWeight: '600', flex: 1 }, configTitle: { fontSize: 16, fontWeight: '600', flex: 1 },
connectedBadge: { fontSize: 13, fontWeight: '600' }, connectedBadge: { fontSize: 13, fontWeight: '600' },
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
authButton: { borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 8 }, authButton: { borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 8 },
authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' }, authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4 }, fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4 },
@@ -422,8 +459,7 @@ const styles = StyleSheet.create({
width: 22, height: 22, borderRadius: 4, borderWidth: 2, width: 22, height: 22, borderRadius: 4, borderWidth: 2,
justifyContent: 'center', alignItems: 'center', marginRight: 12, justifyContent: 'center', alignItems: 'center', marginRight: 12,
}, },
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' }, checkIcon: { marginRight: 8 },
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 }, checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
footer: { footer: {
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14, flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
+5 -6
View File
@@ -6,6 +6,7 @@ import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import PhotoGrid from '../components/PhotoGrid'; import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer'; import MediaViewer from '../components/MediaViewer';
import SearchIcon from '../../assets/icons/search.svg';
const SearchScreen = ({ route }) => { const SearchScreen = ({ route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
@@ -40,7 +41,7 @@ const SearchScreen = ({ route }) => {
return ( return (
<View style={[styles.container, { backgroundColor: theme.background }]}> <View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.searchBar, { backgroundColor: theme.searchBg }]}> <View style={[styles.searchBar, { backgroundColor: theme.searchBg }]}>
<Text style={styles.searchIcon}>🔍</Text> <SearchIcon width={16} height={16} color={theme.textSecondary} />
<TextInput <TextInput
style={[styles.searchInput, { color: theme.text }]} style={[styles.searchInput, { color: theme.text }]}
placeholder="Search photos and videos" placeholder="Search photos and videos"
@@ -66,7 +67,7 @@ const SearchScreen = ({ route }) => {
{!searching && query.length > 0 && results.length === 0 && ( {!searching && query.length > 0 && results.length === 0 && (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text> <SearchIcon width={48} height={48} color={theme.textTertiary} />
<Text style={[styles.emptyTitle, { color: theme.text }]}>No results</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>No results</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Try different keywords</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>Try different keywords</Text>
</View> </View>
@@ -74,10 +75,10 @@ const SearchScreen = ({ route }) => {
{query.length === 0 && ( {query.length === 0 && (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text> <SearchIcon width={48} height={48} color={theme.textTertiary} />
<Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</Text> <Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Search by filename and indexed metadata (bag-of-words style matching) Search by filename and indexed metadata
</Text> </Text>
</View> </View>
)} )}
@@ -106,12 +107,10 @@ const styles = StyleSheet.create({
marginTop: 48, marginBottom: 8, borderRadius: 24, marginTop: 48, marginBottom: 8, borderRadius: 24,
paddingHorizontal: 14, paddingVertical: 10, gap: 8, paddingHorizontal: 14, paddingVertical: 10, gap: 8,
}, },
searchIcon: { fontSize: 16 },
searchInput: { flex: 1, fontSize: 15, padding: 0 }, searchInput: { flex: 1, fontSize: 15, padding: 0 },
clearButton: { fontSize: 16, padding: 4 }, clearButton: { fontSize: 16, padding: 4 },
loadingContainer: { padding: 20, alignItems: 'center' }, loadingContainer: { padding: 20, alignItems: 'center' },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
emptyIcon: { fontSize: 48, marginBottom: 12 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 }, emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' }, emptyText: { fontSize: 13, textAlign: 'center' },
resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13 }, resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13 },
+39 -20
View File
@@ -10,14 +10,25 @@ import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator'; import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import NASAUTH from '../AUTH/NASAUTH'; import NASAUTH from '../AUTH/NASAUTH';
import GoogleAUTH from '../AUTH/GoogleAUTH'; import GoogleAUTH from '../AUTH/GoogleAUTH';
import { clearStoredUserId } from '../services/AuthSession';
import CheckIcon from '../../assets/icons/check.svg';
import DeviceIcon from '../../assets/icons/device.svg';
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
import ICloudIcon from '../../assets/icons/icloud.svg';
import NasIcon from '../../assets/icons/nas.svg';
const SERVICE_ICONS = { GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
const SERVICE_NAMES = { GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }; const SERVICE_NAMES = { GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
const AUTO_BACKUP_SERVICES = [ const AUTO_BACKUP_SERVICES = [
{ type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' }, { type: 'GOOGLE_PHOTOS', name: 'Google Photos' },
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' }, { type: 'ICLOUD_PHOTOS', name: 'iCloud Photos' },
{ type: 'NAS', name: 'NAS Storage', icon: '💾' }, { type: 'NAS', name: 'NAS Storage' },
]; ];
const ServiceGlyph = ({ serviceType, size = 20 }) => {
if (serviceType === 'GOOGLE_PHOTOS') return <GooglePhotosIcon width={size} height={size} />;
if (serviceType === 'ICLOUD_PHOTOS') return <ICloudIcon width={size} height={size} />;
if (serviceType === 'NAS') return <NasIcon width={size} height={size} />;
return <DeviceIcon width={size} height={size} />;
};
const SettingsScreen = ({ navigation, route }) => { const SettingsScreen = ({ navigation, route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
@@ -224,19 +235,24 @@ const SettingsScreen = ({ navigation, route }) => {
const handleLogout = () => { const handleLogout = () => {
Alert.alert('Logout', 'Are you sure you want to sign out?', [ Alert.alert('Logout', 'Are you sure you want to sign out?', [
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ text: 'Sign Out', style: 'destructive', onPress: () => navigation.replace('Login') }, {
text: 'Sign Out',
style: 'destructive',
onPress: async () => {
await clearStoredUserId();
navigation.replace('Login');
},
},
]); ]);
}; };
const getServiceIcon = (type) =>
(SERVICE_ICONS[type] || '📱');
const getServiceName = (type) => const getServiceName = (type) =>
(SERVICE_NAMES[type] || type); (SERVICE_NAMES[type] || type);
const themeOptions = [ const themeOptions = [
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' }, { key: 'light', label: 'Light', desc: 'Standard light mode' },
{ key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' }, { key: 'dark', label: 'Dark', desc: 'Dark mode for low light' },
{ key: 'liquidGlass', label: 'Liquid Glass', icon: '💎', desc: 'iOS-style frosted glass' }, { key: 'liquidGlass', label: 'Liquid Glass', desc: 'iOS-style frosted glass' },
]; ];
const hasNasConnected = accounts.some((a) => a.service_type === 'NAS' && a.is_enabled); const hasNasConnected = accounts.some((a) => a.service_type === 'NAS' && a.is_enabled);
@@ -264,7 +280,6 @@ const SettingsScreen = ({ navigation, route }) => {
]} ]}
onPress={() => setThemeName(opt.key)} onPress={() => setThemeName(opt.key)}
> >
<Text style={styles.themeIcon}>{opt.icon}</Text>
<View style={styles.themeInfo}> <View style={styles.themeInfo}>
<Text style={[styles.themeName, { color: theme.text }]}>{opt.label}</Text> <Text style={[styles.themeName, { color: theme.text }]}>{opt.label}</Text>
<Text style={[styles.themeDesc, { color: theme.textSecondary }]}>{opt.desc}</Text> <Text style={[styles.themeDesc, { color: theme.textSecondary }]}>{opt.desc}</Text>
@@ -291,7 +306,9 @@ const SettingsScreen = ({ navigation, route }) => {
<TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}> <TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}>
<ServiceHealthIndicator status={health} size={10} /> <ServiceHealthIndicator status={health} size={10} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.serviceIcon}>{getServiceIcon(acc.service_type)}</Text> <View style={styles.serviceIcon}>
<ServiceGlyph serviceType={acc.service_type} size={22} />
</View>
<View style={styles.serviceInfo}> <View style={styles.serviceInfo}>
<Text style={[styles.serviceName, { color: theme.text }]}> <Text style={[styles.serviceName, { color: theme.text }]}>
{getServiceName(acc.service_type)} {getServiceName(acc.service_type)}
@@ -369,7 +386,9 @@ const SettingsScreen = ({ navigation, route }) => {
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text> <Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.serviceRow}> <View style={styles.serviceRow}>
<Text style={styles.serviceIcon}>📷</Text> <View style={styles.serviceIcon}>
<ServiceGlyph serviceType="GOOGLE_PHOTOS" size={22} />
</View>
<View style={styles.serviceInfo}> <View style={styles.serviceInfo}>
<Text style={[styles.serviceName, { color: theme.text }]}>Offload Google Photos to NAS</Text> <Text style={[styles.serviceName, { color: theme.text }]}>Offload Google Photos to NAS</Text>
<Text style={[styles.serviceStatus, { color: theme.textSecondary }]}> <Text style={[styles.serviceStatus, { color: theme.textSecondary }]}>
@@ -407,9 +426,11 @@ const SettingsScreen = ({ navigation, route }) => {
{ borderColor: isConnected ? theme.primary : theme.border }, { borderColor: isConnected ? theme.primary : theme.border },
autoBackup.includes(svc.type) && { backgroundColor: theme.primary, borderColor: theme.primary }, autoBackup.includes(svc.type) && { backgroundColor: theme.primary, borderColor: theme.primary },
]}> ]}>
{autoBackup.includes(svc.type) && <Text style={styles.checkmark}>✓</Text>} {autoBackup.includes(svc.type) && <CheckIcon width={12} height={12} color="#fff" />}
</View>
<View style={styles.checkIcon}>
<ServiceGlyph serviceType={svc.type} size={18} />
</View> </View>
<Text style={styles.checkIcon}>{svc.icon}</Text>
<Text style={[ <Text style={[
styles.checkLabel, styles.checkLabel,
{ color: isConnected ? theme.text : theme.textTertiary }, { color: isConnected ? theme.text : theme.textTertiary },
@@ -437,7 +458,7 @@ const SettingsScreen = ({ navigation, route }) => {
{ borderColor: theme.border }, { borderColor: theme.border },
autoBackup.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary }, autoBackup.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary },
]}> ]}>
{autoBackup.length === 0 && <Text style={styles.checkmark}>✓</Text>} {autoBackup.length === 0 && <CheckIcon width={12} height={12} color="#fff" />}
</View> </View>
<Text style={[styles.checkLabel, { color: theme.text, marginLeft: 0 }]}> <Text style={[styles.checkLabel, { color: theme.text, marginLeft: 0 }]}>
Don't automatically back up Don't automatically back up
@@ -478,7 +499,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
themeIcon: { fontSize: 22, marginRight: 12 },
themeInfo: { flex: 1 }, themeInfo: { flex: 1 },
themeName: { fontSize: 14, fontWeight: '600', marginBottom: 2 }, themeName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
themeDesc: { fontSize: 12 }, themeDesc: { fontSize: 12 },
@@ -489,7 +509,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
serviceIcon: { fontSize: 22, marginRight: 8 }, serviceIcon: { marginRight: 8 },
serviceInfo: { flex: 1 }, serviceInfo: { flex: 1 },
serviceName: { fontSize: 14, fontWeight: '600', marginBottom: 2 }, serviceName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
serviceStatus: { fontSize: 12 }, serviceStatus: { fontSize: 12 },
@@ -520,8 +540,7 @@ const styles = StyleSheet.create({
width: 22, height: 22, borderRadius: 4, borderWidth: 2, width: 22, height: 22, borderRadius: 4, borderWidth: 2,
justifyContent: 'center', alignItems: 'center', marginRight: 12, justifyContent: 'center', alignItems: 'center', marginRight: 12,
}, },
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' }, checkIcon: { marginRight: 8 },
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 }, checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
notConnected: { fontSize: 11 }, notConnected: { fontSize: 11 },
logoutButton: { logoutButton: {
+129 -57
View File
@@ -5,6 +5,7 @@ import { logError } from './ErrorHandler';
let db = null; let db = null;
let initPromise = null; let initPromise = null;
let writeQueue = Promise.resolve();
const MEDIA_INSERT_SQL = `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date, metadata_json, search_text) const MEDIA_INSERT_SQL = `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date, metadata_json, search_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false }; const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
@@ -22,6 +23,21 @@ const normalizeMetaString = (itemOrParams) => {
} }
return toNullable(itemOrParams.metadata_json); return toNullable(itemOrParams.metadata_json);
}; };
const parseJsonSafe = (value, fallback = {}) => {
if (!value || typeof value !== 'string') return { ...fallback };
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : { ...fallback };
} catch {
return { ...fallback };
}
};
const enqueueDbWrite = async (operation) => {
const run = async () => operation();
const next = writeQueue.then(run, run);
writeQueue = next.catch(() => {});
return next;
};
const buildMediaInsertParams = (itemOrParams) => { const buildMediaInsertParams = (itemOrParams) => {
const metaStr = normalizeMetaString(itemOrParams); const metaStr = normalizeMetaString(itemOrParams);
const searchText = const searchText =
@@ -366,68 +382,73 @@ const DatabaseService = {
}, },
saveMediaItem: async (userId, item) => { saveMediaItem: async (userId, item) => {
try { return enqueueDbWrite(async () => {
if (!(await DatabaseService.ensureDb())) return null; try {
if (!userId || !item?.source_service) if (!(await DatabaseService.ensureDb())) return null;
return null; if (!userId || !item?.source_service)
return null;
const incomingMeta = normalizeMetaString(item); const incomingMeta = normalizeMetaString(item);
const mergedFromIncoming = incomingMeta const mergedFromIncoming = incomingMeta
? MediaMetadataService.mergeMetadataJson('{}', incomingMeta) ? MediaMetadataService.mergeMetadataJson('{}', incomingMeta)
: null; : null;
if (item.source_id) { if (item.source_id && item.source_service) {
const existing = await db.getFirstAsync( const existing = await db.getFirstAsync(
'SELECT * FROM media WHERE user_id = ? AND source_id = ?', 'SELECT * FROM media WHERE user_id = ? AND source_service = ? AND source_id = ?',
[userId, item.source_id] [userId, item.source_service, item.source_id]
);
if (existing) {
const mergedMeta = incomingMeta
? MediaMetadataService.mergeMetadataJson(existing.metadata_json, incomingMeta)
: existing.metadata_json;
const searchText = MediaMetadataService.buildSearchText(
{ ...existing, ...item, title: item.title || existing.title },
mergedMeta
); );
const rowForCapture = { if (existing) {
...existing, const existingMetaObj = parseJsonSafe(existing.metadata_json);
...item, const keepManualTitle = existingMetaObj.renameLocked === true;
title: item.title || existing.title, const mergedMeta = incomingMeta
metadata_json: mergedMeta, ? MediaMetadataService.mergeMetadataJson(existing.metadata_json, incomingMeta)
creationTime: item.creationTime ?? existing.creationTime, : existing.metadata_json;
modifyTime: item.modifyTime ?? existing.modifyTime, const effectiveTitle = keepManualTitle ? existing.title : (item.title || existing.title);
}; const searchText = MediaMetadataService.buildSearchText(
const createdForDb = { ...existing, ...item, title: effectiveTitle },
MediaMetadataService.resolveCreatedDateIsoForStorage(rowForCapture) mergedMeta
?? toNullable(item.created_date) );
?? toNullable(existing.created_date); const rowForCapture = {
await db.runAsync( ...existing,
`UPDATE media SET metadata_json = ?, search_text = ?, ...item,
local_path = COALESCE(?, local_path), remote_path = COALESCE(?, remote_path), title: effectiveTitle,
title = COALESCE(?, title), created_date = ? metadata_json: mergedMeta,
WHERE id = ?`, creationTime: item.creationTime ?? existing.creationTime,
[ modifyTime: item.modifyTime ?? existing.modifyTime,
mergedMeta, };
searchText, const createdForDb =
toNullable(item.local_path), MediaMetadataService.resolveCreatedDateIsoForStorage(rowForCapture)
toNullable(item.remote_path), ?? toNullable(item.created_date)
toNullable(item.title), ?? toNullable(existing.created_date);
createdForDb, await db.runAsync(
existing.id, `UPDATE media SET metadata_json = ?, search_text = ?,
] local_path = COALESCE(?, local_path), remote_path = COALESCE(?, remote_path),
); title = COALESCE(?, title), created_date = ?
return existing.id; WHERE id = ?`,
[
mergedMeta,
searchText,
toNullable(item.local_path),
toNullable(item.remote_path),
keepManualTitle ? toNullable(existing.title) : toNullable(item.title),
createdForDb,
existing.id,
]
);
return existing.id;
}
} }
}
const insertRow = { ...item, userId, metadata_json: mergedFromIncoming }; const insertRow = { ...item, userId, metadata_json: mergedFromIncoming };
const resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow); const resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow);
if (resolvedInsert) insertRow.created_date = resolvedInsert; if (resolvedInsert) insertRow.created_date = resolvedInsert;
return await DatabaseService.runMediaInsert(insertRow); return await DatabaseService.runMediaInsert(insertRow);
} catch (error) { } catch (error) {
logError('Save Media Item', error); logError('Save Media Item', error);
return null; return null;
} }
});
}, },
getMediaByUser: async (userId) => { getMediaByUser: async (userId) => {
@@ -460,6 +481,57 @@ const DatabaseService = {
} }
}, },
renameMediaTitle: async (userId, item, newTitle) => {
try {
if (!(await DatabaseService.ensureDb())) return false;
const trimmed = (newTitle || '').trim();
if (!userId || !item || !trimmed) return false;
let row = null;
if (item.source_id) {
row = await db.getFirstAsync(
'SELECT * FROM media WHERE user_id = ? AND source_service = ? AND source_id = ?',
[userId, item.source_service, item.source_id]
);
}
if (!row && hasValue(item.id)) {
row = await db.getFirstAsync(
'SELECT * FROM media WHERE id = ? AND user_id = ?',
[item.id, userId]
);
}
if (!row) return false;
const currentMeta = parseJsonSafe(row.metadata_json);
const metadata_json = JSON.stringify({
...currentMeta,
renameLocked: true,
originalTitle: currentMeta.originalTitle || row.title || item.title || null,
});
const search_text = MediaMetadataService.buildSearchText(
{ ...row, title: trimmed },
metadata_json
);
await db.runAsync(
'UPDATE media SET title = ?, metadata_json = ?, search_text = ? WHERE id = ?',
[trimmed, metadata_json, search_text, row.id]
);
if (item.source_service && item.source_id) {
await db.runAsync(
`UPDATE album_entries
SET title_snapshot = ?
WHERE source_service = ? AND media_fingerprint LIKE ?`,
[trimmed, item.source_service, `${item.source_service}:${item.source_id}%`]
);
}
return true;
} catch (error) {
logError('Rename Media Title', error);
return false;
}
},
searchMedia: async (userId, query) => { searchMedia: async (userId, query) => {
try { try {
if (!(await DatabaseService.ensureDb())) return []; if (!(await DatabaseService.ensureDb())) return [];