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 [];