mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
refactor: clean up comments and unused code in various files
This commit is contained in:
1 parent
9b20804f5a
commit
bb6bd0f4b3
28 files changed
+14
-392
No files matched your search
@@ -14,14 +14,10 @@ import HomeScreen from './src/screens/HomeScreen';
|
|||||||
import SearchScreen from './src/screens/SearchScreen';
|
import SearchScreen from './src/screens/SearchScreen';
|
||||||
import CollectionsScreen from './src/screens/AlbumsScreen';
|
import CollectionsScreen from './src/screens/AlbumsScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
|
|
||||||
// Required for OAuth deep link redirect to close the browser and return to the app
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator();
|
const Stack = createNativeStackNavigator();
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
// Bottom tab navigator shown after login
|
|
||||||
function MainTabs({ route }) {
|
function MainTabs({ route }) {
|
||||||
const { userId } = route.params || {};
|
const { userId } = route.params || {};
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -73,8 +69,6 @@ function MainTabs({ route }) {
|
|||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root stack navigator
|
|
||||||
function AppNavigator() {
|
function AppNavigator() {
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
|||||||
+2
-41
@@ -14,17 +14,12 @@ const execFileAsync = util.promisify(execFile);
|
|||||||
|
|
||||||
const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
|
const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
|
||||||
|
|
||||||
// Read the default NAS photo directory from the config file
|
|
||||||
const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt');
|
const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt');
|
||||||
let DEFAULT_PHOTO_DIR = '/home/user/photos';
|
let DEFAULT_PHOTO_DIR = '/home/user/photos';
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim();
|
const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim();
|
||||||
if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim();
|
if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim();
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// Config file missing — keep the hard-coded default
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSH/SFTP shell helpers
|
|
||||||
|
|
||||||
function runCommand(cmd, args, options = {}) {
|
function runCommand(cmd, args, options = {}) {
|
||||||
return execFileAsync(cmd, args, {
|
return execFileAsync(cmd, args, {
|
||||||
@@ -66,8 +61,6 @@ async function runSftpBatch(conn, batchCommands) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Express setup
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
@@ -82,8 +75,6 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// JWT verification middleware
|
|
||||||
|
|
||||||
function verifyToken(req, res, next) {
|
function verifyToken(req, res, next) {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
const token = authHeader?.split(' ')[1];
|
const token = authHeader?.split(' ')[1];
|
||||||
@@ -100,10 +91,6 @@ function verifyToken(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── REST Endpoints ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
@@ -113,14 +100,10 @@ app.get('/api/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the current NAS photo directory read from nas_config.txt
|
|
||||||
|
|
||||||
app.get('/api/config/photo-dir', (req, res) => {
|
app.get('/api/config/photo-dir', (req, res) => {
|
||||||
res.json({ photoDir: DEFAULT_PHOTO_DIR });
|
res.json({ photoDir: DEFAULT_PHOTO_DIR });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Establish an SFTP connection (stores credentials, returns a JWT)
|
|
||||||
|
|
||||||
app.post('/api/sftp/connect', (req, res) => {
|
app.post('/api/sftp/connect', (req, res) => {
|
||||||
const { host, port = 22, username, password } = req.body;
|
const { host, port = 22, username, password } = req.body;
|
||||||
if (!host || !username || !password) {
|
if (!host || !username || !password) {
|
||||||
@@ -141,8 +124,6 @@ app.post('/api/sftp/connect', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// List directory contents
|
|
||||||
|
|
||||||
app.post('/api/sftp/list', verifyToken, async (req, res) => {
|
app.post('/api/sftp/list', verifyToken, async (req, res) => {
|
||||||
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
|
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
|
||||||
const conn = req.connection;
|
const conn = req.connection;
|
||||||
@@ -170,8 +151,6 @@ app.post('/api/sftp/list', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download a file from the NAS
|
|
||||||
|
|
||||||
app.get('/api/sftp/download', verifyToken, async (req, res) => {
|
app.get('/api/sftp/download', verifyToken, async (req, res) => {
|
||||||
const { path: filePath } = req.query;
|
const { path: filePath } = req.query;
|
||||||
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
||||||
@@ -183,7 +162,7 @@ app.get('/api/sftp/download', verifyToken, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
|
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
|
||||||
res.download(tempFilePath, filename, () => {
|
res.download(tempFilePath, filename, () => {
|
||||||
try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ }
|
try { fs.unlinkSync(tempFilePath); } catch (_) {}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Download error:', err.message);
|
console.error('Download error:', err.message);
|
||||||
@@ -191,8 +170,6 @@ app.get('/api/sftp/download', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload a file to the NAS
|
|
||||||
|
|
||||||
app.post('/api/sftp/upload', verifyToken, async (req, res) => {
|
app.post('/api/sftp/upload', verifyToken, async (req, res) => {
|
||||||
const { remotePath } = req.body;
|
const { remotePath } = req.body;
|
||||||
if (!remotePath || !req.files?.file) {
|
if (!remotePath || !req.files?.file) {
|
||||||
@@ -218,8 +195,6 @@ app.post('/api/sftp/upload', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a directory on the NAS
|
|
||||||
|
|
||||||
app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
|
app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
|
||||||
const { path: dirPath } = req.body;
|
const { path: dirPath } = req.body;
|
||||||
if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' });
|
if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' });
|
||||||
@@ -234,8 +209,6 @@ app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a file on the NAS
|
|
||||||
|
|
||||||
app.post('/api/sftp/delete', verifyToken, async (req, res) => {
|
app.post('/api/sftp/delete', verifyToken, async (req, res) => {
|
||||||
const { path: filePath } = req.body;
|
const { path: filePath } = req.body;
|
||||||
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
||||||
@@ -250,18 +223,12 @@ app.post('/api/sftp/delete', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disconnect from the NAS
|
|
||||||
|
|
||||||
app.post('/api/sftp/disconnect', verifyToken, (req, res) => {
|
app.post('/api/sftp/disconnect', verifyToken, (req, res) => {
|
||||||
connections.delete(req.connectionId);
|
connections.delete(req.connectionId);
|
||||||
console.log(`Disconnected: ${req.connectionId}`);
|
console.log(`Disconnected: ${req.connectionId}`);
|
||||||
res.json({ success: true, message: 'Disconnected from SFTP server' });
|
res.json({ success: true, message: 'Disconnected from SFTP server' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Google Photos → NAS Proxy Endpoints ─────────────────────────
|
|
||||||
|
|
||||||
// Fetch media items from Google Photos on behalf of the mobile client
|
|
||||||
|
|
||||||
app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
||||||
const { googleAccessToken, pageSize = 50, pageToken } = req.body;
|
const { googleAccessToken, pageSize = 50, pageToken } = req.body;
|
||||||
if (!googleAccessToken) {
|
if (!googleAccessToken) {
|
||||||
@@ -302,8 +269,6 @@ app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download a Google Photos item and save it directly to the NAS
|
|
||||||
|
|
||||||
app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => {
|
app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => {
|
||||||
const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
|
const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
|
||||||
const conn = req.connection;
|
const conn = req.connection;
|
||||||
@@ -337,14 +302,10 @@ app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync status (placeholder for future batch sync)
|
|
||||||
|
|
||||||
app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
|
app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
|
||||||
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
|
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Lifecycle ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\nShutting down...');
|
console.log('\nShutting down...');
|
||||||
connections.forEach((_, id) => connections.delete(id));
|
connections.forEach((_, id) => connections.delete(id));
|
||||||
|
|||||||
@@ -1,21 +1,3 @@
|
|||||||
// Google Photos Picker API Test
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>
|
|
||||||
// GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js
|
|
||||||
//
|
|
||||||
// The token must have been granted the scope:
|
|
||||||
// https://www.googleapis.com/auth/photospicker.mediaitems.readonly
|
|
||||||
//
|
|
||||||
// Getting a token from OAuth Playground (https://developers.google.com/oauthplayground):
|
|
||||||
// 1. Click the gear icon → check "Use your own OAuth credentials"
|
|
||||||
// 2. Enter your Client ID and Client Secret from Google Cloud Console
|
|
||||||
// 3. In "Input your own scopes" enter:
|
|
||||||
// https://www.googleapis.com/auth/photospicker.mediaitems.readonly
|
|
||||||
// 4. Click "Authorize APIs" and sign in with your test Google account
|
|
||||||
// 5. Click "Exchange authorization code for tokens"
|
|
||||||
// 6. Copy the Access token (starts with ya29.) and pass it here
|
|
||||||
|
|
||||||
const PICKER_BASE = 'https://photospicker.googleapis.com/v1';
|
const PICKER_BASE = 'https://photospicker.googleapis.com/v1';
|
||||||
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
||||||
|
|
||||||
@@ -37,7 +19,6 @@ async function main() {
|
|||||||
|
|
||||||
console.log('\n--- Google Photos Picker API Test ---\n');
|
console.log('\n--- Google Photos Picker API Test ---\n');
|
||||||
|
|
||||||
// Test 1 — User info
|
|
||||||
console.log('[1] Fetching user info...');
|
console.log('[1] Fetching user info...');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(USERINFO_URL, { headers });
|
const res = await fetch(USERINFO_URL, { headers });
|
||||||
@@ -51,7 +32,6 @@ async function main() {
|
|||||||
console.log(` Failed: ${err.message}\n`);
|
console.log(` Failed: ${err.message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 2 — Create Picker session
|
|
||||||
console.log('[2] Creating Picker session...');
|
console.log('[2] Creating Picker session...');
|
||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
try {
|
try {
|
||||||
@@ -68,7 +48,6 @@ async function main() {
|
|||||||
console.log(` Session created: ${sessionId}`);
|
console.log(` Session created: ${sessionId}`);
|
||||||
console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`);
|
console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`);
|
||||||
|
|
||||||
// Wait for session to be ready
|
|
||||||
if (!session.mediaItemsSet) {
|
if (!session.mediaItemsSet) {
|
||||||
console.log(' Waiting for session to become ready...');
|
console.log(' Waiting for session to become ready...');
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
@@ -87,7 +66,6 @@ async function main() {
|
|||||||
console.log(` Failed: ${err.message}\n`);
|
console.log(` Failed: ${err.message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 3 — List media items from session
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
console.log('[3] Fetching media items from session...');
|
console.log('[3] Fetching media items from session...');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,31 +1,9 @@
|
|||||||
/**
|
|
||||||
* NAS Backend API Test
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node TEST/testNASAPI.js
|
|
||||||
*
|
|
||||||
* Environment variables:
|
|
||||||
* BACKEND_URL (default http://localhost:3001)
|
|
||||||
* NAS_HOST e.g. 192.168.1.100
|
|
||||||
* NAS_PORT default 22
|
|
||||||
* NAS_USER SSH username
|
|
||||||
* NAS_PASS SSH password
|
|
||||||
* NAS_FOLDER default /home/user/photos
|
|
||||||
*
|
|
||||||
* Tests:
|
|
||||||
* 1. Backend health check
|
|
||||||
* 2. SFTP connect
|
|
||||||
* 3. List directory
|
|
||||||
* 4. Disconnect
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('\n--- NAS Backend API Test ---');
|
console.log('\n--- NAS Backend API Test ---');
|
||||||
console.log(`Backend: ${BACKEND_URL}\n`);
|
console.log(`Backend: ${BACKEND_URL}\n`);
|
||||||
|
|
||||||
// 1 — Health check
|
|
||||||
console.log('[1] Health check...');
|
console.log('[1] Health check...');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/health`);
|
const res = await fetch(`${BACKEND_URL}/api/health`);
|
||||||
@@ -50,7 +28,6 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2 — Connect
|
|
||||||
let connectionToken = null;
|
let connectionToken = null;
|
||||||
console.log(`[2] SFTP connect to ${host}:${port}...`);
|
console.log(`[2] SFTP connect to ${host}:${port}...`);
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +51,6 @@ async function main() {
|
|||||||
Authorization: `Bearer ${connectionToken}`,
|
Authorization: `Bearer ${connectionToken}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3 — List directory
|
|
||||||
console.log(`[3] List directory "${folder}"...`);
|
console.log(`[3] List directory "${folder}"...`);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
|
const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
|
||||||
@@ -93,7 +69,6 @@ async function main() {
|
|||||||
console.log(` Failed: ${err.message}\n`);
|
console.log(` Failed: ${err.message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4 — Disconnect
|
|
||||||
console.log('[4] Disconnect...');
|
console.log('[4] Disconnect...');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
|
const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
|
"entryPoint": "./App.js",
|
||||||
"name": "MASS Photo App",
|
"name": "MASS Photo App",
|
||||||
"slug": "mass-expo",
|
"slug": "mass-expo",
|
||||||
"owner": "bobthebob",
|
"owner": "bobthebob",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
|
||||||
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
|
||||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
|
||||||
// the environment is set up appropriately
|
|
||||||
registerRootComponent(App);
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "media-aggregation-and-sorting-system",
|
"name": "media-aggregation-and-sorting-system",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "App.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Google Photos Picker API wrapper — replaces the deprecated Photos Library API.
|
|
||||||
// Uses sessions to access user photos from Google Photos cloud.
|
|
||||||
const GooglePhotosAPI = (getAccessToken) => {
|
const GooglePhotosAPI = (getAccessToken) => {
|
||||||
const PICKER_ROOT = 'https://photospicker.googleapis.com/v1';
|
const PICKER_ROOT = 'https://photospicker.googleapis.com/v1';
|
||||||
|
|
||||||
@@ -29,13 +27,10 @@ const GooglePhotosAPI = (getAccessToken) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Create a Picker session — must be called before listing items
|
|
||||||
createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}),
|
createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}),
|
||||||
|
|
||||||
// Poll session status
|
|
||||||
getSession: (sessionId) => callFetch(`${PICKER_ROOT}/sessions/${sessionId}`, 'GET'),
|
getSession: (sessionId) => callFetch(`${PICKER_ROOT}/sessions/${sessionId}`, 'GET'),
|
||||||
|
|
||||||
// List media items from a ready session
|
|
||||||
listMediaItems: (sessionId, pageSize = 25, pageToken) => {
|
listMediaItems: (sessionId, pageSize = 25, pageToken) => {
|
||||||
const params = new URLSearchParams({ sessionId, pageSize: String(pageSize) });
|
const params = new URLSearchParams({ sessionId, pageSize: String(pageSize) });
|
||||||
if (pageToken) params.append('pageToken', pageToken);
|
if (pageToken) params.append('pageToken', pageToken);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// iCloud Photos API placeholder — Apple does not provide a public REST API for iCloud Photos
|
|
||||||
const iCloudPhotosAPI = () => ({
|
const iCloudPhotosAPI = () => ({
|
||||||
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||||
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||||
|
|||||||
+1
-4
@@ -1,6 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
// HTTP client that communicates with the SFTP backend (ServerFiles/server.js)
|
|
||||||
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
|
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
|
||||||
|
|
||||||
class NASAPI {
|
class NASAPI {
|
||||||
@@ -11,7 +10,6 @@ class NASAPI {
|
|||||||
this.credentials = null;
|
this.credentials = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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') {
|
async connect(host, port, username, password, photoFolder = '/Photos') {
|
||||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
||||||
|
|
||||||
@@ -50,7 +48,6 @@ class NASAPI {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter directory listing for supported photo/video files
|
|
||||||
async getPhotos(folder = null) {
|
async getPhotos(folder = null) {
|
||||||
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
|
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' };
|
||||||
@@ -120,7 +117,7 @@ class NASAPI {
|
|||||||
if (this.connectionToken) {
|
if (this.connectionToken) {
|
||||||
try {
|
try {
|
||||||
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
|
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
this.connectionToken = null;
|
this.connectionToken = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Abstract base class for authentication services — provides a common interface for all providers
|
|
||||||
export class AuthServiceBase {
|
export class AuthServiceBase {
|
||||||
constructor() {
|
constructor() {
|
||||||
if (new.target === AuthServiceBase) {
|
if (new.target === AuthServiceBase) {
|
||||||
|
|||||||
+3
-75
@@ -7,11 +7,8 @@ import { logError } from '../services/ErrorHandler';
|
|||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
// Android client ID — created in Google Cloud Console as "Android" type
|
|
||||||
// with package name com.bobthebob.massphotoapp and the SHA-1 from
|
|
||||||
// `eas credentials --platform android`.
|
|
||||||
const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com';
|
const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com';
|
||||||
const IOS_CLIENT_ID = '313090284964-luka809nuc66bb4cg4gameovtqg8uoj1.apps.googleusercontent.com'; // Optional, just in case being kept here.
|
const IOS_CLIENT_ID = '313090284964-luka809nuc66bb4cg4gameovtqg8uoj1.apps.googleusercontent.com';
|
||||||
|
|
||||||
const GOOGLE_DISCOVERY = {
|
const GOOGLE_DISCOVERY = {
|
||||||
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
@@ -19,9 +16,6 @@ const GOOGLE_DISCOVERY = {
|
|||||||
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
|
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
|
||||||
};
|
};
|
||||||
|
|
||||||
// The old Photos Library API (photoslibrary.googleapis.com) was shut down
|
|
||||||
// in 2025. Google's replacement is the Photos Picker API which uses its
|
|
||||||
// own scope.
|
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
'openid',
|
'openid',
|
||||||
'https://www.googleapis.com/auth/userinfo.email',
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
@@ -44,8 +38,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
this.email = null;
|
this.email = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────── Authentication ─────────────────────
|
|
||||||
|
|
||||||
async authenticate() {
|
async authenticate() {
|
||||||
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
|
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
|
||||||
console.warn('Google Auth: no Android client ID — using placeholder mode');
|
console.warn('Google Auth: no Android client ID — using placeholder mode');
|
||||||
@@ -188,23 +180,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
|
return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────── Photos Picker API ──────────────────
|
|
||||||
//
|
|
||||||
// The Picker API is interactive — the user must open the pickerUri in a
|
|
||||||
// browser to select which photos to share with the app. Once the user
|
|
||||||
// finishes selecting, mediaItemsSet becomes true and we can fetch items.
|
|
||||||
//
|
|
||||||
// Flow:
|
|
||||||
// 1. POST /v1/sessions → get { id, pickerUri }
|
|
||||||
// 2. Open pickerUri in browser → user selects photos → browser closes
|
|
||||||
// 3. Poll GET /v1/sessions/{id} until mediaItemsSet = true
|
|
||||||
// 4. GET /v1/mediaItems?sessionId={id}&pageSize=N → selected items
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new Picker session.
|
|
||||||
* Returns the full session object { id, pickerUri, mediaItemsSet, ... }
|
|
||||||
* or { error: { code, message } } on failure.
|
|
||||||
*/
|
|
||||||
async _createPickerSession() {
|
async _createPickerSession() {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const res = await fetch(`${PICKER_API}/sessions`, {
|
const res = await fetch(`${PICKER_API}/sessions`, {
|
||||||
@@ -225,10 +200,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll a session until mediaItemsSet becomes true.
|
|
||||||
* Waits up to ~30 seconds (user needs time to select photos).
|
|
||||||
*/
|
|
||||||
async _waitForSession(sessionId) {
|
async _waitForSession(sessionId) {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const maxAttempts = 20;
|
const maxAttempts = 20;
|
||||||
@@ -254,9 +225,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch media items from a ready Picker session.
|
|
||||||
*/
|
|
||||||
async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) {
|
async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -277,7 +245,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Debug: log raw response structure
|
|
||||||
if (data.mediaItems?.length) {
|
if (data.mediaItems?.length) {
|
||||||
console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2));
|
console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2));
|
||||||
} else {
|
} else {
|
||||||
@@ -287,19 +254,10 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise Picker API media items to the app's standard format.
|
|
||||||
*
|
|
||||||
* The Picker API response shape per item:
|
|
||||||
* { id, mediaFile: { baseUrl, mimeType, filename, mediaFileMetadata: { ... } } }
|
|
||||||
*
|
|
||||||
* baseUrl requires an Authorization header to fetch the actual image bytes.
|
|
||||||
*/
|
|
||||||
_normalisePickerItems(items) {
|
_normalisePickerItems(items) {
|
||||||
if (!items?.length) return [];
|
if (!items?.length) return [];
|
||||||
|
|
||||||
return items.map((item, idx) => {
|
return items.map((item, idx) => {
|
||||||
// The item may have mediaFile nested, or may be flat
|
|
||||||
const file = item.mediaFile || item;
|
const file = item.mediaFile || item;
|
||||||
const baseUrl = file.baseUrl || item.baseUrl || null;
|
const baseUrl = file.baseUrl || item.baseUrl || null;
|
||||||
const filename = file.filename || item.filename || 'Untitled';
|
const filename = file.filename || item.filename || 'Untitled';
|
||||||
@@ -328,24 +286,12 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Download Picker images to local cache so <Image> can render them.
|
|
||||||
*
|
|
||||||
* The Picker API's baseUrl requires an Authorization header. React
|
|
||||||
* Native's <Image> can't add custom headers reliably, so we download
|
|
||||||
* each photo to a cache file and set local_path to the file:// URI.
|
|
||||||
*
|
|
||||||
* @param {Array} items — normalised items from _normalisePickerItems
|
|
||||||
* @returns {Array} — same items with local_path populated
|
|
||||||
*/
|
|
||||||
async downloadPickerPhotos(items) {
|
async downloadPickerPhotos(items) {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
|
|
||||||
// Ensure cache directory exists
|
|
||||||
if (!CACHE_DIR.exists) {
|
if (!CACHE_DIR.exists) {
|
||||||
CACHE_DIR.create({ intermediates: true });
|
CACHE_DIR.create({ intermediates: true });
|
||||||
} else {
|
} else {
|
||||||
// Clean up stale files from previous failed downloads (< 1KB = likely error pages)
|
|
||||||
try {
|
try {
|
||||||
const entries = CACHE_DIR.list();
|
const entries = CACHE_DIR.list();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -353,7 +299,7 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
entry.delete();
|
entry.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) { /* ignore cleanup errors */ }
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -371,14 +317,11 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
: '.jpg';
|
: '.jpg';
|
||||||
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
|
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
|
||||||
|
|
||||||
// Skip download if already cached and non-empty
|
|
||||||
if (destFile.exists && destFile.size > 1024) {
|
if (destFile.exists && destFile.size > 1024) {
|
||||||
results.push({ ...item, local_path: destFile.uri });
|
results.push({ ...item, local_path: destFile.uri });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Picker API baseUrl is a direct download link when used with
|
|
||||||
// the Authorization header. No suffix needed.
|
|
||||||
console.log(`Downloading: ${item.title} (${item.picker_base_url.substring(0, 80)}…)`);
|
console.log(`Downloading: ${item.title} (${item.picker_base_url.substring(0, 80)}…)`);
|
||||||
|
|
||||||
const downloaded = await File.downloadFileAsync(
|
const downloaded = await File.downloadFileAsync(
|
||||||
@@ -392,19 +335,17 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
if (downloaded.exists && downloaded.size > 1024) {
|
if (downloaded.exists && downloaded.size > 1024) {
|
||||||
results.push({ ...item, local_path: downloaded.uri });
|
results.push({ ...item, local_path: downloaded.uri });
|
||||||
} else if (downloaded.exists && downloaded.size > 0) {
|
} else if (downloaded.exists && downloaded.size > 0) {
|
||||||
// Suspiciously small — check if it's an error page
|
|
||||||
try {
|
try {
|
||||||
const peek = await downloaded.text();
|
const peek = await downloaded.text();
|
||||||
if (peek.startsWith('<') || peek.startsWith('{')) {
|
if (peek.startsWith('<') || peek.startsWith('{')) {
|
||||||
logError('Download Picker Photo',
|
logError('Download Picker Photo',
|
||||||
`Got error page for ${item.title}: ${peek.substring(0, 150)}`);
|
`Got error page for ${item.title}: ${peek.substring(0, 150)}`);
|
||||||
downloaded.delete();
|
downloaded.delete();
|
||||||
results.push(item); // no local_path
|
results.push(item);
|
||||||
} else {
|
} else {
|
||||||
results.push({ ...item, local_path: downloaded.uri });
|
results.push({ ...item, local_path: downloaded.uri });
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Can't read as text = probably binary = probably an image
|
|
||||||
results.push({ ...item, local_path: downloaded.uri });
|
results.push({ ...item, local_path: downloaded.uri });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -421,15 +362,8 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Full interactive flow: create session → open picker in browser →
|
|
||||||
* wait for user to select photos → fetch the selected items.
|
|
||||||
*
|
|
||||||
* @returns {{ mediaItems, nextPageToken, sessionId, error? }}
|
|
||||||
*/
|
|
||||||
async pickPhotos() {
|
async pickPhotos() {
|
||||||
try {
|
try {
|
||||||
// 1. Create session
|
|
||||||
console.log('Creating Google Photos Picker session…');
|
console.log('Creating Google Photos Picker session…');
|
||||||
const session = await this._createPickerSession();
|
const session = await this._createPickerSession();
|
||||||
|
|
||||||
@@ -449,7 +383,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
console.log('Picker session created:', session.id);
|
console.log('Picker session created:', session.id);
|
||||||
console.log('Opening picker for user selection…');
|
console.log('Opening picker for user selection…');
|
||||||
|
|
||||||
// 2. Open the picker URI in a browser for the user to select photos
|
|
||||||
await WebBrowser.openBrowserAsync(session.pickerUri, {
|
await WebBrowser.openBrowserAsync(session.pickerUri, {
|
||||||
dismissButtonStyle: 'done',
|
dismissButtonStyle: 'done',
|
||||||
presentationStyle: 'pageSheet',
|
presentationStyle: 'pageSheet',
|
||||||
@@ -457,7 +390,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
|
|
||||||
console.log('Browser closed — polling for selected photos…');
|
console.log('Browser closed — polling for selected photos…');
|
||||||
|
|
||||||
// 3. Poll until the user's selections are ready
|
|
||||||
const ready = await this._waitForSession(session.id);
|
const ready = await this._waitForSession(session.id);
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return {
|
return {
|
||||||
@@ -466,7 +398,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fetch selected items and download to local cache
|
|
||||||
const result = await this._fetchPickerMediaItems(session.id, 100, null);
|
const result = await this._fetchPickerMediaItems(session.id, 100, null);
|
||||||
let mediaItems = this._normalisePickerItems(result.mediaItems);
|
let mediaItems = this._normalisePickerItems(result.mediaItems);
|
||||||
|
|
||||||
@@ -485,9 +416,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch more items from an existing Picker session (pagination).
|
|
||||||
*/
|
|
||||||
async getMorePhotos(pageSize = 100, pageToken, sessionId) {
|
async getMorePhotos(pageSize = 100, pageToken, sessionId) {
|
||||||
try {
|
try {
|
||||||
if (!sessionId || !pageToken) {
|
if (!sessionId || !pageToken) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AuthServiceBase } from './AuthServiceBase';
|
import { AuthServiceBase } from './AuthServiceBase';
|
||||||
|
|
||||||
// iCloud Photos placeholder — Apple does not provide a public iCloud Photos API
|
|
||||||
class IOSAUTH extends AuthServiceBase {
|
class IOSAUTH extends AuthServiceBase {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { AuthServiceBase } from './AuthServiceBase';
|
import { AuthServiceBase } from './AuthServiceBase';
|
||||||
import NASAPI from '../API/NASAPI';
|
import NASAPI from '../API/NASAPI';
|
||||||
|
|
||||||
// NAS SFTP authentication — connects to the backend server via NASAPI
|
|
||||||
class NASAUTH extends AuthServiceBase {
|
class NASAUTH extends AuthServiceBase {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ const getServiceIcon = (type) =>
|
|||||||
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||||
const getServiceName = (type) =>
|
const getServiceName = (type) =>
|
||||||
({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mainListRef = useRef(null);
|
const mainListRef = useRef(null);
|
||||||
@@ -33,7 +23,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
|
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
|
||||||
const hasCarousel = mediaList.length > 1;
|
const hasCarousel = mediaList.length > 1;
|
||||||
|
|
||||||
// When the modal opens or media prop changes, scroll to the correct item
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !media || mediaList.length === 0) return;
|
if (!visible || !media || mediaList.length === 0) return;
|
||||||
|
|
||||||
@@ -44,7 +33,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
setActiveIndex(newIndex);
|
setActiveIndex(newIndex);
|
||||||
setCurrentMedia(mediaList[newIndex]);
|
setCurrentMedia(mediaList[newIndex]);
|
||||||
|
|
||||||
// Delay scroll so FlatList has rendered
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (mainListRef.current && newIndex >= 0) {
|
if (mainListRef.current && newIndex >= 0) {
|
||||||
mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false });
|
mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false });
|
||||||
@@ -52,7 +40,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
if (thumbListRef.current && newIndex >= 0) {
|
if (thumbListRef.current && newIndex >= 0) {
|
||||||
try {
|
try {
|
||||||
thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 });
|
thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 });
|
||||||
} catch (e) { /* index might be out of range during initial render */ }
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [visible, media]);
|
}, [visible, media]);
|
||||||
@@ -69,7 +57,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
if (thumbListRef.current) {
|
if (thumbListRef.current) {
|
||||||
try {
|
try {
|
||||||
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
|
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -120,12 +108,10 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
|
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
|
||||||
{/* Close button */}
|
|
||||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||||
<Text style={styles.closeText}>✕</Text>
|
<Text style={styles.closeText}>✕</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Counter */}
|
|
||||||
{hasCarousel && (
|
{hasCarousel && (
|
||||||
<View style={styles.counter}>
|
<View style={styles.counter}>
|
||||||
<Text style={styles.counterText}>
|
<Text style={styles.counterText}>
|
||||||
@@ -134,7 +120,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main image area – swipeable */}
|
|
||||||
{hasCarousel ? (
|
{hasCarousel ? (
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={mainListRef}
|
ref={mainListRef}
|
||||||
@@ -153,7 +138,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
style={styles.mainList}
|
style={styles.mainList}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Single image mode (backward compat)
|
|
||||||
displayMedia.local_path ? (
|
displayMedia.local_path ? (
|
||||||
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
|
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
|
||||||
) : (
|
) : (
|
||||||
@@ -163,7 +147,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thumbnail carousel */}
|
|
||||||
{hasCarousel && (
|
{hasCarousel && (
|
||||||
<View style={styles.thumbCarousel}>
|
<View style={styles.thumbCarousel}>
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -183,7 +166,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info panel */}
|
|
||||||
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
|
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
|
||||||
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
|
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
@@ -234,7 +216,6 @@ const styles = StyleSheet.create({
|
|||||||
mainList: { flex: 1 },
|
mainList: { flex: 1 },
|
||||||
fullImage: { flex: 1, width: '100%' },
|
fullImage: { flex: 1, width: '100%' },
|
||||||
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
// Thumbnail carousel
|
|
||||||
thumbCarousel: {
|
thumbCarousel: {
|
||||||
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
|
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
@@ -250,7 +231,6 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%', height: '100%', backgroundColor: '#333',
|
width: '100%', height: '100%', backgroundColor: '#333',
|
||||||
justifyContent: 'center', alignItems: 'center',
|
justifyContent: 'center', alignItems: 'center',
|
||||||
},
|
},
|
||||||
// Info panel
|
|
||||||
infoPanel: {
|
infoPanel: {
|
||||||
padding: 16, paddingBottom: 32,
|
padding: 16, paddingBottom: 32,
|
||||||
borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const PhotoGrid = ({
|
|||||||
: [item.source_service];
|
: [item.source_service];
|
||||||
|
|
||||||
if (sources.length === 1) {
|
if (sources.length === 1) {
|
||||||
// Single source — simple badge
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sourceBadge}>
|
<View style={styles.sourceBadge}>
|
||||||
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
|
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
|
||||||
@@ -37,7 +36,6 @@ const PhotoGrid = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple sources — show all icons with a subtle indicator
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.multiSourceBadge}>
|
<View style={styles.multiSourceBadge}>
|
||||||
{sources.map((src, i) => (
|
{sources.map((src, i) => (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
// Circular health indicator: inactive=grey, healthy=green, error=red, checking=amber
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
inactive: '#9e9e9e',
|
inactive: '#9e9e9e',
|
||||||
healthy: '#34a853',
|
healthy: '#34a853',
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
const allMedia = await DatabaseService.getMediaByUser(userId);
|
const allMedia = await DatabaseService.getMediaByUser(userId);
|
||||||
const sectionList = [];
|
const sectionList = [];
|
||||||
|
|
||||||
// ─── Utility albums (top) ───
|
|
||||||
const utilityAlbums = [
|
const utilityAlbums = [
|
||||||
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
|
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
|
||||||
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
|
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
|
||||||
@@ -48,7 +47,6 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
];
|
];
|
||||||
sectionList.push({ title: '', data: [{ albums: utilityAlbums }] });
|
sectionList.push({ title: '', data: [{ albums: utilityAlbums }] });
|
||||||
|
|
||||||
// ─── Per-service categories ───
|
|
||||||
for (const acc of accounts) {
|
for (const acc of accounts) {
|
||||||
const media = await DatabaseService.getMediaByService(userId, acc.service_type);
|
const media = await DatabaseService.getMediaByService(userId, acc.service_type);
|
||||||
const serviceAlbums = [
|
const serviceAlbums = [
|
||||||
@@ -77,7 +75,6 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Album detail view
|
|
||||||
if (selectedAlbum) {
|
if (selectedAlbum) {
|
||||||
const gridItemSize = (width - 6) / 3;
|
const gridItemSize = (width - 6) / 3;
|
||||||
return (
|
return (
|
||||||
@@ -209,13 +206,11 @@ const styles = StyleSheet.create({
|
|||||||
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
|
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
|
||||||
headerTitle: { fontSize: 24, fontWeight: '700' },
|
headerTitle: { fontSize: 24, fontWeight: '700' },
|
||||||
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
|
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
|
||||||
// Section headers
|
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8,
|
flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8,
|
||||||
},
|
},
|
||||||
sectionIcon: { fontSize: 20 },
|
sectionIcon: { fontSize: 20 },
|
||||||
sectionTitle: { fontSize: 16, fontWeight: '700' },
|
sectionTitle: { fontSize: 16, fontWeight: '700' },
|
||||||
// Album grid
|
|
||||||
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
|
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
|
||||||
albumCard: {},
|
albumCard: {},
|
||||||
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
|
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
|
||||||
@@ -225,7 +220,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
|
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
|
||||||
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
|
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
|
||||||
// Album detail
|
|
||||||
albumHeader: {
|
albumHeader: {
|
||||||
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
|
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
|
||||||
paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1,
|
paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1,
|
||||||
@@ -238,7 +232,6 @@ const styles = StyleSheet.create({
|
|||||||
gridItem: { margin: 1 },
|
gridItem: { margin: 1 },
|
||||||
gridImage: { width: '100%', height: '100%' },
|
gridImage: { width: '100%', height: '100%' },
|
||||||
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
|
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
|
||||||
// Empty / Loading
|
|
||||||
loadingText: { marginTop: 12, fontSize: 14 },
|
loadingText: { marginTop: 12, fontSize: 14 },
|
||||||
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
||||||
emptyText: { fontSize: 13, textAlign: 'center' },
|
emptyText: { fontSize: 13, textAlign: 'center' },
|
||||||
|
|||||||
@@ -14,16 +14,7 @@ import PhotoGrid from '../components/PhotoGrid';
|
|||||||
import MediaViewer from '../components/MediaViewer';
|
import MediaViewer from '../components/MediaViewer';
|
||||||
import { logError } from '../services/ErrorHandler';
|
import { logError } from '../services/ErrorHandler';
|
||||||
import { TouchableOpacity } from 'react-native';
|
import { TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge media items from different services.
|
|
||||||
* Items with the same filename (title) are merged into a single entry
|
|
||||||
* with a `sources` array listing every service it appears in.
|
|
||||||
* The best local_path is kept (prefer file:// from DEVICE over a cached
|
|
||||||
* Picker download, since device URIs are always fresh).
|
|
||||||
*/
|
|
||||||
const mergeMediaAcrossServices = (items) => {
|
const mergeMediaAcrossServices = (items) => {
|
||||||
// First pass: group by normalised filename
|
|
||||||
const byFilename = new Map();
|
const byFilename = new Map();
|
||||||
const noFilename = [];
|
const noFilename = [];
|
||||||
|
|
||||||
@@ -36,15 +27,12 @@ const mergeMediaAcrossServices = (items) => {
|
|||||||
|
|
||||||
if (byFilename.has(key)) {
|
if (byFilename.has(key)) {
|
||||||
const existing = byFilename.get(key);
|
const existing = byFilename.get(key);
|
||||||
// Add this service to the sources list
|
|
||||||
if (!existing.sources.includes(item.source_service)) {
|
if (!existing.sources.includes(item.source_service)) {
|
||||||
existing.sources.push(item.source_service);
|
existing.sources.push(item.source_service);
|
||||||
}
|
}
|
||||||
// Prefer a local device path over a cached download
|
|
||||||
if (item.source_service === 'DEVICE' && item.local_path) {
|
if (item.source_service === 'DEVICE' && item.local_path) {
|
||||||
existing.local_path = item.local_path;
|
existing.local_path = item.local_path;
|
||||||
}
|
}
|
||||||
// Keep the best local_path if the existing one is missing
|
|
||||||
if (!existing.local_path && item.local_path) {
|
if (!existing.local_path && item.local_path) {
|
||||||
existing.local_path = item.local_path;
|
existing.local_path = item.local_path;
|
||||||
}
|
}
|
||||||
@@ -52,8 +40,6 @@ const mergeMediaAcrossServices = (items) => {
|
|||||||
byFilename.set(key, { ...item, sources: [item.source_service] });
|
byFilename.set(key, { ...item, sources: [item.source_service] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: also deduplicate by source_id for items with different titles
|
|
||||||
const bySourceId = new Map();
|
const bySourceId = new Map();
|
||||||
for (const item of [...byFilename.values(), ...noFilename]) {
|
for (const item of [...byFilename.values(), ...noFilename]) {
|
||||||
const key = item.source_id || item.id || item.local_path || Math.random().toString();
|
const key = item.source_id || item.id || item.local_path || Math.random().toString();
|
||||||
@@ -118,7 +104,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
try {
|
try {
|
||||||
const newMedia = [];
|
const newMedia = [];
|
||||||
|
|
||||||
// ─── Device photos (local camera roll, screenshots, etc.) ───
|
|
||||||
try {
|
try {
|
||||||
const deviceResult = await DeviceMediaService.getPhotos(100);
|
const deviceResult = await DeviceMediaService.getPhotos(100);
|
||||||
if (deviceResult.mediaItems.length) {
|
if (deviceResult.mediaItems.length) {
|
||||||
@@ -130,11 +115,9 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
console.error('Device media fetch error:', err);
|
console.error('Device media fetch error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Previously imported Google Photos (stored in DB) ───
|
|
||||||
try {
|
try {
|
||||||
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
||||||
if (googleMedia?.length) {
|
if (googleMedia?.length) {
|
||||||
// Validate cached files still exist — clear stale paths
|
|
||||||
const validated = googleMedia.map((item) => {
|
const validated = googleMedia.map((item) => {
|
||||||
if (item.local_path) {
|
if (item.local_path) {
|
||||||
try {
|
try {
|
||||||
@@ -154,7 +137,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
console.error('DB Google media fetch error:', err);
|
console.error('DB Google media fetch error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── NAS fetch ───
|
|
||||||
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
||||||
if (nasAccount) {
|
if (nasAccount) {
|
||||||
try {
|
try {
|
||||||
@@ -169,15 +151,11 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── iCloud (placeholder) ───
|
|
||||||
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
|
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
|
||||||
if (iosAccount) {
|
if (iosAccount) {
|
||||||
console.log('iCloud: placeholder — would fetch photos here');
|
console.log('iCloud: placeholder — would fetch photos here');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Merge & cross-reference across services ───
|
|
||||||
// Match items across sources by filename so we can show "this photo
|
|
||||||
// exists on Device AND Google Photos" etc.
|
|
||||||
const merged = mergeMediaAcrossServices(newMedia);
|
const merged = mergeMediaAcrossServices(newMedia);
|
||||||
setAllMedia(merged);
|
setAllMedia(merged);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -188,7 +166,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Import from Google Photos (opens picker in browser) ───
|
|
||||||
const importGooglePhotos = async () => {
|
const importGooglePhotos = async () => {
|
||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
if (!googleAccount) {
|
if (!googleAccount) {
|
||||||
@@ -206,7 +183,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
let result = await googleAuth.pickPhotos();
|
let result = await googleAuth.pickPhotos();
|
||||||
|
|
||||||
// If 403 (stale scopes), auto re-auth and retry
|
|
||||||
if (result?.error?.code === 403) {
|
if (result?.error?.code === 403) {
|
||||||
console.log('Stale token — re-authenticating…');
|
console.log('Stale token — re-authenticating…');
|
||||||
await googleAuth.revokeAccess();
|
await googleAuth.revokeAccess();
|
||||||
@@ -223,7 +199,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logError('Import Google Photos', result.error.message);
|
logError('Import Google Photos', result.error.message);
|
||||||
} else if (result?.mediaItems?.length) {
|
} else if (result?.mediaItems?.length) {
|
||||||
// Save imported items to DB so they persist across app restarts
|
|
||||||
for (const item of result.mediaItems) {
|
for (const item of result.mediaItems) {
|
||||||
await DatabaseService.saveMediaItem(userId, item);
|
await DatabaseService.saveMediaItem(userId, item);
|
||||||
}
|
}
|
||||||
@@ -231,13 +206,11 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
setGoogleNextPage(result.nextPageToken || null);
|
setGoogleNextPage(result.nextPageToken || null);
|
||||||
setGoogleSessionId(result.sessionId || null);
|
setGoogleSessionId(result.sessionId || null);
|
||||||
|
|
||||||
// Merge into current view with cross-service matching
|
|
||||||
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
|
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
|
||||||
|
|
||||||
console.log(`Imported ${result.mediaItems.length} photos from Google`);
|
console.log(`Imported ${result.mediaItems.length} photos from Google`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist refreshed tokens
|
|
||||||
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
|
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
|
||||||
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
||||||
accessToken: googleAuth.accessToken,
|
accessToken: googleAuth.accessToken,
|
||||||
@@ -256,7 +229,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const filterMedia = () => {
|
const filterMedia = () => {
|
||||||
let filtered = allMedia;
|
let filtered = allMedia;
|
||||||
if (selectedFilter !== 'all') {
|
if (selectedFilter !== 'all') {
|
||||||
// Check both source_service and the sources array for cross-service items
|
|
||||||
filtered = allMedia.filter((m) =>
|
filtered = allMedia.filter((m) =>
|
||||||
m.source_service === selectedFilter ||
|
m.source_service === selectedFilter ||
|
||||||
(m.sources && m.sources.includes(selectedFilter))
|
(m.sources && m.sources.includes(selectedFilter))
|
||||||
@@ -328,7 +300,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the list of available source filters
|
|
||||||
const sourceFilters = [
|
const sourceFilters = [
|
||||||
{ key: 'DEVICE', icon: '📱', name: 'On Device' },
|
{ key: 'DEVICE', icon: '📱', name: 'On Device' },
|
||||||
...linkedAccounts.map((acc) => ({
|
...linkedAccounts.map((acc) => ({
|
||||||
@@ -372,7 +343,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
<FilterChips />
|
<FilterChips />
|
||||||
|
|
||||||
{/* Import from Google Photos button */}
|
|
||||||
{hasGoogleAccount && (
|
{hasGoogleAccount && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.importButton, { backgroundColor: theme.primary }]}
|
style={[styles.importButton, { backgroundColor: theme.primary }]}
|
||||||
@@ -437,7 +407,6 @@ const styles = StyleSheet.create({
|
|||||||
container: { flex: 1 },
|
container: { flex: 1 },
|
||||||
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
||||||
loadingText: { marginTop: 12, fontSize: 14 },
|
loadingText: { marginTop: 12, fontSize: 14 },
|
||||||
// Filter chips
|
|
||||||
filterContainer: { maxHeight: 40 },
|
filterContainer: { maxHeight: 40 },
|
||||||
filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
|
filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
|
||||||
chip: {
|
chip: {
|
||||||
@@ -446,7 +415,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
chipIcon: { fontSize: 11 },
|
chipIcon: { fontSize: 11 },
|
||||||
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
|
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
|
||||||
// Import button
|
|
||||||
importButton: {
|
importButton: {
|
||||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
|
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
|
||||||
@@ -454,7 +422,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
importIcon: { fontSize: 16 },
|
importIcon: { fontSize: 16 },
|
||||||
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||||
// Empty
|
|
||||||
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
||||||
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
|
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
|
||||||
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
|
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ const LoginScreen = ({ navigation }) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Login button – blue */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
|
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
@@ -97,7 +96,6 @@ const LoginScreen = ({ navigation }) => {
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Register button – white/outlined */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
|
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
|
||||||
onPress={() => navigation.navigate('Register')}
|
onPress={() => navigation.navigate('Register')}
|
||||||
|
|||||||
@@ -16,29 +16,24 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Service toggles
|
|
||||||
const [googleEnabled, setGoogleEnabled] = useState(false);
|
const [googleEnabled, setGoogleEnabled] = useState(false);
|
||||||
const [iosEnabled, setIosEnabled] = useState(false);
|
const [iosEnabled, setIosEnabled] = useState(false);
|
||||||
const [nasEnabled, setNasEnabled] = useState(false);
|
const [nasEnabled, setNasEnabled] = useState(false);
|
||||||
|
|
||||||
// Service connection status
|
|
||||||
const [googleConnected, setGoogleConnected] = useState(false);
|
const [googleConnected, setGoogleConnected] = useState(false);
|
||||||
const [iosConnected, setIosConnected] = useState(false);
|
const [iosConnected, setIosConnected] = useState(false);
|
||||||
const [nasConnected, setNasConnected] = useState(false);
|
const [nasConnected, setNasConnected] = useState(false);
|
||||||
|
|
||||||
// Service health: 'inactive' | 'healthy' | 'error' | 'checking'
|
|
||||||
const [googleHealth, setGoogleHealth] = useState('inactive');
|
const [googleHealth, setGoogleHealth] = useState('inactive');
|
||||||
const [iosHealth, setIosHealth] = useState('inactive');
|
const [iosHealth, setIosHealth] = useState('inactive');
|
||||||
const [nasHealth, setNasHealth] = useState('inactive');
|
const [nasHealth, setNasHealth] = useState('inactive');
|
||||||
|
|
||||||
// NAS config
|
|
||||||
const [nasHost, setNasHost] = useState('');
|
const [nasHost, setNasHost] = useState('');
|
||||||
const [nasPort, setNasPort] = useState('22');
|
const [nasPort, setNasPort] = useState('22');
|
||||||
const [nasUsername, setNasUsername] = useState('');
|
const [nasUsername, setNasUsername] = useState('');
|
||||||
const [nasPassword, setNasPassword] = useState('');
|
const [nasPassword, setNasPassword] = useState('');
|
||||||
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
|
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
|
||||||
|
|
||||||
// Auto-backup checklist (multi-select)
|
|
||||||
const [autoBackupServices, setAutoBackupServices] = useState([]);
|
const [autoBackupServices, setAutoBackupServices] = useState([]);
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
@@ -165,7 +160,6 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
{/* Progress bar */}
|
|
||||||
<View style={styles.progressBar}>
|
<View style={styles.progressBar}>
|
||||||
{steps.map((_, i) => (
|
{steps.map((_, i) => (
|
||||||
<View key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} />
|
<View key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} />
|
||||||
@@ -179,7 +173,6 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
|
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Step 0: Select services */}
|
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
<View style={styles.cardsContainer}>
|
<View style={styles.cardsContainer}>
|
||||||
<ServiceToggleCard theme={theme} icon="📷" name="Google Photos"
|
<ServiceToggleCard theme={theme} icon="📷" name="Google Photos"
|
||||||
@@ -192,12 +185,10 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
description="Connect to your Ubuntu server via SFTP"
|
description="Connect to your Ubuntu server via SFTP"
|
||||||
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
|
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
|
||||||
|
|
||||||
{/* NAS setup instructions dropdown */}
|
|
||||||
{nasEnabled && <NASSetupInstructions />}
|
{nasEnabled && <NASSetupInstructions />}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 1: Configure each service */}
|
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<View style={styles.cardsContainer}>
|
<View style={styles.cardsContainer}>
|
||||||
{googleEnabled && (
|
{googleEnabled && (
|
||||||
@@ -281,7 +272,6 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Auto-backup multi-checkbox */}
|
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<View style={styles.cardsContainer}>
|
<View style={styles.cardsContainer}>
|
||||||
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
@@ -327,7 +317,6 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<View style={[styles.footer, { borderTopColor: theme.border }]}>
|
<View style={[styles.footer, { borderTopColor: theme.border }]}>
|
||||||
{currentStep > 0 ? (
|
{currentStep > 0 ? (
|
||||||
<TouchableOpacity style={[styles.backButton, { backgroundColor: theme.surface, borderColor: theme.border }]}
|
<TouchableOpacity style={[styles.backButton, { backgroundColor: theme.surface, borderColor: theme.border }]}
|
||||||
@@ -383,7 +372,6 @@ const styles = StyleSheet.create({
|
|||||||
title: { fontSize: 26, fontWeight: '700', marginBottom: 6 },
|
title: { fontSize: 26, fontWeight: '700', marginBottom: 6 },
|
||||||
description: { fontSize: 14 },
|
description: { fontSize: 14 },
|
||||||
cardsContainer: { gap: 12 },
|
cardsContainer: { gap: 12 },
|
||||||
// Toggle cards (Step 0)
|
|
||||||
toggleCard: {
|
toggleCard: {
|
||||||
borderRadius: 12, padding: 16, flexDirection: 'row',
|
borderRadius: 12, padding: 16, flexDirection: 'row',
|
||||||
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
|
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
|
||||||
@@ -393,7 +381,6 @@ const styles = StyleSheet.create({
|
|||||||
toggleInfo: { flex: 1 },
|
toggleInfo: { flex: 1 },
|
||||||
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
|
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
|
||||||
toggleDescription: { fontSize: 12 },
|
toggleDescription: { fontSize: 12 },
|
||||||
// Config cards (Step 1)
|
|
||||||
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
|
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
|
||||||
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
|
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
|
||||||
configIcon: { fontSize: 24, marginRight: 10 },
|
configIcon: { fontSize: 24, marginRight: 10 },
|
||||||
@@ -405,7 +392,6 @@ const styles = StyleSheet.create({
|
|||||||
fieldInput: {
|
fieldInput: {
|
||||||
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
|
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
|
||||||
},
|
},
|
||||||
// Auto-backup (Step 2)
|
|
||||||
checklistInfo: { fontSize: 13, marginBottom: 8 },
|
checklistInfo: { fontSize: 13, marginBottom: 8 },
|
||||||
checkRow: {
|
checkRow: {
|
||||||
flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
|
flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
|
||||||
@@ -418,7 +404,6 @@ const styles = StyleSheet.create({
|
|||||||
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
|
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
|
||||||
checkIcon: { fontSize: 18, marginRight: 8 },
|
checkIcon: { fontSize: 18, marginRight: 8 },
|
||||||
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
|
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
|
||||||
// Footer
|
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
|
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
|
||||||
borderTopWidth: 1, gap: 12,
|
borderTopWidth: 1, gap: 12,
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ const RegisterScreen = ({ navigation }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Create Account button – blue */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
|
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
|
||||||
onPress={handleRegister}
|
onPress={handleRegister}
|
||||||
@@ -101,7 +100,6 @@ const RegisterScreen = ({ navigation }) => {
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Back to Login button – white/outlined */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
|
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
|
||||||
onPress={() => navigation.navigate('Login')}
|
onPress={() => navigation.navigate('Login')}
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
const [accounts, setAccounts] = useState([]);
|
const [accounts, setAccounts] = useState([]);
|
||||||
const [autoBackup, setAutoBackup] = useState([]);
|
const [autoBackup, setAutoBackup] = useState([]);
|
||||||
const [nasOffload, setNasOffload] = useState(false);
|
const [nasOffload, setNasOffload] = useState(false);
|
||||||
|
|
||||||
// Per-service health: { GOOGLE_PHOTOS: 'healthy'|'error'|'inactive', ... }
|
|
||||||
const [serviceHealth, setServiceHealth] = useState({});
|
const [serviceHealth, setServiceHealth] = useState({});
|
||||||
|
|
||||||
// NAS edit fields
|
|
||||||
const [editingNas, setEditingNas] = useState(false);
|
const [editingNas, setEditingNas] = useState(false);
|
||||||
const [nasHost, setNasHost] = useState('');
|
const [nasHost, setNasHost] = useState('');
|
||||||
const [nasPort, setNasPort] = useState('22');
|
const [nasPort, setNasPort] = useState('22');
|
||||||
@@ -51,14 +47,12 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
|
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check health for each enabled account
|
|
||||||
checkAllServiceHealth(allAccounts);
|
checkAllServiceHealth(allAccounts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading settings:', err);
|
console.error('Error loading settings:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Health checks ─────────────────────────────────────────
|
|
||||||
const checkAllServiceHealth = async (accs) => {
|
const checkAllServiceHealth = async (accs) => {
|
||||||
const health = {};
|
const health = {};
|
||||||
for (const acc of accs) {
|
for (const acc of accs) {
|
||||||
@@ -76,8 +70,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
const checkSingleHealth = async (acc) => {
|
const checkSingleHealth = async (acc) => {
|
||||||
try {
|
try {
|
||||||
if (acc.service_type === 'GOOGLE_PHOTOS') {
|
if (acc.service_type === 'GOOGLE_PHOTOS') {
|
||||||
// The Photos Library REST API is deprecated; health-check the token
|
|
||||||
// by calling the userinfo endpoint instead.
|
|
||||||
const gAuth = new GoogleAUTH();
|
const gAuth = new GoogleAUTH();
|
||||||
gAuth.accessToken = acc.account_data?.accessToken;
|
gAuth.accessToken = acc.account_data?.accessToken;
|
||||||
gAuth.refreshToken = acc.account_data?.refreshToken;
|
gAuth.refreshToken = acc.account_data?.refreshToken;
|
||||||
@@ -93,7 +85,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
return 'healthy';
|
return 'healthy';
|
||||||
}
|
}
|
||||||
if (acc.service_type === 'ICLOUD_PHOTOS') {
|
if (acc.service_type === 'ICLOUD_PHOTOS') {
|
||||||
// Placeholder – always "healthy" since it's a stub
|
|
||||||
return 'healthy';
|
return 'healthy';
|
||||||
}
|
}
|
||||||
return 'inactive';
|
return 'inactive';
|
||||||
@@ -108,7 +99,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
|
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Service actions ───────────────────────────────────────
|
|
||||||
const toggleService = async (accountId, currentEnabled) => {
|
const toggleService = async (accountId, currentEnabled) => {
|
||||||
try {
|
try {
|
||||||
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
|
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
|
||||||
@@ -168,7 +158,7 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
} else if (acc.service_type === 'NAS') {
|
} else if (acc.service_type === 'NAS') {
|
||||||
setEditingNas(true);
|
setEditingNas(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return; // user edits inline
|
return;
|
||||||
}
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -249,7 +239,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||||
{/* ── Theme ──────────────────────────────────────── */}
|
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>APPEARANCE</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>APPEARANCE</Text>
|
||||||
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
{themeOptions.map((opt) => (
|
{themeOptions.map((opt) => (
|
||||||
@@ -272,7 +261,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Services ──────────────────────────────────── */}
|
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text>
|
||||||
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
{accounts.length === 0 ? (
|
{accounts.length === 0 ? (
|
||||||
@@ -286,7 +274,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
const health = serviceHealth[acc.service_type] || (acc.is_enabled ? 'checking' : 'inactive');
|
const health = serviceHealth[acc.service_type] || (acc.is_enabled ? 'checking' : 'inactive');
|
||||||
return (
|
return (
|
||||||
<View key={acc.id}>
|
<View key={acc.id}>
|
||||||
{/* Main row */}
|
|
||||||
<View style={[styles.serviceRow, { borderBottomColor: theme.borderLight }]}>
|
<View style={[styles.serviceRow, { borderBottomColor: theme.borderLight }]}>
|
||||||
<TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}>
|
<TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}>
|
||||||
<ServiceHealthIndicator status={health} size={10} />
|
<ServiceHealthIndicator status={health} size={10} />
|
||||||
@@ -307,7 +294,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
thumbColor={acc.is_enabled ? theme.accent : '#f4f3f4'}
|
thumbColor={acc.is_enabled ? theme.accent : '#f4f3f4'}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{/* Action buttons row */}
|
|
||||||
<View style={[styles.serviceActions, { borderBottomColor: theme.borderLight }]}>
|
<View style={[styles.serviceActions, { borderBottomColor: theme.borderLight }]}>
|
||||||
{acc.service_type === 'NAS' && acc.is_enabled && (
|
{acc.service_type === 'NAS' && acc.is_enabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -339,7 +325,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* NAS Edit Config */}
|
|
||||||
{editingNas && (
|
{editingNas && (
|
||||||
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<Text style={[styles.cardTitle, { color: theme.text }]}>Edit NAS Configuration</Text>
|
<Text style={[styles.cardTitle, { color: theme.text }]}>Edit NAS Configuration</Text>
|
||||||
@@ -364,10 +349,8 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* NAS Setup Instructions */}
|
|
||||||
<NASSetupInstructions />
|
<NASSetupInstructions />
|
||||||
|
|
||||||
{/* NAS Google Photos Offloading */}
|
|
||||||
{hasNasConnected && (
|
{hasNasConnected && (
|
||||||
<>
|
<>
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text>
|
||||||
@@ -391,7 +374,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Auto Backup ───────────────────────────────── */}
|
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>AUTOMATIC BACKUP</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>AUTOMATIC BACKUP</Text>
|
||||||
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
|
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
|
||||||
@@ -452,7 +434,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Sign Out ──────────────────────────────────── */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.logoutButton, { borderColor: theme.danger }]}
|
style={[styles.logoutButton, { borderColor: theme.danger }]}
|
||||||
onPress={handleLogout}
|
onPress={handleLogout}
|
||||||
@@ -482,7 +463,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
cardTitle: { fontSize: 15, fontWeight: '600', padding: 14, paddingBottom: 4 },
|
cardTitle: { fontSize: 15, fontWeight: '600', padding: 14, paddingBottom: 4 },
|
||||||
cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 },
|
cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 },
|
||||||
// Theme rows
|
|
||||||
themeRow: {
|
themeRow: {
|
||||||
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
|
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
@@ -494,7 +474,6 @@ const styles = StyleSheet.create({
|
|||||||
radio: {
|
radio: {
|
||||||
width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0',
|
width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0',
|
||||||
},
|
},
|
||||||
// Service rows
|
|
||||||
serviceRow: {
|
serviceRow: {
|
||||||
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
|
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
@@ -513,7 +492,6 @@ const styles = StyleSheet.create({
|
|||||||
actionBtnText: { fontSize: 12, fontWeight: '600' },
|
actionBtnText: { fontSize: 12, fontWeight: '600' },
|
||||||
emptyRow: { padding: 16 },
|
emptyRow: { padding: 16 },
|
||||||
emptyText: { fontSize: 13, textAlign: 'center' },
|
emptyText: { fontSize: 13, textAlign: 'center' },
|
||||||
// NAS fields
|
|
||||||
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4, paddingHorizontal: 14 },
|
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4, paddingHorizontal: 14 },
|
||||||
fieldInput: {
|
fieldInput: {
|
||||||
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10,
|
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10,
|
||||||
@@ -523,7 +501,6 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 8, paddingVertical: 12, alignItems: 'center', margin: 14,
|
borderRadius: 8, paddingVertical: 12, alignItems: 'center', margin: 14,
|
||||||
},
|
},
|
||||||
saveButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
saveButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||||
// Auto backup checkbox rows
|
|
||||||
checkRow: {
|
checkRow: {
|
||||||
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
|
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
@@ -536,7 +513,6 @@ const styles = StyleSheet.create({
|
|||||||
checkIcon: { fontSize: 18, marginRight: 8 },
|
checkIcon: { fontSize: 18, marginRight: 8 },
|
||||||
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
|
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
|
||||||
notConnected: { fontSize: 11 },
|
notConnected: { fontSize: 11 },
|
||||||
// Logout
|
|
||||||
logoutButton: {
|
logoutButton: {
|
||||||
borderWidth: 1.5, borderRadius: 8, paddingVertical: 14,
|
borderWidth: 1.5, borderRadius: 8, paddingVertical: 14,
|
||||||
alignItems: 'center', marginTop: 24,
|
alignItems: 'center', marginTop: 24,
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import * as Crypto from 'expo-crypto';
|
import * as Crypto from 'expo-crypto';
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles password hashing (SHA-256) and base64 encoding of account data objects
|
|
||||||
*/
|
|
||||||
class CredentialEncryption {
|
class CredentialEncryption {
|
||||||
/**
|
|
||||||
* Hash a password with SHA-256 and return a hex digest
|
|
||||||
*/
|
|
||||||
async hashPassword(password) {
|
async hashPassword(password) {
|
||||||
try {
|
try {
|
||||||
if (!password) throw new Error('Password is required');
|
if (!password) throw new Error('Password is required');
|
||||||
@@ -20,9 +14,6 @@ class CredentialEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare a plaintext password against a stored hash
|
|
||||||
*/
|
|
||||||
async verifyPassword(password, storedHash) {
|
async verifyPassword(password, storedHash) {
|
||||||
try {
|
try {
|
||||||
const hash = await this.hashPassword(password);
|
const hash = await this.hashPassword(password);
|
||||||
@@ -33,9 +24,6 @@ class CredentialEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode an object as a base64 JSON string for database storage
|
|
||||||
*/
|
|
||||||
encrypt(data) {
|
encrypt(data) {
|
||||||
try {
|
try {
|
||||||
if (!data) throw new Error('No data provided to encrypt');
|
if (!data) throw new Error('No data provided to encrypt');
|
||||||
@@ -47,9 +35,6 @@ class CredentialEncryption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a base64 JSON string back to an object
|
|
||||||
*/
|
|
||||||
decrypt(encryptedData) {
|
decrypt(encryptedData) {
|
||||||
try {
|
try {
|
||||||
if (!encryptedData) return null;
|
if (!encryptedData) return null;
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
// noinspection SqlNoDataSourceInspection,SqlResolve
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database service — all tables are created at runtime by expo-sqlite.
|
|
||||||
* The IDE cannot resolve them statically; the SQL warnings are safe to ignore.
|
|
||||||
*/
|
|
||||||
import * as SQLite from 'expo-sqlite';
|
import * as SQLite from 'expo-sqlite';
|
||||||
import CredentialEncryption from './CredentialEncryption';
|
import CredentialEncryption from './CredentialEncryption';
|
||||||
import { logError } from './ErrorHandler';
|
import { logError } from './ErrorHandler';
|
||||||
@@ -12,8 +6,6 @@ let db = null;
|
|||||||
let initPromise = null;
|
let initPromise = null;
|
||||||
|
|
||||||
const DatabaseService = {
|
const DatabaseService = {
|
||||||
// ─── Initialisation ────────────────────────────────────────
|
|
||||||
|
|
||||||
initDatabase: async () => {
|
initDatabase: async () => {
|
||||||
if (initPromise) return initPromise;
|
if (initPromise) return initPromise;
|
||||||
|
|
||||||
@@ -32,7 +24,6 @@ const DatabaseService = {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create all tables in one batch
|
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -68,7 +59,6 @@ const DatabaseService = {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Safe migrations — 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 auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {}
|
||||||
try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
|
try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
|
||||||
|
|
||||||
@@ -94,8 +84,6 @@ const DatabaseService = {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Users ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
createUser: async (username, email, password) => {
|
createUser: async (username, email, password) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return null;
|
if (!(await DatabaseService.ensureDb())) return null;
|
||||||
@@ -155,8 +143,6 @@ const DatabaseService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Linked Accounts ──────────────────────────────────────
|
|
||||||
|
|
||||||
linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
|
linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return null;
|
if (!(await DatabaseService.ensureDb())) return null;
|
||||||
@@ -171,7 +157,6 @@ const DatabaseService = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace any existing link for the same service type
|
|
||||||
await db.runAsync(
|
await db.runAsync(
|
||||||
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
|
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
|
||||||
[userId, serviceType]
|
[userId, serviceType]
|
||||||
@@ -286,8 +271,6 @@ const DatabaseService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Media ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
|
addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return null;
|
if (!(await DatabaseService.ensureDb())) return null;
|
||||||
@@ -308,16 +291,12 @@ const DatabaseService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a normalised media item object to the DB.
|
|
||||||
* Skips duplicates (same user_id + source_id).
|
|
||||||
*/
|
|
||||||
saveMediaItem: async (userId, item) => {
|
saveMediaItem: async (userId, item) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return null;
|
if (!(await DatabaseService.ensureDb())) return null;
|
||||||
if (!userId || !item?.source_service) return null;
|
if (!userId || !item?.source_service)
|
||||||
|
return null;
|
||||||
|
|
||||||
// Skip if already exists
|
|
||||||
if (item.source_id) {
|
if (item.source_id) {
|
||||||
const existing = await db.getFirstAsync(
|
const existing = await db.getFirstAsync(
|
||||||
'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
|
'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
|
||||||
@@ -384,8 +363,6 @@ const DatabaseService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Settings ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
getAutoBackupServices: async (userId) => {
|
getAutoBackupServices: async (userId) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return [];
|
if (!(await DatabaseService.ensureDb())) return [];
|
||||||
@@ -413,8 +390,6 @@ const DatabaseService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Lifecycle ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
closeDatabase: async () => {
|
closeDatabase: async () => {
|
||||||
try {
|
try {
|
||||||
if (db?.closeAsync) await db.closeAsync();
|
if (db?.closeAsync) await db.closeAsync();
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches photos and videos directly from the Android device media store.
|
|
||||||
*
|
|
||||||
* This replaces the deprecated Google Photos Library REST API.
|
|
||||||
* On Android, the media store includes all photos visible to the device —
|
|
||||||
* camera roll, screenshots, downloads, and images synced by the Google
|
|
||||||
* Photos app. No OAuth token is needed; only the MEDIA_LIBRARY permission.
|
|
||||||
*/
|
|
||||||
const DeviceMediaService = {
|
const DeviceMediaService = {
|
||||||
/**
|
|
||||||
* Request media-library permission (camera roll / storage).
|
|
||||||
* Returns true if granted.
|
|
||||||
*/
|
|
||||||
requestPermission: async () => {
|
requestPermission: async () => {
|
||||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||||
if (status !== 'granted') {
|
if (status !== 'granted') {
|
||||||
@@ -22,14 +10,6 @@ const DeviceMediaService = {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a page of media assets from the device.
|
|
||||||
*
|
|
||||||
* @param {number} pageSize Number of items per page (default 100).
|
|
||||||
* @param {string} after Cursor returned by a previous call (for pagination).
|
|
||||||
* @param {string} mediaType 'photo' | 'video' | 'all' (default 'all').
|
|
||||||
* @returns {{ mediaItems: Array, endCursor: string|null, hasNextPage: boolean }}
|
|
||||||
*/
|
|
||||||
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
|
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
|
||||||
const hasPermission = await DeviceMediaService.requestPermission();
|
const hasPermission = await DeviceMediaService.requestPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
@@ -44,7 +24,7 @@ const DeviceMediaService = {
|
|||||||
const options = {
|
const options = {
|
||||||
first: pageSize,
|
first: pageSize,
|
||||||
mediaType: mediaTypeFilter,
|
mediaType: mediaTypeFilter,
|
||||||
sortBy: [[MediaLibrary.SortBy.creationTime, false]], // newest first
|
sortBy: [[MediaLibrary.SortBy.creationTime, false]],
|
||||||
};
|
};
|
||||||
if (after) options.after = after;
|
if (after) options.after = after;
|
||||||
|
|
||||||
@@ -73,9 +53,6 @@ const DeviceMediaService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all albums from the device (including smart albums).
|
|
||||||
*/
|
|
||||||
getAlbums: async () => {
|
getAlbums: async () => {
|
||||||
const hasPermission = await DeviceMediaService.requestPermission();
|
const hasPermission = await DeviceMediaService.requestPermission();
|
||||||
if (!hasPermission) return [];
|
if (!hasPermission) return [];
|
||||||
@@ -91,9 +68,6 @@ const DeviceMediaService = {
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch assets inside a specific album.
|
|
||||||
*/
|
|
||||||
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
|
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
|
||||||
const hasPermission = await DeviceMediaService.requestPermission();
|
const hasPermission = await DeviceMediaService.requestPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
/**
|
|
||||||
* Centralised error handler for the app.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { logError, alertError } from '../services/ErrorHandler';
|
|
||||||
*
|
|
||||||
* logError('Google Auth', error); // console.error only
|
|
||||||
* alertError('Upload Failed', error); // Alert + console.error
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log an error to the console with a contextual tag.
|
|
||||||
* @param {string} tag – short label, e.g. 'Google Auth', 'NAS'
|
|
||||||
* @param {Error|string} error
|
|
||||||
*/
|
|
||||||
export const logError = (tag, error) => {
|
export const logError = (tag, error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[${tag}] ${message}`);
|
console.error(`[${tag}] ${message}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a user-facing Alert AND log to the console.
|
|
||||||
* @param {string} title – Alert title
|
|
||||||
* @param {Error|string} error
|
|
||||||
* @param {Array} [buttons] – optional Alert button array
|
|
||||||
*/
|
|
||||||
export const alertError = (title, error, buttons) => {
|
export const alertError = (title, error, buttons) => {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[${title}] ${message}`);
|
console.error(`[${title}] ${message}`);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
const THEME_KEY = 'app_theme_preference';
|
const THEME_KEY = 'app_theme_preference';
|
||||||
|
|
||||||
// Available theme definitions
|
|
||||||
const lightTheme = {
|
const lightTheme = {
|
||||||
name: 'light',
|
name: 'light',
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
|
|||||||
Reference in new issue
Block a user