mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
slight refactor of all the code, made it so that login and onboarding works, need to add system to validate connections to google photos + NAS before just blindly accepting
Added library home page and navigation bar at the bottom, using emojis as icon placeholders, album page also works. elipses for settings and icon also work.
This commit is contained in:
1 parent
37511780b7
commit
c1d7139055
17 files changed
+1411
-10020
No files matched your search
@@ -2,66 +2,98 @@ import React, { useEffect, useState } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
|
||||
import DatabaseService from './src/services/DatabaseService';
|
||||
import LoginScreen from './src/screens/LoginScreen';
|
||||
import RegisterScreen from './src/screens/RegisterScreen';
|
||||
import OnboardingScreen from './src/screens/OnboardingScreen';
|
||||
import HomeScreen from './src/screens/HomeScreen';
|
||||
import SearchScreen from './src/screens/SearchScreen';
|
||||
import AlbumsScreen from './src/screens/AlbumsScreen';
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
/**
|
||||
* App.js - Navigation Controller Only
|
||||
*
|
||||
* This component ONLY handles:
|
||||
* 1. Database initialization
|
||||
* 2. Navigation stack setup
|
||||
* 3. Routing to appropriate first screen
|
||||
*
|
||||
* NO DATA STORAGE OR DISPLAY HERE
|
||||
* All screens handle their own state and data
|
||||
* MainTabs - Bottom tab navigator for logged-in users
|
||||
* Tabs: Home (Library), Search, Albums
|
||||
*/
|
||||
function MainTabs({ route }) {
|
||||
const { userId } = route.params || {};
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: '#1a73e8',
|
||||
tabBarInactiveTintColor: '#5f6368',
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopColor: '#e0e0e0',
|
||||
borderTopWidth: 1,
|
||||
height: 56,
|
||||
paddingBottom: 6,
|
||||
paddingTop: 6,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Photos"
|
||||
component={HomeScreen}
|
||||
initialParams={{ userId }}
|
||||
options={{
|
||||
tabBarLabel: 'Photos',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Text style={{ fontSize: 22, color }}>🖼️</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
initialParams={{ userId }}
|
||||
options={{
|
||||
tabBarLabel: 'Search',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Text style={{ fontSize: 22, color }}>🔍</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Albums"
|
||||
component={AlbumsScreen}
|
||||
initialParams={{ userId }}
|
||||
options={{
|
||||
tabBarLabel: 'Albums',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Text style={{ fontSize: 22, color }}>📁</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AppNavigator - Root stack navigator
|
||||
* Handles auth flow (Login/Register/Onboarding) then MainTabs
|
||||
*/
|
||||
function AppNavigator() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
cardStyle: { backgroundColor: '#f5f5f5' },
|
||||
}}
|
||||
>
|
||||
{/* Authentication Stack - User not logged in */}
|
||||
<Stack.Screen
|
||||
name="Login"
|
||||
component={LoginScreen}
|
||||
options={{
|
||||
animationEnabled: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Register"
|
||||
component={RegisterScreen}
|
||||
options={{
|
||||
animationEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Onboarding"
|
||||
component={OnboardingScreen}
|
||||
options={{
|
||||
animationEnabled: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* App Stack - User logged in */}
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{
|
||||
animationEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<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.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -74,41 +106,35 @@ export default function App() {
|
||||
initializeApp();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize database - only purpose of App.js useEffect
|
||||
* All other logic belongs in screen components
|
||||
*/
|
||||
const initializeApp = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await DatabaseService.initDatabase();
|
||||
setIsReady(true);
|
||||
} catch (error) {
|
||||
console.error('Database initialization failed:', error);
|
||||
setError(error.message || 'Database initialization failed');
|
||||
} catch (err) {
|
||||
console.error('Database initialization failed:', err);
|
||||
setError(err.message || 'Database initialization failed');
|
||||
setIsReady(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading while database initializes
|
||||
if (!isReady && !error) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff' }}>
|
||||
<ActivityIndicator size="large" color="#1a73e8" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error if database initialization fails
|
||||
if (error) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, backgroundColor: '#fff' }}>
|
||||
<Text style={{ fontSize: 16, color: '#333', marginBottom: 12, textAlign: 'center' }}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={initializeApp}
|
||||
style={{ backgroundColor: '#007AFF', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }}
|
||||
style={{ backgroundColor: '#1a73e8', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -121,7 +147,7 @@ export default function App() {
|
||||
<NavigationContainer>
|
||||
<AppNavigator />
|
||||
</NavigationContainer>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#f5f5f5" />
|
||||
<StatusBar style="dark" backgroundColor="#fff" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Generated
-8072
File diff suppressed because it is too large.
Load diff
+9
-14
@@ -7,33 +7,28 @@
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"backend": "node backend/server.js",
|
||||
"backend-dev": "nodemon backend/server.js"
|
||||
"backend": "node ServerFiles/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-community/masked-view": "^0.1.11",
|
||||
"@react-navigation/bottom-tabs": "^6.5.20",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"@react-navigation/stack": "^6.4.1",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "^7.0.10",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "^8.0.11",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "^15.0.10",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"ssh2-sftp-client": "^12.0.1"
|
||||
"react-native-screens": "~4.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* iCloud Photos API - Placeholder
|
||||
*
|
||||
* Apple does not provide a public REST API for iCloud Photos.
|
||||
* This file is reserved for future integration if Apple provides API access
|
||||
* or if a third-party solution becomes available.
|
||||
*/
|
||||
|
||||
const iCloudPhotosAPI = () => {
|
||||
return {
|
||||
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
};
|
||||
};
|
||||
|
||||
export default iCloudPhotosAPI;
|
||||
|
||||
+155
-132
@@ -1,53 +1,66 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* NAS API - Direct Connection Handler
|
||||
* NAS API - HTTP Client for SFTP Backend
|
||||
*
|
||||
* Handles NAS/SFTP connections directly from the React Native app
|
||||
* Note: Since React Native cannot use SSH2 directly, this provides
|
||||
* credential management and placeholder methods. For production,
|
||||
* consider using a WebDAV server on your NAS instead of SFTP.
|
||||
* Connects to the Node.js backend server (ServerFiles/server.js)
|
||||
* which handles all actual SSH/SFTP operations.
|
||||
*/
|
||||
|
||||
// Default backend URL - update this to match your server address
|
||||
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
|
||||
|
||||
class NASAPI {
|
||||
constructor() {
|
||||
this.connection = null;
|
||||
this.credentials = null;
|
||||
constructor(backendUrl = DEFAULT_BACKEND_URL) {
|
||||
this.backendUrl = backendUrl;
|
||||
this.connectionToken = null;
|
||||
this.isConnected = false;
|
||||
this.credentials = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test NAS connection
|
||||
* Stores credentials for later use
|
||||
* Connect to NAS via the backend server
|
||||
* The backend handles the actual SSH connection
|
||||
*/
|
||||
async connect(host, port, username, password, photoFolder = '/Photos') {
|
||||
try {
|
||||
// Validate credentials
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('Missing required connection details');
|
||||
throw new Error('Host, username, and password are required');
|
||||
}
|
||||
|
||||
// Store credentials securely
|
||||
this.credentials = {
|
||||
// Store credentials locally
|
||||
this.credentials = { host, port: port || 22, username, photoFolder };
|
||||
|
||||
// Try to connect via backend
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/connect`,
|
||||
{
|
||||
host,
|
||||
port: port || 22,
|
||||
username,
|
||||
password, // In production, encrypt this
|
||||
photoFolder,
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
password,
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
if (response.data?.success) {
|
||||
this.connectionToken = response.data.connectionToken;
|
||||
this.isConnected = true;
|
||||
console.log(`NAS connected via backend: ${host}:${port}`);
|
||||
return { success: true, message: `Connected to ${host}` };
|
||||
}
|
||||
} catch (backendErr) {
|
||||
// Backend not available - store credentials for when it becomes available
|
||||
console.log('Backend not available, storing NAS credentials locally');
|
||||
}
|
||||
|
||||
console.log(`✓ NAS credentials stored for ${host}:${port}`);
|
||||
|
||||
// Even without backend, store config so user can proceed
|
||||
this.isConnected = true;
|
||||
return {
|
||||
success: true,
|
||||
message: `NAS configured: ${host}`,
|
||||
connectionData: {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
photoFolder,
|
||||
},
|
||||
message: `NAS configured: ${host} (backend connection pending)`,
|
||||
connectionData: this.credentials,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('NAS connection error:', error);
|
||||
@@ -56,147 +69,157 @@ class NASAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from NAS
|
||||
* Get authorization headers for backend requests
|
||||
*/
|
||||
async disconnect() {
|
||||
this.connection = null;
|
||||
this.credentials = null;
|
||||
this.isConnected = false;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Disconnected from NAS',
|
||||
};
|
||||
getHeaders() {
|
||||
if (!this.connectionToken) return {};
|
||||
return { Authorization: `Bearer ${this.connectionToken}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in NAS directory
|
||||
* Note: Placeholder - In production, use WebDAV or backend server
|
||||
* List directory contents on the NAS
|
||||
*/
|
||||
async listDirectory(path = '/') {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
if (!this.connectionToken) {
|
||||
return { path, files: [], message: 'Backend not connected' };
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/list`,
|
||||
{ path },
|
||||
{ headers: this.getHeaders(), timeout: 15000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('List directory error:', error.message);
|
||||
throw new Error('Failed to list directory: ' + error.message);
|
||||
}
|
||||
|
||||
console.log(`📁 Listing directory: ${path}`);
|
||||
|
||||
// Placeholder response
|
||||
return {
|
||||
path,
|
||||
files: [],
|
||||
message: 'NAS file listing requires WebDAV or backend server',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get photos from NAS
|
||||
* Note: Placeholder - In production, use WebDAV or backend server
|
||||
* Get photos from designated NAS folder
|
||||
*/
|
||||
async getPhotos(folder = null) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
|
||||
|
||||
if (!this.connectionToken) {
|
||||
return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
|
||||
}
|
||||
|
||||
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
|
||||
console.log(`📷 Getting photos from: ${targetFolder}`);
|
||||
try {
|
||||
const listing = await this.listDirectory(targetFolder);
|
||||
const photoExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
|
||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
||||
|
||||
// Placeholder response
|
||||
return {
|
||||
mediaItems: [],
|
||||
folder: targetFolder,
|
||||
message: 'NAS photo retrieval requires WebDAV or backend server',
|
||||
};
|
||||
const mediaItems = (listing.files || [])
|
||||
.filter((file) => {
|
||||
const ext = (file.name || '').toLowerCase().split('.').pop();
|
||||
return photoExtensions.includes('.' + ext) || videoExtensions.includes('.' + ext);
|
||||
})
|
||||
.map((file) => ({
|
||||
source_service: 'NAS',
|
||||
source_id: `nas_${file.name}`,
|
||||
title: file.name,
|
||||
remote_path: `${targetFolder}/${file.name}`,
|
||||
media_type: videoExtensions.includes('.' + (file.name || '').toLowerCase().split('.').pop()) ? 'video' : 'photo',
|
||||
created_date: file.modifyTime,
|
||||
size: file.size,
|
||||
}));
|
||||
|
||||
return { mediaItems, folder: targetFolder };
|
||||
} catch (error) {
|
||||
console.error('Get photos error:', error.message);
|
||||
return { mediaItems: [], folder: targetFolder };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to NAS
|
||||
* Note: Placeholder - In production, use WebDAV or backend server
|
||||
*/
|
||||
async uploadFile(localPath, remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
if (!this.connectionToken) {
|
||||
throw new Error('Backend not connected - cannot upload');
|
||||
}
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('remotePath', remotePath);
|
||||
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
|
||||
|
||||
console.log(`⬆️ Upload: ${localPath} -> ${remotePath}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'NAS file upload requires WebDAV or backend server',
|
||||
};
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/upload`,
|
||||
formData,
|
||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error.message);
|
||||
throw new Error('Upload failed: ' + error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file from NAS
|
||||
* Note: Placeholder - In production, use WebDAV or backend server
|
||||
*/
|
||||
async downloadFile(remotePath, localPath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
console.log(`⬇️ Download: ${remotePath} -> ${localPath}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'NAS file download requires WebDAV or backend server',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from NAS
|
||||
*/
|
||||
async deleteFile(remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
console.log(`🗑️ Delete: ${remotePath}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'NAS file deletion requires WebDAV or backend server',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create directory on NAS
|
||||
*/
|
||||
async createDirectory(remotePath) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
if (!this.connectionToken) {
|
||||
throw new Error('Backend not connected');
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/mkdir`,
|
||||
{ path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Create directory error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`📁 Create directory: ${remotePath}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'NAS directory creation requires WebDAV or backend server',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
* Delete file from NAS
|
||||
*/
|
||||
async deleteFile(remotePath) {
|
||||
if (!this.connectionToken) {
|
||||
throw new Error('Backend not connected');
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/delete`,
|
||||
{ path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from NAS
|
||||
*/
|
||||
async disconnect() {
|
||||
if (this.connectionToken) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${this.backendUrl}/api/sftp/disconnect`,
|
||||
{},
|
||||
{ headers: this.getHeaders(), timeout: 5000 }
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
}
|
||||
this.connectionToken = null;
|
||||
this.isConnected = false;
|
||||
this.credentials = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
getConnectionInfo() {
|
||||
if (!this.isConnected || !this.credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: this.credentials.host,
|
||||
port: this.credentials.port,
|
||||
username: this.credentials.username,
|
||||
photoFolder: this.credentials.photoFolder,
|
||||
connectedAt: this.credentials.connectedAt,
|
||||
isConnected: this.isConnected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnectedToNAS() {
|
||||
return this.isConnected;
|
||||
return this.credentials ? { ...this.credentials, isConnected: this.isConnected } : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-125
@@ -2,80 +2,68 @@ import { AuthServiceBase } from './AuthServiceBase';
|
||||
|
||||
/**
|
||||
* Google Photos Authentication Service
|
||||
* Handles OAuth2 authentication with Google Photos API
|
||||
*
|
||||
* Uses placeholder tokens for now.
|
||||
* To enable real Google OAuth, set up a Google Cloud project with Photos Library API,
|
||||
* create OAuth 2.0 credentials, and replace GOOGLE_CLIENT_ID below.
|
||||
*
|
||||
* Real OAuth flow uses expo-auth-session + expo-web-browser.
|
||||
*/
|
||||
|
||||
// TODO: Replace with your real Google Cloud OAuth Client ID
|
||||
const GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com';
|
||||
|
||||
class GoogleAUTH extends AuthServiceBase {
|
||||
constructor() {
|
||||
super();
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.expiresAt = null;
|
||||
this.email = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Google OAuth
|
||||
* In a real app, you would use Google Sign-In library
|
||||
* Authenticate with Google
|
||||
* Currently uses placeholder tokens. Replace with real OAuth when client ID is configured.
|
||||
*/
|
||||
async authenticate(authCode) {
|
||||
async authenticate() {
|
||||
try {
|
||||
// Validate input
|
||||
if (!authCode) {
|
||||
throw new Error('Authorization code is required');
|
||||
}
|
||||
|
||||
// TODO: Implement actual Google OAuth flow
|
||||
// This would typically involve:
|
||||
// 1. Initiating Google Sign-In
|
||||
// 2. Getting the authorization code
|
||||
// 3. Exchanging it for access and refresh tokens
|
||||
console.log('Authenticating with Google...');
|
||||
|
||||
// Placeholder: In production, exchange authCode for tokens
|
||||
this.accessToken = 'google_access_token_placeholder';
|
||||
this.refreshToken = 'google_refresh_token_placeholder';
|
||||
this.expiresAt = Date.now() + 3600000; // 1 hour from now
|
||||
if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
|
||||
// Placeholder mode - no real client ID configured
|
||||
console.log('Google Auth: Using placeholder mode (no client ID configured)');
|
||||
this.accessToken = 'placeholder_google_token_' + Date.now();
|
||||
this.refreshToken = 'placeholder_refresh_' + Date.now();
|
||||
this.expiresAt = Date.now() + 3600000;
|
||||
this.email = 'google-user';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Google authentication successful',
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
email: this.email,
|
||||
};
|
||||
}
|
||||
|
||||
// Real OAuth flow would go here using expo-auth-session
|
||||
// const discovery = { authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token' };
|
||||
// Use AuthSession.makeRedirectUri() and AuthSession.useAuthRequest()
|
||||
throw new Error('Google OAuth client ID configured but real OAuth flow not yet implemented');
|
||||
} catch (error) {
|
||||
console.error('Google authentication error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*/
|
||||
async getAccessToken() {
|
||||
if (this.accessToken && this.expiresAt > Date.now()) {
|
||||
return this.accessToken;
|
||||
}
|
||||
// If token is expired, refresh it
|
||||
if (this.refreshToken) {
|
||||
return await this.refreshToken();
|
||||
}
|
||||
throw new Error('No valid access token available');
|
||||
if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken;
|
||||
throw new Error('No valid Google access token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using refresh token
|
||||
*/
|
||||
async refreshToken() {
|
||||
async refreshAccessToken() {
|
||||
try {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// TODO: Implement actual token refresh with Google API
|
||||
console.log('Refreshing Google access token...');
|
||||
|
||||
// Placeholder: In production, call Google token endpoint
|
||||
this.accessToken = 'new_google_access_token_placeholder';
|
||||
this.accessToken = 'refreshed_google_token_' + Date.now();
|
||||
this.expiresAt = Date.now() + 3600000;
|
||||
|
||||
return this.accessToken;
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
@@ -83,111 +71,43 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke access and clear tokens
|
||||
*/
|
||||
async revokeAccess() {
|
||||
try {
|
||||
// TODO: Implement actual revocation with Google API
|
||||
console.log('Revoking Google access...');
|
||||
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.expiresAt = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Google access revoked',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Revoke error:', error);
|
||||
throw error;
|
||||
}
|
||||
this.email = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from Google
|
||||
*/
|
||||
async getUserInfo() {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual API call to Google People API
|
||||
console.log('Fetching user info from Google...');
|
||||
|
||||
// Placeholder user info
|
||||
return {
|
||||
id: 'google_user_id_placeholder',
|
||||
email: 'user@gmail.com',
|
||||
id: 'google_user',
|
||||
email: this.email || 'unknown',
|
||||
name: 'Google User',
|
||||
photoUrl: 'https://via.placeholder.com/150',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get user info error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media list from Google Photos
|
||||
*/
|
||||
async getPhotos(pageSize = 50, pageToken = null) {
|
||||
async getPhotos(pageSize = 50) {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual API call to Google Photos API
|
||||
console.log('Fetching photos from Google Photos...');
|
||||
|
||||
// Placeholder response
|
||||
return {
|
||||
mediaItems: [],
|
||||
nextPageToken: null,
|
||||
};
|
||||
await this.getAccessToken();
|
||||
// In production: call Google Photos API
|
||||
// https://photoslibrary.googleapis.com/v1/mediaItems
|
||||
return { mediaItems: [], nextPageToken: null };
|
||||
} catch (error) {
|
||||
console.error('Get photos error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums from Google Photos
|
||||
*/
|
||||
async getAlbums(pageSize = 50, pageToken = null) {
|
||||
async getAlbums() {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual API call to Google Photos API
|
||||
console.log('Fetching albums from Google Photos...');
|
||||
|
||||
return {
|
||||
albums: [],
|
||||
nextPageToken: null,
|
||||
};
|
||||
await this.getAccessToken();
|
||||
return { albums: [], nextPageToken: null };
|
||||
} catch (error) {
|
||||
console.error('Get albums error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search photos by date range
|
||||
*/
|
||||
async searchPhotos(filters = {}) {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual API call with filters
|
||||
console.log('Searching photos with filters:', filters);
|
||||
|
||||
return {
|
||||
mediaItems: [],
|
||||
nextPageToken: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Search photos error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleAUTH;
|
||||
|
||||
+16
-158
@@ -1,192 +1,50 @@
|
||||
import { AuthServiceBase } from './AuthServiceBase';
|
||||
|
||||
/**
|
||||
* iOS iCloud Authentication Service
|
||||
* Handles authentication with Apple iCloud Photos (placeholder implementation)
|
||||
* iCloud Photos Authentication Service
|
||||
* Placeholder - Apple does not provide a public iCloud Photos API.
|
||||
* This is kept for future integration if/when Apple provides API access.
|
||||
*/
|
||||
class IOSAUTH extends AuthServiceBase {
|
||||
constructor() {
|
||||
super();
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.expiresAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with iCloud
|
||||
* Placeholder - actual implementation would use Apple's authentication
|
||||
*/
|
||||
async authenticate(username, password) {
|
||||
try {
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
throw new Error('Username and password are required');
|
||||
}
|
||||
|
||||
// TODO: Implement actual Apple iCloud authentication
|
||||
// This would require:
|
||||
// 1. Apple Sign-In framework
|
||||
// 2. iCloud session management
|
||||
// 3. Secure credential storage
|
||||
console.log('Authenticating with iCloud...');
|
||||
|
||||
// Placeholder: In production, authenticate with Apple servers
|
||||
this.accessToken = 'icloud_access_token_placeholder';
|
||||
this.refreshToken = 'icloud_refresh_token_placeholder';
|
||||
this.expiresAt = Date.now() + 3600000; // 1 hour from now
|
||||
|
||||
async authenticate() {
|
||||
console.log('iCloud Auth: Placeholder - no Apple API available');
|
||||
this.accessToken = 'icloud_placeholder_' + Date.now();
|
||||
return {
|
||||
success: true,
|
||||
message: 'iCloud authentication successful',
|
||||
accessToken: this.accessToken,
|
||||
message: 'iCloud placeholder - full integration pending',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('iCloud authentication error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*/
|
||||
async getAccessToken() {
|
||||
if (this.accessToken && this.expiresAt > Date.now()) {
|
||||
return this.accessToken;
|
||||
}
|
||||
// If token is expired, refresh it
|
||||
if (this.refreshToken) {
|
||||
return await this.refreshToken();
|
||||
}
|
||||
throw new Error('No valid access token available');
|
||||
if (this.accessToken) return this.accessToken;
|
||||
throw new Error('Not authenticated with iCloud');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token
|
||||
*/
|
||||
async refreshToken() {
|
||||
try {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
// TODO: Implement actual token refresh with iCloud API
|
||||
console.log('Refreshing iCloud access token...');
|
||||
|
||||
// Placeholder
|
||||
this.accessToken = 'new_icloud_access_token_placeholder';
|
||||
this.expiresAt = Date.now() + 3600000;
|
||||
|
||||
return this.accessToken;
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke access and clear tokens
|
||||
*/
|
||||
async revokeAccess() {
|
||||
try {
|
||||
// TODO: Implement actual revocation with Apple iCloud
|
||||
console.log('Revoking iCloud access...');
|
||||
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.expiresAt = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'iCloud access revoked',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Revoke error:', error);
|
||||
throw error;
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from iCloud
|
||||
*/
|
||||
async getUserInfo() {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual API call to iCloud
|
||||
console.log('Fetching user info from iCloud...');
|
||||
|
||||
// Placeholder user info
|
||||
return {
|
||||
id: 'icloud_user_id_placeholder',
|
||||
email: 'user@icloud.com',
|
||||
name: 'iCloud User',
|
||||
photoUrl: 'https://via.placeholder.com/150',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get user info error:', error);
|
||||
throw error;
|
||||
}
|
||||
return { id: 'icloud_placeholder', name: 'iCloud User' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get photos from iCloud Photos
|
||||
*/
|
||||
async getPhotos(pageSize = 50, pageToken = null) {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual API call to iCloud Photos API
|
||||
console.log('Fetching photos from iCloud Photos...');
|
||||
|
||||
// Placeholder response
|
||||
return {
|
||||
mediaItems: [],
|
||||
nextPageToken: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get photos error:', error);
|
||||
throw error;
|
||||
}
|
||||
async getPhotos() {
|
||||
return { mediaItems: [], nextPageToken: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums from iCloud Photos
|
||||
*/
|
||||
async getAlbums(pageSize = 50, pageToken = null) {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual API call to iCloud Photos API
|
||||
console.log('Fetching albums from iCloud Photos...');
|
||||
|
||||
return {
|
||||
albums: [],
|
||||
nextPageToken: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get albums error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search photos in iCloud
|
||||
*/
|
||||
async searchPhotos(filters = {}) {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// TODO: Implement actual search with filters
|
||||
console.log('Searching photos in iCloud with filters:', filters);
|
||||
|
||||
return {
|
||||
mediaItems: [],
|
||||
nextPageToken: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Search photos error:', error);
|
||||
throw error;
|
||||
}
|
||||
async getAlbums() {
|
||||
return { albums: [], nextPageToken: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
StyleSheet, View, Text, FlatList, TouchableOpacity,
|
||||
Dimensions, Image, ActivityIndicator, RefreshControl,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const ALBUM_COLUMNS = 2;
|
||||
const ALBUM_GAP = 12;
|
||||
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
|
||||
|
||||
const AlbumsScreen = ({ route }) => {
|
||||
const userId = route?.params?.userId;
|
||||
const [albums, setAlbums] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState(null);
|
||||
const [albumMedia, setAlbumMedia] = useState([]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (userId) loadAlbums();
|
||||
}, [userId])
|
||||
);
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
||||
const albumList = [];
|
||||
|
||||
for (const acc of accounts) {
|
||||
const media = await DatabaseService.getMediaByService(userId, acc.service_type);
|
||||
albumList.push({
|
||||
service_type: acc.service_type,
|
||||
name: getServiceName(acc.service_type),
|
||||
icon: getServiceIcon(acc.service_type),
|
||||
count: media.length,
|
||||
coverImage: media.length > 0 ? media[0].local_path : null,
|
||||
media,
|
||||
});
|
||||
}
|
||||
|
||||
// Also add an "All Photos" album
|
||||
const allMedia = await DatabaseService.getMediaByUser(userId);
|
||||
albumList.unshift({
|
||||
service_type: 'ALL',
|
||||
name: 'All Photos',
|
||||
icon: '📸',
|
||||
count: allMedia.length,
|
||||
coverImage: allMedia.length > 0 ? allMedia[0].local_path : null,
|
||||
media: allMedia,
|
||||
});
|
||||
|
||||
setAlbums(albumList);
|
||||
} catch (err) {
|
||||
console.error('Error loading albums:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
||||
|
||||
const renderAlbumCard = ({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.albumCard}
|
||||
onPress={() => {
|
||||
setSelectedAlbum(item);
|
||||
setAlbumMedia(item.media);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.albumCover}>
|
||||
{item.coverImage ? (
|
||||
<Image source={{ uri: item.coverImage }} style={styles.albumCoverImage} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={styles.albumCoverPlaceholder}>
|
||||
<Text style={{ fontSize: 36 }}>{item.icon}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.albumName} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.albumCount}>{item.count} item{item.count !== 1 ? 's' : ''}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderMediaItem = ({ item }) => (
|
||||
<TouchableOpacity style={styles.gridItem} activeOpacity={0.8}>
|
||||
{item.local_path ? (
|
||||
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={styles.gridPlaceholder}>
|
||||
<Text style={{ fontSize: 24 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color="#1a73e8" />
|
||||
<Text style={styles.loadingText}>Loading albums...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Album detail view
|
||||
if (selectedAlbum) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.albumHeader}>
|
||||
<TouchableOpacity onPress={() => { setSelectedAlbum(null); setAlbumMedia([]); }}>
|
||||
<Text style={styles.backArrow}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.albumHeaderInfo}>
|
||||
<Text style={styles.albumHeaderTitle}>{selectedAlbum.name}</Text>
|
||||
<Text style={styles.albumHeaderCount}>{selectedAlbum.count} items</Text>
|
||||
</View>
|
||||
</View>
|
||||
{albumMedia.length === 0 ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={{ fontSize: 48, marginBottom: 12 }}>📭</Text>
|
||||
<Text style={styles.emptyTitle}>No photos in this album</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={albumMedia}
|
||||
renderItem={renderMediaItem}
|
||||
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
|
||||
numColumns={3}
|
||||
contentContainerStyle={styles.gridContent}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Albums grid view
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Albums</Text>
|
||||
</View>
|
||||
{albums.length === 0 ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
|
||||
<Text style={styles.emptyTitle}>No Albums</Text>
|
||||
<Text style={styles.emptyText}>Albums from your connected services will appear here</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={albums}
|
||||
renderItem={renderAlbumCard}
|
||||
keyExtractor={(item) => item.service_type}
|
||||
numColumns={ALBUM_COLUMNS}
|
||||
columnWrapperStyle={styles.albumRow}
|
||||
contentContainerStyle={styles.albumsContent}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAlbums(); }} />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const gridItemSize = (width - 6) / 3;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#fff' },
|
||||
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
||||
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
|
||||
headerTitle: { fontSize: 24, fontWeight: '700', color: '#202124' },
|
||||
albumsContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
|
||||
albumRow: { gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
|
||||
albumCard: { width: albumSize },
|
||||
albumCover: {
|
||||
width: albumSize, height: albumSize, borderRadius: 12, overflow: 'hidden',
|
||||
backgroundColor: '#f1f3f4', marginBottom: 8,
|
||||
},
|
||||
albumCoverImage: { width: '100%', height: '100%' },
|
||||
albumCoverPlaceholder: {
|
||||
width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed',
|
||||
},
|
||||
albumName: { fontSize: 14, fontWeight: '600', color: '#202124', paddingHorizontal: 2 },
|
||||
albumCount: { fontSize: 12, color: '#5f6368', paddingHorizontal: 2, marginTop: 2 },
|
||||
// Album detail
|
||||
albumHeader: {
|
||||
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
|
||||
paddingTop: 52, paddingBottom: 12, gap: 12,
|
||||
},
|
||||
backArrow: { fontSize: 24, color: '#202124' },
|
||||
albumHeaderInfo: { flex: 1 },
|
||||
albumHeaderTitle: { fontSize: 20, fontWeight: '700', color: '#202124' },
|
||||
albumHeaderCount: { fontSize: 12, color: '#5f6368' },
|
||||
gridContent: { paddingBottom: 20 },
|
||||
gridItem: { width: gridItemSize, height: gridItemSize, margin: 1, backgroundColor: '#f1f3f4' },
|
||||
gridImage: { width: '100%', height: '100%' },
|
||||
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed' },
|
||||
// Empty
|
||||
loadingText: { marginTop: 12, fontSize: 14, color: '#5f6368' },
|
||||
emptyTitle: { fontSize: 18, fontWeight: '600', color: '#202124', marginBottom: 6 },
|
||||
emptyText: { fontSize: 13, color: '#5f6368', textAlign: 'center' },
|
||||
});
|
||||
|
||||
export default AlbumsScreen;
|
||||
|
||||
+218
-664
File diff suppressed because it is too large.
Load diff
+48
-42
@@ -13,26 +13,29 @@ import {
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
const LoginScreen = ({ navigation }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim() || !password.trim()) {
|
||||
Alert.alert('Error', 'Please enter both email and password');
|
||||
if (!identifier.trim() || !password.trim()) {
|
||||
Alert.alert('Error', 'Please enter your username/email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await DatabaseService.initDatabase();
|
||||
const user = await DatabaseService.loginUser(email, password);
|
||||
|
||||
const user = await DatabaseService.loginUser(identifier.trim(), password);
|
||||
if (user) {
|
||||
// Successfully logged in - navigate to home
|
||||
navigation.replace('Onboarding', { userId: user.id });
|
||||
// Check if user has completed onboarding (has linked accounts)
|
||||
const accounts = await DatabaseService.getLinkedAccounts(user.id);
|
||||
if (accounts.length > 0) {
|
||||
navigation.replace('MainTabs', { userId: user.id });
|
||||
} else {
|
||||
Alert.alert('Error', 'Invalid email or password');
|
||||
navigation.replace('Onboarding', { userId: user.id });
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Error', 'Invalid username/email or password');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
@@ -49,32 +52,33 @@ const LoginScreen = ({ navigation }) => {
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.appIcon}>📸</Text>
|
||||
<Text style={styles.title}>Photo Library</Text>
|
||||
<Text style={styles.subtitle}>Login to your account</Text>
|
||||
<Text style={styles.subtitle}>Sign in to your account</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<Text style={styles.label}>Username or Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter username or email"
|
||||
placeholderTextColor="#9aa0a6"
|
||||
value={identifier}
|
||||
onChangeText={setIdentifier}
|
||||
editable={!loading}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your password"
|
||||
placeholderTextColor="#999"
|
||||
placeholderTextColor="#9aa0a6"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
editable={!loading}
|
||||
secureTextEntry={true}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
@@ -87,7 +91,7 @@ const LoginScreen = ({ navigation }) => {
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Login</Text>
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -97,7 +101,7 @@ const LoginScreen = ({ navigation }) => {
|
||||
onPress={() => navigation.navigate('Register')}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.registerLink}>Register here</Text>
|
||||
<Text style={styles.registerLink}>Create one</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -108,53 +112,56 @@ const LoginScreen = ({ navigation }) => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
headerContainer: {
|
||||
marginBottom: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
appIcon: {
|
||||
fontSize: 56,
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 10,
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#202124',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
fontSize: 15,
|
||||
color: '#5f6368',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 30,
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
color: '#3c4043',
|
||||
marginBottom: 6,
|
||||
marginTop: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: '#dadce0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
backgroundColor: '#fff',
|
||||
color: '#333',
|
||||
color: '#202124',
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
backgroundColor: '#1a73e8',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
@@ -163,7 +170,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
registerContainer: {
|
||||
@@ -172,15 +179,14 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
registerText: {
|
||||
color: '#666',
|
||||
color: '#5f6368',
|
||||
fontSize: 14,
|
||||
},
|
||||
registerLink: {
|
||||
color: '#007AFF',
|
||||
color: '#1a73e8',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginScreen;
|
||||
|
||||
+264
-450
@@ -12,7 +12,6 @@ import {
|
||||
} from 'react-native';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
import GoogleAUTH from '../AUTH/GoogleAUTH';
|
||||
import IOSAUTH from '../AUTH/IOSAUTH';
|
||||
import NASAUTH from '../AUTH/NASAUTH';
|
||||
|
||||
const OnboardingScreen = ({ navigation, route }) => {
|
||||
@@ -20,41 +19,37 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Service selections
|
||||
// Service toggles
|
||||
const [googleEnabled, setGoogleEnabled] = useState(false);
|
||||
const [iosEnabled, setIosEnabled] = useState(false);
|
||||
const [nasEnabled, setNasEnabled] = useState(false);
|
||||
|
||||
// NAS configuration
|
||||
// Service connection status
|
||||
const [googleConnected, setGoogleConnected] = useState(false);
|
||||
const [iosConnected, setIosConnected] = useState(false);
|
||||
const [nasConnected, setNasConnected] = useState(false);
|
||||
|
||||
// NAS config
|
||||
const [nasHost, setNasHost] = useState('');
|
||||
const [nasPort, setNasPort] = useState('22');
|
||||
const [nasUsername, setNasUsername] = useState('');
|
||||
const [nasPassword, setNasPassword] = useState('');
|
||||
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
|
||||
|
||||
// Default service selection
|
||||
// Default service
|
||||
const [defaultService, setDefaultService] = useState(null);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: 'Select Services',
|
||||
description: 'Choose which services to enable (you can enable 1-3)',
|
||||
},
|
||||
{
|
||||
title: 'Configure Services',
|
||||
description: 'Set up the enabled services',
|
||||
},
|
||||
{
|
||||
title: 'Choose Default Service',
|
||||
description: 'Select which service to use for uploads by default',
|
||||
},
|
||||
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
|
||||
{ title: 'Connect Services', description: 'Set up each selected service' },
|
||||
{ title: 'Default Upload', description: 'Choose where new photos upload by default' },
|
||||
];
|
||||
|
||||
const getEnabledServices = () => {
|
||||
const services = [];
|
||||
if (googleEnabled) services.push('Google Photos');
|
||||
if (iosEnabled) services.push('iCloud Photos');
|
||||
if (nasEnabled) services.push('NAS');
|
||||
if (googleEnabled) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS' });
|
||||
if (iosEnabled) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS' });
|
||||
if (nasEnabled) services.push({ name: 'NAS Storage', type: 'NAS' });
|
||||
return services;
|
||||
};
|
||||
|
||||
@@ -62,21 +57,18 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const googleAuth = new GoogleAUTH();
|
||||
// In a real app, this would trigger OAuth flow
|
||||
const result = await googleAuth.authenticate('auth_code');
|
||||
const result = await googleAuth.authenticate();
|
||||
|
||||
await DatabaseService.linkAccount(
|
||||
userId,
|
||||
'GOOGLE_PHOTOS',
|
||||
{
|
||||
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
||||
accessToken: result.accessToken,
|
||||
email: 'user@gmail.com',
|
||||
},
|
||||
defaultService === 'GOOGLE_PHOTOS'
|
||||
);
|
||||
refreshToken: result.refreshToken || null,
|
||||
email: result.email || 'google-user',
|
||||
});
|
||||
|
||||
setGoogleConnected(true);
|
||||
Alert.alert('Success', 'Google Photos connected!');
|
||||
} catch (error) {
|
||||
console.error('Google auth error:', error);
|
||||
Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -86,57 +78,50 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
const handleIosAuth = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const iosAuth = new IOSAUTH();
|
||||
const result = await iosAuth.authenticate('icloud_user', 'icloud_password');
|
||||
// Placeholder - iCloud API not yet available
|
||||
await DatabaseService.linkAccount(userId, 'ICLOUD_PHOTOS', {
|
||||
status: 'placeholder',
|
||||
note: 'iCloud integration pending Apple API access',
|
||||
});
|
||||
|
||||
await DatabaseService.linkAccount(
|
||||
userId,
|
||||
'ICLOUD_PHOTOS',
|
||||
{
|
||||
accessToken: result.accessToken,
|
||||
email: 'user@icloud.com',
|
||||
},
|
||||
defaultService === 'ICLOUD_PHOTOS'
|
||||
);
|
||||
|
||||
Alert.alert('Success', 'iCloud Photos connected!');
|
||||
setIosConnected(true);
|
||||
Alert.alert('Info', 'iCloud Photos registered. Full integration coming soon.');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to connect iCloud Photos: ' + error.message);
|
||||
console.error('iCloud auth error:', error);
|
||||
Alert.alert('Error', 'Failed to set up iCloud: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNasAuth = async () => {
|
||||
if (!nasHost || !nasUsername || !nasPassword) {
|
||||
Alert.alert('Error', 'Please fill in all NAS connection details');
|
||||
if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
|
||||
Alert.alert('Error', 'Please fill in all NAS connection fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const nasAuth = new NASAUTH();
|
||||
const result = await nasAuth.authenticate(
|
||||
nasHost,
|
||||
await nasAuth.authenticate(
|
||||
nasHost.trim(),
|
||||
parseInt(nasPort) || 22,
|
||||
nasUsername,
|
||||
nasPassword
|
||||
nasUsername.trim(),
|
||||
nasPassword,
|
||||
nasPhotoFolder.trim()
|
||||
);
|
||||
|
||||
await DatabaseService.linkAccount(
|
||||
userId,
|
||||
'NAS',
|
||||
{
|
||||
host: nasHost,
|
||||
port: nasPort,
|
||||
username: nasUsername,
|
||||
photoFolder: nasPhotoFolder,
|
||||
},
|
||||
defaultService === 'NAS'
|
||||
);
|
||||
await DatabaseService.linkAccount(userId, 'NAS', {
|
||||
host: nasHost.trim(),
|
||||
port: parseInt(nasPort) || 22,
|
||||
username: nasUsername.trim(),
|
||||
photoFolder: nasPhotoFolder.trim(),
|
||||
});
|
||||
|
||||
setNasConnected(true);
|
||||
Alert.alert('Success', 'NAS connected!');
|
||||
} catch (error) {
|
||||
console.error('NAS auth error:', error);
|
||||
Alert.alert('Error', 'Failed to connect NAS: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -145,49 +130,37 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 0) {
|
||||
const enabledCount = getEnabledServices().length;
|
||||
if (enabledCount === 0) {
|
||||
if (getEnabledServices().length === 0) {
|
||||
Alert.alert('Error', 'Please select at least one service');
|
||||
return;
|
||||
}
|
||||
if (enabledCount > 3) {
|
||||
Alert.alert('Error', 'You can enable a maximum of 3 services');
|
||||
return;
|
||||
}
|
||||
setCurrentStep(1);
|
||||
} else if (currentStep === 1) {
|
||||
// Configure services
|
||||
try {
|
||||
setLoading(true);
|
||||
if (googleEnabled) await handleGoogleAuth();
|
||||
if (iosEnabled) await handleIosAuth();
|
||||
if (nasEnabled) await handleNasAuth();
|
||||
setCurrentStep(2);
|
||||
} catch (error) {
|
||||
console.error('Configuration error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Check at least one service is connected
|
||||
const anyConnected =
|
||||
(googleEnabled && googleConnected) ||
|
||||
(iosEnabled && iosConnected) ||
|
||||
(nasEnabled && nasConnected);
|
||||
|
||||
if (!anyConnected) {
|
||||
Alert.alert('Error', 'Please connect at least one service before continuing');
|
||||
return;
|
||||
}
|
||||
setCurrentStep(2);
|
||||
} else if (currentStep === 2) {
|
||||
// Complete onboarding
|
||||
if (!defaultService) {
|
||||
Alert.alert('Error', 'Please select a default service');
|
||||
Alert.alert('Error', 'Please select a default upload service');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Set default service
|
||||
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
||||
const defaultAccount = accounts.find(
|
||||
(acc) => acc.service_type === defaultService
|
||||
);
|
||||
const defaultAccount = accounts.find((a) => a.service_type === defaultService);
|
||||
if (defaultAccount) {
|
||||
await DatabaseService.setDefaultService(userId, defaultAccount.id);
|
||||
}
|
||||
|
||||
// Navigate to home screen
|
||||
navigation.replace('Home', { userId });
|
||||
navigation.replace('MainTabs', { userId });
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to complete setup: ' + error.message);
|
||||
} finally {
|
||||
@@ -196,192 +169,184 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.stepIndicator}>
|
||||
Step {currentStep + 1} of {steps.length}
|
||||
</Text>
|
||||
<Text style={styles.title}>{steps[currentStep].title}</Text>
|
||||
<Text style={styles.description}>
|
||||
{steps[currentStep].description}
|
||||
</Text>
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressBar}>
|
||||
{steps.map((_, i) => (
|
||||
<View key={i} style={[styles.progressDot, i <= currentStep && styles.progressDotActive]} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.stepLabel}>Step {currentStep + 1} of {steps.length}</Text>
|
||||
<Text style={styles.title}>{steps[currentStep].title}</Text>
|
||||
<Text style={styles.description}>{steps[currentStep].description}</Text>
|
||||
</View>
|
||||
|
||||
{/* Step 0: Select services */}
|
||||
{currentStep === 0 && (
|
||||
<View style={styles.servicesContainer}>
|
||||
<ServiceCard
|
||||
<View style={styles.cardsContainer}>
|
||||
<ServiceToggleCard
|
||||
icon="📷"
|
||||
name="Google Photos"
|
||||
description="Access your Google Photos library"
|
||||
enabled={googleEnabled}
|
||||
onToggle={() => setGoogleEnabled(!googleEnabled)}
|
||||
icon="📷"
|
||||
description="Connect to your Google Photos account"
|
||||
/>
|
||||
<ServiceCard
|
||||
<ServiceToggleCard
|
||||
icon="🍎"
|
||||
name="iCloud Photos"
|
||||
description="Access your iCloud photo library (placeholder)"
|
||||
enabled={iosEnabled}
|
||||
onToggle={() => setIosEnabled(!iosEnabled)}
|
||||
icon="🍎"
|
||||
description="Connect to your iCloud Photos account"
|
||||
/>
|
||||
<ServiceCard
|
||||
name="Network Storage (NAS)"
|
||||
<ServiceToggleCard
|
||||
icon="💾"
|
||||
name="NAS Storage"
|
||||
description="Connect to your Ubuntu server via SFTP"
|
||||
enabled={nasEnabled}
|
||||
onToggle={() => setNasEnabled(!nasEnabled)}
|
||||
icon="💾"
|
||||
description="Connect to your Ubuntu NAS via SFTP"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Step 1: Configure each service */}
|
||||
{currentStep === 1 && (
|
||||
<View style={styles.configContainer}>
|
||||
<View style={styles.cardsContainer}>
|
||||
{googleEnabled && (
|
||||
<ServiceConfigCard
|
||||
title="Google Photos"
|
||||
<View style={styles.configCard}>
|
||||
<View style={styles.configHeader}>
|
||||
<Text style={styles.configIcon}>📷</Text>
|
||||
<Text style={styles.configTitle}>Google Photos</Text>
|
||||
{googleConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
|
||||
</View>
|
||||
{!googleConnected && (
|
||||
<TouchableOpacity
|
||||
style={[styles.authButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleGoogleAuth}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.configText}>
|
||||
Tap to authenticate with your Google account
|
||||
</Text>
|
||||
</ServiceConfigCard>
|
||||
{loading ? <ActivityIndicator color="#fff" /> : (
|
||||
<Text style={styles.authButtonText}>Sign in with Google</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{iosEnabled && (
|
||||
<ServiceConfigCard
|
||||
title="iCloud Photos"
|
||||
onPress={handleIosAuth}
|
||||
loading={loading}
|
||||
>
|
||||
<Text style={styles.configText}>
|
||||
Tap to authenticate with your Apple account
|
||||
</Text>
|
||||
</ServiceConfigCard>
|
||||
)}
|
||||
|
||||
{nasEnabled && (
|
||||
<ServiceConfigCard title="Network Storage (NAS)" loading={loading}>
|
||||
<Text style={styles.configLabel}>Server Address</Text>
|
||||
<TextInput
|
||||
style={styles.configInput}
|
||||
placeholder="192.168.1.100 or nas.example.com"
|
||||
value={nasHost}
|
||||
onChangeText={setNasHost}
|
||||
editable={!loading}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
|
||||
<Text style={styles.configLabel}>Port</Text>
|
||||
<TextInput
|
||||
style={styles.configInput}
|
||||
placeholder="22"
|
||||
value={nasPort}
|
||||
onChangeText={setNasPort}
|
||||
editable={!loading}
|
||||
keyboardType="numeric"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
|
||||
<Text style={styles.configLabel}>Username</Text>
|
||||
<TextInput
|
||||
style={styles.configInput}
|
||||
placeholder="SSH username"
|
||||
value={nasUsername}
|
||||
onChangeText={setNasUsername}
|
||||
editable={!loading}
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
|
||||
<Text style={styles.configLabel}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.configInput}
|
||||
placeholder="SSH password"
|
||||
value={nasPassword}
|
||||
onChangeText={setNasPassword}
|
||||
editable={!loading}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
|
||||
<Text style={styles.configLabel}>Photos Folder Path</Text>
|
||||
<TextInput
|
||||
style={styles.configInput}
|
||||
placeholder="/home/user/photos"
|
||||
value={nasPhotoFolder}
|
||||
onChangeText={setNasPhotoFolder}
|
||||
editable={!loading}
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
|
||||
<View style={styles.configCard}>
|
||||
<View style={styles.configHeader}>
|
||||
<Text style={styles.configIcon}>🍎</Text>
|
||||
<Text style={styles.configTitle}>iCloud Photos</Text>
|
||||
{iosConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
|
||||
</View>
|
||||
{!iosConnected && (
|
||||
<TouchableOpacity
|
||||
style={[styles.testButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleNasAuth}
|
||||
style={[styles.authButton, { backgroundColor: '#333' }, loading && styles.buttonDisabled]}
|
||||
onPress={handleIosAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Test Connection</Text>
|
||||
{loading ? <ActivityIndicator color="#fff" /> : (
|
||||
<Text style={styles.authButtonText}>Connect iCloud</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ServiceConfigCard>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{nasEnabled && (
|
||||
<View style={styles.configCard}>
|
||||
<View style={styles.configHeader}>
|
||||
<Text style={styles.configIcon}>💾</Text>
|
||||
<Text style={styles.configTitle}>NAS Storage</Text>
|
||||
{nasConnected && <Text style={styles.connectedBadge}>✓ Connected</Text>}
|
||||
</View>
|
||||
{!nasConnected && (
|
||||
<>
|
||||
<Text style={styles.fieldLabel}>Server Address</Text>
|
||||
<TextInput style={styles.fieldInput} placeholder="192.168.1.100" value={nasHost}
|
||||
onChangeText={setNasHost} editable={!loading} placeholderTextColor="#9aa0a6" />
|
||||
|
||||
<Text style={styles.fieldLabel}>Port</Text>
|
||||
<TextInput style={styles.fieldInput} placeholder="22" value={nasPort}
|
||||
onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor="#9aa0a6" />
|
||||
|
||||
<Text style={styles.fieldLabel}>Username</Text>
|
||||
<TextInput style={styles.fieldInput} placeholder="SSH username" value={nasUsername}
|
||||
onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor="#9aa0a6" />
|
||||
|
||||
<Text style={styles.fieldLabel}>Password</Text>
|
||||
<TextInput style={styles.fieldInput} placeholder="SSH password" value={nasPassword}
|
||||
onChangeText={setNasPassword} editable={!loading} secureTextEntry autoCapitalize="none" placeholderTextColor="#9aa0a6" />
|
||||
|
||||
<Text style={styles.fieldLabel}>Photos Folder</Text>
|
||||
<TextInput style={styles.fieldInput} placeholder="/home/user/photos" value={nasPhotoFolder}
|
||||
onChangeText={setNasPhotoFolder} editable={!loading} placeholderTextColor="#9aa0a6" />
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.authButton, { backgroundColor: '#34a853' }, loading && styles.buttonDisabled]}
|
||||
onPress={handleNasAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <ActivityIndicator color="#fff" /> : (
|
||||
<Text style={styles.authButtonText}>Test & Connect</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Step 2: Default service */}
|
||||
{currentStep === 2 && (
|
||||
<View style={styles.defaultServiceContainer}>
|
||||
<Text style={styles.defaultServiceTitle}>
|
||||
Select your default upload service:
|
||||
<View style={styles.cardsContainer}>
|
||||
{getEnabledServices().map((svc) => {
|
||||
const connected =
|
||||
(svc.type === 'GOOGLE_PHOTOS' && googleConnected) ||
|
||||
(svc.type === 'ICLOUD_PHOTOS' && iosConnected) ||
|
||||
(svc.type === 'NAS' && nasConnected);
|
||||
|
||||
if (!connected) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={svc.type}
|
||||
style={[styles.selectCard, defaultService === svc.type && styles.selectCardActive]}
|
||||
onPress={() => setDefaultService(svc.type)}
|
||||
>
|
||||
<View style={[styles.radio, defaultService === svc.type && styles.radioActive]} />
|
||||
<Text style={[styles.selectText, defaultService === svc.type && styles.selectTextActive]}>
|
||||
{svc.name}
|
||||
</Text>
|
||||
{getEnabledServices().map((service) => (
|
||||
<ServiceSelectCard
|
||||
key={service}
|
||||
service={service}
|
||||
selected={defaultService === service}
|
||||
onSelect={() => {
|
||||
const serviceTypeMap = {
|
||||
'Google Photos': 'GOOGLE_PHOTOS',
|
||||
'iCloud Photos': 'ICLOUD_PHOTOS',
|
||||
NAS: 'NAS',
|
||||
};
|
||||
setDefaultService(serviceTypeMap[service]);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.footerButton, currentStep === 0 && styles.hidden]}
|
||||
onPress={handleBack}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.footerButtonText}>Back</Text>
|
||||
{currentStep > 0 ? (
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => setCurrentStep(currentStep - 1)} disabled={loading}>
|
||||
<Text style={styles.backButtonText}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
) : (
|
||||
<View style={{ flex: 1 }} />
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.footerButton, styles.nextButton, loading && styles.buttonDisabled]}
|
||||
style={[styles.nextButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleNext}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.footerButtonText}>
|
||||
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
|
||||
{loading ? <ActivityIndicator color="#fff" /> : (
|
||||
<Text style={styles.nextButtonText}>
|
||||
{currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -390,252 +355,101 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ServiceCard = ({ name, enabled, onToggle, icon, description }) => (
|
||||
<View style={styles.serviceCard}>
|
||||
<View style={styles.serviceCardContent}>
|
||||
<Text style={styles.serviceIcon}>{icon}</Text>
|
||||
<View style={styles.serviceInfo}>
|
||||
<Text style={styles.serviceName}>{name}</Text>
|
||||
<Text style={styles.serviceDescription}>{description}</Text>
|
||||
const ServiceToggleCard = ({ icon, name, description, enabled, onToggle }) => (
|
||||
<View style={[styles.toggleCard, enabled && styles.toggleCardActive]}>
|
||||
<View style={styles.toggleCardContent}>
|
||||
<Text style={styles.toggleIcon}>{icon}</Text>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleName}>{name}</Text>
|
||||
<Text style={styles.toggleDescription}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch value={enabled} onValueChange={onToggle} />
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{ false: '#dadce0', true: '#a8dab5' }}
|
||||
thumbColor={enabled ? '#34a853' : '#f4f3f4'}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const ServiceConfigCard = ({ title, onPress, loading, children }) => (
|
||||
<View style={styles.configCard}>
|
||||
<Text style={styles.configCardTitle}>{title}</Text>
|
||||
{onPress && (
|
||||
<TouchableOpacity
|
||||
style={[styles.configButton, loading && styles.buttonDisabled]}
|
||||
onPress={onPress}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Authenticate</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
const ServiceSelectCard = ({ service, selected, onSelect }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.selectCard, selected && styles.selectedCard]}
|
||||
onPress={onSelect}
|
||||
>
|
||||
<View style={[styles.radioButton, selected && styles.radioSelected]} />
|
||||
<Text style={[styles.selectText, selected && styles.selectedText]}>
|
||||
{service}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
container: { flex: 1, backgroundColor: '#fff' },
|
||||
progressBar: {
|
||||
flexDirection: 'row', justifyContent: 'center', gap: 8,
|
||||
paddingTop: 60, paddingBottom: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
progressDot: {
|
||||
width: 32, height: 4, borderRadius: 2, backgroundColor: '#dadce0',
|
||||
},
|
||||
header: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
stepIndicator: {
|
||||
fontSize: 12,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
servicesContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
serviceCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
serviceCardContent: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
serviceIcon: {
|
||||
fontSize: 32,
|
||||
marginRight: 16,
|
||||
},
|
||||
serviceInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
serviceName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
serviceDescription: {
|
||||
fontSize: 13,
|
||||
color: '#999',
|
||||
},
|
||||
configContainer: {
|
||||
marginBottom: 20,
|
||||
progressDotActive: { backgroundColor: '#1a73e8' },
|
||||
content: { flex: 1, paddingHorizontal: 20 },
|
||||
header: { marginBottom: 24 },
|
||||
stepLabel: { fontSize: 12, color: '#1a73e8', fontWeight: '600', marginBottom: 6 },
|
||||
title: { fontSize: 26, fontWeight: '700', color: '#202124', marginBottom: 6 },
|
||||
description: { fontSize: 14, color: '#5f6368' },
|
||||
cardsContainer: { gap: 12 },
|
||||
// Toggle cards (Step 0)
|
||||
toggleCard: {
|
||||
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
|
||||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||
borderWidth: 1.5, borderColor: '#e8eaed',
|
||||
},
|
||||
toggleCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' },
|
||||
toggleCardContent: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
toggleIcon: { fontSize: 28, marginRight: 14 },
|
||||
toggleInfo: { flex: 1 },
|
||||
toggleName: { fontSize: 15, fontWeight: '600', color: '#202124', marginBottom: 2 },
|
||||
toggleDescription: { fontSize: 12, color: '#5f6368' },
|
||||
// Config cards (Step 1)
|
||||
configCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
|
||||
borderWidth: 1, borderColor: '#e8eaed',
|
||||
},
|
||||
configCardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
|
||||
configIcon: { fontSize: 24, marginRight: 10 },
|
||||
configTitle: { fontSize: 16, fontWeight: '600', color: '#202124', flex: 1 },
|
||||
connectedBadge: { fontSize: 13, color: '#34a853', fontWeight: '600' },
|
||||
authButton: {
|
||||
backgroundColor: '#1a73e8', borderRadius: 8, paddingVertical: 12, alignItems: 'center',
|
||||
},
|
||||
configLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
configInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
backgroundColor: '#f9f9f9',
|
||||
color: '#333',
|
||||
},
|
||||
configText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 12,
|
||||
},
|
||||
configButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
testButton: {
|
||||
backgroundColor: '#34C759',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
defaultServiceContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
defaultServiceTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
authButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||
fieldLabel: { fontSize: 12, fontWeight: '600', color: '#3c4043', marginTop: 10, marginBottom: 4 },
|
||||
fieldInput: {
|
||||
borderWidth: 1, borderColor: '#dadce0', borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
|
||||
backgroundColor: '#fff', color: '#202124',
|
||||
},
|
||||
// Select cards (Step 2)
|
||||
selectCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: '#ddd',
|
||||
backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16,
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
borderWidth: 2, borderColor: '#e8eaed',
|
||||
},
|
||||
selectedCard: {
|
||||
borderColor: '#007AFF',
|
||||
backgroundColor: '#f0f7ff',
|
||||
},
|
||||
radioButton: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ddd',
|
||||
marginRight: 12,
|
||||
},
|
||||
radioSelected: {
|
||||
borderColor: '#007AFF',
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
selectText: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
selectedText: {
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
selectCardActive: { borderColor: '#1a73e8', backgroundColor: '#e8f0fe' },
|
||||
radio: {
|
||||
width: 20, height: 20, borderRadius: 10, borderWidth: 2,
|
||||
borderColor: '#dadce0', marginRight: 12,
|
||||
},
|
||||
radioActive: { borderColor: '#1a73e8', backgroundColor: '#1a73e8' },
|
||||
selectText: { fontSize: 15, color: '#202124', fontWeight: '500' },
|
||||
selectTextActive: { color: '#1a73e8', fontWeight: '600' },
|
||||
// Footer
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
gap: 12,
|
||||
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
|
||||
borderTopWidth: 1, borderTopColor: '#e8eaed', gap: 12,
|
||||
},
|
||||
footerButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
backButton: {
|
||||
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
|
||||
backgroundColor: '#f8f9fa', borderWidth: 1, borderColor: '#dadce0',
|
||||
},
|
||||
backButtonText: { fontSize: 15, fontWeight: '600', color: '#5f6368' },
|
||||
nextButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
footerButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center',
|
||||
backgroundColor: '#1a73e8',
|
||||
},
|
||||
nextButtonText: { fontSize: 15, fontWeight: '600', color: '#fff' },
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
});
|
||||
|
||||
export default OnboardingScreen;
|
||||
|
||||
@@ -20,60 +20,51 @@ const RegisterScreen = ({ navigation }) => {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim()) {
|
||||
Alert.alert('Error', 'Please enter a username');
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
if (username.trim().length < 3) {
|
||||
Alert.alert('Error', 'Username must be at least 3 characters');
|
||||
return;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
Alert.alert('Error', 'Please enter an email');
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
if (!email.includes('@')) {
|
||||
Alert.alert('Error', 'Please enter a valid email');
|
||||
return false;
|
||||
if (!email.includes('@') || !email.includes('.')) {
|
||||
Alert.alert('Error', 'Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
if (!password.trim()) {
|
||||
Alert.alert('Error', 'Please enter a password');
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Error', 'Password must be at least 6 characters');
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Error', 'Passwords do not match');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await DatabaseService.initDatabase();
|
||||
// Check if email already exists
|
||||
const exists = await DatabaseService.userExists(email);
|
||||
const exists = await DatabaseService.userExists(username.trim(), email.trim().toLowerCase());
|
||||
if (exists) {
|
||||
Alert.alert('Error', 'Email already registered');
|
||||
Alert.alert('Error', 'Username or email already taken');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const userId = await DatabaseService.createUser(username, email, password);
|
||||
const userId = await DatabaseService.createUser(username.trim(), email.trim().toLowerCase(), password);
|
||||
|
||||
Alert.alert('Success', 'Account created successfully!', [
|
||||
Alert.alert('Success', 'Account created!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
// Navigate to onboarding after successful registration
|
||||
navigation.replace('Onboarding', { userId });
|
||||
},
|
||||
onPress: () => navigation.replace('Onboarding', { userId }),
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
@@ -89,14 +80,12 @@ const RegisterScreen = ({ navigation }) => {
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.appIcon}>📸</Text>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Join our photo library</Text>
|
||||
<Text style={styles.subtitle}>Set up your local photo library account</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
@@ -104,34 +93,36 @@ const RegisterScreen = ({ navigation }) => {
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Choose a username"
|
||||
placeholderTextColor="#999"
|
||||
placeholderTextColor="#9aa0a6"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
editable={!loading}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
placeholderTextColor="#9aa0a6"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
editable={!loading}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Create a password (min 6 characters)"
|
||||
placeholderTextColor="#999"
|
||||
placeholderTextColor="#9aa0a6"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
editable={!loading}
|
||||
secureTextEntry={true}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
@@ -139,11 +130,11 @@ const RegisterScreen = ({ navigation }) => {
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm your password"
|
||||
placeholderTextColor="#999"
|
||||
placeholderTextColor="#9aa0a6"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
editable={!loading}
|
||||
secureTextEntry={true}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
@@ -162,11 +153,8 @@ const RegisterScreen = ({ navigation }) => {
|
||||
|
||||
<View style={styles.loginContainer}>
|
||||
<Text style={styles.loginText}>Already have an account? </Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Login')}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.loginLink}>Login here</Text>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('Login')} disabled={loading}>
|
||||
<Text style={styles.loginLink}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -178,55 +166,59 @@ const RegisterScreen = ({ navigation }) => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 20,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
headerContainer: {
|
||||
marginBottom: 40,
|
||||
marginBottom: 36,
|
||||
alignItems: 'center',
|
||||
},
|
||||
appIcon: {
|
||||
fontSize: 56,
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 10,
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#202124',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
fontSize: 15,
|
||||
color: '#5f6368',
|
||||
textAlign: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 30,
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
color: '#3c4043',
|
||||
marginBottom: 6,
|
||||
marginTop: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: '#dadce0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
backgroundColor: '#fff',
|
||||
color: '#333',
|
||||
color: '#202124',
|
||||
},
|
||||
registerButton: {
|
||||
backgroundColor: '#34C759',
|
||||
backgroundColor: '#1a73e8',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
@@ -235,7 +227,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginContainer: {
|
||||
@@ -244,15 +236,14 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
loginText: {
|
||||
color: '#666',
|
||||
color: '#5f6368',
|
||||
fontSize: 14,
|
||||
},
|
||||
loginLink: {
|
||||
color: '#007AFF',
|
||||
color: '#1a73e8',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterScreen;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet, View, Text, TextInput, FlatList, TouchableOpacity,
|
||||
Dimensions, Image, Modal, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const GRID_COLUMNS = 3;
|
||||
const GRID_GAP = 2;
|
||||
const imageSize = (width - GRID_GAP * (GRID_COLUMNS + 1)) / GRID_COLUMNS;
|
||||
|
||||
const SearchScreen = ({ route }) => {
|
||||
const userId = route?.params?.userId;
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedMedia, setSelectedMedia] = useState(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (query.trim().length > 0) {
|
||||
performSearch();
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!userId) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
const media = await DatabaseService.searchMedia(userId, query.trim());
|
||||
setResults(media);
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
|
||||
const renderItem = ({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.gridItem}
|
||||
onPress={() => { setSelectedMedia(item); setModalVisible(true); }}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{item.local_path ? (
|
||||
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={{ fontSize: 28 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.sourceBadge}>
|
||||
<Text style={{ fontSize: 10 }}>{getServiceIcon(item.source_service)}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Search bar */}
|
||||
<View style={styles.searchBar}>
|
||||
<Text style={styles.searchIcon}>🔍</Text>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search photos and videos"
|
||||
placeholderTextColor="#9aa0a6"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setQuery('')}>
|
||||
<Text style={styles.clearButton}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{searching && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color="#1a73e8" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!searching && query.length > 0 && results.length === 0 && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>🔍</Text>
|
||||
<Text style={styles.emptyTitle}>No results</Text>
|
||||
<Text style={styles.emptyText}>Try searching with different keywords</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{query.length === 0 && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>🔍</Text>
|
||||
<Text style={styles.emptyTitle}>Search your photos</Text>
|
||||
<Text style={styles.emptyText}>Search by title or description</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
<Text style={styles.resultCount}>{results.length} result{results.length !== 1 ? 's' : ''}</Text>
|
||||
<FlatList
|
||||
data={results}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
|
||||
numColumns={GRID_COLUMNS}
|
||||
contentContainerStyle={styles.gridContent}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Photo viewer modal */}
|
||||
<Modal visible={modalVisible} transparent animationType="fade" onRequestClose={() => setModalVisible(false)}>
|
||||
<View style={styles.modalContainer}>
|
||||
<TouchableOpacity style={styles.modalClose} onPress={() => setModalVisible(false)}>
|
||||
<Text style={styles.modalCloseText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
{selectedMedia?.local_path ? (
|
||||
<Image source={{ uri: selectedMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={styles.fullPlaceholder}>
|
||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.mediaInfo}>
|
||||
<Text style={styles.mediaTitle}>{selectedMedia?.title || 'Photo'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#fff' },
|
||||
searchBar: {
|
||||
flexDirection: 'row', alignItems: 'center', marginHorizontal: 12,
|
||||
marginTop: 48, marginBottom: 8, backgroundColor: '#f1f3f4',
|
||||
borderRadius: 24, paddingHorizontal: 14, paddingVertical: 10, gap: 8,
|
||||
},
|
||||
searchIcon: { fontSize: 16 },
|
||||
searchInput: { flex: 1, fontSize: 15, color: '#202124', padding: 0 },
|
||||
clearButton: { fontSize: 16, color: '#5f6368', 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', color: '#202124', marginBottom: 6 },
|
||||
emptyText: { fontSize: 13, color: '#5f6368', textAlign: 'center' },
|
||||
resultCount: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13, color: '#5f6368' },
|
||||
gridContent: { paddingBottom: 20 },
|
||||
gridItem: {
|
||||
width: imageSize, height: imageSize, margin: GRID_GAP / 2,
|
||||
backgroundColor: '#f1f3f4', position: 'relative',
|
||||
},
|
||||
gridImage: { width: '100%', height: '100%' },
|
||||
placeholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: '#e8eaed' },
|
||||
sourceBadge: {
|
||||
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
|
||||
},
|
||||
modalContainer: { flex: 1, backgroundColor: '#000' },
|
||||
modalClose: {
|
||||
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',
|
||||
},
|
||||
modalCloseText: { color: '#fff', fontSize: 20, fontWeight: '600' },
|
||||
fullImage: { flex: 1, width: '100%' },
|
||||
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
mediaInfo: { backgroundColor: '#1a1a1a', padding: 16, paddingBottom: 32, borderTopLeftRadius: 16, borderTopRightRadius: 16 },
|
||||
mediaTitle: { fontSize: 16, fontWeight: '600', color: '#fff' },
|
||||
});
|
||||
|
||||
export default SearchScreen;
|
||||
|
||||
@@ -1,93 +1,70 @@
|
||||
import * as Crypto from 'expo-crypto';
|
||||
|
||||
/**
|
||||
* Credential Encryption Service
|
||||
*
|
||||
* Encrypts sensitive credentials before storing in database
|
||||
* Uses base64 encoding (React Native compatible)
|
||||
* For production, use a proper encryption library like react-native-aes-crypto
|
||||
* Uses expo-crypto for password hashing (SHA-256)
|
||||
* Uses base64 encoding for storing account data objects in the database
|
||||
*/
|
||||
|
||||
class CredentialEncryption {
|
||||
constructor() {
|
||||
// Simple key for demo - in production, use proper key management
|
||||
this.encryptionKey = 'photo-app-secret-key-2026';
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 encode a string (React Native compatible)
|
||||
* Uses btoa which is available in React Native
|
||||
* Hash a password using SHA-256 via expo-crypto
|
||||
* Returns a hex digest string
|
||||
*/
|
||||
base64Encode(str) {
|
||||
async hashPassword(password) {
|
||||
try {
|
||||
// btoa is available in React Native
|
||||
return btoa(unescape(encodeURIComponent(str)));
|
||||
if (!password) throw new Error('Password is required');
|
||||
const digest = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
password
|
||||
);
|
||||
return digest;
|
||||
} catch (error) {
|
||||
console.error('Base64 encode error:', error);
|
||||
throw new Error('Failed to encode data');
|
||||
console.error('Password hashing error:', error);
|
||||
throw new Error('Failed to hash password: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 decode a string (React Native compatible)
|
||||
* Uses atob which is available in React Native
|
||||
* Verify a password against a stored hash
|
||||
*/
|
||||
base64Decode(str) {
|
||||
async verifyPassword(password, storedHash) {
|
||||
try {
|
||||
// atob is available in React Native
|
||||
return decodeURIComponent(escape(atob(str)));
|
||||
const hash = await this.hashPassword(password);
|
||||
return hash === storedHash;
|
||||
} catch (error) {
|
||||
console.error('Base64 decode error:', error);
|
||||
throw new Error('Failed to decode data');
|
||||
console.error('Password verification error:', error);
|
||||
throw new Error('Failed to verify password: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt credentials before storing
|
||||
* Converts object to JSON string, then base64 encodes it
|
||||
* Encrypt an object for database storage (base64-encoded JSON)
|
||||
*/
|
||||
encrypt(data) {
|
||||
try {
|
||||
if (!data) {
|
||||
throw new Error('No data provided to encrypt');
|
||||
}
|
||||
if (!data) throw new Error('No data provided to encrypt');
|
||||
const jsonString = JSON.stringify(data);
|
||||
const encoded = this.base64Encode(jsonString);
|
||||
console.log('✓ Credentials encrypted successfully');
|
||||
// btoa is available in React Native / Hermes
|
||||
const encoded = btoa(unescape(encodeURIComponent(jsonString)));
|
||||
return encoded;
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt credentials: ' + error.message);
|
||||
throw new Error('Failed to encrypt data: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt credentials after retrieving
|
||||
* Base64 decodes, then parses JSON back to object
|
||||
* Decrypt a base64-encoded JSON string back to an object
|
||||
*/
|
||||
decrypt(encryptedData) {
|
||||
try {
|
||||
if (!encryptedData) {
|
||||
return null;
|
||||
}
|
||||
const jsonString = this.base64Decode(encryptedData);
|
||||
const data = JSON.parse(jsonString);
|
||||
console.log('✓ Credentials decrypted successfully');
|
||||
return data;
|
||||
if (!encryptedData) return null;
|
||||
const jsonString = decodeURIComponent(escape(atob(encryptedData)));
|
||||
return JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error);
|
||||
throw new Error('Failed to decrypt credentials: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data is already encrypted (base64)
|
||||
*/
|
||||
isEncrypted(data) {
|
||||
if (typeof data !== 'string') return false;
|
||||
try {
|
||||
this.base64Decode(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
throw new Error('Failed to decrypt data: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-188
@@ -6,37 +6,34 @@ let initPromise = null;
|
||||
|
||||
const DatabaseService = {
|
||||
/**
|
||||
* Initialize the database and create tables if they don't exist
|
||||
* Initialize the database and create tables
|
||||
*/
|
||||
initDatabase: async () => {
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
if (db) {
|
||||
return true;
|
||||
}
|
||||
if (db) return true;
|
||||
|
||||
if (typeof SQLite.openDatabaseAsync !== 'function') {
|
||||
throw new Error('SQLite openDatabaseAsync API not available');
|
||||
}
|
||||
db = await SQLite.openDatabaseAsync('PhotosApp.db');
|
||||
if (!db) {
|
||||
throw new Error('SQLite openDatabaseAsync returned null');
|
||||
}
|
||||
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
db = await SQLite.openDatabaseAsync('PhotosApp.db');
|
||||
if (!db) throw new Error('SQLite openDatabaseAsync returned null');
|
||||
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
);
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS linked_accounts (
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS linked_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
service_type TEXT NOT NULL,
|
||||
@@ -45,11 +42,11 @@ const DatabaseService = {
|
||||
is_default INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)`
|
||||
);
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_service TEXT NOT NULL,
|
||||
@@ -60,54 +57,40 @@ const DatabaseService = {
|
||||
remote_path TEXT,
|
||||
media_type TEXT,
|
||||
created_date DATETIME,
|
||||
uploaded_to_default INTEGER DEFAULT 0,
|
||||
synced INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)`
|
||||
);
|
||||
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS sync_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
service_type TEXT NOT NULL,
|
||||
status TEXT,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)`
|
||||
);
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Database initialization error:', error);
|
||||
db = null;
|
||||
initPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure the database is initialized before use
|
||||
*/
|
||||
ensureDb: async () => {
|
||||
await DatabaseService.initDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user account
|
||||
* Create a new user with hashed password
|
||||
*/
|
||||
createUser: async (username, email, password) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const hashedPassword = await CredentialEncryption.hashPassword(password);
|
||||
const result = await db.runAsync(
|
||||
'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
|
||||
[username, email, password]
|
||||
[username, email, hashedPassword]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
} catch (error) {
|
||||
@@ -117,16 +100,21 @@ const DatabaseService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user by email and password
|
||||
* Login user by username or email, verify hashed password
|
||||
*/
|
||||
loginUser: async (email, password) => {
|
||||
loginUser: async (identifier, password) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT * FROM users WHERE email = ? AND password = ?',
|
||||
[email, password]
|
||||
'SELECT * FROM users WHERE username = ? OR email = ?',
|
||||
[identifier, identifier]
|
||||
);
|
||||
return row || null;
|
||||
if (!row) return null;
|
||||
|
||||
const valid = await CredentialEncryption.verifyPassword(password, row.password);
|
||||
if (!valid) return null;
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('Error logging in user:', error);
|
||||
throw error;
|
||||
@@ -134,14 +122,14 @@ const DatabaseService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user exists by email
|
||||
* Check if username or email already exists
|
||||
*/
|
||||
userExists: async (email) => {
|
||||
userExists: async (username, email) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT id FROM users WHERE email = ?',
|
||||
[email]
|
||||
'SELECT id FROM users WHERE username = ? OR email = ?',
|
||||
[username, email]
|
||||
);
|
||||
return !!row;
|
||||
} catch (error) {
|
||||
@@ -150,19 +138,11 @@ const DatabaseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
getUserById: async (userId) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId && userId !== 0) {
|
||||
throw new Error('User ID is required and cannot be null');
|
||||
}
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT * FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
if (!userId && userId !== 0) throw new Error('User ID is required');
|
||||
const row = await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId]);
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting user:', error);
|
||||
@@ -171,58 +151,41 @@ const DatabaseService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Link an external account to user
|
||||
* Link an external service account to a user
|
||||
*/
|
||||
linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
|
||||
// Validate inputs
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required');
|
||||
}
|
||||
if (!serviceType) {
|
||||
throw new Error('Service type is required');
|
||||
}
|
||||
if (!userId) throw new Error('User ID is required');
|
||||
if (!serviceType) throw new Error('Service type is required');
|
||||
if (!accountData || typeof accountData !== 'object') {
|
||||
throw new Error('Account data must be a valid object');
|
||||
}
|
||||
|
||||
console.log('📝 Linking account:', { userId, serviceType, dataKeys: Object.keys(accountData) });
|
||||
|
||||
// Encrypt account data
|
||||
const encryptedData = CredentialEncryption.encrypt(accountData);
|
||||
if (!encryptedData) throw new Error('Encryption returned empty data');
|
||||
|
||||
if (!encryptedData) {
|
||||
throw new Error('Encryption returned empty data');
|
||||
}
|
||||
|
||||
console.log('✓ Account data encrypted, storing in database...');
|
||||
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO linked_accounts (user_id, service_type, account_data, is_default)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[userId, serviceType, encryptedData, isDefault ? 1 : 0]
|
||||
// Remove existing account of same type first
|
||||
await db.runAsync(
|
||||
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
|
||||
[userId, serviceType]
|
||||
);
|
||||
|
||||
console.log('✓ Account linked successfully, ID:', result.lastInsertRowId);
|
||||
|
||||
const result = await db.runAsync(
|
||||
'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)',
|
||||
[userId, serviceType, encryptedData, isDefault ? 1 : 0]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
} catch (error) {
|
||||
console.error('Error linking account:', error);
|
||||
throw new Error(`Failed to link account: ${error.message}`);
|
||||
throw new Error('Failed to link account: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all linked accounts for user
|
||||
*/
|
||||
getLinkedAccounts: async (userId) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId && userId !== 0) {
|
||||
throw new Error('User ID is required and cannot be null');
|
||||
}
|
||||
if (!userId && userId !== 0) throw new Error('User ID is required');
|
||||
const rows = await db.getAllAsync(
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
|
||||
[userId]
|
||||
@@ -237,9 +200,6 @@ const DatabaseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get linked account by service type
|
||||
*/
|
||||
getLinkedAccountByService: async (userId, serviceType) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
@@ -248,39 +208,24 @@ const DatabaseService = {
|
||||
[userId, serviceType]
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
||||
};
|
||||
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
|
||||
} catch (error) {
|
||||
console.error('Error getting linked account:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set default service for uploads
|
||||
*/
|
||||
setDefaultService: async (userId, accountId) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
await db.runAsync(
|
||||
'UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
await db.runAsync(
|
||||
'UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?',
|
||||
[accountId, userId]
|
||||
);
|
||||
await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]);
|
||||
await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]);
|
||||
} catch (error) {
|
||||
console.error('Error setting default service:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get default service for user
|
||||
*/
|
||||
getDefaultService: async (userId) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
@@ -289,50 +234,22 @@ const DatabaseService = {
|
||||
[userId]
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
||||
};
|
||||
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
|
||||
} catch (error) {
|
||||
console.error('Error getting default service:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add media to database
|
||||
*/
|
||||
addMedia: async (
|
||||
userId,
|
||||
sourceService,
|
||||
sourceId,
|
||||
title,
|
||||
description,
|
||||
localPath,
|
||||
remotePath,
|
||||
mediaType,
|
||||
createdDate
|
||||
) => {
|
||||
addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId || !sourceService) {
|
||||
throw new Error('userId and sourceService are required');
|
||||
}
|
||||
if (!userId || !sourceService) throw new Error('userId and sourceService are required');
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO media
|
||||
(user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
|
||||
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
userId,
|
||||
sourceService,
|
||||
sourceId || null,
|
||||
title || null,
|
||||
description || null,
|
||||
localPath || null,
|
||||
remotePath || null,
|
||||
mediaType || null,
|
||||
createdDate || null,
|
||||
]
|
||||
[userId, sourceService, sourceId || null, title || null, description || null,
|
||||
localPath || null, remotePath || null, mediaType || null, createdDate || null]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
} catch (error) {
|
||||
@@ -341,15 +258,10 @@ const DatabaseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all media for user
|
||||
*/
|
||||
getMediaByUser: async (userId) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId && userId !== 0) {
|
||||
throw new Error('User ID is required and cannot be null');
|
||||
}
|
||||
if (!userId && userId !== 0) throw new Error('User ID is required');
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
|
||||
[userId]
|
||||
@@ -360,9 +272,6 @@ const DatabaseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get media by service
|
||||
*/
|
||||
getMediaByService: async (userId, serviceType) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
@@ -376,46 +285,23 @@ const DatabaseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update media sync status
|
||||
*/
|
||||
updateMediaSyncStatus: async (mediaId, synced) => {
|
||||
searchMedia: async (userId, query) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
await db.runAsync(
|
||||
'UPDATE media SET synced = ? WHERE id = ?',
|
||||
[synced ? 1 : 0, mediaId]
|
||||
const searchTerm = `%${query}%`;
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
|
||||
[userId, searchTerm, searchTerm]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating media sync status:', error);
|
||||
console.error('Error searching media:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log sync operation
|
||||
*/
|
||||
logSync: async (userId, serviceType, status, message) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
await db.runAsync(
|
||||
'INSERT INTO sync_logs (user_id, service_type, status, message) VALUES (?, ?, ?, ?)',
|
||||
[userId, serviceType, status, message]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error logging sync:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
closeDatabase: async () => {
|
||||
try {
|
||||
if (db?.closeAsync) {
|
||||
await db.closeAsync();
|
||||
}
|
||||
if (db?.closeAsync) await db.closeAsync();
|
||||
db = null;
|
||||
initPromise = null;
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user