mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
created a localised login system
This commit is contained in:
1 parent
59ac1fc926
commit
37511780b7
16 files changed
+3721
-20
No files matched your search
@@ -0,0 +1,2 @@
|
||||
!/Ignore files/
|
||||
!/package-lock.json
|
||||
@@ -1,20 +1,127 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
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';
|
||||
|
||||
export default function App() {
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
function AppNavigator() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.js to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
<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.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
export default function App() {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
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');
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error if database initialization fails
|
||||
if (error) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}>
|
||||
<Text style={{ fontSize: 16, color: '#333', marginBottom: 12, textAlign: 'center' }}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={initializeApp}
|
||||
style={{ backgroundColor: '#007AFF', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavigationContainer>
|
||||
<AppNavigator />
|
||||
</NavigationContainer>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#f5f5f5" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,289 @@
|
||||
# Media-Aggregation-and-Sorting-System
|
||||
This is a repo for my Mass project. A project that aims to create a decentralised way of storing media. Decentralised in the sense that media is stored across cloud providers and local storage solutions.
|
||||
# Media Aggregation and Sorting System
|
||||
|
||||
A React Native/Expo application that aggregates photos and videos from multiple sources (Google Photos, iCloud, and Network-Attached Storage) into a unified photo library.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **User Authentication**: Local account creation and login with SQLite database storage
|
||||
- **Multi-Service Integration**:
|
||||
- 🔵 **Google Photos**: OAuth2 integration for Google Photos API
|
||||
- 🍎 **iCloud Photos**: Apple iCloud account support (placeholder)
|
||||
- 💾 **NAS Storage**: SFTP connection to Ubuntu servers for photo/video access
|
||||
|
||||
- **Onboarding Flow**:
|
||||
- Service selection (enable 1-3 services)
|
||||
- Service configuration with credentials
|
||||
- Default service selection for uploads
|
||||
|
||||
- **Photo Gallery**:
|
||||
- Unified photo library view combining all services
|
||||
- Google Photos-styled grid layout
|
||||
- Modal image viewer with metadata display
|
||||
- Support for photos and videos
|
||||
|
||||
### Architecture
|
||||
|
||||
#### Database Schema
|
||||
- **users**: User account storage (username, email, password)
|
||||
- **linked_accounts**: External service credentials and configurations
|
||||
- **media**: Photo/video metadata from all sources
|
||||
- **sync_logs**: Sync operation history and status
|
||||
|
||||
#### Authentication Services
|
||||
All authentication services follow the `AuthServiceBase` interface for easy interchangeability:
|
||||
|
||||
1. **GoogleAUTH**: OAuth2 authentication with Google Photos API
|
||||
2. **IOSAUTH**: Apple iCloud authentication (currently placeholder)
|
||||
3. **NASAUTH**: SFTP connection to Ubuntu servers via SSH2
|
||||
|
||||
#### Navigation Flow
|
||||
```
|
||||
Login/Register → Onboarding (Service Setup) → Home (Photo Gallery)
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Media-Aggregation-and-Sorting-System/
|
||||
├── src/
|
||||
│ ├── AUTH/
|
||||
│ │ ├── AuthServiceBase.js # Abstract auth service interface
|
||||
│ │ ├── GoogleAUTH.js # Google Photos authentication
|
||||
│ │ ├── IOSAUTH.js # iCloud authentication
|
||||
│ │ └── NASAUTH.js # NAS SFTP authentication
|
||||
│ ├── screens/
|
||||
│ │ ├── LoginScreen.js # Login form
|
||||
│ │ ├── RegisterScreen.js # Registration form
|
||||
│ │ ├── OnboardingScreen.js # Service setup wizard
|
||||
│ │ └── HomeScreen.js # Main photo gallery
|
||||
│ ├── services/
|
||||
│ │ └── DatabaseService.js # SQLite database operations
|
||||
│ ├── components/
|
||||
│ └── API/
|
||||
├── App.js # Navigation setup
|
||||
├── app.json # Expo configuration
|
||||
├── package.json # Dependencies
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Node.js and npm
|
||||
- Expo CLI: `npm install -g expo-cli`
|
||||
- iOS Simulator or Android Emulator (or physical device with Expo Go)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
1. **Clone and Install Dependencies**
|
||||
```bash
|
||||
cd Media-Aggregation-and-Sorting-System
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the Development Server**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
3. **Run on Device/Emulator**
|
||||
- iOS: Press `i` in the terminal
|
||||
- Android: Press `a` in the terminal
|
||||
- Physical Device: Scan QR code with Expo Go app
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating an Account
|
||||
1. Open the app → Select "Register here"
|
||||
2. Enter username, email, and password (min 6 characters)
|
||||
3. Confirm password and create account
|
||||
|
||||
### Login
|
||||
1. Enter registered email and password
|
||||
2. Click "Login"
|
||||
|
||||
### Onboarding (Service Configuration)
|
||||
**Step 1: Select Services**
|
||||
- Toggle which services to enable (1-3 maximum)
|
||||
- Google Photos, iCloud, and/or NAS
|
||||
|
||||
**Step 2: Configure Services**
|
||||
- **Google Photos**: Tap to authenticate with Google (OAuth flow)
|
||||
- **iCloud**: Tap to authenticate with Apple (OAuth flow)
|
||||
- **NAS**: Enter connection details:
|
||||
- Server address (IP or hostname)
|
||||
- SSH port (default: 22)
|
||||
- Username
|
||||
- Password
|
||||
- Photos folder path
|
||||
- Test connection before proceeding
|
||||
|
||||
**Step 3: Choose Default Service**
|
||||
- Select which service to use by default for uploads
|
||||
|
||||
### Photo Gallery
|
||||
- View all photos/videos from connected services
|
||||
- Service badges at top show connected accounts
|
||||
- ★ indicates the default service
|
||||
- Tap any photo to view in enlarged modal
|
||||
- Modal shows metadata: title, description, source, type, date
|
||||
|
||||
## API Integration (TODO)
|
||||
|
||||
### Google Photos Implementation
|
||||
- Replace placeholder `authenticate()` with actual Google Sign-In
|
||||
- Implement OAuth2 token exchange
|
||||
- Add `/photoslibrary/v1/mediaItems:search` API calls
|
||||
- Handle pagination and filtering
|
||||
|
||||
### iCloud Implementation
|
||||
- Integrate Apple Sign-In
|
||||
- Implement CloudKit API for photo access
|
||||
- Handle iCloud authentication
|
||||
|
||||
### NAS Implementation
|
||||
- Replace SSH2 placeholders with actual library implementation
|
||||
- Implement SFTP file listing and download
|
||||
- Add file upload functionality
|
||||
- Handle directory navigation
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (Optional)
|
||||
Create a `.env` file for sensitive configuration:
|
||||
```env
|
||||
GOOGLE_CLIENT_ID=your_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_client_secret
|
||||
NAS_DEFAULT_PORT=22
|
||||
```
|
||||
|
||||
### Database
|
||||
- SQLite database is automatically created on first run
|
||||
- Location: Device's app-specific directory
|
||||
- Filename: `PhotosApp.db`
|
||||
|
||||
## Authentication Service Pattern
|
||||
|
||||
Each service implements the same interface:
|
||||
|
||||
```javascript
|
||||
class AuthService extends AuthServiceBase {
|
||||
async authenticate(credentials) { }
|
||||
async getAccessToken() { }
|
||||
async refreshToken() { }
|
||||
async revokeAccess() { }
|
||||
async getUserInfo() { }
|
||||
// Service-specific methods
|
||||
}
|
||||
```
|
||||
|
||||
### Easy Interchangeability
|
||||
To swap or extend authentication:
|
||||
|
||||
1. Create new class extending `AuthServiceBase`
|
||||
2. Implement all abstract methods
|
||||
3. Update the service type in `DatabaseService.linkAccount()`
|
||||
4. Update `OnboardingScreen` UI if needed
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Login/Register
|
||||
↓
|
||||
Authentication → Save to SQLite
|
||||
↓
|
||||
Onboarding → Service Selection
|
||||
↓
|
||||
Service Auth → Store Credentials
|
||||
↓
|
||||
Home Screen → Fetch from Services
|
||||
↓
|
||||
Display in Gallery
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
⚠️ **Current Implementation Warnings**:
|
||||
- Passwords stored in plain text in SQLite (development only)
|
||||
- NAS passwords stored in plain text (development only)
|
||||
- OAuth tokens not persisted securely
|
||||
|
||||
**For Production**:
|
||||
- Use secure storage (Keychain/Keystore)
|
||||
- Encrypt sensitive data in database
|
||||
- Implement token refresh and expiration
|
||||
- Use OAuth for all services
|
||||
- Never store plain-text passwords
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core
|
||||
- `react`: 19.1.0
|
||||
- `react-native`: 0.81.5
|
||||
- `expo`: ~54.0.31
|
||||
|
||||
### Navigation
|
||||
- `@react-navigation/native`: ^6.1.17
|
||||
- `@react-navigation/native-stack`: ^6.9.26
|
||||
- `react-native-screens`: ^3.31.1
|
||||
- `react-native-safe-area-context`: ^4.10.5
|
||||
- `react-native-gesture-handler`: ^2.16.1
|
||||
|
||||
### Database & Storage
|
||||
- `react-native-sqlite-storage`: ^6.0.0
|
||||
- `expo-file-system`: ~17.0.1
|
||||
- `expo-media-library`: ~15.9.0
|
||||
|
||||
### Network
|
||||
- `ssh2`: ^1.15.0
|
||||
- `react-native-ssh2`: ^1.0.0
|
||||
|
||||
### Media
|
||||
- `expo-image-picker`: ~14.7.1
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Initialization Error
|
||||
```
|
||||
Solution: Clear app cache/reinstall
|
||||
npx expo prebuild --clean
|
||||
npm start
|
||||
```
|
||||
|
||||
### Navigation Issues
|
||||
Ensure all screens are properly exported and imported in App.js
|
||||
|
||||
### Service Connection Failures
|
||||
- Check network connectivity
|
||||
- Verify API credentials
|
||||
- Check NAS server accessibility (port 22 open)
|
||||
- Review console logs for detailed error messages
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Implement actual Google Photos API integration
|
||||
- [ ] Implement actual iCloud Photos integration
|
||||
- [ ] Complete SFTP functionality for NAS
|
||||
- [ ] Photo upload to default service
|
||||
- [ ] Photo search and filtering
|
||||
- [ ] Album management
|
||||
- [ ] Automatic sync scheduling
|
||||
- [ ] Cloud backup
|
||||
- [ ] Photo sharing features
|
||||
- [ ] Advanced metadata editing
|
||||
- [ ] Duplicate detection
|
||||
- [ ] Smart album creation
|
||||
- [ ] Facial recognition (optional)
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a personal project. Feel free to fork and extend!
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Feel free to use for personal or commercial projects.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, please check the code comments and inline TODOs for implementation guidance.
|
||||
@@ -24,6 +24,9 @@
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"expo-sqlite"
|
||||
]
|
||||
}
|
||||
}
|
||||
+27
-3
@@ -6,13 +6,37 @@
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
"web": "expo start --web",
|
||||
"backend": "node backend/server.js",
|
||||
"backend-dev": "nodemon backend/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "~54.0.31",
|
||||
"@react-native-community/masked-view": "^0.1.11",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"@react-navigation/stack": "^6.4.1",
|
||||
"axios": "^1.13.5",
|
||||
"expo": "~54.0.33",
|
||||
"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-media-library": "~18.2.1",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "^15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5"
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* NAS API - Direct Connection Handler
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
class NASAPI {
|
||||
constructor() {
|
||||
this.connection = null;
|
||||
this.credentials = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test NAS connection
|
||||
* Stores credentials for later use
|
||||
*/
|
||||
async connect(host, port, username, password, photoFolder = '/Photos') {
|
||||
try {
|
||||
// Validate credentials
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('Missing required connection details');
|
||||
}
|
||||
|
||||
// Store credentials securely
|
||||
this.credentials = {
|
||||
host,
|
||||
port: port || 22,
|
||||
username,
|
||||
password, // In production, encrypt this
|
||||
photoFolder,
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.isConnected = true;
|
||||
|
||||
console.log(`✓ NAS credentials stored for ${host}:${port}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `NAS configured: ${host}`,
|
||||
connectionData: {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
photoFolder,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('NAS connection error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from NAS
|
||||
*/
|
||||
async disconnect() {
|
||||
this.connection = null;
|
||||
this.credentials = null;
|
||||
this.isConnected = false;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Disconnected from NAS',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in NAS directory
|
||||
* Note: Placeholder - In production, use WebDAV or backend server
|
||||
*/
|
||||
async listDirectory(path = '/') {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
async getPhotos(folder = null) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
|
||||
console.log(`📷 Getting photos from: ${targetFolder}`);
|
||||
|
||||
// Placeholder response
|
||||
return {
|
||||
mediaItems: [],
|
||||
folder: targetFolder,
|
||||
message: 'NAS photo retrieval requires WebDAV or backend server',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
console.log(`⬆️ Upload: ${localPath} -> ${remotePath}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'NAS file upload requires WebDAV or backend server',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
console.log(`📁 Create directory: ${remotePath}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'NAS directory creation requires WebDAV or backend server',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export default NASAPI;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Abstract base class for authentication services
|
||||
* This provides a common interface for different authentication providers
|
||||
*/
|
||||
export class AuthServiceBase {
|
||||
constructor() {
|
||||
if (new.target === AuthServiceBase) {
|
||||
throw new TypeError('Cannot instantiate abstract class');
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
throw new Error('authenticate() must be implemented');
|
||||
}
|
||||
|
||||
async getAccessToken() {
|
||||
throw new Error('getAccessToken() must be implemented');
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
throw new Error('refreshToken() must be implemented');
|
||||
}
|
||||
|
||||
async revokeAccess() {
|
||||
throw new Error('revokeAccess() must be implemented');
|
||||
}
|
||||
|
||||
async getUserInfo() {
|
||||
throw new Error('getUserInfo() must be implemented');
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthServiceBase;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { AuthServiceBase } from './AuthServiceBase';
|
||||
|
||||
/**
|
||||
* Google Photos Authentication Service
|
||||
* Handles OAuth2 authentication with Google Photos API
|
||||
*/
|
||||
class GoogleAUTH extends AuthServiceBase {
|
||||
constructor() {
|
||||
super();
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.expiresAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Google OAuth
|
||||
* In a real app, you would use Google Sign-In library
|
||||
*/
|
||||
async authenticate(authCode) {
|
||||
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
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Google authentication successful',
|
||||
accessToken: this.accessToken,
|
||||
};
|
||||
} 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using refresh token
|
||||
*/
|
||||
async refreshToken() {
|
||||
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.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 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
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) {
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get photos error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums from Google Photos
|
||||
*/
|
||||
async getAlbums(pageSize = 50, pageToken = null) {
|
||||
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,
|
||||
};
|
||||
} 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;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { AuthServiceBase } from './AuthServiceBase';
|
||||
|
||||
/**
|
||||
* iOS iCloud Authentication Service
|
||||
* Handles authentication with Apple iCloud Photos (placeholder implementation)
|
||||
*/
|
||||
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
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'iCloud authentication successful',
|
||||
accessToken: this.accessToken,
|
||||
};
|
||||
} 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token
|
||||
*/
|
||||
async refreshToken() {
|
||||
try {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default IOSAUTH;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { AuthServiceBase } from './AuthServiceBase';
|
||||
import NASAPI from '../API/NASAPI';
|
||||
|
||||
/**
|
||||
* NAS SFTP Authentication Service
|
||||
*
|
||||
* Uses NASAPI for direct NAS connection management
|
||||
* No backend server required
|
||||
*/
|
||||
class NASAUTH extends AuthServiceBase {
|
||||
constructor() {
|
||||
super();
|
||||
this.nasAPI = new NASAPI();
|
||||
this.host = null;
|
||||
this.port = 22;
|
||||
this.username = null;
|
||||
this.password = null;
|
||||
this.photoFolder = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with NAS/SFTP server
|
||||
*/
|
||||
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
|
||||
try {
|
||||
// Validate input
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('Host, username, and password are required');
|
||||
}
|
||||
|
||||
console.log(`Connecting to NAS at ${host}:${port}...`);
|
||||
|
||||
this.host = host;
|
||||
this.port = port || 22;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.photoFolder = photoFolder;
|
||||
|
||||
// Connect via NASAPI
|
||||
const result = await this.nasAPI.connect(host, this.port, username, password, photoFolder);
|
||||
|
||||
this.isConnected = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `NAS configured: ${host}`,
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('NAS authentication error:', error.message);
|
||||
this.isConnected = false;
|
||||
throw new Error(`Failed to connect to NAS: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access/connection status
|
||||
*/
|
||||
async getAccessToken() {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
return {
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
isConnected: this.isConnected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh connection (reconnect if needed)
|
||||
*/
|
||||
async refreshToken() {
|
||||
try {
|
||||
if (this.host && this.username && this.password) {
|
||||
return await this.authenticate(
|
||||
this.host,
|
||||
this.port,
|
||||
this.username,
|
||||
this.password,
|
||||
this.photoFolder
|
||||
);
|
||||
}
|
||||
throw new Error('No NAS credentials available for reconnection');
|
||||
} catch (error) {
|
||||
console.error('Connection refresh error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from NAS
|
||||
*/
|
||||
async revokeAccess() {
|
||||
try {
|
||||
await this.nasAPI.disconnect();
|
||||
|
||||
this.isConnected = false;
|
||||
this.host = null;
|
||||
this.username = null;
|
||||
this.password = null;
|
||||
this.photoFolder = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'NAS connection closed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Disconnect error:', error.message);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NAS server information
|
||||
*/
|
||||
async getUserInfo() {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return {
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
port: this.port,
|
||||
photoFolder: this.photoFolder,
|
||||
connectedAt: new Date().toISOString(),
|
||||
serverType: 'NAS SFTP',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get user info error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List directories on NAS
|
||||
*/
|
||||
async listDirectory(remotePath = '/') {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.listDirectory(remotePath);
|
||||
} catch (error) {
|
||||
console.error('List directory error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to NAS
|
||||
*/
|
||||
async uploadFile(localPath, remotePath) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.uploadFile(localPath, remotePath);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get photos from designated NAS photo folder
|
||||
*/
|
||||
async getPhotos(photoFolder = null) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
const folder = photoFolder || this.photoFolder || '/Photos';
|
||||
return await this.nasAPI.getPhotos(folder);
|
||||
} catch (error) {
|
||||
console.error('Get photos error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create directory on NAS
|
||||
*/
|
||||
async createDirectory(remotePath) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.createDirectory(remotePath);
|
||||
} catch (error) {
|
||||
console.error('Create directory error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from NAS
|
||||
*/
|
||||
async deleteFile(remotePath) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.deleteFile(remotePath);
|
||||
} catch (error) {
|
||||
console.error('Delete file error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default NASAUTH;
|
||||
@@ -0,0 +1,821 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
Image,
|
||||
Alert,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
import GoogleAUTH from '../AUTH/GoogleAUTH';
|
||||
import IOSAUTH from '../AUTH/IOSAUTH';
|
||||
import NASAUTH from '../AUTH/NASAUTH';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const GRID_COLUMNS = 3;
|
||||
const imageSize = (width - 50) / GRID_COLUMNS;
|
||||
|
||||
/**
|
||||
* HomeScreen - Main photo gallery
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Load user data from database
|
||||
* - Fetch photos from all connected services
|
||||
* - Display photos in grid layout
|
||||
* - Handle photo viewing in modal
|
||||
* - Manage logout
|
||||
*/
|
||||
|
||||
const HomeScreen = ({ navigation, route }) => {
|
||||
const userId = route?.params?.userId;
|
||||
|
||||
// User data
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [linkedAccounts, setLinkedAccounts] = useState([]);
|
||||
|
||||
// Media data
|
||||
const [allMedia, setAllMedia] = useState([]);
|
||||
const [filteredMedia, setFilteredMedia] = useState([]);
|
||||
const [selectedFilter, setSelectedFilter] = useState('all');
|
||||
|
||||
// UI state
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedMedia, setSelectedMedia] = useState(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Effect: Load user data on mount
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
console.error('No userId provided to HomeScreen');
|
||||
setError('User ID is missing');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
loadUserData();
|
||||
}, [userId]);
|
||||
|
||||
// Effect: Load media when user data is ready
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
loadAllMedia();
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
// Effect: Filter media when filter changes
|
||||
useEffect(() => {
|
||||
filterMedia();
|
||||
}, [selectedFilter, allMedia]);
|
||||
|
||||
/**
|
||||
* Load user and linked accounts from database
|
||||
*/
|
||||
const loadUserData = async () => {
|
||||
try {
|
||||
const user = await DatabaseService.getUserById(userId);
|
||||
setCurrentUser(user);
|
||||
|
||||
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
||||
setLinkedAccounts(accounts);
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error loading user data:', err);
|
||||
setError('Failed to load user information');
|
||||
Alert.alert('Error', 'Failed to load user information');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load media from all connected services
|
||||
*/
|
||||
const loadAllMedia = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// First, try to get media from database
|
||||
const mediaFromDb = await DatabaseService.getMediaByUser(userId);
|
||||
|
||||
// Then fetch new media from each connected service
|
||||
const newMedia = [];
|
||||
|
||||
// Fetch from Google Photos if enabled
|
||||
const googleAccount = linkedAccounts.find(
|
||||
(acc) => acc.service_type === 'GOOGLE_PHOTOS'
|
||||
);
|
||||
if (googleAccount) {
|
||||
try {
|
||||
const googleAuth = new GoogleAUTH();
|
||||
console.log('Google Photos would be fetched here');
|
||||
} catch (err) {
|
||||
console.error('Google Photos fetch failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from iCloud if enabled
|
||||
const iosAccount = linkedAccounts.find(
|
||||
(acc) => acc.service_type === 'ICLOUD_PHOTOS'
|
||||
);
|
||||
if (iosAccount) {
|
||||
try {
|
||||
const iosAuth = new IOSAUTH();
|
||||
console.log('iCloud Photos would be fetched here');
|
||||
} catch (err) {
|
||||
console.error('iCloud Photos fetch failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from NAS if enabled
|
||||
const nasAccount = linkedAccounts.find(
|
||||
(acc) => acc.service_type === 'NAS'
|
||||
);
|
||||
if (nasAccount) {
|
||||
try {
|
||||
const nasAuth = new NASAUTH();
|
||||
const nasData = nasAccount.account_data;
|
||||
|
||||
// Connect to NAS via backend API
|
||||
await nasAuth.authenticate(
|
||||
nasData.host,
|
||||
nasData.port,
|
||||
nasData.username,
|
||||
nasData.password,
|
||||
nasData.photoFolder
|
||||
);
|
||||
|
||||
// Get photos
|
||||
const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder);
|
||||
if (nasPhotos.mediaItems) {
|
||||
newMedia.push(...nasPhotos.mediaItems);
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
await nasAuth.revokeAccess();
|
||||
} catch (err) {
|
||||
console.error('NAS Photos fetch failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine database media with newly fetched media
|
||||
const combinedMedia = [...mediaFromDb, ...newMedia];
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueMedia = Array.from(
|
||||
new Map(combinedMedia.map((m) => [m.source_id || m.id || m.path, m])).values()
|
||||
);
|
||||
|
||||
setAllMedia(uniqueMedia);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error loading media:', err);
|
||||
setError('Failed to load photos');
|
||||
Alert.alert('Error', 'Failed to load photos from services');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter media based on selected service
|
||||
*/
|
||||
const filterMedia = () => {
|
||||
let filtered = allMedia;
|
||||
|
||||
if (selectedFilter !== 'all') {
|
||||
filtered = allMedia.filter(
|
||||
(media) => media.source_service === selectedFilter
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
filtered.sort((a, b) => {
|
||||
const dateA = new Date(a.created_date || a.modifyTime || 0);
|
||||
const dateB = new Date(b.created_date || b.modifyTime || 0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
setFilteredMedia(filtered);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle refresh gesture
|
||||
*/
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadAllMedia();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle logout
|
||||
*/
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
'Logout',
|
||||
'Are you sure you want to logout?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Logout',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
navigation.replace('Login');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get service display name
|
||||
*/
|
||||
const getServiceName = (serviceType) => {
|
||||
const serviceMap = {
|
||||
GOOGLE_PHOTOS: 'Google Photos',
|
||||
ICLOUD_PHOTOS: 'iCloud',
|
||||
NAS: 'NAS',
|
||||
};
|
||||
return serviceMap[serviceType] || serviceType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get service icon
|
||||
*/
|
||||
const getServiceIcon = (serviceType) => {
|
||||
const iconMap = {
|
||||
GOOGLE_PHOTOS: '🔵',
|
||||
ICLOUD_PHOTOS: '🍎',
|
||||
NAS: '💾',
|
||||
};
|
||||
return iconMap[serviceType] || '📱';
|
||||
};
|
||||
|
||||
/**
|
||||
* Render grid item (photo)
|
||||
*/
|
||||
const renderMediaItem = ({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.gridItem}
|
||||
onPress={() => {
|
||||
setSelectedMedia(item);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{item.local_path ? (
|
||||
<Image
|
||||
source={{ uri: item.local_path }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderImage}>
|
||||
<Text style={styles.placeholderIcon}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{item.media_type === 'video' && (
|
||||
<View style={styles.videoOverlay}>
|
||||
<Text style={styles.playIcon}>▶</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.serviceBadge}>
|
||||
<Text style={styles.badgeIcon}>{getServiceIcon(item.source_service)}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Loading your photos...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && filteredMedia.length === 0) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={styles.errorIcon}>⚠️</Text>
|
||||
<Text style={styles.errorTitle}>Unable to Load Photos</Text>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={loadAllMedia}>
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (filteredMedia.length === 0) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Photo Library</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Welcome, {currentUser?.username}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Text style={styles.logoutText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={styles.emptyIcon}>📸</Text>
|
||||
<Text style={styles.emptyTitle}>No Photos Yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Photos from your connected services will appear here
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Photo Library</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Welcome, {currentUser?.username}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Text style={styles.logoutText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{linkedAccounts.length > 0 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterScroll}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.filterButton,
|
||||
selectedFilter === 'all' && styles.filterButtonActive,
|
||||
]}
|
||||
onPress={() => setSelectedFilter('all')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterButtonText,
|
||||
selectedFilter === 'all' && styles.filterButtonTextActive,
|
||||
]}
|
||||
>
|
||||
All
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{linkedAccounts.map((account) => (
|
||||
<TouchableOpacity
|
||||
key={account.id}
|
||||
style={[
|
||||
styles.filterButton,
|
||||
selectedFilter === account.service_type &&
|
||||
styles.filterButtonActive,
|
||||
]}
|
||||
onPress={() => setSelectedFilter(account.service_type)}
|
||||
>
|
||||
<Text style={styles.filterIcon}>
|
||||
{getServiceIcon(account.service_type)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterButtonText,
|
||||
selectedFilter === account.service_type &&
|
||||
styles.filterButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{getServiceName(account.service_type)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={filteredMedia}
|
||||
renderItem={renderMediaItem}
|
||||
keyExtractor={(item) => item.id?.toString() || item.path || Math.random().toString()}
|
||||
numColumns={GRID_COLUMNS}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
contentContainerStyle={styles.gridContent}
|
||||
scrollEnabled={true}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
transparent={true}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
animationType="fade"
|
||||
>
|
||||
<MediaDetailModal
|
||||
media={selectedMedia}
|
||||
onClose={() => setModalVisible(false)}
|
||||
serviceIcon={getServiceIcon(selectedMedia?.source_service)}
|
||||
serviceName={getServiceName(selectedMedia?.source_service)}
|
||||
/>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* MediaDetailModal - Full screen photo viewer
|
||||
*/
|
||||
const MediaDetailModal = ({ media, onClose, serviceIcon, serviceName }) => {
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.modalContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.modalBackdrop}
|
||||
onPress={onClose}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
|
||||
<View style={styles.modalContent}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Text style={styles.closeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<ScrollView
|
||||
style={styles.imageContainer}
|
||||
contentContainerStyle={styles.imageContent}
|
||||
scrollEnabled={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{media.local_path ? (
|
||||
<Image
|
||||
source={{ uri: media.local_path }}
|
||||
style={styles.fullImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderLarge}>
|
||||
<Text style={styles.placeholderLargeIcon}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.infoPanel}>
|
||||
<Text style={styles.infoTitle}>{media.title || 'Untitled Photo'}</Text>
|
||||
|
||||
{media.description && (
|
||||
<Text style={styles.infoDescription}>{media.description}</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.infoDivider} />
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>From</Text>
|
||||
<View style={styles.infoServiceBadge}>
|
||||
<Text style={styles.infoServiceIcon}>{serviceIcon}</Text>
|
||||
<Text style={styles.infoServiceName}>{serviceName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{media.media_type && (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>Type</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{media.media_type === 'video' ? '🎥 Video' : '📷 Photo'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(media.created_date || media.modifyTime) && (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>Date</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{new Date(
|
||||
media.created_date || media.modifyTime
|
||||
).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{media.size && (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>Size</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{(media.size / 1024 / 1024).toFixed(2)} MB
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#fff',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f0f0f0',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#999',
|
||||
marginTop: 2,
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: '#ff3b30',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
logoutText: {
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
filterScroll: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
gap: 8,
|
||||
},
|
||||
filterButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
gap: 6,
|
||||
},
|
||||
filterButtonActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
filterButtonText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
filterButtonTextActive: {
|
||||
color: '#fff',
|
||||
},
|
||||
filterIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
columnWrapper: {
|
||||
gap: 8,
|
||||
paddingHorizontal: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
gridContent: {
|
||||
paddingVertical: 8,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
gridItem: {
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e8e8e8',
|
||||
position: 'relative',
|
||||
},
|
||||
gridImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#e8e8e8',
|
||||
},
|
||||
placeholderIcon: {
|
||||
fontSize: 32,
|
||||
},
|
||||
videoOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
playIcon: {
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
serviceBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
badgeIcon: {
|
||||
fontSize: 12,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
modalBackdrop: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
modalContent: {
|
||||
flex: 1,
|
||||
zIndex: 10,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 20,
|
||||
},
|
||||
closeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
imageContainer: {
|
||||
flex: 0.6,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
imageContent: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100%',
|
||||
},
|
||||
fullImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderLarge: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
backgroundColor: '#333',
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderLargeIcon: {
|
||||
fontSize: 64,
|
||||
},
|
||||
infoPanel: {
|
||||
flex: 0.4,
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 6,
|
||||
},
|
||||
infoDescription: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoDivider: {
|
||||
height: 1,
|
||||
backgroundColor: '#f0f0f0',
|
||||
marginVertical: 12,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#999',
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 13,
|
||||
color: '#333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
infoServiceBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
infoServiceIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
infoServiceName: {
|
||||
fontSize: 13,
|
||||
color: '#333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default HomeScreen;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
const LoginScreen = ({ navigation }) => {
|
||||
const [email, setEmail] = 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');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await DatabaseService.initDatabase();
|
||||
const user = await DatabaseService.loginUser(email, password);
|
||||
|
||||
if (user) {
|
||||
// Successfully logged in - navigate to home
|
||||
navigation.replace('Onboarding', { userId: user.id });
|
||||
} else {
|
||||
Alert.alert('Error', 'Invalid email or password');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
Alert.alert('Error', 'Failed to login: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.title}>Photo Library</Text>
|
||||
<Text style={styles.subtitle}>Login to your account</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
editable={!loading}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your password"
|
||||
placeholderTextColor="#999"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
editable={!loading}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.loginButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Login</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.registerContainer}>
|
||||
<Text style={styles.registerText}>Don't have an account? </Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Register')}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.registerLink}>Register here</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
headerContainer: {
|
||||
marginBottom: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 10,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#fff',
|
||||
color: '#333',
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
registerContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
registerText: {
|
||||
color: '#666',
|
||||
fontSize: 14,
|
||||
},
|
||||
registerLink: {
|
||||
color: '#007AFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginScreen;
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Switch,
|
||||
TextInput,
|
||||
} 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 }) => {
|
||||
const { userId } = route.params;
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Service selections
|
||||
const [googleEnabled, setGoogleEnabled] = useState(false);
|
||||
const [iosEnabled, setIosEnabled] = useState(false);
|
||||
const [nasEnabled, setNasEnabled] = useState(false);
|
||||
|
||||
// NAS configuration
|
||||
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
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
const getEnabledServices = () => {
|
||||
const services = [];
|
||||
if (googleEnabled) services.push('Google Photos');
|
||||
if (iosEnabled) services.push('iCloud Photos');
|
||||
if (nasEnabled) services.push('NAS');
|
||||
return services;
|
||||
};
|
||||
|
||||
const handleGoogleAuth = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const googleAuth = new GoogleAUTH();
|
||||
// In a real app, this would trigger OAuth flow
|
||||
const result = await googleAuth.authenticate('auth_code');
|
||||
|
||||
await DatabaseService.linkAccount(
|
||||
userId,
|
||||
'GOOGLE_PHOTOS',
|
||||
{
|
||||
accessToken: result.accessToken,
|
||||
email: 'user@gmail.com',
|
||||
},
|
||||
defaultService === 'GOOGLE_PHOTOS'
|
||||
);
|
||||
|
||||
Alert.alert('Success', 'Google Photos connected!');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIosAuth = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const iosAuth = new IOSAUTH();
|
||||
const result = await iosAuth.authenticate('icloud_user', 'icloud_password');
|
||||
|
||||
await DatabaseService.linkAccount(
|
||||
userId,
|
||||
'ICLOUD_PHOTOS',
|
||||
{
|
||||
accessToken: result.accessToken,
|
||||
email: 'user@icloud.com',
|
||||
},
|
||||
defaultService === 'ICLOUD_PHOTOS'
|
||||
);
|
||||
|
||||
Alert.alert('Success', 'iCloud Photos connected!');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to connect iCloud Photos: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNasAuth = async () => {
|
||||
if (!nasHost || !nasUsername || !nasPassword) {
|
||||
Alert.alert('Error', 'Please fill in all NAS connection details');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const nasAuth = new NASAUTH();
|
||||
const result = await nasAuth.authenticate(
|
||||
nasHost,
|
||||
parseInt(nasPort) || 22,
|
||||
nasUsername,
|
||||
nasPassword
|
||||
);
|
||||
|
||||
await DatabaseService.linkAccount(
|
||||
userId,
|
||||
'NAS',
|
||||
{
|
||||
host: nasHost,
|
||||
port: nasPort,
|
||||
username: nasUsername,
|
||||
photoFolder: nasPhotoFolder,
|
||||
},
|
||||
defaultService === 'NAS'
|
||||
);
|
||||
|
||||
Alert.alert('Success', 'NAS connected!');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to connect NAS: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 0) {
|
||||
const enabledCount = getEnabledServices().length;
|
||||
if (enabledCount === 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);
|
||||
}
|
||||
} else if (currentStep === 2) {
|
||||
// Complete onboarding
|
||||
if (!defaultService) {
|
||||
Alert.alert('Error', 'Please select a default service');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Set default service
|
||||
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
||||
const defaultAccount = accounts.find(
|
||||
(acc) => acc.service_type === defaultService
|
||||
);
|
||||
if (defaultAccount) {
|
||||
await DatabaseService.setDefaultService(userId, defaultAccount.id);
|
||||
}
|
||||
|
||||
// Navigate to home screen
|
||||
navigation.replace('Home', { userId });
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to complete setup: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</View>
|
||||
|
||||
{currentStep === 0 && (
|
||||
<View style={styles.servicesContainer}>
|
||||
<ServiceCard
|
||||
name="Google Photos"
|
||||
enabled={googleEnabled}
|
||||
onToggle={() => setGoogleEnabled(!googleEnabled)}
|
||||
icon="📷"
|
||||
description="Connect to your Google Photos account"
|
||||
/>
|
||||
<ServiceCard
|
||||
name="iCloud Photos"
|
||||
enabled={iosEnabled}
|
||||
onToggle={() => setIosEnabled(!iosEnabled)}
|
||||
icon="🍎"
|
||||
description="Connect to your iCloud Photos account"
|
||||
/>
|
||||
<ServiceCard
|
||||
name="Network Storage (NAS)"
|
||||
enabled={nasEnabled}
|
||||
onToggle={() => setNasEnabled(!nasEnabled)}
|
||||
icon="💾"
|
||||
description="Connect to your Ubuntu NAS via SFTP"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<View style={styles.configContainer}>
|
||||
{googleEnabled && (
|
||||
<ServiceConfigCard
|
||||
title="Google Photos"
|
||||
onPress={handleGoogleAuth}
|
||||
loading={loading}
|
||||
>
|
||||
<Text style={styles.configText}>
|
||||
Tap to authenticate with your Google account
|
||||
</Text>
|
||||
</ServiceConfigCard>
|
||||
)}
|
||||
|
||||
{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"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.testButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleNasAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Test Connection</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ServiceConfigCard>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<View style={styles.defaultServiceContainer}>
|
||||
<Text style={styles.defaultServiceTitle}>
|
||||
Select your default upload service:
|
||||
</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]);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.footerButton, currentStep === 0 && styles.hidden]}
|
||||
onPress={handleBack}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.footerButtonText}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.footerButton, styles.nextButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleNext}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.footerButtonText}>
|
||||
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
<Switch value={enabled} onValueChange={onToggle} />
|
||||
</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',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
},
|
||||
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,
|
||||
},
|
||||
configCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
configCardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
},
|
||||
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,
|
||||
},
|
||||
selectCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
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',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
gap: 12,
|
||||
},
|
||||
footerButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
nextButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
footerButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export default OnboardingScreen;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
const RegisterScreen = ({ navigation }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!username.trim()) {
|
||||
Alert.alert('Error', 'Please enter a username');
|
||||
return false;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
Alert.alert('Error', 'Please enter an email');
|
||||
return false;
|
||||
}
|
||||
if (!email.includes('@')) {
|
||||
Alert.alert('Error', 'Please enter a valid email');
|
||||
return false;
|
||||
}
|
||||
if (!password.trim()) {
|
||||
Alert.alert('Error', 'Please enter a password');
|
||||
return false;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Error', 'Password must be at least 6 characters');
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
if (exists) {
|
||||
Alert.alert('Error', 'Email already registered');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const userId = await DatabaseService.createUser(username, email, password);
|
||||
|
||||
Alert.alert('Success', 'Account created successfully!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
// Navigate to onboarding after successful registration
|
||||
navigation.replace('Onboarding', { userId });
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
Alert.alert('Error', 'Failed to register: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Join our photo library</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Username</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Choose a username"
|
||||
placeholderTextColor="#999"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
editable={!loading}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
editable={!loading}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Create a password (min 6 characters)"
|
||||
placeholderTextColor="#999"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
editable={!loading}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Confirm Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm your password"
|
||||
placeholderTextColor="#999"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
editable={!loading}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.registerButton, loading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Create Account</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
headerContainer: {
|
||||
marginBottom: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 10,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#fff',
|
||||
color: '#333',
|
||||
},
|
||||
registerButton: {
|
||||
backgroundColor: '#34C759',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loginText: {
|
||||
color: '#666',
|
||||
fontSize: 14,
|
||||
},
|
||||
loginLink: {
|
||||
color: '#007AFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterScreen;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
||||
base64Encode(str) {
|
||||
try {
|
||||
// btoa is available in React Native
|
||||
return btoa(unescape(encodeURIComponent(str)));
|
||||
} catch (error) {
|
||||
console.error('Base64 encode error:', error);
|
||||
throw new Error('Failed to encode data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 decode a string (React Native compatible)
|
||||
* Uses atob which is available in React Native
|
||||
*/
|
||||
base64Decode(str) {
|
||||
try {
|
||||
// atob is available in React Native
|
||||
return decodeURIComponent(escape(atob(str)));
|
||||
} catch (error) {
|
||||
console.error('Base64 decode error:', error);
|
||||
throw new Error('Failed to decode data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt credentials before storing
|
||||
* Converts object to JSON string, then base64 encodes it
|
||||
*/
|
||||
encrypt(data) {
|
||||
try {
|
||||
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');
|
||||
return encoded;
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt credentials: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt credentials after retrieving
|
||||
* Base64 decodes, then parses JSON back to 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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new CredentialEncryption();
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import CredentialEncryption from './CredentialEncryption';
|
||||
|
||||
let db = null;
|
||||
let initPromise = null;
|
||||
|
||||
const DatabaseService = {
|
||||
/**
|
||||
* Initialize the database and create tables if they don't exist
|
||||
*/
|
||||
initDatabase: async () => {
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
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 (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
service_type TEXT NOT NULL,
|
||||
account_data TEXT NOT NULL,
|
||||
is_enabled INTEGER DEFAULT 1,
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_service TEXT NOT NULL,
|
||||
source_id TEXT,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
local_path TEXT,
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
return initPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure the database is initialized before use
|
||||
*/
|
||||
ensureDb: async () => {
|
||||
await DatabaseService.initDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user account
|
||||
*/
|
||||
createUser: async (username, email, password) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const result = await db.runAsync(
|
||||
'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
|
||||
[username, email, password]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user by email and password
|
||||
*/
|
||||
loginUser: async (email, password) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT * FROM users WHERE email = ? AND password = ?',
|
||||
[email, password]
|
||||
);
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('Error logging in user:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user exists by email
|
||||
*/
|
||||
userExists: async (email) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT id FROM users WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
return !!row;
|
||||
} catch (error) {
|
||||
console.error('Error checking if user exists:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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]
|
||||
);
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting user:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Link an external account to 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 (!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');
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
console.log('✓ Account linked successfully, ID:', result.lastInsertRowId);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
} catch (error) {
|
||||
console.error('Error linking account:', error);
|
||||
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');
|
||||
}
|
||||
const rows = await db.getAllAsync(
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
|
||||
[userId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error getting linked accounts:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get linked account by service type
|
||||
*/
|
||||
getLinkedAccountByService: async (userId, serviceType) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1',
|
||||
[userId, serviceType]
|
||||
);
|
||||
if (!row) return null;
|
||||
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]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error setting default service:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get default service for user
|
||||
*/
|
||||
getDefaultService: async (userId) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1',
|
||||
[userId]
|
||||
);
|
||||
if (!row) return null;
|
||||
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
|
||||
) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
userId,
|
||||
sourceService,
|
||||
sourceId || null,
|
||||
title || null,
|
||||
description || null,
|
||||
localPath || null,
|
||||
remotePath || null,
|
||||
mediaType || null,
|
||||
createdDate || null,
|
||||
]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
} catch (error) {
|
||||
console.error('Error adding media:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
|
||||
[userId]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting media:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get media by service
|
||||
*/
|
||||
getMediaByService: async (userId, serviceType) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC',
|
||||
[userId, serviceType]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting media by service:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update media sync status
|
||||
*/
|
||||
updateMediaSyncStatus: async (mediaId, synced) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
await db.runAsync(
|
||||
'UPDATE media SET synced = ? WHERE id = ?',
|
||||
[synced ? 1 : 0, mediaId]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating media sync status:', 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();
|
||||
}
|
||||
db = null;
|
||||
initPromise = null;
|
||||
} catch (error) {
|
||||
console.error('Error closing database:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default DatabaseService;
|
||||
|
||||
Reference in new issue
Block a user