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 { registerRootComponent } from 'expo';
import * as WebBrowser from 'expo-web-browser';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
import DatabaseService from './src/services/DatabaseService';
import { getStoredUserId } from './src/services/AuthSession';
import LoginScreen from './src/screens/LoginScreen';
import RegisterScreen from './src/screens/RegisterScreen';
import OnboardingScreen from './src/screens/OnboardingScreen';
@@ -15,6 +17,9 @@ import HomeScreen from './src/screens/HomeScreen';
import SearchScreen from './src/screens/SearchScreen';
import CollectionsScreen from './src/screens/AlbumsScreen';
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();
const Stack = createNativeStackNavigator();
@@ -22,6 +27,8 @@ const Tab = createBottomTabNavigator();
function MainTabs({ route }) {
const { userId } = route.params || {};
const { theme } = useTheme();
const insets = useSafeAreaInsets();
const bottomInset = Math.max(insets.bottom, 8);
return (
<Tab.Navigator
@@ -33,8 +40,8 @@ function MainTabs({ route }) {
backgroundColor: theme.tabBar,
borderTopColor: theme.tabBarBorder,
borderTopWidth: 1,
height: 56,
paddingBottom: 6,
height: 56 + bottomInset,
paddingBottom: bottomInset,
paddingTop: 6,
},
tabBarLabelStyle: { fontSize: 11, fontWeight: '500' },
@@ -46,7 +53,7 @@ function MainTabs({ route }) {
initialParams={{ userId }}
options={{
tabBarLabel: 'Library',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🖼️</Text>,
tabBarIcon: ({ color }) => <LibraryGridIcon width={20} height={20} color={color} />,
}}
/>
<Tab.Screen
@@ -55,7 +62,7 @@ function MainTabs({ route }) {
initialParams={{ userId }}
options={{
tabBarLabel: 'Search',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>🔍</Text>,
tabBarIcon: ({ color }) => <SearchIcon width={20} height={20} color={color} />,
}}
/>
<Tab.Screen
@@ -64,28 +71,20 @@ function MainTabs({ route }) {
initialParams={{ userId }}
options={{
tabBarLabel: 'Collections',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 22, color }}>📁</Text>,
tabBarIcon: ({ color }) => <FolderIcon width={20} height={20} color={color} />,
}}
/>
</Tab.Navigator>
);
}
function AppNavigator() {
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>
);
}
// App navigation is initialized inside AppInner.
function AppInner() {
const { theme } = useTheme();
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState(null);
const [bootUserId, setBootUserId] = useState(null);
const [bootChecked, setBootChecked] = useState(false);
useEffect(() => { initializeApp(); }, []);
@@ -93,6 +92,9 @@ function AppInner() {
try {
setError(null);
await DatabaseService.initDatabase();
const stored = await getStoredUserId();
setBootUserId(stored);
setBootChecked(true);
setIsReady(true);
} catch (err) {
console.error('Database initialization failed:', err);
@@ -101,7 +103,7 @@ function AppInner() {
}
};
if (!isReady && !error) {
if ((!isReady || !bootChecked) && !error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: theme.background }}>
<ActivityIndicator size="large" color={theme.primary} />
@@ -126,7 +128,20 @@ function AppInner() {
return (
<>
<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>
<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
Useful scripts:
```bash
expo run:android
npx expo run:android
```
+3
View File
@@ -28,12 +28,15 @@
"expo-status-bar": "~55.0.5",
"expo-system-ui": "^55.0.15",
"expo-web-browser": "~55.0.14",
"metro-react-native-babel-transformer": "^0.77.0",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"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"
},
"devDependencies": {
+5
View File
@@ -228,6 +228,11 @@ class NASAPI {
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() {
if (this.connectionToken) {
try {
+5
View File
@@ -106,6 +106,11 @@ class NASAUTH extends AuthServiceBase {
this.ensureConnected();
return await this.nasAPI.deleteFile(remotePath);
}
async renameMedia(mediaId, newFilename) {
this.ensureConnected();
return await this.nasAPI.renameMedia(mediaId, newFilename);
}
}
export default NASAUTH;
+61 -15
View File
@@ -5,18 +5,29 @@ import {
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import MediaDetailDrawer from './MediaDetailDrawer';
import RenameMediaModal from './RenameMediaModal';
import CameraIcon from '../../assets/icons/camera.svg';
const DRAWER_PEEK = 124;
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const THUMB_SIZE = 60;
const THUMB_GAP = 4;
const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata }) => {
const MediaViewer = ({
visible,
media,
allMedia,
onClose,
resolveNasMetadata,
onRenameMedia,
renaming,
}) => {
const { theme } = useTheme();
const mainListRef = useRef(null);
const thumbListRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(0);
const [currentMedia, setCurrentMedia] = useState(media);
const [renameVisible, setRenameVisible] = useState(false);
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
const hasCarousel = mediaList.length > 1;
@@ -43,6 +54,10 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
}, 100);
}, [visible, media]);
useEffect(() => {
if (!visible) setRenameVisible(false);
}, [visible]);
const activeIndexRef = useRef(0);
const onMainScroll = (event) => {
@@ -74,7 +89,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
}
return (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: placeholderSize }}>📷</Text>
<CameraIcon width={placeholderSize} height={placeholderSize} color="#7C7F85" />
</View>
);
};
@@ -98,31 +113,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
<Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" />
) : (
<View style={styles.thumbPlaceholder}>
<Text style={{ fontSize: 20 }}>📷</Text>
<CameraIcon width={20} height={20} color="#7C7F85" />
</View>
)}
</TouchableOpacity>
);
const displayMedia = currentMedia || media;
const canRename = !!displayMedia && typeof onRenameMedia === 'function';
if (!media) return null;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<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 ? (
<FlatList
ref={mainListRef}
@@ -172,6 +176,36 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata })
resolveNasMetadata={resolveNasMetadata}
/>
</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>
</Modal>
);
@@ -185,12 +219,24 @@ const styles = StyleSheet.create({
elevation: 50,
justifyContent: 'flex-end',
},
topControls: {
...StyleSheet.absoluteFillObject,
zIndex: 120,
elevation: 120,
},
closeButton: {
position: 'absolute', top: 44, right: 16, zIndex: 20,
width: 36, height: 36, borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.2)',
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' },
counter: {
position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20,
+9 -4
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '../services/ThemeContext';
import NasIcon from '../../assets/icons/nas.svg';
const NASSetupInstructions = () => {
const { theme } = useTheme();
@@ -13,11 +14,14 @@ const NASSetupInstructions = () => {
onPress={() => setExpanded(!expanded)}
activeOpacity={0.7}
>
<Text style={[styles.headerText, { color: theme.text }]}>
📋 How to set up a NAS for photo storage
</Text>
<View style={styles.headerLabel}>
<NasIcon width={16} height={16} />
<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 }]}>
{expanded ? '▲' : '▼'}
{expanded ? '^' : 'v'}
</Text>
</TouchableOpacity>
@@ -71,6 +75,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
padding: 14,
},
headerLabel: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1, marginRight: 8 },
headerText: { fontSize: 13, fontWeight: '600', flex: 1, marginRight: 8 },
arrow: { fontSize: 12 },
body: { paddingHorizontal: 14, paddingBottom: 14 },
+19 -9
View File
@@ -4,10 +4,22 @@ import {
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
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 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 = ({
data,
@@ -28,9 +40,6 @@ const PhotoGrid = ({
const { theme } = useTheme();
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
const getServiceIcon = (type) =>
(SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE);
const renderSourceBadges = (item) => {
const sources = item.sources && item.sources.length > 0
? item.sources
@@ -39,7 +48,7 @@ const PhotoGrid = ({
if (sources.length === 1) {
return (
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
<ServiceGlyph serviceType={sources[0]} size={10} />
</View>
);
}
@@ -47,7 +56,9 @@ const PhotoGrid = ({
return (
<View style={styles.multiSourceBadge}>
{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>
);
@@ -98,7 +109,7 @@ const PhotoGrid = ({
/>
) : (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 28 }}>📷</Text>
<CameraIcon width={28} height={28} color="#7C7F85" />
</View>
)}
{item.media_type === 'video' && (
@@ -109,7 +120,7 @@ const PhotoGrid = ({
{renderSourceBadges(item)}
{selected && (
<View style={[styles.checkBadge, { borderColor: theme.primary }]}>
<Text style={[styles.checkMark, { color: theme.primary }]}>✓</Text>
<CheckIcon width={11} height={11} color={theme.primary} />
</View>
)}
</TouchableOpacity>
@@ -215,7 +226,6 @@ const styles = StyleSheet.create({
borderWidth: 2,
justifyContent: 'center', alignItems: 'center',
},
checkMark: { fontSize: 14, fontWeight: '800' },
});
export default PhotoGrid;
+2 -2
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useTheme } from '../services/ThemeContext';
import SearchIcon from '../../assets/icons/search.svg';
const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => {
const { theme } = useTheme();
@@ -12,7 +13,7 @@ const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) =>
style={[styles.searchPill, { backgroundColor: theme.searchBg }]}
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>
</TouchableOpacity>
<TouchableOpacity
@@ -46,7 +47,6 @@ const styles = StyleSheet.create({
paddingVertical: 10,
gap: 8,
},
searchIcon: { fontSize: 16 },
searchText: { fontSize: 14 },
profileButton: {
width: 32,
+51 -15
View File
@@ -8,13 +8,38 @@ import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import MediaMetadataService from '../services/MediaMetadataService';
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 ALBUM_COLUMNS = 2;
const ALBUM_GAP = 12;
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 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 userId = route?.params?.userId;
@@ -33,7 +58,6 @@ const CollectionsScreen = ({ route }) => {
}, [userId])
);
const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
const getServiceName = (type) => SERVICE_NAMES[type] || type;
const refreshCollections = () => {
@@ -54,7 +78,7 @@ const CollectionsScreen = ({ route }) => {
const resolved = MediaMetadataService.resolveAlbumEntriesToMediaItems(entries, allMedia);
myAlbums.push({
name: al.name,
icon: '📁',
iconType: 'folder',
count: resolved.length,
coverImage: resolved[0]?.local_path || null,
media: resolved,
@@ -64,16 +88,16 @@ const CollectionsScreen = ({ route }) => {
if (myAlbums.length > 0) {
sectionList.push({
title: 'My albums',
icon: '📂',
iconType: 'folderOpen',
data: [{ albums: myAlbums }],
});
}
const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
{ name: 'Duplicates', icon: '📋', count: 0, media: [], type: 'utility' },
{ name: 'Recently Deleted', icon: '🗑️', count: 0, media: [], type: 'utility' },
{ name: 'All Photos', iconType: 'allPhotos', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', iconType: 'hidden', count: 0, media: [], type: 'utility' },
{ name: 'Duplicates', iconType: 'duplicates', count: 0, media: [], type: 'utility' },
{ name: 'Recently Deleted', iconType: 'trash', count: 0, media: [], type: 'utility' },
];
sectionList.push({ title: '', data: [{ albums: utilityAlbums }] });
@@ -82,7 +106,7 @@ const CollectionsScreen = ({ route }) => {
const serviceAlbums = [
{
name: `All ${getServiceName(acc.service_type)}`,
icon: getServiceIcon(acc.service_type),
serviceType: acc.service_type,
count: media.length,
coverImage: media.length > 0 ? media[0].local_path : null,
media,
@@ -91,7 +115,7 @@ const CollectionsScreen = ({ route }) => {
];
sectionList.push({
title: getServiceName(acc.service_type),
icon: getServiceIcon(acc.service_type),
serviceType: acc.service_type,
data: [{ albums: serviceAlbums }],
});
}
@@ -122,7 +146,7 @@ const CollectionsScreen = ({ route }) => {
</View>
{albumMedia.length === 0 ? (
<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>
</View>
) : (
@@ -138,7 +162,7 @@ const CollectionsScreen = ({ route }) => {
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
) : (
<View style={[styles.gridPlaceholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 24 }}>📷</Text>
<CameraIcon width={24} height={24} color="#7C7F85" />
</View>
)}
</TouchableOpacity>
@@ -176,7 +200,15 @@ const CollectionsScreen = ({ route }) => {
<Image source={{ uri: album.coverImage }} style={styles.albumCoverImage} resizeMode="cover" />
) : (
<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>
@@ -193,7 +225,11 @@ const CollectionsScreen = ({ route }) => {
if (!section.title) return null;
return (
<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>
</View>
);
@@ -208,7 +244,7 @@ const CollectionsScreen = ({ route }) => {
{sections.length === 0
|| !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? (
<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.emptyText, { color: theme.textSecondary }]}>
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 SelectionToolbar from '../components/SelectionToolbar';
import AlbumPickerModal from '../components/AlbumPickerModal';
import MediaSortControls from '../components/MediaSortControls';
import MediaMetadataService from '../services/MediaMetadataService';
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 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. */
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 googleAuth = new GoogleAUTH();
googleAuth.accessToken = accountData.accessToken || null;
@@ -138,6 +171,7 @@ const HomeScreen = ({ navigation, route }) => {
const [googleSessionId, setGoogleSessionId] = useState(null);
const [loadingMore, setLoadingMore] = useState(false);
const [importing, setImporting] = useState(false);
const [renaming, setRenaming] = useState(false);
const [sortMode, setSortMode] = useState('date_taken');
const [selectionMode, setSelectionMode] = useState(false);
const [selectedKeys, setSelectedKeys] = useState(() => new Set());
@@ -225,7 +259,8 @@ const HomeScreen = ({ navigation, route }) => {
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);
} catch (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 hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
@@ -441,10 +517,9 @@ const HomeScreen = ({ navigation, route }) => {
}
const sourceFilters = [
{ key: 'DEVICE', icon: '📱', name: 'On Device' },
{ key: 'DEVICE', name: 'On Device' },
...linkedAccounts.map((acc) => ({
key: acc.service_type,
icon: getServiceIcon(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 }]}
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' }]}>
{f.name}
</Text>
@@ -483,8 +558,6 @@ const HomeScreen = ({ navigation, route }) => {
<FilterChips />
<MediaSortControls value={sortMode} onChange={setSortMode} />
<SelectionToolbar
visible={selectionMode}
selectedCount={selectedKeys.size}
@@ -503,7 +576,7 @@ const HomeScreen = ({ navigation, route }) => {
<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>
</>
)}
@@ -512,7 +585,9 @@ const HomeScreen = ({ navigation, route }) => {
{filteredMedia.length === 0 ? (
<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.emptyText, { color: theme.textSecondary }]}>
{hasGoogleAccount
@@ -561,6 +636,8 @@ const HomeScreen = ({ navigation, route }) => {
allMedia={filteredMedia}
onClose={() => setViewerVisible(false)}
resolveNasMetadata={resolveNasMetadata}
onRenameMedia={handleRenameMedia}
renaming={renaming}
/>
</View>
);
@@ -576,14 +653,12 @@ const styles = StyleSheet.create({
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 16, gap: 4, height: 28,
},
chipIcon: { fontSize: 11 },
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
importButton: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
borderRadius: 12, gap: 8, minHeight: 42,
},
importIcon: { fontSize: 16 },
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
+2
View File
@@ -5,6 +5,7 @@ import {
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import { setStoredUserId } from '../services/AuthSession';
const APP_ICON = require('../../assets/icon.png');
const isBlank = (value) => !value?.trim();
@@ -25,6 +26,7 @@ const LoginScreen = ({ navigation }) => {
try {
const user = await DatabaseService.loginUser(identifier.trim(), password);
if (user) {
await setStoredUserId(user.id);
const accounts = await DatabaseService.getLinkedAccounts(user.id);
if (accounts.length > 0) {
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 NASSetupInstructions from '../components/NASSetupInstructions';
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 = {
GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' },
ICLOUD_PHOTOS: { name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' },
NAS: { name: 'NAS Storage', type: 'NAS', icon: '💾' },
GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS' },
ICLOUD_PHOTOS: { name: 'iCloud Photos', type: 'ICLOUD_PHOTOS' },
NAS: { name: 'NAS Storage', type: 'NAS' },
};
const ONBOARDING_STEPS = [
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
{ title: 'Connect Services', description: 'Set up each selected service' },
{ 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 userId = route?.params?.userId;
@@ -196,13 +207,13 @@ const OnboardingScreen = ({ navigation, route }) => {
{currentStep === 0 && (
<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"
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)"
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"
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.configHeader}>
<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>
{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>
{!googleConnected && (
<TouchableOpacity
@@ -235,9 +253,16 @@ const OnboardingScreen = ({ navigation, route }) => {
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<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>
{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>
{!iosConnected && (
<TouchableOpacity
@@ -254,9 +279,16 @@ const OnboardingScreen = ({ navigation, route }) => {
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.configHeader}>
<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>
{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>
{!nasConnected && (
<>
@@ -312,9 +344,11 @@ const OnboardingScreen = ({ navigation, route }) => {
{ borderColor: 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>
<Text style={styles.checkIcon}>{svc.icon}</Text>
<Text style={[styles.checkLabel, { color: theme.text }]}>{svc.name}</Text>
</TouchableOpacity>
))}
@@ -329,7 +363,7 @@ const OnboardingScreen = ({ navigation, route }) => {
{ borderColor: theme.border },
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>
<Text style={[styles.checkLabel, { color: theme.text }]}>Don't automatically back up</Text>
</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.toggleCardContent}>
<Text style={styles.toggleIcon}>{icon}</Text>
<View style={styles.toggleIcon}>
<ServiceGlyph serviceType={serviceType} size={28} />
</View>
<View style={styles.toggleInfo}>
<Text style={[styles.toggleName, { color: theme.text }]}>{name}</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,
},
toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' },
toggleIcon: { fontSize: 28, marginRight: 14 },
toggleIcon: { marginRight: 14 },
toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12 },
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 },
configIcon: { marginRight: 10 },
configTitle: { fontSize: 16, fontWeight: '600', flex: 1 },
connectedBadge: { fontSize: 13, fontWeight: '600' },
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
authButton: { borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginTop: 8 },
authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
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,
justifyContent: 'center', alignItems: 'center', marginRight: 12,
},
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 },
checkIcon: { marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
footer: {
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 PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
import SearchIcon from '../../assets/icons/search.svg';
const SearchScreen = ({ route }) => {
const userId = route?.params?.userId;
@@ -40,7 +41,7 @@ const SearchScreen = ({ route }) => {
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.searchBar, { backgroundColor: theme.searchBg }]}>
<Text style={styles.searchIcon}>🔍</Text>
<SearchIcon width={16} height={16} color={theme.textSecondary} />
<TextInput
style={[styles.searchInput, { color: theme.text }]}
placeholder="Search photos and videos"
@@ -66,7 +67,7 @@ const SearchScreen = ({ route }) => {
{!searching && query.length > 0 && results.length === 0 && (
<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.emptyText, { color: theme.textSecondary }]}>Try different keywords</Text>
</View>
@@ -74,10 +75,10 @@ const SearchScreen = ({ route }) => {
{query.length === 0 && (
<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.emptyText, { color: theme.textSecondary }]}>
Search by filename and indexed metadata (bag-of-words style matching)
Search by filename and indexed metadata
</Text>
</View>
)}
@@ -106,12 +107,10 @@ const styles = StyleSheet.create({
marginTop: 48, marginBottom: 8, borderRadius: 24,
paddingHorizontal: 14, paddingVertical: 10, gap: 8,
},
searchIcon: { fontSize: 16 },
searchInput: { flex: 1, fontSize: 15, padding: 0 },
clearButton: { fontSize: 16, padding: 4 },
loadingContainer: { padding: 20, alignItems: 'center' },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
emptyIcon: { fontSize: 48, marginBottom: 12 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' },
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 NASAUTH from '../AUTH/NASAUTH';
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 AUTO_BACKUP_SERVICES = [
{ type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' },
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' },
{ type: 'NAS', name: 'NAS Storage', icon: '💾' },
{ type: 'GOOGLE_PHOTOS', name: 'Google Photos' },
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos' },
{ 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 userId = route?.params?.userId;
@@ -224,19 +235,24 @@ const SettingsScreen = ({ navigation, route }) => {
const handleLogout = () => {
Alert.alert('Logout', 'Are you sure you want to sign out?', [
{ 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) =>
(SERVICE_NAMES[type] || type);
const themeOptions = [
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
{ key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' },
{ key: 'liquidGlass', label: 'Liquid Glass', icon: '💎', desc: 'iOS-style frosted glass' },
{ key: 'light', label: 'Light', desc: 'Standard light mode' },
{ key: 'dark', label: 'Dark', desc: 'Dark mode for low light' },
{ key: 'liquidGlass', label: 'Liquid Glass', desc: 'iOS-style frosted glass' },
];
const hasNasConnected = accounts.some((a) => a.service_type === 'NAS' && a.is_enabled);
@@ -264,7 +280,6 @@ const SettingsScreen = ({ navigation, route }) => {
]}
onPress={() => setThemeName(opt.key)}
>
<Text style={styles.themeIcon}>{opt.icon}</Text>
<View style={styles.themeInfo}>
<Text style={[styles.themeName, { color: theme.text }]}>{opt.label}</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}>
<ServiceHealthIndicator status={health} size={10} />
</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}>
<Text style={[styles.serviceName, { color: theme.text }]}>
{getServiceName(acc.service_type)}
@@ -369,7 +386,9 @@ const SettingsScreen = ({ navigation, route }) => {
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.serviceRow}>
<Text style={styles.serviceIcon}>📷</Text>
<View style={styles.serviceIcon}>
<ServiceGlyph serviceType="GOOGLE_PHOTOS" size={22} />
</View>
<View style={styles.serviceInfo}>
<Text style={[styles.serviceName, { color: theme.text }]}>Offload Google Photos to NAS</Text>
<Text style={[styles.serviceStatus, { color: theme.textSecondary }]}>
@@ -407,9 +426,11 @@ const SettingsScreen = ({ navigation, route }) => {
{ borderColor: isConnected ? theme.primary : theme.border },
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>
<Text style={styles.checkIcon}>{svc.icon}</Text>
<Text style={[
styles.checkLabel,
{ color: isConnected ? theme.text : theme.textTertiary },
@@ -437,7 +458,7 @@ const SettingsScreen = ({ navigation, route }) => {
{ borderColor: theme.border },
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>
<Text style={[styles.checkLabel, { color: theme.text, marginLeft: 0 }]}>
Don't automatically back up
@@ -478,7 +499,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
themeIcon: { fontSize: 22, marginRight: 12 },
themeInfo: { flex: 1 },
themeName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
themeDesc: { fontSize: 12 },
@@ -489,7 +509,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
serviceIcon: { fontSize: 22, marginRight: 8 },
serviceIcon: { marginRight: 8 },
serviceInfo: { flex: 1 },
serviceName: { fontSize: 14, fontWeight: '600', marginBottom: 2 },
serviceStatus: { fontSize: 12 },
@@ -520,8 +540,7 @@ const styles = StyleSheet.create({
width: 22, height: 22, borderRadius: 4, borderWidth: 2,
justifyContent: 'center', alignItems: 'center', marginRight: 12,
},
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 },
checkIcon: { marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
notConnected: { fontSize: 11 },
logoutButton: {
+129 -57
View File
@@ -5,6 +5,7 @@ import { logError } from './ErrorHandler';
let db = 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
@@ -22,6 +23,21 @@ const normalizeMetaString = (itemOrParams) => {
}
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 metaStr = normalizeMetaString(itemOrParams);
const searchText =
@@ -366,68 +382,73 @@ const DatabaseService = {
},
saveMediaItem: async (userId, item) => {
try {
if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !item?.source_service)
return null;
return enqueueDbWrite(async () => {
try {
if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !item?.source_service)
return null;
const incomingMeta = normalizeMetaString(item);
const mergedFromIncoming = incomingMeta
? MediaMetadataService.mergeMetadataJson('{}', incomingMeta)
: null;
const incomingMeta = normalizeMetaString(item);
const mergedFromIncoming = incomingMeta
? MediaMetadataService.mergeMetadataJson('{}', incomingMeta)
: null;
if (item.source_id) {
const existing = await db.getFirstAsync(
'SELECT * FROM media WHERE user_id = ? AND source_id = ?',
[userId, 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
if (item.source_id && item.source_service) {
const existing = await db.getFirstAsync(
'SELECT * FROM media WHERE user_id = ? AND source_service = ? AND source_id = ?',
[userId, item.source_service, item.source_id]
);
const rowForCapture = {
...existing,
...item,
title: item.title || existing.title,
metadata_json: mergedMeta,
creationTime: item.creationTime ?? existing.creationTime,
modifyTime: item.modifyTime ?? existing.modifyTime,
};
const createdForDb =
MediaMetadataService.resolveCreatedDateIsoForStorage(rowForCapture)
?? toNullable(item.created_date)
?? toNullable(existing.created_date);
await db.runAsync(
`UPDATE media SET metadata_json = ?, search_text = ?,
local_path = COALESCE(?, local_path), remote_path = COALESCE(?, remote_path),
title = COALESCE(?, title), created_date = ?
WHERE id = ?`,
[
mergedMeta,
searchText,
toNullable(item.local_path),
toNullable(item.remote_path),
toNullable(item.title),
createdForDb,
existing.id,
]
);
return existing.id;
if (existing) {
const existingMetaObj = parseJsonSafe(existing.metadata_json);
const keepManualTitle = existingMetaObj.renameLocked === true;
const mergedMeta = incomingMeta
? MediaMetadataService.mergeMetadataJson(existing.metadata_json, incomingMeta)
: existing.metadata_json;
const effectiveTitle = keepManualTitle ? existing.title : (item.title || existing.title);
const searchText = MediaMetadataService.buildSearchText(
{ ...existing, ...item, title: effectiveTitle },
mergedMeta
);
const rowForCapture = {
...existing,
...item,
title: effectiveTitle,
metadata_json: mergedMeta,
creationTime: item.creationTime ?? existing.creationTime,
modifyTime: item.modifyTime ?? existing.modifyTime,
};
const createdForDb =
MediaMetadataService.resolveCreatedDateIsoForStorage(rowForCapture)
?? toNullable(item.created_date)
?? toNullable(existing.created_date);
await db.runAsync(
`UPDATE media SET metadata_json = ?, search_text = ?,
local_path = COALESCE(?, local_path), remote_path = COALESCE(?, remote_path),
title = COALESCE(?, title), created_date = ?
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 resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow);
if (resolvedInsert) insertRow.created_date = resolvedInsert;
return await DatabaseService.runMediaInsert(insertRow);
} catch (error) {
logError('Save Media Item', error);
return null;
}
const insertRow = { ...item, userId, metadata_json: mergedFromIncoming };
const resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow);
if (resolvedInsert) insertRow.created_date = resolvedInsert;
return await DatabaseService.runMediaInsert(insertRow);
} catch (error) {
logError('Save Media Item', error);
return null;
}
});
},
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) => {
try {
if (!(await DatabaseService.ensureDb())) return [];