diff --git a/App.js b/App.js index e8b05cf..72014d1 100644 --- a/App.js +++ b/App.js @@ -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 ( 🖼️, + tabBarIcon: ({ color }) => , }} /> 🔍, + tabBarIcon: ({ color }) => , }} /> 📁, + tabBarIcon: ({ color }) => , }} /> ); } -function AppNavigator() { - return ( - - - - - - - - ); -} +// 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 ( @@ -126,7 +128,20 @@ function AppInner() { return ( <> - + + + + + + + diff --git a/README.md b/README.md index 31c8727..52c4bce 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/package.json b/package.json index de3cebd..6b01935 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js index 6dffbfc..5d42600 100644 --- a/src/API/NASAPI.js +++ b/src/API/NASAPI.js @@ -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 { diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js index 8bd78fe..506f36b 100644 --- a/src/AUTH/NASAUTH.js +++ b/src/AUTH/NASAUTH.js @@ -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; diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index c7a5525..baeb917 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -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 ( - 📷 + ); }; @@ -98,31 +113,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata }) ) : ( - 📷 + )} ); const displayMedia = currentMedia || media; + const canRename = !!displayMedia && typeof onRenameMedia === 'function'; if (!media) return null; return ( - - ✕ - - - {hasCarousel && ( - - - {activeIndex + 1} / {mediaList.length} - - - )} - {hasCarousel ? ( + + + {canRename && ( + setRenameVisible(true)}> + Rename + + )} + + ✕ + + + {hasCarousel && ( + + + {activeIndex + 1} / {mediaList.length} + + + )} + + + setRenameVisible(false)} + onSave={async (nextName) => { + const ok = await onRenameMedia?.(displayMedia, nextName); + if (ok) setRenameVisible(false); + }} + /> ); @@ -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, diff --git a/src/components/NASSetupInstructions.js b/src/components/NASSetupInstructions.js index df13b6f..565e333 100644 --- a/src/components/NASSetupInstructions.js +++ b/src/components/NASSetupInstructions.js @@ -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} > - - 📋 How to set up a NAS for photo storage - + + + + How to set up a NAS for photo storage + + - {expanded ? '▲' : '▼'} + {expanded ? '^' : 'v'} @@ -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 }, diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js index ba92966..1ca0f0c 100644 --- a/src/components/PhotoGrid.js +++ b/src/components/PhotoGrid.js @@ -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 ; + if (serviceType === 'ICLOUD_PHOTOS') return ; + if (serviceType === 'NAS') return ; + return ; +}; 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 ( - {getServiceIcon(sources[0])} + ); } @@ -47,7 +56,9 @@ const PhotoGrid = ({ return ( {sources.map((src, i) => ( - {getServiceIcon(src)} + + + ))} ); @@ -98,7 +109,7 @@ const PhotoGrid = ({ /> ) : ( - 📷 + )} {item.media_type === 'video' && ( @@ -109,7 +120,7 @@ const PhotoGrid = ({ {renderSourceBadges(item)} {selected && ( - ✓ + )} @@ -215,7 +226,6 @@ const styles = StyleSheet.create({ borderWidth: 2, justifyContent: 'center', alignItems: 'center', }, - checkMark: { fontSize: 14, fontWeight: '800' }, }); export default PhotoGrid; diff --git a/src/components/TopBar.js b/src/components/TopBar.js index b15f45b..9ff977f 100644 --- a/src/components/TopBar.js +++ b/src/components/TopBar.js @@ -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} > - 🔍 + Search your photos { + if (serviceType === 'GOOGLE_PHOTOS') return ; + if (serviceType === 'ICLOUD_PHOTOS') return ; + if (serviceType === 'NAS') return ; + return ; +}; + +const UtilityGlyph = ({ name, size = 20, color = '#8B949E' }) => { + if (name === 'hidden') return ; + if (name === 'duplicates') return ; + if (name === 'trash') return ; + if (name === 'emptyMail') return ; + return ; +}; 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 }) => { {albumMedia.length === 0 ? ( - 📭 + No items in this collection ) : ( @@ -138,7 +162,7 @@ const CollectionsScreen = ({ route }) => { ) : ( - 📷 + )} @@ -176,7 +200,15 @@ const CollectionsScreen = ({ route }) => { ) : ( - {album.icon} + {album.serviceType ? ( + + ) : album.iconType === 'folder' ? ( + + ) : album.iconType === 'allPhotos' ? ( + + ) : ( + + )} )} @@ -193,7 +225,11 @@ const CollectionsScreen = ({ route }) => { if (!section.title) return null; return ( - {section.icon} + {section.serviceType ? ( + + ) : ( + + )} {section.title} ); @@ -208,7 +244,7 @@ const CollectionsScreen = ({ route }) => { {sections.length === 0 || !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? ( - 📁 + No Collections Collections from your connected services will appear here diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index d4fc76e..d1d4433 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -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 ; + if (serviceType === 'ICLOUD_PHOTOS') return ; + if (serviceType === 'NAS') return ; + return ; +}; /** 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)} > - {f.icon} + {f.name} @@ -483,8 +558,6 @@ const HomeScreen = ({ navigation, route }) => { - - { ) : ( <> - 📷 + Import from Google Photos )} @@ -512,7 +585,9 @@ const HomeScreen = ({ navigation, route }) => { {filteredMedia.length === 0 ? ( - 📸 + + + No Photos Yet {hasGoogleAccount @@ -561,6 +636,8 @@ const HomeScreen = ({ navigation, route }) => { allMedia={filteredMedia} onClose={() => setViewerVisible(false)} resolveNasMetadata={resolveNasMetadata} + onRenameMedia={handleRenameMedia} + renaming={renaming} /> ); @@ -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 }, diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js index 5a1a609..6e25b91 100644 --- a/src/screens/LoginScreen.js +++ b/src/screens/LoginScreen.js @@ -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 }); diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js index 2bba23e..3b8315c 100644 --- a/src/screens/OnboardingScreen.js +++ b/src/screens/OnboardingScreen.js @@ -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 ; + if (serviceType === 'ICLOUD_PHOTOS') return ; + if (serviceType === 'NAS') return ; + return ; +}; const OnboardingScreen = ({ navigation, route }) => { const userId = route?.params?.userId; @@ -196,13 +207,13 @@ const OnboardingScreen = ({ navigation, route }) => { {currentStep === 0 && ( - setGoogleEnabled(!googleEnabled)} /> - setIosEnabled(!iosEnabled)} /> - setNasEnabled(!nasEnabled)} /> @@ -216,9 +227,16 @@ const OnboardingScreen = ({ navigation, route }) => { - 📷 + + + Google Photos - {googleConnected && ✓ Connected} + {googleConnected && ( + + + Connected + + )} {!googleConnected && ( { - 🍎 + + + iCloud Photos - {iosConnected && ✓ Connected} + {iosConnected && ( + + + Connected + + )} {!iosConnected && ( { - 💾 + + + NAS Storage - {nasConnected && ✓ Connected} + {nasConnected && ( + + + Connected + + )} {!nasConnected && ( <> @@ -312,9 +344,11 @@ const OnboardingScreen = ({ navigation, route }) => { { borderColor: theme.primary }, autoBackupServices.includes(svc.type) && { backgroundColor: theme.primary }, ]}> - {autoBackupServices.includes(svc.type) && ✓} + {autoBackupServices.includes(svc.type) && } + + + - {svc.icon} {svc.name} ))} @@ -329,7 +363,7 @@ const OnboardingScreen = ({ navigation, route }) => { { borderColor: theme.border }, autoBackupServices.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary }, ]}> - {autoBackupServices.length === 0 && ✓} + {autoBackupServices.length === 0 && } Don't automatically back up @@ -362,10 +396,12 @@ const OnboardingScreen = ({ navigation, route }) => { ); }; -const ServiceToggleCard = ({ theme, icon, name, description, enabled, onToggle }) => ( +const ServiceToggleCard = ({ theme, serviceType, name, description, enabled, onToggle }) => ( - {icon} + + + {name} {description} @@ -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, diff --git a/src/screens/SearchScreen.js b/src/screens/SearchScreen.js index 0acc496..552ad5e 100644 --- a/src/screens/SearchScreen.js +++ b/src/screens/SearchScreen.js @@ -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 ( - 🔍 + { {!searching && query.length > 0 && results.length === 0 && ( - 🔍 + No results Try different keywords @@ -74,10 +75,10 @@ const SearchScreen = ({ route }) => { {query.length === 0 && ( - 🔍 + Search your photos - Search by filename and indexed metadata (bag-of-words style matching) + Search by filename and indexed metadata )} @@ -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 }, diff --git a/src/screens/SettingsScreen.js b/src/screens/SettingsScreen.js index 86d187d..3664538 100644 --- a/src/screens/SettingsScreen.js +++ b/src/screens/SettingsScreen.js @@ -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 ; + if (serviceType === 'ICLOUD_PHOTOS') return ; + if (serviceType === 'NAS') return ; + return ; +}; 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)} > - {opt.icon} {opt.label} {opt.desc} @@ -291,7 +306,9 @@ const SettingsScreen = ({ navigation, route }) => { recheckHealth(acc)} activeOpacity={0.6}> - {getServiceIcon(acc.service_type)} + + + {getServiceName(acc.service_type)} @@ -369,7 +386,9 @@ const SettingsScreen = ({ navigation, route }) => { NAS OFFLOADING - 📷 + + + Offload Google Photos to NAS @@ -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) && ✓} + {autoBackup.includes(svc.type) && } + + + - {svc.icon} { { borderColor: theme.border }, autoBackup.length === 0 && { backgroundColor: theme.primary, borderColor: theme.primary }, ]}> - {autoBackup.length === 0 && ✓} + {autoBackup.length === 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: { diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index c23cb3a..6e4a01a 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -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 [];