added NAS configuration file; updated MediaViewer to support thumbnail carousel; improved error handling in NASAPI and NASAUTH; refactored various comments for clarity

This commit is contained in:
bobbert committed 2026-02-22 19:19:55 +00:00
1 parent 9519b32d68
commit f8e11fc368
27 files changed
+1036 -558

No files matched your search

+36 -61
View File
@@ -1,71 +1,46 @@
// Google Photos Library API wrapper — uses the provided access token getter for authenticated requests
const GooglePhotosAPI = (getAccessToken) => {
const callFetch = async (endpoint, method = 'GET', dataObj = null) => {
const token = await getAccessToken();
if (!token) return { isSuccess: false, message: 'Missing access token' };
const callFetch = async (endpoint, method = 'GET', dataObj = null) => {
const token = await getAccessToken();
if (!token) return { isSuccess: false, message: 'Missing access token' };
let requestObj = {
method,
headers: {
Authorization: `Bearer ${token}`,
},
};
if (dataObj) {
requestObj = {
...requestObj,
headers: {
...requestObj.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(dataObj),
};
}
try {
const response = await fetch(endpoint, requestObj);
const result = response.status !== 204 ? await response.json() : null;
return response.ok
? { isSuccess: true, result }
: { isSuccess: false, message: result?.error?.message || 'Request failed' };
} catch (error) {
return { isSuccess: false, message: error.message };
}
let requestObj = {
method,
headers: { Authorization: `Bearer ${token}` },
};
const API_ROOT = 'https://photoslibrary.googleapis.com/v1';
if (dataObj) {
requestObj.headers['Content-Type'] = 'application/json';
requestObj.body = JSON.stringify(dataObj);
}
return {
// List media items
listMediaItems: (pageSize = 25, pageToken) => {
const params = new URLSearchParams({ pageSize });
if (pageToken) params.append('pageToken', pageToken);
return callFetch(`${API_ROOT}/mediaItems?${params.toString()}`, 'GET');
},
try {
const response = await fetch(endpoint, requestObj);
const result = response.status !== 204 ? await response.json() : null;
return response.ok
? { isSuccess: true, result }
: { isSuccess: false, message: result?.error?.message || 'Request failed' };
} catch (error) {
return { isSuccess: false, message: error.message };
}
};
// Get a single media item by ID
getMediaItem: (mediaItemId) =>
callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
const API_ROOT = 'https://photoslibrary.googleapis.com/v1';
// Search media items with filters (e.g., albumId)
searchMediaItems: (filters) =>
callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
// Create an album
createAlbum: (title) =>
callFetch(`${API_ROOT}/albums`, 'POST', { album: { title } }),
// Add media items to an album
addItemsToAlbum: (albumId, mediaItemIds) =>
callFetch(`${API_ROOT}/albums/${albumId}:batchAddMediaItems`, 'POST', {
mediaItemIds,
}),
// Remove media items from an album
removeItemsFromAlbum: (albumId, mediaItemIds) =>
callFetch(`${API_ROOT}/albums/${albumId}:batchRemoveMediaItems`, 'POST', {
mediaItemIds,
}),
};
return {
listMediaItems: (pageSize = 25, pageToken) => {
const params = new URLSearchParams({ pageSize });
if (pageToken) params.append('pageToken', pageToken);
return callFetch(`${API_ROOT}/mediaItems?${params.toString()}`, 'GET');
},
getMediaItem: (mediaItemId) => callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
searchMediaItems: (filters) => callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
createAlbum: (title) => callFetch(`${API_ROOT}/albums`, 'POST', { album: { title } }),
addItemsToAlbum: (albumId, mediaItemIds) =>
callFetch(`${API_ROOT}/albums/${albumId}:batchAddMediaItems`, 'POST', { mediaItemIds }),
removeItemsFromAlbum: (albumId, mediaItemIds) =>
callFetch(`${API_ROOT}/albums/${albumId}:batchRemoveMediaItems`, 'POST', { mediaItemIds }),
};
};
export default GooglePhotosAPI;
+6 -16
View File
@@ -1,18 +1,8 @@
/**
* iCloud Photos API - Placeholder
*
* Apple does not provide a public REST API for iCloud Photos.
* This file is reserved for future integration if Apple provides API access
* or if a third-party solution becomes available.
*/
const iCloudPhotosAPI = () => {
return {
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
};
};
// iCloud Photos API placeholder — Apple does not provide a public REST API for iCloud Photos
const iCloudPhotosAPI = () => ({
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
});
export default iCloudPhotosAPI;
+67 -157
View File
@@ -1,13 +1,6 @@
import axios from 'axios';
/**
* NAS API - HTTP Client for SFTP Backend
*
* Connects to the Node.js backend server (ServerFiles/server.js)
* which handles all actual SSH/SFTP operations.
*/
// Default backend URL - update this to match your server address
// HTTP client that communicates with the SFTP backend (ServerFiles/server.js)
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
class NASAPI {
@@ -18,113 +11,72 @@ class NASAPI {
this.credentials = null;
}
/**
* Connect to NAS via the backend server
* The backend handles the actual SSH connection
*/
// Establish a connection via the backend; falls back to local-only config if the backend is unreachable
async connect(host, port, username, password, photoFolder = '/Photos') {
if (!host || !username || !password) throw new Error('Host, username, and password are required');
this.credentials = { host, port: port || 22, username, photoFolder };
try {
if (!host || !username || !password) {
throw new Error('Host, username, and password are required');
const response = await axios.post(
`${this.backendUrl}/api/sftp/connect`,
{ host, port: port || 22, username, password },
{ timeout: 10000 }
);
if (response.data?.success) {
this.connectionToken = response.data.connectionToken;
this.isConnected = true;
return { success: true, message: `Connected to ${host}` };
}
// Store credentials locally
this.credentials = { host, port: port || 22, username, photoFolder };
// Try to connect via backend
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/connect`,
{
host,
port: port || 22,
username,
password,
},
{ timeout: 10000 }
);
if (response.data?.success) {
this.connectionToken = response.data.connectionToken;
this.isConnected = true;
console.log(`NAS connected via backend: ${host}:${port}`);
return { success: true, message: `Connected to ${host}` };
}
} catch (backendErr) {
// Backend not available - store credentials for when it becomes available
console.log('Backend not available, storing NAS credentials locally');
}
// Even without backend, store config so user can proceed
this.isConnected = true;
return {
success: true,
message: `NAS configured: ${host} (backend connection pending)`,
connectionData: this.credentials,
};
} catch (error) {
console.error('NAS connection error:', error);
throw error;
} catch (_) {
console.log('Backend not available, storing NAS credentials locally');
}
this.isConnected = true;
return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials };
}
/**
* Get authorization headers for backend requests
*/
getHeaders() {
if (!this.connectionToken) return {};
return { Authorization: `Bearer ${this.connectionToken}` };
}
/**
* List directory contents on the NAS
*/
async listDirectory(path = '/') {
if (!this.connectionToken) {
return { path, files: [], message: 'Backend not connected' };
}
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/list`,
{ path },
{ headers: this.getHeaders(), timeout: 15000 }
);
return response.data;
} catch (error) {
console.error('List directory error:', error.message);
throw new Error('Failed to list directory: ' + error.message);
}
if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' };
const response = await axios.post(
`${this.backendUrl}/api/sftp/list`, { path },
{ headers: this.getHeaders(), timeout: 15000 }
);
return response.data;
}
/**
* Get photos from designated NAS folder
*/
// Filter directory listing for supported photo/video files
async getPhotos(folder = null) {
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
if (!this.connectionToken) {
return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
}
if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
try {
const listing = await this.listDirectory(targetFolder);
const photoExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
const mediaItems = (listing.files || [])
.filter((file) => {
const ext = (file.name || '').toLowerCase().split('.').pop();
return photoExtensions.includes('.' + ext) || videoExtensions.includes('.' + ext);
const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
return photoExts.includes(ext) || videoExts.includes(ext);
})
.map((file) => ({
source_service: 'NAS',
source_id: `nas_${file.name}`,
title: file.name,
remote_path: `${targetFolder}/${file.name}`,
media_type: videoExtensions.includes('.' + (file.name || '').toLowerCase().split('.').pop()) ? 'video' : 'photo',
created_date: file.modifyTime,
size: file.size,
}));
.map((file) => {
const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
return {
source_service: 'NAS',
source_id: `nas_${file.name}`,
title: file.name,
remote_path: `${targetFolder}/${file.name}`,
media_type: videoExts.includes(ext) ? 'video' : 'photo',
created_date: file.modifyTime,
size: file.size,
};
});
return { mediaItems, folder: targetFolder };
} catch (error) {
@@ -133,84 +85,42 @@ class NASAPI {
}
}
/**
* Upload file to NAS
*/
async uploadFile(localPath, remotePath) {
if (!this.connectionToken) {
throw new Error('Backend not connected - cannot upload');
}
try {
const formData = new FormData();
formData.append('remotePath', remotePath);
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
if (!this.connectionToken) throw new Error('Backend not connected — cannot upload');
const formData = new FormData();
formData.append('remotePath', remotePath);
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
const response = await axios.post(
`${this.backendUrl}/api/sftp/upload`,
formData,
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
);
return response.data;
} catch (error) {
console.error('Upload error:', error.message);
throw new Error('Upload failed: ' + error.message);
}
const response = await axios.post(
`${this.backendUrl}/api/sftp/upload`, formData,
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
);
return response.data;
}
/**
* Create directory on NAS
*/
async createDirectory(remotePath) {
if (!this.connectionToken) {
throw new Error('Backend not connected');
}
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/mkdir`,
{ path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
);
return response.data;
} catch (error) {
console.error('Create directory error:', error.message);
throw error;
}
if (!this.connectionToken) throw new Error('Backend not connected');
const response = await axios.post(
`${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
);
return response.data;
}
/**
* Delete file from NAS
*/
async deleteFile(remotePath) {
if (!this.connectionToken) {
throw new Error('Backend not connected');
}
try {
const response = await axios.post(
`${this.backendUrl}/api/sftp/delete`,
{ path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
);
return response.data;
} catch (error) {
console.error('Delete error:', error.message);
throw error;
}
if (!this.connectionToken) throw new Error('Backend not connected');
const response = await axios.post(
`${this.backendUrl}/api/sftp/delete`, { path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
);
return response.data;
}
/**
* Disconnect from NAS
*/
async disconnect() {
if (this.connectionToken) {
try {
await axios.post(
`${this.backendUrl}/api/sftp/disconnect`,
{},
{ headers: this.getHeaders(), timeout: 5000 }
);
} catch (err) {
// Ignore disconnect errors
}
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
} catch (_) { /* ignore */ }
}
this.connectionToken = null;
this.isConnected = false;
+6 -24
View File
@@ -1,7 +1,4 @@
/**
* Abstract base class for authentication services
* This provides a common interface for different authentication providers
*/
// Abstract base class for authentication services — provides a common interface for all providers
export class AuthServiceBase {
constructor() {
if (new.target === AuthServiceBase) {
@@ -9,26 +6,11 @@ export class AuthServiceBase {
}
}
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');
}
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;
+12 -19
View File
@@ -4,7 +4,7 @@ import { AuthServiceBase } from './AuthServiceBase';
WebBrowser.maybeCompleteAuthSession();
// TODO: Replace with your real Google Cloud OAuth Client ID
// Replace with your real Google Cloud OAuth Client ID (see ignorefiles/GOOGLE_OAUTH_SETUP.md)
const GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com';
const GOOGLE_DISCOVERY = {
@@ -13,7 +13,7 @@ const GOOGLE_DISCOVERY = {
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
};
// Google Photos Library API scopes - read and write
// Google Photos Library API scopes — read, write, and user profile
const SCOPES = [
'https://www.googleapis.com/auth/photoslibrary',
'https://www.googleapis.com/auth/photoslibrary.appendonly',
@@ -31,19 +31,15 @@ class GoogleAUTH extends AuthServiceBase {
this.idToken = null;
}
/**
* Authenticate with Google using expo-auth-session
* Falls back to placeholder tokens when no real client ID is set
*/
// Authenticate via Google OAuth; uses placeholder tokens when no real client ID is configured
async authenticate() {
try {
if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
console.log('Google Auth: Using placeholder mode (no client ID configured)');
console.log('Google Auth: placeholder mode (no client ID configured)');
this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now();
this.expiresAt = Date.now() + 3600000;
this.email = 'google-user@placeholder.com';
return {
success: true,
accessToken: this.accessToken,
@@ -52,8 +48,10 @@ class GoogleAUTH extends AuthServiceBase {
};
}
// Real OAuth flow
const redirectUri = AuthSession.makeRedirectUri({ preferLocalhost: true });
const redirectUri = AuthSession.makeRedirectUri({
scheme: 'media-aggregation',
preferLocalhost: true,
});
const authRequest = new AuthSession.AuthRequest({
clientId: GOOGLE_CLIENT_ID,
@@ -114,6 +112,7 @@ class GoogleAUTH extends AuthServiceBase {
}
}
// Return a valid access token, refreshing if expired
async getAccessToken() {
if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken;
if (this.refreshToken) return await this.refreshAccessToken();
@@ -165,9 +164,7 @@ class GoogleAUTH extends AuthServiceBase {
return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
}
/**
* Fetch media items from Google Photos Library API
*/
// Fetch media items from the Google Photos Library API
async getPhotos(pageSize = 50, pageToken = null) {
try {
const token = await this.getAccessToken();
@@ -204,9 +201,7 @@ class GoogleAUTH extends AuthServiceBase {
}
}
/**
* Fetch albums from Google Photos Library API
*/
// Fetch album list from the Google Photos Library API
async getAlbums(pageSize = 50, pageToken = null) {
try {
const token = await this.getAccessToken();
@@ -227,9 +222,7 @@ class GoogleAUTH extends AuthServiceBase {
}
}
/**
* Upload media to Google Photos Library
*/
// Upload a photo to Google Photos Library
async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') {
try {
const token = await this.getAccessToken();
+6 -25
View File
@@ -1,10 +1,6 @@
import { AuthServiceBase } from './AuthServiceBase';
/**
* iCloud Photos Authentication Service
* Placeholder - Apple does not provide a public iCloud Photos API.
* This is kept for future integration if/when Apple provides API access.
*/
// iCloud Photos placeholder — Apple does not provide a public iCloud Photos API
class IOSAUTH extends AuthServiceBase {
constructor() {
super();
@@ -12,13 +8,8 @@ class IOSAUTH extends AuthServiceBase {
}
async authenticate() {
console.log('iCloud Auth: Placeholder - no Apple API available');
this.accessToken = 'icloud_placeholder_' + Date.now();
return {
success: true,
accessToken: this.accessToken,
message: 'iCloud placeholder - full integration pending',
};
return { success: true, accessToken: this.accessToken, message: 'iCloud placeholder — full integration pending' };
}
async getAccessToken() {
@@ -26,26 +17,16 @@ class IOSAUTH extends AuthServiceBase {
throw new Error('Not authenticated with iCloud');
}
async refreshToken() {
return this.authenticate();
}
async refreshToken() { return this.authenticate(); }
async revokeAccess() {
this.accessToken = null;
return { success: true };
}
async getUserInfo() {
return { id: 'icloud_placeholder', name: 'iCloud User' };
}
async getPhotos() {
return { mediaItems: [], nextPageToken: null };
}
async getAlbums() {
return { albums: [], nextPageToken: null };
}
async getUserInfo() { return { id: 'icloud_placeholder', name: 'iCloud User' }; }
async getPhotos() { return { mediaItems: [], nextPageToken: null }; }
async getAlbums() { return { albums: [], nextPageToken: null }; }
}
export default IOSAUTH;
+34 -154
View File
@@ -1,12 +1,7 @@
import { AuthServiceBase } from './AuthServiceBase';
import NASAPI from '../API/NASAPI';
/**
* NAS SFTP Authentication Service
*
* Uses NASAPI for direct NAS connection management
* No backend server required
*/
// NAS SFTP authentication — connects to the backend server via NASAPI
class NASAUTH extends AuthServiceBase {
constructor() {
super();
@@ -19,17 +14,9 @@ class NASAUTH extends AuthServiceBase {
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}...`);
if (!host || !username || !password) throw new Error('Host, username, and password are required');
this.host = host;
this.port = port || 22;
@@ -37,17 +24,10 @@ class NASAUTH extends AuthServiceBase {
this.password = password;
this.photoFolder = photoFolder;
// Connect via NASAPI
const result = await this.nasAPI.connect(host, this.port, username, password, photoFolder);
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,
};
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;
@@ -55,167 +35,67 @@ class NASAUTH extends AuthServiceBase {
}
}
/**
* 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,
};
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;
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');
}
/**
* 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;
}
this.isConnected = false;
this.host = null;
this.username = null;
this.password = null;
this.photoFolder = null;
return { success: true, message: 'NAS connection closed' };
}
/**
* 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;
}
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',
};
}
/**
* 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;
}
if (!this.isConnected) throw new Error('Not connected to NAS');
return await this.nasAPI.listDirectory(remotePath);
}
/**
* 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;
}
if (!this.isConnected) throw new Error('Not connected to NAS');
return await this.nasAPI.uploadFile(localPath, remotePath);
}
/**
* 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;
}
if (!this.isConnected) throw new Error('Not connected to NAS');
return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos');
}
/**
* 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;
}
if (!this.isConnected) throw new Error('Not connected to NAS');
return await this.nasAPI.createDirectory(remotePath);
}
/**
* 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;
}
if (!this.isConnected) throw new Error('Not connected to NAS');
return await this.nasAPI.deleteFile(remotePath);
}
}
+191 -11
View File
@@ -1,45 +1,202 @@
import React from 'react';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import {
Modal, View, Text, TouchableOpacity, Image, StyleSheet,
Dimensions, FlatList,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const THUMB_SIZE = 60;
const THUMB_GAP = 4;
const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) =>
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
const MediaViewer = ({ visible, media, onClose }) => {
/**
* Fullscreen media viewer with thumbnail carousel.
*
* Props:
* visible – boolean to show/hide the modal
* media – currently selected media item
* allMedia – (optional) full array of media items for carousel navigation
* onClose – callback when user dismisses the viewer
*/
const MediaViewer = ({ visible, media, allMedia, onClose }) => {
const { theme } = useTheme();
const mainListRef = useRef(null);
const thumbListRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(0);
const [currentMedia, setCurrentMedia] = useState(media);
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
const hasCarousel = mediaList.length > 1;
// When the modal opens or media prop changes, scroll to the correct item
useEffect(() => {
if (!visible || !media || mediaList.length === 0) return;
const idx = mediaList.findIndex(
(m) => (m.source_id || m.id) === (media.source_id || media.id)
);
const newIndex = idx >= 0 ? idx : 0;
setActiveIndex(newIndex);
setCurrentMedia(mediaList[newIndex]);
// Delay scroll so FlatList has rendered
setTimeout(() => {
if (mainListRef.current && newIndex >= 0) {
mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false });
}
if (thumbListRef.current && newIndex >= 0) {
try {
thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 });
} catch (e) { /* index might be out of range during initial render */ }
}
}, 100);
}, [visible, media]);
const onMainScroll = useCallback(
(event) => {
const offsetX = event.nativeEvent.contentOffset.x;
const idx = Math.round(offsetX / SCREEN_WIDTH);
if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) {
setActiveIndex(idx);
setCurrentMedia(mediaList[idx]);
if (thumbListRef.current) {
try {
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch (e) { /* ignore */ }
}
}
},
[activeIndex, mediaList]
);
const onThumbPress = (index) => {
setActiveIndex(index);
setCurrentMedia(mediaList[index]);
if (mainListRef.current) {
mainListRef.current.scrollToOffset({ offset: index * SCREEN_WIDTH, animated: true });
}
};
if (!media) return null;
const renderMainItem = ({ item }) => (
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
</View>
);
const renderThumb = ({ item, index }) => (
<TouchableOpacity
style={[
styles.thumbItem,
index === activeIndex && { borderColor: '#fff', borderWidth: 2 },
]}
onPress={() => onThumbPress(index)}
activeOpacity={0.8}
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" />
) : (
<View style={styles.thumbPlaceholder}>
<Text style={{ fontSize: 20 }}>📷</Text>
</View>
)}
</TouchableOpacity>
);
const displayMedia = currentMedia || media;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
{/* Close button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{media.local_path ? (
<Image source={{ uri: media.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
{/* Counter */}
{hasCarousel && (
<View style={styles.counter}>
<Text style={styles.counterText}>
{activeIndex + 1} / {mediaList.length}
</Text>
</View>
)}
{/* Main image area – swipeable */}
{hasCarousel ? (
<FlatList
ref={mainListRef}
data={mediaList}
renderItem={renderMainItem}
keyExtractor={(item, i) => item.source_id || item.id?.toString() || i.toString()}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onMainScroll}
getItemLayout={(_, index) => ({
length: SCREEN_WIDTH,
offset: SCREEN_WIDTH * index,
index,
})}
style={styles.mainList}
/>
) : (
// Single image mode (backward compat)
displayMedia.local_path ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)
)}
{/* Thumbnail carousel */}
{hasCarousel && (
<View style={styles.thumbCarousel}>
<FlatList
ref={thumbListRef}
data={mediaList}
renderItem={renderThumb}
keyExtractor={(item, i) => 'thumb_' + (item.source_id || item.id?.toString() || i)}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.thumbContent}
getItemLayout={(_, index) => ({
length: THUMB_SIZE + THUMB_GAP,
offset: (THUMB_SIZE + THUMB_GAP) * index,
index,
})}
/>
</View>
)}
{/* Info panel */}
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
<Text style={styles.title}>{media.title || 'Photo'}</Text>
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
<View style={styles.row}>
<Text style={styles.label}>Source</Text>
<Text style={styles.value}>
{getServiceIcon(media.source_service)} {getServiceName(media.source_service)}
{getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)}
</Text>
</View>
{media.created_date && (
{displayMedia.created_date && (
<View style={styles.row}>
<Text style={styles.label}>Date</Text>
<Text style={styles.value}>
{new Date(media.created_date).toLocaleDateString()}
{new Date(displayMedia.created_date).toLocaleDateString()}
</Text>
</View>
)}
@@ -58,8 +215,31 @@ const styles = StyleSheet.create({
justifyContent: 'center', alignItems: 'center',
},
closeText: { color: '#fff', fontSize: 20, fontWeight: '600' },
counter: {
position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20,
alignItems: 'center',
},
counterText: { color: '#fff', fontSize: 14, fontWeight: '600' },
mainList: { flex: 1 },
fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
// Thumbnail carousel
thumbCarousel: {
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
backgroundColor: 'rgba(0,0,0,0.4)',
paddingVertical: 8,
},
thumbContent: { paddingHorizontal: 12, gap: THUMB_GAP },
thumbItem: {
width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: 6,
overflow: 'hidden', borderWidth: 2, borderColor: 'transparent',
},
thumbImage: { width: '100%', height: '100%' },
thumbPlaceholder: {
width: '100%', height: '100%', backgroundColor: '#333',
justifyContent: 'center', alignItems: 'center',
},
// Info panel
infoPanel: {
padding: 16, paddingBottom: 32,
borderTopLeftRadius: 16, borderTopRightRadius: 16,
+1 -9
View File
@@ -1,15 +1,7 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
/**
* Circular health indicator dot
*
* status values:
* 'inactive' → grey (not used / not connected)
* 'healthy' → green (connected and working)
* 'error' → red (connected but errors fetching data)
* 'checking' → amber (currently checking)
*/
// Circular health indicator: inactive=grey, healthy=green, error=red, checking=amber
const COLORS = {
inactive: '#9e9e9e',
healthy: '#34a853',
+1 -1
View File
@@ -121,7 +121,7 @@ const CollectionsScreen = ({ route, navigation }) => {
contentContainerStyle={styles.gridContent}
/>
)}
<MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
<MediaViewer visible={viewerVisible} media={selectedMedia} allMedia={albumMedia} onClose={() => setViewerVisible(false)} />
</View>
);
}
+1
View File
@@ -187,6 +187,7 @@ const HomeScreen = ({ navigation, route }) => {
<MediaViewer
visible={viewerVisible}
media={selectedMedia}
allMedia={filteredMedia}
onClose={() => setViewerVisible(false)}
/>
</View>
+1 -4
View File
@@ -6,10 +6,7 @@ import {
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
// ──────────────────────────────────────────────────────────
// App icon placeholder – change the require path when you
// have a final image in the assets folder.
// ──────────────────────────────────────────────────────────
// App icon — change this path when the final asset is ready
const APP_ICON = require('../../assets/icon.png');
const LoginScreen = ({ navigation }) => {
+1 -1
View File
@@ -6,7 +6,7 @@ import {
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
// Same icon constant as LoginScreen – edit in one place
// App icon — same constant as LoginScreen
const APP_ICON = require('../../assets/icon.png');
const RegisterScreen = ({ navigation }) => {
+1 -1
View File
@@ -92,7 +92,7 @@ const SearchScreen = ({ route }) => {
</>
)}
<MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
<MediaViewer visible={viewerVisible} media={selectedMedia} allMedia={results} onClose={() => setViewerVisible(false)} />
</View>
);
};
+7 -15
View File
@@ -1,24 +1,19 @@
import * as Crypto from 'expo-crypto';
/**
* Credential Encryption Service
*
* Uses expo-crypto for password hashing (SHA-256)
* Uses base64 encoding for storing account data objects in the database
* Handles password hashing (SHA-256) and base64 encoding of account data objects
*/
class CredentialEncryption {
/**
* Hash a password using SHA-256 via expo-crypto
* Returns a hex digest string
* Hash a password with SHA-256 and return a hex digest
*/
async hashPassword(password) {
try {
if (!password) throw new Error('Password is required');
const digest = await Crypto.digestStringAsync(
return await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
password
);
return digest;
} catch (error) {
console.error('Password hashing error:', error);
throw new Error('Failed to hash password: ' + error.message);
@@ -26,7 +21,7 @@ class CredentialEncryption {
}
/**
* Verify a password against a stored hash
* Compare a plaintext password against a stored hash
*/
async verifyPassword(password, storedHash) {
try {
@@ -39,15 +34,13 @@ class CredentialEncryption {
}
/**
* Encrypt an object for database storage (base64-encoded JSON)
* Encode an object as a base64 JSON string for database storage
*/
encrypt(data) {
try {
if (!data) throw new Error('No data provided to encrypt');
const jsonString = JSON.stringify(data);
// btoa is available in React Native / Hermes
const encoded = btoa(unescape(encodeURIComponent(jsonString)));
return encoded;
return btoa(unescape(encodeURIComponent(jsonString)));
} catch (error) {
console.error('Encryption error:', error);
throw new Error('Failed to encrypt data: ' + error.message);
@@ -55,7 +48,7 @@ class CredentialEncryption {
}
/**
* Decrypt a base64-encoded JSON string back to an object
* Decode a base64 JSON string back to an object
*/
decrypt(encryptedData) {
try {
@@ -70,4 +63,3 @@ class CredentialEncryption {
}
export default new CredentialEncryption();
+16 -52
View File
@@ -5,9 +5,7 @@ let db = null;
let initPromise = null;
const DatabaseService = {
/**
* Initialize the database and create tables
*/
// Open the database and create all tables
initDatabase: async () => {
if (initPromise) return initPromise;
@@ -63,13 +61,9 @@ const DatabaseService = {
)
`);
// Migration: add columns if they don't exist
try {
await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`);
} catch (e) { /* column already exists */ }
try {
await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`);
} catch (e) { /* column already exists */ }
// Add optional columns (safe migration — errors ignored if columns already exist)
try { await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`); } catch (_) {}
try { await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`); } catch (_) {}
console.log('Database initialized successfully');
return true;
@@ -89,9 +83,6 @@ const DatabaseService = {
if (!db) throw new Error('Database not initialized');
},
/**
* Create a new user with hashed password
*/
createUser: async (username, email, password) => {
try {
await DatabaseService.ensureDb();
@@ -107,9 +98,7 @@ const DatabaseService = {
}
},
/**
* Login user by username or email, verify hashed password
*/
// Authenticate by username or email
loginUser: async (identifier, password) => {
try {
await DatabaseService.ensureDb();
@@ -129,9 +118,6 @@ const DatabaseService = {
}
},
/**
* Check if username or email already exists
*/
userExists: async (username, email) => {
try {
await DatabaseService.ensureDb();
@@ -150,34 +136,26 @@ const DatabaseService = {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) throw new Error('User ID is required');
const row = await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId]);
return row || null;
return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null;
} catch (error) {
console.error('Error getting user:', error);
throw error;
}
},
/**
* Link an external service account to a user
*/
// Link an external service account (Google, iCloud, NAS) to a local user
linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
try {
await DatabaseService.ensureDb();
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');
}
if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object');
const encryptedData = CredentialEncryption.encrypt(accountData);
if (!encryptedData) throw new Error('Encryption returned empty data');
// Remove existing account of same type first
await db.runAsync(
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
[userId, serviceType]
);
// Replace any existing link for the same service type
await db.runAsync('DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', [userId, serviceType]);
const result = await db.runAsync(
'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)',
@@ -195,13 +173,9 @@ const DatabaseService = {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) throw new Error('User ID is required');
const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
[userId]
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', [userId]
);
return rows.map((row) => ({
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
}));
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }));
} catch (error) {
console.error('Error getting linked accounts:', error);
throw error;
@@ -238,8 +212,7 @@ const DatabaseService = {
try {
await DatabaseService.ensureDb();
const row = await db.getFirstAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1',
[userId]
'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) };
@@ -270,10 +243,7 @@ const DatabaseService = {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) throw new Error('User ID is required');
return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
[userId]
);
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;
@@ -352,14 +322,8 @@ const DatabaseService = {
getAllLinkedAccounts: async (userId) => {
try {
await DatabaseService.ensureDb();
const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ?',
[userId]
);
return rows.map((row) => ({
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
}));
const rows = await db.getAllAsync('SELECT * FROM linked_accounts WHERE user_id = ?', [userId]);
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }));
} catch (error) {
console.error('Error getting all linked accounts:', error);
throw error;
+1
View File
@@ -3,6 +3,7 @@ import * as SecureStore from 'expo-secure-store';
const THEME_KEY = 'app_theme_preference';
// Available theme definitions
const lightTheme = {
name: 'light',
background: '#ffffff',