mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
feat: add media renaming functionality; implement rename feature in MediaViewer and NASAPI
This commit is contained in:
1 parent
ac9e4aee43
commit
bb1eb375ad
16 files changed
+456
-128
No files matched your search
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<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
|
||||
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 },
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,6 +382,7 @@ const DatabaseService = {
|
||||
},
|
||||
|
||||
saveMediaItem: async (userId, item) => {
|
||||
return enqueueDbWrite(async () => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return null;
|
||||
if (!userId || !item?.source_service)
|
||||
@@ -376,23 +393,26 @@ const DatabaseService = {
|
||||
? MediaMetadataService.mergeMetadataJson('{}', incomingMeta)
|
||||
: null;
|
||||
|
||||
if (item.source_id) {
|
||||
if (item.source_id && item.source_service) {
|
||||
const existing = await db.getFirstAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? AND source_id = ?',
|
||||
[userId, item.source_id]
|
||||
'SELECT * FROM media WHERE user_id = ? AND source_service = ? AND source_id = ?',
|
||||
[userId, item.source_service, item.source_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: item.title || existing.title },
|
||||
{ ...existing, ...item, title: effectiveTitle },
|
||||
mergedMeta
|
||||
);
|
||||
const rowForCapture = {
|
||||
...existing,
|
||||
...item,
|
||||
title: item.title || existing.title,
|
||||
title: effectiveTitle,
|
||||
metadata_json: mergedMeta,
|
||||
creationTime: item.creationTime ?? existing.creationTime,
|
||||
modifyTime: item.modifyTime ?? existing.modifyTime,
|
||||
@@ -411,7 +431,7 @@ const DatabaseService = {
|
||||
searchText,
|
||||
toNullable(item.local_path),
|
||||
toNullable(item.remote_path),
|
||||
toNullable(item.title),
|
||||
keepManualTitle ? toNullable(existing.title) : toNullable(item.title),
|
||||
createdForDb,
|
||||
existing.id,
|
||||
]
|
||||
@@ -428,6 +448,7 @@ const DatabaseService = {
|
||||
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 [];
|
||||
|
||||
Reference in new issue
Block a user