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 { 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 (
|
return (
|
||||||
<View style={styles.container}>
|
<Stack.Navigator
|
||||||
<Text>Open up App.js to start working on your app!</Text>
|
screenOptions={{
|
||||||
<StatusBar style="auto" />
|
headerShown: false,
|
||||||
</View>
|
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({
|
export default function App() {
|
||||||
container: {
|
const [isReady, setIsReady] = useState(false);
|
||||||
flex: 1,
|
const [error, setError] = useState(null);
|
||||||
backgroundColor: '#fff',
|
|
||||||
alignItems: 'center',
|
useEffect(() => {
|
||||||
justifyContent: 'center',
|
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
|
# 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.
|
|
||||||
|
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": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
}
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-sqlite"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+27
-3
@@ -6,13 +6,37 @@
|
|||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web",
|
||||||
|
"backend": "node backend/server.js",
|
||||||
|
"backend-dev": "nodemon backend/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-status-bar": "~3.0.9",
|
||||||
|
"expo-web-browser": "^15.0.10",
|
||||||
"react": "19.1.0",
|
"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
|
"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