mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
feat: update app structure and improve user experience; refactor media handling and enhance NAS integration
This commit is contained in:
1 parent
4006cd93f1
commit
ac9e4aee43
23 files changed
+1229
-643
No files matched your search
@@ -11,3 +11,4 @@
|
||||
*.tmp
|
||||
.env
|
||||
!.env.example.txt
|
||||
ServerFiles/data/
|
||||
@@ -4,6 +4,7 @@ import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
|
||||
import { registerRootComponent } from 'expo';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
|
||||
import DatabaseService from './src/services/DatabaseService';
|
||||
@@ -132,10 +133,14 @@ function AppInner() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppInner />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
registerRootComponent(App);
|
||||
|
||||
export default App;
|
||||
+11
-13
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "media-app-sftp-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "SFTP Backend API for Media Aggregation App",
|
||||
"name": "media-app-nas-backend",
|
||||
"version": "2.0.0",
|
||||
"description": "NAS REST backend (CRUDL vault, base64 storage) for Media Aggregation App",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
@@ -9,23 +9,21 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"sftp",
|
||||
"backend",
|
||||
"api",
|
||||
"nas",
|
||||
"rest",
|
||||
"crud",
|
||||
"express"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"body-parser": "^2.2.2",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.22.1",
|
||||
"express-fileupload": "^1.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^2.7.0"
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
+98
-182
@@ -2,69 +2,15 @@ const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const util = require('util');
|
||||
const { execFile } = require('child_process');
|
||||
const fileUpload = require('express-fileupload');
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
const execFileAsync = util.promisify(execFile);
|
||||
const nasVaultStore = require('./nasVaultStore');
|
||||
|
||||
const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
|
||||
|
||||
const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt');
|
||||
let DEFAULT_PHOTO_DIR = '/home/user/photos';
|
||||
try {
|
||||
const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim();
|
||||
if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim();
|
||||
} catch (_) {}
|
||||
|
||||
function runCommand(cmd, args, options = {}) {
|
||||
return execFileAsync(cmd, args, {
|
||||
timeout: 30000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function buildScpArgs(conn, source, dest) {
|
||||
const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS];
|
||||
if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath);
|
||||
args.push(source, dest);
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildSftpArgs(conn, batchFilePath) {
|
||||
const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS, '-b', batchFilePath];
|
||||
if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath);
|
||||
args.push(`${conn.username}@${conn.host}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function runSftpBatch(conn, batchCommands) {
|
||||
const batchFilePath = path.join(
|
||||
os.tmpdir(),
|
||||
`sftp_batch_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`
|
||||
);
|
||||
fs.writeFileSync(batchFilePath, batchCommands.join('\n'), 'utf8');
|
||||
try {
|
||||
const args = buildSftpArgs(conn, batchFilePath);
|
||||
if (conn.password) {
|
||||
const sshpass = process.env.SSHPASS_PATH || 'sshpass';
|
||||
return await runCommand(sshpass, ['-p', conn.password, 'sftp', ...args]);
|
||||
}
|
||||
return await runCommand('sftp', args);
|
||||
} finally {
|
||||
fs.unlinkSync(batchFilePath);
|
||||
}
|
||||
}
|
||||
const fetch = (...args) => import('node-fetch').then(({ default: nodeFetch }) => nodeFetch(...args));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(fileUpload());
|
||||
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '150mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '150mb' }));
|
||||
app.use(cors());
|
||||
|
||||
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
@@ -75,16 +21,18 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
function verifyToken(req, res, next) {
|
||||
function verifyNasSession(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ error: 'No token provided' });
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, SECRET_KEY);
|
||||
const conn = connections.get(decoded.connectionId);
|
||||
if (!conn) return res.status(401).json({ error: 'Session expired or not found' });
|
||||
req.connectionId = decoded.connectionId;
|
||||
req.connection = connections.get(decoded.connectionId);
|
||||
if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' });
|
||||
req.connection = conn;
|
||||
req.vaultKey = conn.vaultKey;
|
||||
next();
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: 'Invalid token: ' + err.message });
|
||||
@@ -95,141 +43,103 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
activeConnections: connections.size,
|
||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
||||
activeSessions: connections.size,
|
||||
nasMode: 'rest-crudl',
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/config/photo-dir', (req, res) => {
|
||||
res.json({ photoDir: DEFAULT_PHOTO_DIR });
|
||||
});
|
||||
|
||||
app.post('/api/sftp/connect', (req, res) => {
|
||||
/**
|
||||
* Open a NAS client session (REST vault). Credentials scope the vault; media is stored
|
||||
* server-side as base64 with preserved metadata (no SFTP / exifr pipeline).
|
||||
*/
|
||||
app.post('/api/nas/sessions', (req, res) => {
|
||||
const { host, port = 22, username, password } = req.body;
|
||||
if (!host || !username || !password) {
|
||||
return res.status(400).json({ error: 'Missing required fields: host, username, password' });
|
||||
}
|
||||
|
||||
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
connections.set(connectionId, { host, port, username, password, createdAt: new Date() });
|
||||
const vaultKey = nasVaultStore.vaultKeyFromCredentials(host, username);
|
||||
const connectionId = `nas_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
connections.set(connectionId, {
|
||||
host,
|
||||
port: Number(port) || 22,
|
||||
username,
|
||||
password,
|
||||
vaultKey,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' });
|
||||
console.log(`Connection prepared: ${host}:${port} (${connectionId})`);
|
||||
console.log(`NAS session: vault=${vaultKey.slice(0, 12)}… (${connectionId})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
connectionToken: token,
|
||||
message: `Connection prepared for ${host}:${port}`,
|
||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
||||
message: 'NAS REST session established',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/sftp/list', verifyToken, async (req, res) => {
|
||||
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
|
||||
const conn = req.connection;
|
||||
|
||||
/** List — GET /api/nas/media?offset=&limit=&include=metadata|full */
|
||||
app.get('/api/nas/media', verifyNasSession, (req, res) => {
|
||||
try {
|
||||
const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]);
|
||||
const files = result.stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
return {
|
||||
name: parts.pop(),
|
||||
isDirectory: parts[0]?.charAt(0) === 'd',
|
||||
size: parseInt(parts[4], 10),
|
||||
modifyTime: new Date(`${parts[5]} ${parts[6]} ${parts[7]} ${parts[8]}`),
|
||||
permissions: parts[0],
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ path: dirPath, files, count: files.length });
|
||||
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
||||
const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 100));
|
||||
const include = req.query.include === 'full' ? 'full' : 'metadata';
|
||||
const { items, total } = nasVaultStore.list(req.vaultKey, { offset, limit, include });
|
||||
res.json({ items, total, offset, limit, include });
|
||||
} catch (err) {
|
||||
console.error('List directory error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to list directory: ' + err.message });
|
||||
console.error('NAS list error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'List failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sftp/download', verifyToken, async (req, res) => {
|
||||
const { path: filePath } = req.query;
|
||||
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
||||
|
||||
const conn = req.connection;
|
||||
const filename = path.basename(filePath);
|
||||
const tempFilePath = path.join(os.tmpdir(), filename);
|
||||
/** Read — GET /api/nas/media/:id?include=metadata|full */
|
||||
app.get('/api/nas/media/:id', verifyNasSession, (req, res) => {
|
||||
const row = nasVaultStore.getById(req.vaultKey, req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Media not found' });
|
||||
const include = req.query.include === 'full' ? 'full' : 'metadata';
|
||||
res.json(nasVaultStore.shapeRow(row, include));
|
||||
});
|
||||
|
||||
/** Create — POST /api/nas/media */
|
||||
app.post('/api/nas/media', verifyNasSession, (req, res) => {
|
||||
try {
|
||||
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
|
||||
res.download(tempFilePath, filename, () => {
|
||||
try { fs.unlinkSync(tempFilePath); } catch (_) {}
|
||||
});
|
||||
const { filename, mimeType, base64, metadata } = req.body || {};
|
||||
if (!base64) return res.status(400).json({ error: 'Missing base64 in body' });
|
||||
const created = nasVaultStore.create(req.vaultKey, { filename, mimeType, base64, metadata });
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
console.error('Download error:', err.message);
|
||||
res.status(500).json({ error: 'Download failed: ' + err.message });
|
||||
console.error('NAS create error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'Create failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sftp/upload', verifyToken, async (req, res) => {
|
||||
const { remotePath } = req.body;
|
||||
if (!remotePath || !req.files?.file) {
|
||||
return res.status(400).json({ error: 'Missing remotePath or file' });
|
||||
}
|
||||
|
||||
const file = req.files.file;
|
||||
const conn = req.connection;
|
||||
const tempFilePath = path.join(os.tmpdir(), file.name);
|
||||
|
||||
/** Update — PUT /api/nas/media/:id */
|
||||
app.put('/api/nas/media/:id', verifyNasSession, (req, res) => {
|
||||
try {
|
||||
await file.mv(tempFilePath);
|
||||
await runCommand(
|
||||
'scp',
|
||||
buildScpArgs(conn, tempFilePath, `${conn.username}@${conn.host}:${remotePath}`)
|
||||
);
|
||||
fs.unlinkSync(tempFilePath);
|
||||
console.log(`Uploaded: ${remotePath}`);
|
||||
res.json({ success: true, remotePath, message: 'File uploaded' });
|
||||
const updated = nasVaultStore.update(req.vaultKey, req.params.id, req.body || {});
|
||||
if (!updated) return res.status(404).json({ error: 'Media not found' });
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err.message);
|
||||
res.status(500).json({ error: 'Upload failed: ' + err.message });
|
||||
console.error('NAS update error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'Update failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
|
||||
const { path: dirPath } = req.body;
|
||||
if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' });
|
||||
|
||||
try {
|
||||
await runSftpBatch(req.connection, [`mkdir ${dirPath}`]);
|
||||
console.log(`Created directory: ${dirPath}`);
|
||||
res.json({ success: true, path: dirPath, message: 'Directory created' });
|
||||
} catch (err) {
|
||||
console.error('Mkdir error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to create directory: ' + err.message });
|
||||
}
|
||||
/** Delete — DELETE /api/nas/media/:id */
|
||||
app.delete('/api/nas/media/:id', verifyNasSession, (req, res) => {
|
||||
const ok = nasVaultStore.remove(req.vaultKey, req.params.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Media not found' });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
app.post('/api/sftp/delete', verifyToken, async (req, res) => {
|
||||
const { path: filePath } = req.body;
|
||||
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
||||
|
||||
try {
|
||||
await runSftpBatch(req.connection, [`rm ${filePath}`]);
|
||||
console.log(`Deleted: ${filePath}`);
|
||||
res.json({ success: true, path: filePath, message: 'File deleted' });
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to delete file: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sftp/disconnect', verifyToken, (req, res) => {
|
||||
/** End session (does not delete vault data) */
|
||||
app.delete('/api/nas/sessions/current', verifyNasSession, (req, res) => {
|
||||
connections.delete(req.connectionId);
|
||||
console.log(`Disconnected: ${req.connectionId}`);
|
||||
res.json({ success: true, message: 'Disconnected from SFTP server' });
|
||||
res.json({ success: true, message: 'Session closed' });
|
||||
});
|
||||
|
||||
app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
||||
app.post('/api/google-photos/fetch', verifyNasSession, async (req, res) => {
|
||||
const { googleAccessToken, pageSize = 50, pageToken } = req.body;
|
||||
if (!googleAccessToken) {
|
||||
return res.status(400).json({ error: 'Google access token is required' });
|
||||
@@ -269,12 +179,11 @@ app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => {
|
||||
const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
|
||||
const conn = req.connection;
|
||||
|
||||
if (!googleAccessToken || !baseUrl || !remotePath) {
|
||||
return res.status(400).json({ error: 'googleAccessToken, baseUrl, and remotePath are required' });
|
||||
/** Saves a Google image into the NAS vault as base64 with metadata (CRUD Create). */
|
||||
app.post('/api/google-photos/download-to-nas', verifyNasSession, async (req, res) => {
|
||||
const { googleAccessToken, baseUrl, filename, metadata = {} } = req.body;
|
||||
if (!googleAccessToken || !baseUrl) {
|
||||
return res.status(400).json({ error: 'googleAccessToken and baseUrl are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -285,30 +194,39 @@ app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) =>
|
||||
return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' });
|
||||
}
|
||||
|
||||
const buffer = await imageResponse.buffer();
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
||||
const safeName = filename || `gphoto_${Date.now()}.jpg`;
|
||||
const tempFilePath = path.join(os.tmpdir(), safeName);
|
||||
fs.writeFileSync(tempFilePath, buffer);
|
||||
const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg';
|
||||
|
||||
const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`;
|
||||
await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath));
|
||||
fs.unlinkSync(tempFilePath);
|
||||
const created = nasVaultStore.create(req.vaultKey, {
|
||||
filename: safeName,
|
||||
mimeType,
|
||||
base64,
|
||||
metadata: {
|
||||
...metadata,
|
||||
source: 'google_photos_offload',
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
dateTaken: metadata.creationTime || metadata.dateTaken,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Saved Google Photo to NAS: ${remotePath}/${safeName}`);
|
||||
res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` });
|
||||
console.log(`Saved Google Photo to NAS vault: ${created.id}`);
|
||||
res.json({ success: true, media: created, message: 'Photo stored in NAS vault' });
|
||||
} catch (err) {
|
||||
console.error('Download-to-NAS error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message });
|
||||
console.error('Google → NAS error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to save photo to NAS vault: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
|
||||
app.post('/api/google-photos/sync-status', verifyNasSession, (req, res) => {
|
||||
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down...');
|
||||
connections.forEach((_, id) => connections.delete(id));
|
||||
connections.clear();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -326,12 +244,10 @@ app.use((req, res) => {
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n SFTP Backend Server`);
|
||||
console.log(` Port: ${PORT}`);
|
||||
console.log(` URL: http://localhost:${PORT}`);
|
||||
console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`);
|
||||
console.log(` Status: Ready\n`);
|
||||
console.log(`\n NAS REST backend (CRUDL + base64 vault)`);
|
||||
console.log(` Port: ${PORT}`);
|
||||
console.log(` URL: http://localhost:${PORT}`);
|
||||
console.log(` Status: Ready\n`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
+66
-29
@@ -1,7 +1,7 @@
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
async function main() {
|
||||
console.log('\n--- NAS Backend API Test ---');
|
||||
console.log('\n--- NAS REST backend test ---');
|
||||
console.log(`Backend: ${BACKEND_URL}\n`);
|
||||
|
||||
console.log('[1] Health check...');
|
||||
@@ -9,38 +9,31 @@ async function main() {
|
||||
const res = await fetch(`${BACKEND_URL}/api/health`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
console.log(` Status: ${data.status}, connections: ${data.activeConnections}, photo dir: ${data.defaultPhotoDir}\n`);
|
||||
console.log(` Status: ${data.status}, activeSessions: ${data.activeSessions}, mode: ${data.nasMode}\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}`);
|
||||
console.log(' Start the backend first: cd ServerFiles && node server.js\n');
|
||||
console.log(' Start the backend first: cd ServerFiles && npm start\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const host = process.env.NAS_HOST;
|
||||
const host = process.env.NAS_HOST || '127.0.0.1';
|
||||
const port = parseInt(process.env.NAS_PORT || '22', 10);
|
||||
const username = process.env.NAS_USER;
|
||||
const password = process.env.NAS_PASS;
|
||||
const folder = process.env.NAS_FOLDER || '/home/user/photos';
|
||||
|
||||
if (!host || !username || !password) {
|
||||
console.log('[2-4] Skipped — set NAS_HOST, NAS_USER, NAS_PASS to run connection tests.\n');
|
||||
console.log('--- Tests complete (partial) ---\n');
|
||||
return;
|
||||
}
|
||||
const username = process.env.NAS_USER || 'demo';
|
||||
const password = process.env.NAS_PASS || 'demo';
|
||||
|
||||
let connectionToken = null;
|
||||
console.log(`[2] SFTP connect to ${host}:${port}...`);
|
||||
console.log(`[2] POST /api/nas/sessions (${host}, ${username})...`);
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/sftp/connect`, {
|
||||
const res = await fetch(`${BACKEND_URL}/api/nas/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host, port, username, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Connect failed');
|
||||
if (!data.success) throw new Error(data.error || 'Session failed');
|
||||
connectionToken = data.connectionToken;
|
||||
console.log(` Connected.\n`);
|
||||
console.log(' Session OK.\n');
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
@@ -51,29 +44,73 @@ async function main() {
|
||||
Authorization: `Bearer ${connectionToken}`,
|
||||
};
|
||||
|
||||
console.log(`[3] List directory "${folder}"...`);
|
||||
console.log('[3] GET /api/nas/media (list)...');
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({ path: folder }),
|
||||
const res = await fetch(`${BACKEND_URL}/api/nas/media?limit=10&include=metadata`, {
|
||||
headers: { Authorization: `Bearer ${connectionToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
console.log(` Found ${data.count || 0} items in ${data.path}`);
|
||||
(data.files || []).slice(0, 5).forEach((f, i) => {
|
||||
console.log(` ${i + 1}. ${f.name} ${f.isDirectory ? '(dir)' : `(${f.size} bytes)`}`);
|
||||
});
|
||||
console.log();
|
||||
console.log(` total=${data.total}, returned=${(data.items || []).length}\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
console.log('[4] Disconnect...');
|
||||
console.log('[4] POST /api/nas/media (tiny PNG create)...');
|
||||
let createdId = null;
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
|
||||
const onePxPng =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
const res = await fetch(`${BACKEND_URL}/api/nas/media`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
filename: 'test-pixel.png',
|
||||
mimeType: 'image/png',
|
||||
base64: onePxPng,
|
||||
metadata: { DateTimeOriginal: '2020:01:01 12:00:00', test: true },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
createdId = data.id;
|
||||
console.log(` Created id=${createdId}\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
if (createdId) {
|
||||
console.log('[5] GET /api/nas/media/:id ...');
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/nas/media/${encodeURIComponent(createdId)}?include=metadata`,
|
||||
{ headers: { Authorization: `Bearer ${connectionToken}` } }
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
console.log(` filename=${data.filename}, byteLength=${data.byteLength}\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
console.log('[6] DELETE /api/nas/media/:id ...');
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/nas/media/${encodeURIComponent(createdId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${connectionToken}` },
|
||||
});
|
||||
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
|
||||
console.log(' Deleted.\n');
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[7] DELETE /api/nas/sessions/current ...');
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/nas/sessions/current`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${connectionToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"expo": {
|
||||
"entryPoint": "./App.js",
|
||||
"name": "MASS Photo App",
|
||||
"slug": "mass-expo",
|
||||
"owner": "bobthebob",
|
||||
|
||||
+30
-30
@@ -3,42 +3,42 @@
|
||||
"version": "1.0.0",
|
||||
"main": "App.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"start": "npx expo start",
|
||||
"android": "npx expo run:android",
|
||||
"ios": "npx expo run:ios",
|
||||
"web": "npx expo start --web",
|
||||
"backend": "node ServerFiles/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-navigation/bottom-tabs": "^6.5.20",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.33",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "^55.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.11",
|
||||
"axios": "^1.15.0",
|
||||
"expo": "~55.0.15",
|
||||
"expo-application": "~55.0.14",
|
||||
"expo-auth-session": "~55.0.14",
|
||||
"expo-blur": "~55.0.14",
|
||||
"expo-crypto": "~55.0.14",
|
||||
"expo-file-system": "~55.0.16",
|
||||
"expo-image-picker": "~55.0.18",
|
||||
"expo-linking": "~55.0.13",
|
||||
"expo-media-library": "~55.0.14",
|
||||
"expo-secure-store": "~55.0.13",
|
||||
"expo-sqlite": "~55.0.15",
|
||||
"expo-status-bar": "~55.0.5",
|
||||
"expo-system-ui": "^55.0.15",
|
||||
"expo-web-browser": "~55.0.14",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-worklets": "0.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"babel-preset-expo": "~12.0.0"
|
||||
"babel-preset-expo": "~55.0.17"
|
||||
},
|
||||
"overrides": {
|
||||
"jscodeshift": "0.16.1",
|
||||
|
||||
+184
-64
@@ -1,7 +1,33 @@
|
||||
import axios from 'axios';
|
||||
import { File } from 'expo-file-system';
|
||||
|
||||
const DEFAULT_BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunk = 0x8000;
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return typeof btoa !== 'undefined' ? btoa(binary) : Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
function guessMimeFromName(name = '') {
|
||||
const ext = `.${name.toLowerCase().split('.').pop()}`;
|
||||
const map = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.heic': 'image/heic',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mov': 'video/quicktime',
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
class NASAPI {
|
||||
constructor(backendUrl = DEFAULT_BACKEND_URL) {
|
||||
this.backendUrl = backendUrl;
|
||||
@@ -10,6 +36,14 @@ class NASAPI {
|
||||
this.credentials = null;
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
if (!this.connectionToken) return {};
|
||||
return { Authorization: `Bearer ${this.connectionToken}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a REST session (vault scoped by host + username).
|
||||
*/
|
||||
async connect(host, port, username, password, photoFolder = '/Photos') {
|
||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
||||
|
||||
@@ -17,107 +51,193 @@ class NASAPI {
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/connect`,
|
||||
`${this.backendUrl}/api/nas/sessions`,
|
||||
{ host, port: port || 22, username, password },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
if (response.data?.success) {
|
||||
if (response.data?.success && response.data.connectionToken) {
|
||||
this.connectionToken = response.data.connectionToken;
|
||||
this.isConnected = true;
|
||||
return { success: true, message: `Connected to ${host}` };
|
||||
return { success: true, message: `Connected to NAS REST API (${host})` };
|
||||
}
|
||||
} catch (_) {
|
||||
console.log('Backend not available, storing NAS credentials locally');
|
||||
console.log('NAS backend not available, credentials stored locally only');
|
||||
}
|
||||
|
||||
this.isConnected = true;
|
||||
return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials };
|
||||
return {
|
||||
success: true,
|
||||
message: `NAS configured: ${host} (backend session pending)`,
|
||||
connectionData: this.credentials,
|
||||
};
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
if (!this.connectionToken) return {};
|
||||
return { Authorization: `Bearer ${this.connectionToken}` };
|
||||
/** List — GET /api/nas/media */
|
||||
async listMedia({ offset = 0, limit = 100, include = 'metadata' } = {}) {
|
||||
if (!this.connectionToken) return { items: [], total: 0 };
|
||||
const response = await axios.get(`${this.backendUrl}/api/nas/media`, {
|
||||
headers: this.getHeaders(),
|
||||
params: { offset, limit, include },
|
||||
timeout: 120000,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listDirectory(path = '/') {
|
||||
if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' };
|
||||
/** Read — GET /api/nas/media/:id */
|
||||
async getMedia(id, { include = 'metadata' } = {}) {
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
const response = await axios.get(`${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`, {
|
||||
headers: this.getHeaders(),
|
||||
params: { include },
|
||||
timeout: 120000,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Create — POST /api/nas/media */
|
||||
async createMedia({ filename, mimeType, base64, metadata = {} }) {
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/list`, { path },
|
||||
{ headers: this.getHeaders(), timeout: 15000 }
|
||||
`${this.backendUrl}/api/nas/media`,
|
||||
{ filename, mimeType, base64, metadata },
|
||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, timeout: 120000 }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPhotos(folder = null) {
|
||||
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
|
||||
if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
|
||||
/** Update — PUT /api/nas/media/:id */
|
||||
async updateMedia(id, payload) {
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
const response = await axios.put(
|
||||
`${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`,
|
||||
payload,
|
||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, timeout: 120000 }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Delete — DELETE /api/nas/media/:id */
|
||||
async deleteMedia(id) {
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
await axios.delete(`${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`, {
|
||||
headers: this.getHeaders(),
|
||||
timeout: 30000,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps vault rows to app media items. `include=full` loads data URLs for thumbnails.
|
||||
*/
|
||||
async getPhotos(_folder = null) {
|
||||
const targetFolder = _folder || this.credentials?.photoFolder || '/Photos';
|
||||
if (!this.connectionToken) {
|
||||
return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await this.listDirectory(targetFolder);
|
||||
const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
|
||||
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
||||
const { items = [], total = 0 } = await this.listMedia({ offset: 0, limit: 200, include: 'full' });
|
||||
const mediaItems = items.map((row) => {
|
||||
const meta = row.metadata || {};
|
||||
const dateTaken = meta.dateTaken || meta.DateTimeOriginal || meta.creationTime || row.createdAt;
|
||||
const isVideo = (row.mimeType || '').startsWith('video');
|
||||
const dateTakenIso = meta.dateTaken
|
||||
|| (typeof dateTaken === 'string' ? dateTaken : null)
|
||||
|| (dateTaken != null ? new Date(dateTaken).toISOString() : null);
|
||||
return {
|
||||
source_service: 'NAS',
|
||||
source_id: row.id,
|
||||
title: row.filename,
|
||||
remote_path: row.id,
|
||||
media_type: isVideo ? 'video' : 'photo',
|
||||
created_date: dateTakenIso || dateTaken,
|
||||
size: row.byteLength,
|
||||
metadata_json: JSON.stringify({
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
cameraMake: meta.cameraMake || meta.Make,
|
||||
cameraModel: meta.cameraModel || meta.Model,
|
||||
dateTaken: dateTakenIso,
|
||||
mimeType: row.mimeType,
|
||||
...meta,
|
||||
}),
|
||||
local_path: row.dataUrl || null,
|
||||
};
|
||||
});
|
||||
|
||||
const mediaItems = (listing.files || [])
|
||||
.filter((file) => {
|
||||
const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
|
||||
return photoExts.includes(ext) || videoExts.includes(ext);
|
||||
})
|
||||
.map((file) => {
|
||||
const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
|
||||
return {
|
||||
source_service: 'NAS',
|
||||
source_id: `nas_${file.name}`,
|
||||
title: file.name,
|
||||
remote_path: `${targetFolder}/${file.name}`,
|
||||
media_type: videoExts.includes(ext) ? 'video' : 'photo',
|
||||
created_date: file.modifyTime,
|
||||
size: file.size,
|
||||
};
|
||||
});
|
||||
|
||||
return { mediaItems, folder: targetFolder };
|
||||
return { mediaItems, folder: targetFolder, total };
|
||||
} catch (error) {
|
||||
console.error('Get photos error:', error.message);
|
||||
console.error('NAS getPhotos error:', error.message);
|
||||
return { mediaItems: [], folder: targetFolder };
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat shape expected by MediaMetadataService.enrichNasItem */
|
||||
async getFileMetadata(mediaId) {
|
||||
const row = await this.getMedia(mediaId, { include: 'metadata' });
|
||||
const m = row.metadata || {};
|
||||
return {
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
ExifImageWidth: m.width,
|
||||
ExifImageHeight: m.height,
|
||||
Make: m.cameraMake || m.Make,
|
||||
Model: m.cameraModel || m.Model,
|
||||
DateTimeOriginal: m.dateTaken || m.DateTimeOriginal || m.CreateDate || row.createdAt,
|
||||
dateTaken: m.dateTaken,
|
||||
CreateDate: m.CreateDate,
|
||||
MIMEType: row.mimeType,
|
||||
fileSize: row.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
async listDirectory(_path = '/') {
|
||||
const { items = [] } = await this.listMedia({ limit: 500, include: 'metadata' });
|
||||
return {
|
||||
path: '/',
|
||||
files: items.map((i) => ({
|
||||
name: i.filename,
|
||||
isDirectory: false,
|
||||
size: i.byteLength,
|
||||
modifyTime: i.updatedAt,
|
||||
id: i.id,
|
||||
})),
|
||||
count: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async uploadFile(localPath, remotePath) {
|
||||
if (!this.connectionToken) throw new Error('Backend not connected — cannot upload');
|
||||
const formData = new FormData();
|
||||
formData.append('remotePath', remotePath);
|
||||
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
|
||||
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/upload`, formData,
|
||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
||||
);
|
||||
return response.data;
|
||||
const file = new File(localPath);
|
||||
if (!file.exists) throw new Error('Local file not found');
|
||||
const buf = await file.arrayBuffer();
|
||||
const base64 = arrayBufferToBase64(buf);
|
||||
const name = (remotePath && remotePath.split('/').pop()) || file.name || 'upload';
|
||||
return this.createMedia({
|
||||
filename: name,
|
||||
mimeType: guessMimeFromName(name),
|
||||
base64,
|
||||
metadata: { uploadedFrom: localPath },
|
||||
});
|
||||
}
|
||||
|
||||
async createDirectory(remotePath) {
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
async createDirectory(_remotePath) {
|
||||
return { success: true, message: 'REST vault has no directories; use POST /api/nas/media' };
|
||||
}
|
||||
|
||||
async deleteFile(remotePath) {
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/delete`, { path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
async deleteFile(mediaId) {
|
||||
return this.deleteMedia(mediaId);
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.connectionToken) {
|
||||
try {
|
||||
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
|
||||
} catch (_) {}
|
||||
await axios.delete(`${this.backendUrl}/api/nas/sessions/current`, {
|
||||
headers: this.getHeaders(),
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (_) {
|
||||
/* session may already be invalid */
|
||||
}
|
||||
}
|
||||
this.connectionToken = null;
|
||||
this.isConnected = false;
|
||||
|
||||
+37
-31
@@ -3,6 +3,7 @@ import * as WebBrowser from 'expo-web-browser';
|
||||
import * as Application from 'expo-application';
|
||||
import { File, Directory, Paths } from 'expo-file-system';
|
||||
import { AuthServiceBase } from './AuthServiceBase';
|
||||
import MediaMetadataService from '../services/MediaMetadataService';
|
||||
import { logError } from '../services/ErrorHandler';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
@@ -29,6 +30,8 @@ const PICKER_API = 'https://photospicker.googleapis.com/v1';
|
||||
const CACHE_DIR = new Directory(Paths.cache, 'google_photos');
|
||||
|
||||
const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`;
|
||||
const EMPTY_PICK_RESULT = { mediaItems: [], nextPageToken: null, sessionId: null };
|
||||
const TOKEN_FALLBACK_EXPIRES_MS = 3600 * 1000;
|
||||
|
||||
class GoogleAUTH extends AuthServiceBase {
|
||||
constructor() {
|
||||
@@ -39,12 +42,26 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
this.email = null;
|
||||
}
|
||||
|
||||
_setTokenState(tokenData) {
|
||||
this.accessToken = tokenData.access_token;
|
||||
this.refreshToken = tokenData.refresh_token || this.refreshToken || null;
|
||||
this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
|
||||
}
|
||||
|
||||
_buildPickerError(code, message, sessionId = null) {
|
||||
return {
|
||||
...EMPTY_PICK_RESULT,
|
||||
sessionId,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
if (!ACTIVE_CLIENT_ID) {
|
||||
console.warn('Google Auth: no Android client ID — using placeholder mode');
|
||||
this.accessToken = 'placeholder_google_token_' + Date.now();
|
||||
this.refreshToken = 'placeholder_refresh_' + Date.now();
|
||||
this.expiresAt = Date.now() + 3600000;
|
||||
this.expiresAt = Date.now() + TOKEN_FALLBACK_EXPIRES_MS;
|
||||
this.email = 'google-user@placeholder.com';
|
||||
return {
|
||||
success: true,
|
||||
@@ -99,9 +116,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.accessToken = tokenData.access_token;
|
||||
this.refreshToken = tokenData.refresh_token || null;
|
||||
this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
|
||||
this._setTokenState(tokenData);
|
||||
|
||||
console.log('Google token granted scopes:', tokenData.scope || '(none returned)');
|
||||
|
||||
@@ -154,8 +169,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.accessToken = data.access_token;
|
||||
this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
|
||||
this._setTokenState(data);
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
@@ -201,6 +215,13 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
_parseFileExtension(item) {
|
||||
if (item.mime_type?.includes('png')) return '.png';
|
||||
if (item.mime_type?.includes('gif')) return '.gif';
|
||||
if (item.mime_type?.includes('webp')) return '.webp';
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
async _waitForSession(sessionId) {
|
||||
const token = await this.getAccessToken();
|
||||
const maxAttempts = 20;
|
||||
@@ -245,13 +266,6 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.mediaItems?.length) {
|
||||
console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2));
|
||||
} else {
|
||||
console.log('Picker returned no mediaItems. Keys:', Object.keys(data));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -267,6 +281,8 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
const creationTime = file.mediaFileMetadata?.creationTime
|
||||
|| item.mediaFileMetadata?.creationTime
|
||||
|| null;
|
||||
const mw = file.mediaFileMetadata?.width ?? item.mediaFileMetadata?.width;
|
||||
const mh = file.mediaFileMetadata?.height ?? item.mediaFileMetadata?.height;
|
||||
|
||||
if (idx === 0) {
|
||||
console.log(`Normalising item[0]: id=${item.id}, baseUrl=${baseUrl?.substring(0, 60)}…, filename=${filename}, mime=${mimeType}`);
|
||||
@@ -283,8 +299,10 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
media_type: isVideo ? 'video' : 'photo',
|
||||
mime_type: mimeType,
|
||||
created_date: creationTime,
|
||||
width: mw,
|
||||
height: mh,
|
||||
};
|
||||
});
|
||||
}).map((m) => MediaMetadataService.enrichGooglePickerItem(m));
|
||||
}
|
||||
|
||||
async downloadPickerPhotos(items) {
|
||||
@@ -312,10 +330,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
|
||||
try {
|
||||
const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const ext = item.mime_type?.includes('png') ? '.png'
|
||||
: item.mime_type?.includes('gif') ? '.gif'
|
||||
: item.mime_type?.includes('webp') ? '.webp'
|
||||
: '.jpg';
|
||||
const ext = this._parseFileExtension(item);
|
||||
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
|
||||
|
||||
if (destFile.exists && destFile.size > 1024) {
|
||||
@@ -369,16 +384,10 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
const session = await this._createPickerSession();
|
||||
|
||||
if (session?.error) {
|
||||
return {
|
||||
mediaItems: [], nextPageToken: null, sessionId: null,
|
||||
error: { code: session.error.code, message: session.error.message },
|
||||
};
|
||||
return this._buildPickerError(session.error.code, session.error.message);
|
||||
}
|
||||
if (!session?.id || !session?.pickerUri) {
|
||||
return {
|
||||
mediaItems: [], nextPageToken: null, sessionId: null,
|
||||
error: { code: 0, message: 'Failed to create Picker session' },
|
||||
};
|
||||
return this._buildPickerError(0, 'Failed to create Picker session');
|
||||
}
|
||||
|
||||
console.log('Picker session created:', session.id);
|
||||
@@ -393,10 +402,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
|
||||
const ready = await this._waitForSession(session.id);
|
||||
if (!ready) {
|
||||
return {
|
||||
mediaItems: [], nextPageToken: null, sessionId: session.id,
|
||||
error: { code: 0, message: 'No photos were selected or the picker was dismissed' },
|
||||
};
|
||||
return this._buildPickerError(0, 'No photos were selected or the picker was dismissed', session.id);
|
||||
}
|
||||
|
||||
const result = await this._fetchPickerMediaItems(session.id, 100, null);
|
||||
@@ -413,7 +419,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Pick Photos', error);
|
||||
return { mediaItems: [], nextPageToken: null, sessionId: null };
|
||||
return EMPTY_PICK_RESULT;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-8
@@ -13,6 +13,10 @@ class NASAUTH extends AuthServiceBase {
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
ensureConnected() {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
|
||||
try {
|
||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
||||
@@ -35,7 +39,7 @@ class NASAUTH extends AuthServiceBase {
|
||||
}
|
||||
|
||||
async getAccessToken() {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
this.ensureConnected();
|
||||
return { host: this.host, username: this.username, isConnected: this.isConnected };
|
||||
}
|
||||
|
||||
@@ -61,39 +65,45 @@ class NASAUTH extends AuthServiceBase {
|
||||
}
|
||||
|
||||
async getUserInfo() {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
this.ensureConnected();
|
||||
return {
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
port: this.port,
|
||||
photoFolder: this.photoFolder,
|
||||
connectedAt: new Date().toISOString(),
|
||||
serverType: 'NAS SFTP',
|
||||
serverType: 'NAS REST (CRUDL)',
|
||||
};
|
||||
}
|
||||
|
||||
async listDirectory(remotePath = '/') {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
this.ensureConnected();
|
||||
return await this.nasAPI.listDirectory(remotePath);
|
||||
}
|
||||
|
||||
async uploadFile(localPath, remotePath) {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
this.ensureConnected();
|
||||
return await this.nasAPI.uploadFile(localPath, remotePath);
|
||||
}
|
||||
|
||||
async getPhotos(photoFolder = null) {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
this.ensureConnected();
|
||||
return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos');
|
||||
}
|
||||
|
||||
/** Fetches EXIF/size metadata for a remote file via the REST backend (see ServerFiles/server.js). */
|
||||
async fetchFileMetadata(remotePath) {
|
||||
this.ensureConnected();
|
||||
return await this.nasAPI.getFileMetadata(remotePath);
|
||||
}
|
||||
|
||||
async createDirectory(remotePath) {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
this.ensureConnected();
|
||||
return await this.nasAPI.createDirectory(remotePath);
|
||||
}
|
||||
|
||||
async deleteFile(remotePath) {
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
this.ensureConnected();
|
||||
return await this.nasAPI.deleteFile(remotePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,14 @@ import {
|
||||
Dimensions, FlatList,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import MediaDetailDrawer from './MediaDetailDrawer';
|
||||
|
||||
const DRAWER_PEEK = 124;
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
const THUMB_SIZE = 60;
|
||||
const THUMB_GAP = 4;
|
||||
|
||||
const getServiceIcon = (type) =>
|
||||
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
const getServiceName = (type) =>
|
||||
({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
||||
const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||
const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata }) => {
|
||||
const { theme } = useTheme();
|
||||
const mainListRef = useRef(null);
|
||||
const thumbListRef = useRef(null);
|
||||
@@ -70,17 +68,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!media) return null;
|
||||
const renderImageOrPlaceholder = (item, placeholderSize) => {
|
||||
if (item.local_path) {
|
||||
return <Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />;
|
||||
}
|
||||
return (
|
||||
<View style={styles.fullPlaceholder}>
|
||||
<Text style={{ fontSize: placeholderSize }}>📷</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMainItem = ({ item }) => (
|
||||
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
|
||||
{item.local_path ? (
|
||||
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={styles.fullPlaceholder}>
|
||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT, paddingBottom: DRAWER_PEEK }}>
|
||||
{renderImageOrPlaceholder(item, 64)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -105,6 +106,8 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||
|
||||
const displayMedia = currentMedia || media;
|
||||
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
|
||||
@@ -138,17 +141,13 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||
style={styles.mainList}
|
||||
/>
|
||||
) : (
|
||||
displayMedia.local_path ? (
|
||||
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={styles.fullPlaceholder}>
|
||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
||||
</View>
|
||||
)
|
||||
<View style={{ flex: 1, paddingBottom: DRAWER_PEEK }}>
|
||||
{renderImageOrPlaceholder(displayMedia, 64)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{hasCarousel && (
|
||||
<View style={styles.thumbCarousel}>
|
||||
<View style={[styles.thumbCarousel, { bottom: DRAWER_PEEK + 8 }]}>
|
||||
<FlatList
|
||||
ref={thumbListRef}
|
||||
data={mediaList}
|
||||
@@ -166,33 +165,12 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
|
||||
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Source</Text>
|
||||
<Text style={styles.value}>
|
||||
{displayMedia.sources && displayMedia.sources.length > 1
|
||||
? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ')
|
||||
: `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
{displayMedia.sources && displayMedia.sources.length > 1 && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Available on</Text>
|
||||
<Text style={[styles.value, { color: '#8ab4f8' }]}>
|
||||
{displayMedia.sources.length} services
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{displayMedia.created_date && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Date</Text>
|
||||
<Text style={styles.value}>
|
||||
{new Date(displayMedia.created_date).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.drawerLayer} pointerEvents="box-none">
|
||||
<MediaDetailDrawer
|
||||
theme={theme}
|
||||
displayMedia={displayMedia}
|
||||
resolveNasMetadata={resolveNasMetadata}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -201,6 +179,12 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
drawerLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 50,
|
||||
elevation: 50,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute', top: 44, right: 16, zIndex: 20,
|
||||
width: 36, height: 36, borderRadius: 18,
|
||||
@@ -217,7 +201,10 @@ const styles = StyleSheet.create({
|
||||
fullImage: { flex: 1, width: '100%' },
|
||||
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
thumbCarousel: {
|
||||
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
|
||||
position: 'absolute', left: 0, right: 0,
|
||||
/* Below MediaDetailDrawer (drawer zIndex 50) so expanded metadata sits on top. */
|
||||
zIndex: 8,
|
||||
elevation: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
@@ -231,14 +218,6 @@ const styles = StyleSheet.create({
|
||||
width: '100%', height: '100%', backgroundColor: '#333',
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
},
|
||||
infoPanel: {
|
||||
padding: 16, paddingBottom: 32,
|
||||
borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
||||
},
|
||||
title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
||||
label: { fontSize: 12, color: '#9aa0a6' },
|
||||
value: { fontSize: 12, color: '#e8eaed', fontWeight: '500' },
|
||||
});
|
||||
|
||||
export default MediaViewer;
|
||||
|
||||
@@ -23,53 +23,34 @@ const NASSetupInstructions = () => {
|
||||
|
||||
{expanded && (
|
||||
<View style={styles.body}>
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>What this is</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
The app talks to your machine as a REST “NAS client”: photos live in a vault on the Node server (base64 + JSON metadata), not over SFTP directory listing. Host, port, username, and password open a session and choose which vault file your media is stored in.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Prerequisites</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
• An Ubuntu server (20.04 LTS or later) with SSH access enabled{'\n'}
|
||||
• A user account with sudo access on the server{'\n'}
|
||||
• The server's IP address and SSH port (default: 22)
|
||||
• Node.js on the machine running the backend{'\n'}
|
||||
• A reachable URL for that backend (LAN IP or tunnel){'\n'}
|
||||
• Same host/username pair always maps to the same on-disk vault
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 1: Enable SSH</Text>
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 1: Run the backend</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
If SSH is not already enabled on your Ubuntu server, open a terminal on the server and run:{'\n\n'}
|
||||
sudo apt update && sudo apt install openssh-server{'\n'}
|
||||
sudo systemctl enable ssh && sudo systemctl start ssh
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 2: Create a Photos Directory</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
Create a dedicated folder for your photo storage:{'\n\n'}
|
||||
mkdir -p ~/photos{'\n'}
|
||||
chmod 755 ~/photos
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 3: Configure Firewall (if needed)</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
If your server uses UFW, allow SSH connections:{'\n\n'}
|
||||
sudo ufw allow ssh{'\n'}
|
||||
sudo ufw enable
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 4: Find Your Server IP</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
Run this on your server to find its local IP:{'\n\n'}
|
||||
hostname -I{'\n\n'}
|
||||
Use that IP address in the "Server Address" field above.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 5: Connect from the App</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
Enter the server IP, SSH port (22), your username, password, and the photos folder path (e.g. /home/youruser/photos).{'\n\n'}
|
||||
The app's backend server will handle the SFTP connection securely.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 6: Run the Backend Server</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
The NAS connection uses a backend server. Navigate to the ServerFiles folder and run:{'\n\n'}
|
||||
From the ServerFiles folder:{'\n\n'}
|
||||
npm install{'\n'}
|
||||
node server.js{'\n\n'}
|
||||
Ensure the backend URL in the app settings matches the server address (e.g. http://192.168.1.100:3001).
|
||||
npm start{'\n\n'}
|
||||
Optional: set JWT_SECRET and JSON_BODY_LIMIT in .env for production.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 2: Connect from the app</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
Enter the backend host (e.g. your PC LAN IP), port 22 is accepted for compatibility, username, password, and a folder label if shown — listing uses the REST vault (CRUDL), not that path on disk. Set EXPO_PUBLIC_BACKEND_URL so the app points at http://YOUR_IP:3001.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>API shape</Text>
|
||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||
POST /api/nas/sessions → JWT. Then GET/POST/PUT/DELETE /api/nas/media for list/create/read/update/delete. Metadata you send on create is returned on read and used by the app for sorting and the detail drawer.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
+134
-31
@@ -1,16 +1,24 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
|
||||
View, Text, FlatList, SectionList, TouchableOpacity, Image, Dimensions, StyleSheet,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import MediaMetadataService from '../services/MediaMetadataService';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const GRID_GAP = 2;
|
||||
const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
|
||||
|
||||
const PhotoGrid = ({
|
||||
data,
|
||||
onItemPress,
|
||||
timelineSections,
|
||||
columns = 3,
|
||||
onItemPress,
|
||||
onLongPressItem,
|
||||
onToggleSelect,
|
||||
selectionMode,
|
||||
selectedKeys,
|
||||
getItemKey,
|
||||
refreshControl,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
@@ -21,7 +29,7 @@ const PhotoGrid = ({
|
||||
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
|
||||
|
||||
const getServiceIcon = (type) =>
|
||||
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
(SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE);
|
||||
|
||||
const renderSourceBadges = (item) => {
|
||||
const sources = item.sources && item.sources.length > 0
|
||||
@@ -45,38 +53,113 @@ const PhotoGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.gridItem, { width: itemSize, height: itemSize, backgroundColor: theme.chipBg }]}
|
||||
onPress={() => onItemPress?.(item)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{item.local_path ? (
|
||||
<Image
|
||||
source={{ uri: item.local_path }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
onError={(e) => console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)}
|
||||
const keyOf = (item) => (getItemKey ? getItemKey(item) : MediaMetadataService.mediaFingerprint(item));
|
||||
|
||||
const renderCell = (item) => {
|
||||
if (!item) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.gridItem, { width: itemSize, height: itemSize, margin: GRID_GAP / 2 }]}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
|
||||
<Text style={{ fontSize: 28 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.media_type === 'video' && (
|
||||
<View style={styles.videoOverlay}>
|
||||
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
|
||||
</View>
|
||||
)}
|
||||
{renderSourceBadges(item)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
);
|
||||
}
|
||||
const k = keyOf(item);
|
||||
const selected = selectionMode && selectedKeys?.has(k);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.gridItem,
|
||||
{
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
backgroundColor: theme.chipBg,
|
||||
borderWidth: selected ? 3 : 0,
|
||||
borderColor: theme.primary,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
if (selectionMode) {
|
||||
onToggleSelect?.(item);
|
||||
return;
|
||||
}
|
||||
onItemPress?.(item);
|
||||
}}
|
||||
onLongPress={() => onLongPressItem?.(item)}
|
||||
delayLongPress={380}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{item.local_path ? (
|
||||
<Image
|
||||
source={{ uri: item.local_path }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
onError={(e) => console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
|
||||
<Text style={{ fontSize: 28 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.media_type === 'video' && (
|
||||
<View style={styles.videoOverlay}>
|
||||
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
|
||||
</View>
|
||||
)}
|
||||
{renderSourceBadges(item)}
|
||||
{selected && (
|
||||
<View style={[styles.checkBadge, { borderColor: theme.primary }]}>
|
||||
<Text style={[styles.checkMark, { color: theme.primary }]}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRow = ({ item }) => {
|
||||
const row = item.row || item;
|
||||
return (
|
||||
<View style={styles.rowWrap}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<View
|
||||
key={`c${i}-${row[i] ? keyOf(row[i]) : 'empty'}`}
|
||||
style={{ width: itemSize, marginHorizontal: GRID_GAP / 2 }}
|
||||
>
|
||||
{renderCell(row[i])}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (timelineSections && timelineSections.length > 0) {
|
||||
return (
|
||||
<SectionList
|
||||
sections={timelineSections}
|
||||
keyExtractor={(item) => item.rowKey || item.row?.map((c) => (c ? keyOf(c) : '·')).join('|')}
|
||||
renderItem={renderRow}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View style={[styles.sectionHeader, { backgroundColor: theme.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>{section.title}</Text>
|
||||
</View>
|
||||
)}
|
||||
stickySectionHeadersEnabled
|
||||
contentContainerStyle={styles.gridContent}
|
||||
refreshControl={refreshControl}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()}
|
||||
renderItem={({ item }) => renderCell(item)}
|
||||
keyExtractor={(item, i) => keyOf(item) || i.toString()}
|
||||
numColumns={columns}
|
||||
contentContainerStyle={styles.gridContent}
|
||||
refreshControl={refreshControl}
|
||||
@@ -91,6 +174,19 @@ const PhotoGrid = ({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gridContent: { paddingBottom: 20 },
|
||||
rowWrap: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
justifyContent: 'flex-start',
|
||||
paddingHorizontal: GRID_GAP / 2,
|
||||
marginBottom: GRID_GAP,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
sectionTitle: { fontSize: 15, fontWeight: '700' },
|
||||
gridItem: {
|
||||
margin: GRID_GAP / 2,
|
||||
position: 'relative',
|
||||
@@ -112,7 +208,14 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
|
||||
flexDirection: 'row', gap: 2,
|
||||
},
|
||||
checkBadge: {
|
||||
position: 'absolute', top: 4, left: 4,
|
||||
width: 24, height: 24, borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
borderWidth: 2,
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
},
|
||||
checkMark: { fontSize: 14, fontWeight: '800' },
|
||||
});
|
||||
|
||||
export default PhotoGrid;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTheme } from '../services/ThemeContext';
|
||||
|
||||
const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => {
|
||||
const { theme } = useTheme();
|
||||
const profileInitial = currentUser?.username?.charAt(0)?.toUpperCase() || '?';
|
||||
|
||||
return (
|
||||
<View style={[styles.topBar, { backgroundColor: theme.background }]}>
|
||||
@@ -18,9 +19,7 @@ const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) =>
|
||||
style={[styles.profileButton, { backgroundColor: theme.profileBg }]}
|
||||
onPress={onProfilePress}
|
||||
>
|
||||
<Text style={styles.profileInitial}>
|
||||
{currentUser?.username?.charAt(0)?.toUpperCase() || '?'}
|
||||
</Text>
|
||||
<Text style={styles.profileInitial}>{profileInitial}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.menuButton} onPress={onMenuPress}>
|
||||
<Text style={[styles.menuDots, { color: theme.textSecondary }]}>⋮</Text>
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
import MediaMetadataService from '../services/MediaMetadataService';
|
||||
import MediaViewer from '../components/MediaViewer';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const ALBUM_COLUMNS = 2;
|
||||
const ALBUM_GAP = 12;
|
||||
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
|
||||
const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
|
||||
const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
|
||||
|
||||
const CollectionsScreen = ({ route }) => {
|
||||
const userId = route?.params?.userId;
|
||||
@@ -30,8 +33,13 @@ const CollectionsScreen = ({ route }) => {
|
||||
}, [userId])
|
||||
);
|
||||
|
||||
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
||||
const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
|
||||
const getServiceName = (type) => SERVICE_NAMES[type] || type;
|
||||
|
||||
const refreshCollections = () => {
|
||||
setRefreshing(true);
|
||||
loadCollections();
|
||||
};
|
||||
|
||||
const loadCollections = async () => {
|
||||
try {
|
||||
@@ -39,6 +47,28 @@ const CollectionsScreen = ({ route }) => {
|
||||
const allMedia = await DatabaseService.getMediaByUser(userId);
|
||||
const sectionList = [];
|
||||
|
||||
const userAlbumRows = await DatabaseService.getUserAlbums(userId);
|
||||
const myAlbums = [];
|
||||
for (const al of userAlbumRows) {
|
||||
const entries = await DatabaseService.getAlbumEntries(userId, al.id);
|
||||
const resolved = MediaMetadataService.resolveAlbumEntriesToMediaItems(entries, allMedia);
|
||||
myAlbums.push({
|
||||
name: al.name,
|
||||
icon: '📁',
|
||||
count: resolved.length,
|
||||
coverImage: resolved[0]?.local_path || null,
|
||||
media: resolved,
|
||||
type: 'user_album',
|
||||
});
|
||||
}
|
||||
if (myAlbums.length > 0) {
|
||||
sectionList.push({
|
||||
title: 'My albums',
|
||||
icon: '📂',
|
||||
data: [{ albums: myAlbums }],
|
||||
});
|
||||
}
|
||||
|
||||
const utilityAlbums = [
|
||||
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
|
||||
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
|
||||
@@ -175,7 +205,8 @@ const CollectionsScreen = ({ route }) => {
|
||||
<Text style={[styles.headerTitle, { color: theme.text }]}>Collections</Text>
|
||||
</View>
|
||||
|
||||
{sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? (
|
||||
{sections.length === 0
|
||||
|| !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
|
||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
|
||||
@@ -192,7 +223,7 @@ const CollectionsScreen = ({ route }) => {
|
||||
contentContainerStyle={styles.listContent}
|
||||
stickySectionHeadersEnabled={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadCollections(); }} />
|
||||
<RefreshControl refreshing={refreshing} onRefresh={refreshCollections} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
+205
-42
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
|
||||
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
@@ -12,8 +12,28 @@ import { File } from 'expo-file-system';
|
||||
import TopBar from '../components/TopBar';
|
||||
import PhotoGrid from '../components/PhotoGrid';
|
||||
import MediaViewer from '../components/MediaViewer';
|
||||
import SelectionToolbar from '../components/SelectionToolbar';
|
||||
import AlbumPickerModal from '../components/AlbumPickerModal';
|
||||
import MediaSortControls from '../components/MediaSortControls';
|
||||
import MediaMetadataService from '../services/MediaMetadataService';
|
||||
import { logError } from '../services/ErrorHandler';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
|
||||
const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
|
||||
const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' };
|
||||
|
||||
/** Keeps device library timestamps and aligns `created_date` with resolved capture time after merges. */
|
||||
const mergeCaptureFields = (target, donor) => {
|
||||
if (donor.source_service === 'DEVICE' && donor.creationTime != null) {
|
||||
target.creationTime = donor.creationTime ?? target.creationTime;
|
||||
}
|
||||
if (target.creationTime == null && donor.creationTime != null) {
|
||||
target.creationTime = donor.creationTime;
|
||||
}
|
||||
if (donor.modifyTime != null) target.modifyTime = donor.modifyTime ?? target.modifyTime;
|
||||
const iso = MediaMetadataService.resolveCreatedDateIsoForStorage(target);
|
||||
if (iso) target.created_date = iso;
|
||||
};
|
||||
|
||||
const mergeMediaAcrossServices = (items) => {
|
||||
const byFilename = new Map();
|
||||
const noFilename = [];
|
||||
@@ -36,6 +56,15 @@ const mergeMediaAcrossServices = (items) => {
|
||||
if (!existing.local_path && item.local_path) {
|
||||
existing.local_path = item.local_path;
|
||||
}
|
||||
existing.width = existing.width || item.width;
|
||||
existing.height = existing.height || item.height;
|
||||
if (item.metadata_json) {
|
||||
existing.metadata_json = MediaMetadataService.mergeMetadataJson(
|
||||
existing.metadata_json,
|
||||
item.metadata_json
|
||||
);
|
||||
}
|
||||
mergeCaptureFields(existing, item);
|
||||
} else {
|
||||
byFilename.set(key, { ...item, sources: [item.source_service] });
|
||||
}
|
||||
@@ -49,14 +78,48 @@ const mergeMediaAcrossServices = (items) => {
|
||||
if (!existing.sources.includes(src)) existing.sources.push(src);
|
||||
}
|
||||
if (!existing.local_path && item.local_path) existing.local_path = item.local_path;
|
||||
existing.width = existing.width || item.width;
|
||||
existing.height = existing.height || item.height;
|
||||
if (item.metadata_json) {
|
||||
existing.metadata_json = MediaMetadataService.mergeMetadataJson(
|
||||
existing.metadata_json,
|
||||
item.metadata_json
|
||||
);
|
||||
}
|
||||
mergeCaptureFields(existing, item);
|
||||
} else {
|
||||
bySourceId.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySourceId.values());
|
||||
return Array.from(bySourceId.values()).map((row) => {
|
||||
mergeCaptureFields(row, row);
|
||||
return row;
|
||||
});
|
||||
};
|
||||
|
||||
const buildGoogleAuth = (accountData = {}) => {
|
||||
const googleAuth = new GoogleAUTH();
|
||||
googleAuth.accessToken = accountData.accessToken || null;
|
||||
googleAuth.refreshToken = accountData.refreshToken || null;
|
||||
googleAuth.expiresAt = accountData.expiresAt || 0;
|
||||
return googleAuth;
|
||||
};
|
||||
|
||||
const normalizeGoogleMedia = (items = []) =>
|
||||
items.map((item) => {
|
||||
if (!item.local_path) return item;
|
||||
try {
|
||||
const file = new File(item.local_path);
|
||||
if (!file.exists || file.size === 0) {
|
||||
return { ...item, local_path: null };
|
||||
}
|
||||
return item;
|
||||
} catch (_) {
|
||||
return { ...item, local_path: null };
|
||||
}
|
||||
});
|
||||
|
||||
const HomeScreen = ({ navigation, route }) => {
|
||||
const userId = route?.params?.userId;
|
||||
const { theme } = useTheme();
|
||||
@@ -75,6 +138,21 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
const [googleSessionId, setGoogleSessionId] = useState(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [sortMode, setSortMode] = useState('date_taken');
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState(() => new Set());
|
||||
const [albumModalVisible, setAlbumModalVisible] = useState(false);
|
||||
const [userAlbums, setUserAlbums] = useState([]);
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
if (!selectionMode || selectedKeys.size === 0) return [];
|
||||
return filteredMedia.filter((m) => selectedKeys.has(MediaMetadataService.mediaFingerprint(m)));
|
||||
}, [selectionMode, selectedKeys, filteredMedia]);
|
||||
|
||||
const timelineSections = useMemo(() => {
|
||||
if (sortMode !== 'date_taken' || !filteredMedia.length) return null;
|
||||
return MediaMetadataService.groupMediaByDayForTimeline(filteredMedia, 3);
|
||||
}, [filteredMedia, sortMode]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -84,9 +162,9 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) loadAllMedia();
|
||||
}, [currentUser]);
|
||||
}, [currentUser, linkedAccounts]);
|
||||
|
||||
useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]);
|
||||
useEffect(() => { filterMedia(); }, [selectedFilter, allMedia, sortMode]);
|
||||
|
||||
const loadUserData = async () => {
|
||||
try {
|
||||
@@ -94,6 +172,8 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
setCurrentUser(user);
|
||||
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
||||
setLinkedAccounts(accounts);
|
||||
const albums = await DatabaseService.getUserAlbums(userId);
|
||||
setUserAlbums(albums);
|
||||
} catch (err) {
|
||||
console.error('Error loading user data:', err);
|
||||
}
|
||||
@@ -108,6 +188,8 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
const deviceResult = await DeviceMediaService.getPhotos(100);
|
||||
if (deviceResult.mediaItems.length) {
|
||||
newMedia.push(...deviceResult.mediaItems);
|
||||
const chunk = deviceResult.mediaItems.slice(0, 50);
|
||||
void Promise.all(chunk.map((m) => DatabaseService.saveMediaItem(userId, m))).catch(() => {});
|
||||
}
|
||||
setDeviceEndCursor(deviceResult.endCursor);
|
||||
setDeviceHasMore(deviceResult.hasNextPage);
|
||||
@@ -118,20 +200,7 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
try {
|
||||
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
||||
if (googleMedia?.length) {
|
||||
const validated = googleMedia.map((item) => {
|
||||
if (item.local_path) {
|
||||
try {
|
||||
const f = new File(item.local_path);
|
||||
if (!f.exists || f.size === 0) {
|
||||
return { ...item, local_path: null };
|
||||
}
|
||||
} catch (_) {
|
||||
return { ...item, local_path: null };
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
newMedia.push(...validated);
|
||||
newMedia.push(...normalizeGoogleMedia(googleMedia));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('DB Google media fetch error:', err);
|
||||
@@ -166,6 +235,11 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshMedia = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadAllMedia();
|
||||
}, [loadAllMedia]);
|
||||
|
||||
const importGooglePhotos = async () => {
|
||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||
if (!googleAccount) {
|
||||
@@ -176,10 +250,7 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const data = googleAccount.account_data || {};
|
||||
const googleAuth = new GoogleAUTH();
|
||||
googleAuth.accessToken = data.accessToken || null;
|
||||
googleAuth.refreshToken = data.refreshToken || null;
|
||||
googleAuth.expiresAt = data.expiresAt || 0;
|
||||
const googleAuth = buildGoogleAuth(data);
|
||||
|
||||
let result = await googleAuth.pickPhotos();
|
||||
|
||||
@@ -199,9 +270,7 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
if (result?.error) {
|
||||
logError('Import Google Photos', result.error.message);
|
||||
} else if (result?.mediaItems?.length) {
|
||||
for (const item of result.mediaItems) {
|
||||
await DatabaseService.saveMediaItem(userId, item);
|
||||
}
|
||||
await Promise.all(result.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item)));
|
||||
|
||||
setGoogleNextPage(result.nextPageToken || null);
|
||||
setGoogleSessionId(result.sessionId || null);
|
||||
@@ -234,8 +303,84 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
(m.sources && m.sources.includes(selectedFilter))
|
||||
);
|
||||
}
|
||||
filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0));
|
||||
setFilteredMedia(filtered);
|
||||
setFilteredMedia(MediaMetadataService.sortMediaItems(filtered, sortMode));
|
||||
};
|
||||
|
||||
const resolveNasMetadata = useCallback(
|
||||
async (item) => {
|
||||
const nasId = item.remote_path || item.source_id;
|
||||
if (!nasId || item.source_service !== 'NAS') return null;
|
||||
const acc = linkedAccounts.find((a) => a.service_type === 'NAS');
|
||||
if (!acc?.account_data) return null;
|
||||
const nas = new NASAUTH();
|
||||
const d = acc.account_data;
|
||||
try {
|
||||
await nas.authenticate(d.host, d.port, d.username, d.password, d.photoFolder);
|
||||
const raw = await nas.fetchFileMetadata(nasId);
|
||||
await nas.revokeAccess();
|
||||
if (!raw) return null;
|
||||
const enriched = MediaMetadataService.enrichNasItem(item, raw);
|
||||
return MediaMetadataService.parseMetadata(enriched.metadata_json);
|
||||
} catch (e) {
|
||||
console.warn('NAS metadata:', e?.message);
|
||||
try {
|
||||
await nas.revokeAccess();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[linkedAccounts]
|
||||
);
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedKeys(new Set());
|
||||
};
|
||||
|
||||
const toggleSelect = (item) => {
|
||||
const k = MediaMetadataService.mediaFingerprint(item);
|
||||
setSelectedKeys((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(k)) n.delete(k);
|
||||
else n.add(k);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const onLongPressItem = (item) => {
|
||||
setSelectionMode(true);
|
||||
setSelectedKeys((prev) => new Set(prev).add(MediaMetadataService.mediaFingerprint(item)));
|
||||
};
|
||||
|
||||
const addSelectionToAlbum = async (albumId) => {
|
||||
const items = [...selectedItems];
|
||||
for (const m of items) {
|
||||
await DatabaseService.addMediaToAlbum(userId, albumId, m);
|
||||
}
|
||||
Alert.alert('Album', `Added ${items.length} item(s) to album`);
|
||||
clearSelection();
|
||||
setAlbumModalVisible(false);
|
||||
const albums = await DatabaseService.getUserAlbums(userId);
|
||||
setUserAlbums(albums);
|
||||
};
|
||||
|
||||
const createAlbumAndAdd = async (name) => {
|
||||
const id = await DatabaseService.createAlbum(userId, name);
|
||||
if (!id) {
|
||||
Alert.alert('Error', 'Could not create album');
|
||||
return;
|
||||
}
|
||||
const items = [...selectedItems];
|
||||
for (const m of items) {
|
||||
await DatabaseService.addMediaToAlbum(userId, id, m);
|
||||
}
|
||||
Alert.alert('Album', `Added ${items.length} item(s) to “${name.trim()}”`);
|
||||
clearSelection();
|
||||
setAlbumModalVisible(false);
|
||||
const albums = await DatabaseService.getUserAlbums(userId);
|
||||
setUserAlbums(albums);
|
||||
};
|
||||
|
||||
const loadMorePhotos = async () => {
|
||||
@@ -259,17 +404,12 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||
if (googleAccount) {
|
||||
const data = googleAccount.account_data || {};
|
||||
const googleAuth = new GoogleAUTH();
|
||||
googleAuth.accessToken = data.accessToken || null;
|
||||
googleAuth.refreshToken = data.refreshToken || null;
|
||||
googleAuth.expiresAt = data.expiresAt || 0;
|
||||
const googleAuth = buildGoogleAuth(data);
|
||||
|
||||
const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId);
|
||||
if (photos?.mediaItems?.length) {
|
||||
moreMedia.push(...photos.mediaItems);
|
||||
for (const item of photos.mediaItems) {
|
||||
await DatabaseService.saveMediaItem(userId, item);
|
||||
}
|
||||
await Promise.all(photos.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item)));
|
||||
}
|
||||
setGoogleNextPage(photos?.nextPageToken || null);
|
||||
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
|
||||
@@ -286,8 +426,8 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
||||
const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
|
||||
const getServiceName = (type) => SERVICE_NAMES[type] || type;
|
||||
|
||||
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||
|
||||
@@ -336,13 +476,22 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<TopBar
|
||||
currentUser={currentUser}
|
||||
onSearchPress={() => navigation.navigate('Search')}
|
||||
onSearchPress={() => navigation.navigate('Search', { userId })}
|
||||
onProfilePress={() => navigation.navigate('Settings', { userId })}
|
||||
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
||||
/>
|
||||
|
||||
<FilterChips />
|
||||
|
||||
<MediaSortControls value={sortMode} onChange={setSortMode} />
|
||||
|
||||
<SelectionToolbar
|
||||
visible={selectionMode}
|
||||
selectedCount={selectedKeys.size}
|
||||
onAddToAlbum={() => setAlbumModalVisible(true)}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
|
||||
{hasGoogleAccount && (
|
||||
<TouchableOpacity
|
||||
style={[styles.importButton, { backgroundColor: theme.primary }]}
|
||||
@@ -372,18 +521,23 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: theme.primary }]}
|
||||
onPress={() => { setRefreshing(true); loadAllMedia(); }}
|
||||
onPress={refreshMedia}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Refresh</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<PhotoGrid
|
||||
data={filteredMedia}
|
||||
data={sortMode === 'date_taken' ? [] : filteredMedia}
|
||||
timelineSections={timelineSections}
|
||||
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
|
||||
onLongPressItem={onLongPressItem}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
selectedKeys={selectedKeys}
|
||||
onEndReached={loadMorePhotos}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
|
||||
<RefreshControl refreshing={refreshing} onRefresh={refreshMedia} />
|
||||
}
|
||||
ListFooterComponent={loadingMore ? (
|
||||
<View style={{ padding: 16, alignItems: 'center' }}>
|
||||
@@ -393,11 +547,20 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlbumPickerModal
|
||||
visible={albumModalVisible}
|
||||
onClose={() => setAlbumModalVisible(false)}
|
||||
albums={userAlbums}
|
||||
onPickAlbum={(albumId) => addSelectionToAlbum(albumId)}
|
||||
onCreateAlbum={createAlbumAndAdd}
|
||||
/>
|
||||
|
||||
<MediaViewer
|
||||
visible={viewerVisible}
|
||||
media={selectedMedia}
|
||||
allMedia={filteredMedia}
|
||||
onClose={() => setViewerVisible(false)}
|
||||
resolveNasMetadata={resolveNasMetadata}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTheme } from '../services/ThemeContext';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
const APP_ICON = require('../../assets/icon.png');
|
||||
const isBlank = (value) => !value?.trim();
|
||||
|
||||
const LoginScreen = ({ navigation }) => {
|
||||
const { theme } = useTheme();
|
||||
@@ -15,7 +16,7 @@ const LoginScreen = ({ navigation }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!identifier.trim() || !password.trim()) {
|
||||
if (isBlank(identifier) || isBlank(password)) {
|
||||
Alert.alert('Error', 'Please enter your username/email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
|
||||
ActivityIndicator, Switch, TextInput,
|
||||
@@ -10,9 +10,27 @@ import NASAUTH from '../AUTH/NASAUTH';
|
||||
import NASSetupInstructions from '../components/NASSetupInstructions';
|
||||
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
|
||||
|
||||
const SERVICES = {
|
||||
GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' },
|
||||
ICLOUD_PHOTOS: { name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' },
|
||||
NAS: { name: 'NAS Storage', type: 'NAS', icon: '💾' },
|
||||
};
|
||||
const ONBOARDING_STEPS = [
|
||||
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
|
||||
{ title: 'Connect Services', description: 'Set up each selected service' },
|
||||
{ title: 'Automatic Backup', description: 'Choose where new photos are backed up' },
|
||||
];
|
||||
|
||||
const OnboardingScreen = ({ navigation, route }) => {
|
||||
const { userId } = route.params;
|
||||
const userId = route?.params?.userId;
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (userId == null) {
|
||||
navigation.replace('Login');
|
||||
}
|
||||
}, [userId, navigation]);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -36,17 +54,11 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
|
||||
const [autoBackupServices, setAutoBackupServices] = useState([]);
|
||||
|
||||
const steps = [
|
||||
{ title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
|
||||
{ title: 'Connect Services', description: 'Set up each selected service' },
|
||||
{ title: 'Automatic Backup', description: 'Choose where new photos are backed up' },
|
||||
];
|
||||
|
||||
const getConnectedServices = () => {
|
||||
const services = [];
|
||||
if (googleEnabled && googleConnected) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' });
|
||||
if (iosEnabled && iosConnected) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' });
|
||||
if (nasEnabled && nasConnected) services.push({ name: 'NAS Storage', type: 'NAS', icon: '💾' });
|
||||
if (googleEnabled && googleConnected) services.push(SERVICES.GOOGLE_PHOTOS);
|
||||
if (iosEnabled && iosConnected) services.push(SERVICES.ICLOUD_PHOTOS);
|
||||
if (nasEnabled && nasConnected) services.push(SERVICES.NAS);
|
||||
return services;
|
||||
};
|
||||
|
||||
@@ -104,14 +116,15 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setNasHealth('checking');
|
||||
const parsedNasPort = parseInt(nasPort, 10) || 22;
|
||||
const nasAuth = new NASAUTH();
|
||||
await nasAuth.authenticate(
|
||||
nasHost.trim(), parseInt(nasPort) || 22,
|
||||
nasHost.trim(), parsedNasPort,
|
||||
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
||||
);
|
||||
await DatabaseService.linkAccount(userId, 'NAS', {
|
||||
host: nasHost.trim(),
|
||||
port: parseInt(nasPort) || 22,
|
||||
port: parsedNasPort,
|
||||
username: nasUsername.trim(),
|
||||
photoFolder: nasPhotoFolder.trim(),
|
||||
});
|
||||
@@ -158,19 +171,27 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (userId == null) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background, justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<ActivityIndicator size="large" color={theme.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<View style={styles.progressBar}>
|
||||
{steps.map((_, i) => (
|
||||
{ONBOARDING_STEPS.map((_, i) => (
|
||||
<View key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {steps.length}</Text>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{steps[currentStep].title}</Text>
|
||||
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
|
||||
<Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {ONBOARDING_STEPS.length}</Text>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{ONBOARDING_STEPS[currentStep].title}</Text>
|
||||
<Text style={[styles.description, { color: theme.textSecondary }]}>{ONBOARDING_STEPS[currentStep].description}</Text>
|
||||
</View>
|
||||
|
||||
{currentStep === 0 && (
|
||||
@@ -332,7 +353,7 @@ const OnboardingScreen = ({ navigation, route }) => {
|
||||
>
|
||||
{loading ? <ActivityIndicator color="#fff" /> : (
|
||||
<Text style={styles.nextButtonText}>
|
||||
{currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
|
||||
{currentStep === ONBOARDING_STEPS.length - 1 ? 'Get Started' : 'Next'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useTheme } from '../services/ThemeContext';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
const APP_ICON = require('../../assets/icon.png');
|
||||
const isBlank = (value) => !value?.trim();
|
||||
const isEmailValid = (value) => value.includes('@') && value.includes('.');
|
||||
|
||||
const RegisterScreen = ({ navigation }) => {
|
||||
const { theme } = useTheme();
|
||||
@@ -17,11 +19,11 @@ const RegisterScreen = ({ navigation }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim()) { Alert.alert('Error', 'Please enter a username'); return; }
|
||||
if (isBlank(username)) { Alert.alert('Error', 'Please enter a username'); return; }
|
||||
if (username.trim().length < 3) { Alert.alert('Error', 'Username must be at least 3 characters'); return; }
|
||||
if (!email.trim()) { Alert.alert('Error', 'Please enter an email'); return; }
|
||||
if (!email.includes('@') || !email.includes('.')) { Alert.alert('Error', 'Please enter a valid email address'); return; }
|
||||
if (!password.trim()) { Alert.alert('Error', 'Please enter a password'); return; }
|
||||
if (isBlank(email)) { Alert.alert('Error', 'Please enter an email'); return; }
|
||||
if (!isEmailValid(email)) { Alert.alert('Error', 'Please enter a valid email address'); return; }
|
||||
if (isBlank(password)) { Alert.alert('Error', 'Please enter a password'); return; }
|
||||
if (password.length < 6) { Alert.alert('Error', 'Password must be at least 6 characters'); return; }
|
||||
if (password !== confirmPassword) { Alert.alert('Error', 'Passwords do not match'); return; }
|
||||
|
||||
|
||||
@@ -76,7 +76,9 @@ const SearchScreen = ({ route }) => {
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>🔍</Text>
|
||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Search by title or description</Text>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
||||
Search by filename and indexed metadata (bag-of-words style matching)
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@ import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
|
||||
import NASAUTH from '../AUTH/NASAUTH';
|
||||
import GoogleAUTH from '../AUTH/GoogleAUTH';
|
||||
|
||||
const SERVICE_ICONS = { GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
|
||||
const SERVICE_NAMES = { GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
|
||||
const AUTO_BACKUP_SERVICES = [
|
||||
{ type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' },
|
||||
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' },
|
||||
{ type: 'NAS', name: 'NAS Storage', icon: '💾' },
|
||||
];
|
||||
|
||||
const SettingsScreen = ({ navigation, route }) => {
|
||||
const userId = route?.params?.userId;
|
||||
const { theme, themeName, setThemeName } = useTheme();
|
||||
@@ -93,10 +101,14 @@ const SettingsScreen = ({ navigation, route }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateServiceHealth = (serviceType, status) => {
|
||||
setServiceHealth((prev) => ({ ...prev, [serviceType]: status }));
|
||||
};
|
||||
|
||||
const recheckHealth = async (acc) => {
|
||||
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' }));
|
||||
updateServiceHealth(acc.service_type, 'checking');
|
||||
const status = await checkSingleHealth(acc);
|
||||
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
|
||||
updateServiceHealth(acc.service_type, status);
|
||||
};
|
||||
|
||||
const toggleService = async (accountId, currentEnabled) => {
|
||||
@@ -188,13 +200,14 @@ const SettingsScreen = ({ navigation, route }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const nasAuth = new NASAUTH();
|
||||
const parsedNasPort = parseInt(nasPort, 10) || 22;
|
||||
await nasAuth.authenticate(
|
||||
nasHost.trim(), parseInt(nasPort) || 22,
|
||||
nasHost.trim(), parsedNasPort,
|
||||
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
||||
);
|
||||
await DatabaseService.linkAccount(userId, 'NAS', {
|
||||
host: nasHost.trim(),
|
||||
port: parseInt(nasPort) || 22,
|
||||
port: parsedNasPort,
|
||||
username: nasUsername.trim(),
|
||||
photoFolder: nasPhotoFolder.trim(),
|
||||
});
|
||||
@@ -216,9 +229,9 @@ const SettingsScreen = ({ navigation, route }) => {
|
||||
};
|
||||
|
||||
const getServiceIcon = (type) =>
|
||||
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
(SERVICE_ICONS[type] || '📱');
|
||||
const getServiceName = (type) =>
|
||||
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
||||
(SERVICE_NAMES[type] || type);
|
||||
|
||||
const themeOptions = [
|
||||
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
|
||||
@@ -379,11 +392,7 @@ const SettingsScreen = ({ navigation, route }) => {
|
||||
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
|
||||
New photos from your device will be automatically uploaded to the selected services.
|
||||
</Text>
|
||||
{[
|
||||
{ type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' },
|
||||
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' },
|
||||
{ type: 'NAS', name: 'NAS Storage', icon: '💾' },
|
||||
].map((svc) => {
|
||||
{AUTO_BACKUP_SERVICES.map((svc) => {
|
||||
const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -417,7 +426,9 @@ const SettingsScreen = ({ navigation, route }) => {
|
||||
style={[styles.checkRow, { borderBottomWidth: 0 }]}
|
||||
onPress={() => {
|
||||
setAutoBackup([]);
|
||||
DatabaseService.setAutoBackupServices(userId, []);
|
||||
DatabaseService.setAutoBackupServices(userId, []).catch((err) => {
|
||||
console.error('Error saving auto backup:', err);
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
||||
+257
-35
@@ -1,9 +1,55 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import CredentialEncryption from './CredentialEncryption';
|
||||
import MediaMetadataService from './MediaMetadataService';
|
||||
import { logError } from './ErrorHandler';
|
||||
|
||||
let db = null;
|
||||
let initPromise = null;
|
||||
const MEDIA_INSERT_SQL = `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date, metadata_json, search_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
|
||||
|
||||
const hasValue = (value) => value || value === 0;
|
||||
const withDecryptedAccount = (row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) });
|
||||
const toNullable = (value) => value || null;
|
||||
const normalizeMetaString = (itemOrParams) => {
|
||||
if (!itemOrParams) return null;
|
||||
if (itemOrParams.metadata && typeof itemOrParams.metadata === 'object') {
|
||||
return JSON.stringify(itemOrParams.metadata);
|
||||
}
|
||||
if (itemOrParams.metadata_json != null && typeof itemOrParams.metadata_json === 'object') {
|
||||
return JSON.stringify(itemOrParams.metadata_json);
|
||||
}
|
||||
return toNullable(itemOrParams.metadata_json);
|
||||
};
|
||||
const buildMediaInsertParams = (itemOrParams) => {
|
||||
const metaStr = normalizeMetaString(itemOrParams);
|
||||
const searchText =
|
||||
itemOrParams.search_text ||
|
||||
MediaMetadataService.buildSearchText(
|
||||
{
|
||||
title: itemOrParams.title,
|
||||
description: itemOrParams.description,
|
||||
source_service: itemOrParams.sourceService || itemOrParams.source_service,
|
||||
local_path: itemOrParams.localPath || itemOrParams.local_path,
|
||||
remote_path: itemOrParams.remotePath || itemOrParams.remote_path,
|
||||
},
|
||||
metaStr
|
||||
);
|
||||
return [
|
||||
itemOrParams.userId,
|
||||
itemOrParams.sourceService || itemOrParams.source_service,
|
||||
toNullable(itemOrParams.sourceId || itemOrParams.source_id),
|
||||
toNullable(itemOrParams.title),
|
||||
toNullable(itemOrParams.description),
|
||||
toNullable(itemOrParams.localPath || itemOrParams.local_path),
|
||||
toNullable(itemOrParams.remotePath || itemOrParams.remote_path),
|
||||
toNullable(itemOrParams.mediaType || itemOrParams.media_type),
|
||||
toNullable(itemOrParams.createdDate || itemOrParams.created_date),
|
||||
metaStr,
|
||||
searchText,
|
||||
];
|
||||
};
|
||||
|
||||
const DatabaseService = {
|
||||
initDatabase: async () => {
|
||||
@@ -61,6 +107,31 @@ const DatabaseService = {
|
||||
|
||||
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 media ADD COLUMN metadata_json TEXT'); } catch (_) {}
|
||||
try { await db.execAsync('ALTER TABLE media ADD COLUMN search_text TEXT'); } catch (_) {}
|
||||
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS user_albums (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS album_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
album_id INTEGER NOT NULL,
|
||||
media_fingerprint TEXT NOT NULL,
|
||||
source_service TEXT,
|
||||
title_snapshot TEXT,
|
||||
local_path TEXT,
|
||||
remote_path TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(album_id) REFERENCES user_albums(id) ON DELETE CASCADE,
|
||||
UNIQUE(album_id, media_fingerprint)
|
||||
);
|
||||
`);
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
return true;
|
||||
@@ -84,6 +155,11 @@ const DatabaseService = {
|
||||
return true;
|
||||
},
|
||||
|
||||
runMediaInsert: async (params) => {
|
||||
const result = await db.runAsync(MEDIA_INSERT_SQL, buildMediaInsertParams(params));
|
||||
return result.lastInsertRowId;
|
||||
},
|
||||
|
||||
createUser: async (username, email, password) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return null;
|
||||
@@ -132,7 +208,7 @@ const DatabaseService = {
|
||||
getUserById: async (userId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return null;
|
||||
if (!userId && userId !== 0) {
|
||||
if (!hasValue(userId)) {
|
||||
logError('Get User', 'User ID is required');
|
||||
return null;
|
||||
}
|
||||
@@ -175,7 +251,7 @@ const DatabaseService = {
|
||||
getLinkedAccounts: async (userId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return [];
|
||||
if (!userId && userId !== 0) {
|
||||
if (!hasValue(userId)) {
|
||||
logError('Get Linked Accounts', 'User ID is required');
|
||||
return [];
|
||||
}
|
||||
@@ -183,10 +259,7 @@ const DatabaseService = {
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
|
||||
[userId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
||||
}));
|
||||
return rows.map(withDecryptedAccount);
|
||||
} catch (error) {
|
||||
logError('Get Linked Accounts', error);
|
||||
return [];
|
||||
@@ -201,7 +274,7 @@ const DatabaseService = {
|
||||
[userId, serviceType]
|
||||
);
|
||||
if (!row) return null;
|
||||
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
|
||||
return withDecryptedAccount(row);
|
||||
} catch (error) {
|
||||
logError('Get Linked Account By Service', error);
|
||||
return null;
|
||||
@@ -226,7 +299,7 @@ const DatabaseService = {
|
||||
[userId]
|
||||
);
|
||||
if (!row) return null;
|
||||
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
|
||||
return withDecryptedAccount(row);
|
||||
} catch (error) {
|
||||
logError('Get Default Service', error);
|
||||
return null;
|
||||
@@ -261,10 +334,7 @@ const DatabaseService = {
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
||||
}));
|
||||
return rows.map(withDecryptedAccount);
|
||||
} catch (error) {
|
||||
logError('Get All Linked Accounts', error);
|
||||
return [];
|
||||
@@ -278,13 +348,17 @@ const DatabaseService = {
|
||||
logError('Add Media', 'userId and sourceService are required');
|
||||
return null;
|
||||
}
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, sourceService, sourceId || null, title || null, description || null,
|
||||
localPath || null, remotePath || null, mediaType || null, createdDate || null]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
return await DatabaseService.runMediaInsert({
|
||||
userId,
|
||||
sourceService,
|
||||
sourceId,
|
||||
title,
|
||||
description,
|
||||
localPath,
|
||||
remotePath,
|
||||
mediaType,
|
||||
createdDate,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Add Media', error);
|
||||
return null;
|
||||
@@ -297,22 +371,59 @@ const DatabaseService = {
|
||||
if (!userId || !item?.source_service)
|
||||
return null;
|
||||
|
||||
const incomingMeta = normalizeMetaString(item);
|
||||
const mergedFromIncoming = incomingMeta
|
||||
? MediaMetadataService.mergeMetadataJson('{}', incomingMeta)
|
||||
: null;
|
||||
|
||||
if (item.source_id) {
|
||||
const existing = await db.getFirstAsync(
|
||||
'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
|
||||
'SELECT * FROM media WHERE user_id = ? AND source_id = ?',
|
||||
[userId, item.source_id]
|
||||
);
|
||||
if (existing) return existing.id;
|
||||
if (existing) {
|
||||
const mergedMeta = incomingMeta
|
||||
? MediaMetadataService.mergeMetadataJson(existing.metadata_json, incomingMeta)
|
||||
: existing.metadata_json;
|
||||
const searchText = MediaMetadataService.buildSearchText(
|
||||
{ ...existing, ...item, title: item.title || existing.title },
|
||||
mergedMeta
|
||||
);
|
||||
const rowForCapture = {
|
||||
...existing,
|
||||
...item,
|
||||
title: item.title || existing.title,
|
||||
metadata_json: mergedMeta,
|
||||
creationTime: item.creationTime ?? existing.creationTime,
|
||||
modifyTime: item.modifyTime ?? existing.modifyTime,
|
||||
};
|
||||
const createdForDb =
|
||||
MediaMetadataService.resolveCreatedDateIsoForStorage(rowForCapture)
|
||||
?? toNullable(item.created_date)
|
||||
?? toNullable(existing.created_date);
|
||||
await db.runAsync(
|
||||
`UPDATE media SET metadata_json = ?, search_text = ?,
|
||||
local_path = COALESCE(?, local_path), remote_path = COALESCE(?, remote_path),
|
||||
title = COALESCE(?, title), created_date = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
mergedMeta,
|
||||
searchText,
|
||||
toNullable(item.local_path),
|
||||
toNullable(item.remote_path),
|
||||
toNullable(item.title),
|
||||
createdForDb,
|
||||
existing.id,
|
||||
]
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, item.source_service, item.source_id || null, item.title || null,
|
||||
item.description || null, item.local_path || null, item.remote_path || null,
|
||||
item.media_type || null, item.created_date || null]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
const insertRow = { ...item, userId, metadata_json: mergedFromIncoming };
|
||||
const resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow);
|
||||
if (resolvedInsert) insertRow.created_date = resolvedInsert;
|
||||
return await DatabaseService.runMediaInsert(insertRow);
|
||||
} catch (error) {
|
||||
logError('Save Media Item', error);
|
||||
return null;
|
||||
@@ -322,12 +433,12 @@ const DatabaseService = {
|
||||
getMediaByUser: async (userId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return [];
|
||||
if (!userId && userId !== 0) {
|
||||
if (!hasValue(userId)) {
|
||||
logError('Get Media', 'User ID is required');
|
||||
return [];
|
||||
}
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
|
||||
'SELECT * FROM media WHERE user_id = ? ORDER BY datetime(created_date) DESC',
|
||||
[userId]
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -352,17 +463,128 @@ const DatabaseService = {
|
||||
searchMedia: async (userId, query) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return [];
|
||||
const searchTerm = `%${query}%`;
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
|
||||
[userId, searchTerm, searchTerm]
|
||||
const q = (query || '').trim();
|
||||
if (!q) return [];
|
||||
const searchTerm = `%${q}%`;
|
||||
const likeRows = await db.getAllAsync(
|
||||
`SELECT * FROM media WHERE user_id = ? AND (
|
||||
title LIKE ? OR description LIKE ? OR search_text LIKE ? OR metadata_json LIKE ?
|
||||
)`,
|
||||
[userId, searchTerm, searchTerm, searchTerm, searchTerm]
|
||||
);
|
||||
const ranked = MediaMetadataService.rankMediaBySearchQuery(likeRows, q);
|
||||
if (ranked.length > 0) return ranked;
|
||||
const allRows = await db.getAllAsync('SELECT * FROM media WHERE user_id = ?', [userId]);
|
||||
return MediaMetadataService.rankMediaBySearchQuery(allRows, q);
|
||||
} catch (error) {
|
||||
logError('Search Media', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
createAlbum: async (userId, name) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return null;
|
||||
if (!userId || !name?.trim()) return null;
|
||||
const result = await db.runAsync(
|
||||
'INSERT INTO user_albums (user_id, name) VALUES (?, ?)',
|
||||
[userId, name.trim()]
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
} catch (error) {
|
||||
logError('Create Album', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getUserAlbums: async (userId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return [];
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM user_albums WHERE user_id = ? ORDER BY datetime(created_at) DESC',
|
||||
[userId]
|
||||
);
|
||||
} catch (error) {
|
||||
logError('Get User Albums', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getAlbumEntryCount: async (albumId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return 0;
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT COUNT(*) as c FROM album_entries WHERE album_id = ?',
|
||||
[albumId]
|
||||
);
|
||||
return row?.c ?? 0;
|
||||
} catch (error) {
|
||||
logError('Album count', error);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
addMediaToAlbum: async (userId, albumId, item) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return false;
|
||||
const album = await db.getFirstAsync(
|
||||
'SELECT id FROM user_albums WHERE id = ? AND user_id = ?',
|
||||
[albumId, userId]
|
||||
);
|
||||
if (!album || !item) return false;
|
||||
const fp = MediaMetadataService.mediaFingerprint(item);
|
||||
const metaStr = normalizeMetaString(item) || '{}';
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO album_entries
|
||||
(album_id, media_fingerprint, source_service, title_snapshot, local_path, remote_path, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
albumId,
|
||||
fp,
|
||||
item.source_service || null,
|
||||
item.title || null,
|
||||
item.local_path || null,
|
||||
item.remote_path || null,
|
||||
metaStr,
|
||||
]
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logError('Add Media To Album', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getAlbumEntries: async (userId, albumId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return [];
|
||||
const album = await db.getFirstAsync(
|
||||
'SELECT id FROM user_albums WHERE id = ? AND user_id = ?',
|
||||
[albumId, userId]
|
||||
);
|
||||
if (!album) return [];
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM album_entries WHERE album_id = ? ORDER BY datetime(created_at) DESC',
|
||||
[albumId]
|
||||
);
|
||||
} catch (error) {
|
||||
logError('Get Album Entries', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
removeAlbum: async (userId, albumId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return;
|
||||
await db.runAsync(
|
||||
'DELETE FROM user_albums WHERE id = ? AND user_id = ?',
|
||||
[albumId, userId]
|
||||
);
|
||||
} catch (error) {
|
||||
logError('Remove Album', error);
|
||||
}
|
||||
},
|
||||
|
||||
getAutoBackupServices: async (userId) => {
|
||||
try {
|
||||
if (!(await DatabaseService.ensureDb())) return [];
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import MediaMetadataService from './MediaMetadataService';
|
||||
|
||||
const EMPTY_MEDIA_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
|
||||
|
||||
const DeviceMediaService = {
|
||||
requestPermission: async () => {
|
||||
@@ -13,7 +16,7 @@ const DeviceMediaService = {
|
||||
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
|
||||
const hasPermission = await DeviceMediaService.requestPermission();
|
||||
if (!hasPermission) {
|
||||
return { mediaItems: [], endCursor: null, hasNextPage: false };
|
||||
return EMPTY_MEDIA_PAGE;
|
||||
}
|
||||
|
||||
const mediaTypeFilter =
|
||||
@@ -30,21 +33,9 @@ const DeviceMediaService = {
|
||||
|
||||
const result = await MediaLibrary.getAssetsAsync(options);
|
||||
|
||||
const mediaItems = result.assets.map((asset) => ({
|
||||
source_service: 'DEVICE',
|
||||
source_id: asset.id,
|
||||
title: asset.filename || 'Untitled',
|
||||
description: '',
|
||||
local_path: asset.uri,
|
||||
remote_path: '',
|
||||
media_type: asset.mediaType === 'video' ? 'video' : 'photo',
|
||||
created_date: asset.creationTime
|
||||
? new Date(asset.creationTime).toISOString()
|
||||
: null,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration || 0,
|
||||
}));
|
||||
const mediaItems = await Promise.all(
|
||||
result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a))
|
||||
);
|
||||
|
||||
return {
|
||||
mediaItems,
|
||||
@@ -71,11 +62,11 @@ const DeviceMediaService = {
|
||||
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
|
||||
const hasPermission = await DeviceMediaService.requestPermission();
|
||||
if (!hasPermission) {
|
||||
return { mediaItems: [], endCursor: null, hasNextPage: false };
|
||||
return EMPTY_MEDIA_PAGE;
|
||||
}
|
||||
|
||||
const album = await MediaLibrary.getAlbumAsync(albumId);
|
||||
if (!album) return { mediaItems: [], endCursor: null, hasNextPage: false };
|
||||
if (!album) return EMPTY_MEDIA_PAGE;
|
||||
|
||||
const options = {
|
||||
first: pageSize,
|
||||
@@ -86,21 +77,9 @@ const DeviceMediaService = {
|
||||
|
||||
const result = await MediaLibrary.getAssetsAsync(options);
|
||||
|
||||
const mediaItems = result.assets.map((asset) => ({
|
||||
source_service: 'DEVICE',
|
||||
source_id: asset.id,
|
||||
title: asset.filename || 'Untitled',
|
||||
description: '',
|
||||
local_path: asset.uri,
|
||||
remote_path: '',
|
||||
media_type: asset.mediaType === 'video' ? 'video' : 'photo',
|
||||
created_date: asset.creationTime
|
||||
? new Date(asset.creationTime).toISOString()
|
||||
: null,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration || 0,
|
||||
}));
|
||||
const mediaItems = await Promise.all(
|
||||
result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a))
|
||||
);
|
||||
|
||||
return {
|
||||
mediaItems,
|
||||
|
||||
Reference in new issue
Block a user