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
|
*.tmp
|
||||||
.env
|
.env
|
||||||
!.env.example.txt
|
!.env.example.txt
|
||||||
|
ServerFiles/data/
|
||||||
@@ -4,6 +4,7 @@ import { NavigationContainer } from '@react-navigation/native';
|
|||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
|
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { registerRootComponent } from 'expo';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
|
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
|
||||||
import DatabaseService from './src/services/DatabaseService';
|
import DatabaseService from './src/services/DatabaseService';
|
||||||
@@ -132,10 +133,14 @@ function AppInner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppInner />
|
<AppInner />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerRootComponent(App);
|
||||||
|
|
||||||
|
export default App;
|
||||||
+11
-13
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "media-app-sftp-backend",
|
"name": "media-app-nas-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "SFTP Backend API for Media Aggregation App",
|
"description": "NAS REST backend (CRUDL vault, base64 storage) for Media Aggregation App",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
@@ -9,23 +9,21 @@
|
|||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"sftp",
|
"nas",
|
||||||
"backend",
|
"rest",
|
||||||
"api",
|
"crud",
|
||||||
"express"
|
"express"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^2.2.2",
|
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.22.1",
|
"express": "^5.2.1",
|
||||||
"express-fileupload": "^1.4.0",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"node-fetch": "^3.3.2"
|
||||||
"node-fetch": "^2.7.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.1.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+98
-182
@@ -2,69 +2,15 @@ const express = require('express');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
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') });
|
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
||||||
const fetch = require('node-fetch');
|
const nasVaultStore = require('./nasVaultStore');
|
||||||
const execFileAsync = util.promisify(execFile);
|
|
||||||
|
|
||||||
const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
|
const fetch = (...args) => import('node-fetch').then(({ default: nodeFetch }) => nodeFetch(...args));
|
||||||
|
|
||||||
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 app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '150mb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '150mb' }));
|
||||||
app.use(fileUpload());
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||||
@@ -75,16 +21,18 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
function verifyToken(req, res, next) {
|
function verifyNasSession(req, res, next) {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
const token = authHeader?.split(' ')[1];
|
const token = authHeader?.split(' ')[1];
|
||||||
if (!token) return res.status(401).json({ error: 'No token provided' });
|
if (!token) return res.status(401).json({ error: 'No token provided' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, SECRET_KEY);
|
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.connectionId = decoded.connectionId;
|
||||||
req.connection = connections.get(decoded.connectionId);
|
req.connection = conn;
|
||||||
if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' });
|
req.vaultKey = conn.vaultKey;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(401).json({ error: 'Invalid token: ' + err.message });
|
res.status(401).json({ error: 'Invalid token: ' + err.message });
|
||||||
@@ -95,141 +43,103 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
activeConnections: connections.size,
|
activeSessions: connections.size,
|
||||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
nasMode: 'rest-crudl',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/config/photo-dir', (req, res) => {
|
/**
|
||||||
res.json({ photoDir: DEFAULT_PHOTO_DIR });
|
* 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/sftp/connect', (req, res) => {
|
app.post('/api/nas/sessions', (req, res) => {
|
||||||
const { host, port = 22, username, password } = req.body;
|
const { host, port = 22, username, password } = req.body;
|
||||||
if (!host || !username || !password) {
|
if (!host || !username || !password) {
|
||||||
return res.status(400).json({ error: 'Missing required fields: 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)}`;
|
const vaultKey = nasVaultStore.vaultKeyFromCredentials(host, username);
|
||||||
connections.set(connectionId, { host, port, username, password, createdAt: new Date() });
|
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' });
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
connectionToken: token,
|
connectionToken: token,
|
||||||
message: `Connection prepared for ${host}:${port}`,
|
message: 'NAS REST session established',
|
||||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sftp/list', verifyToken, async (req, res) => {
|
/** List — GET /api/nas/media?offset=&limit=&include=metadata|full */
|
||||||
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
|
app.get('/api/nas/media', verifyNasSession, (req, res) => {
|
||||||
const conn = req.connection;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]);
|
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
||||||
const files = result.stdout
|
const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 100));
|
||||||
.split('\n')
|
const include = req.query.include === 'full' ? 'full' : 'metadata';
|
||||||
.filter(Boolean)
|
const { items, total } = nasVaultStore.list(req.vaultKey, { offset, limit, include });
|
||||||
.map((line) => {
|
res.json({ items, total, offset, limit, include });
|
||||||
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 });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('List directory error:', err.message);
|
console.error('NAS list error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to list directory: ' + err.message });
|
res.status(500).json({ error: err.message || 'List failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/sftp/download', verifyToken, async (req, res) => {
|
/** Read — GET /api/nas/media/:id?include=metadata|full */
|
||||||
const { path: filePath } = req.query;
|
app.get('/api/nas/media/:id', verifyNasSession, (req, res) => {
|
||||||
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
const row = nasVaultStore.getById(req.vaultKey, req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'Media not found' });
|
||||||
const conn = req.connection;
|
const include = req.query.include === 'full' ? 'full' : 'metadata';
|
||||||
const filename = path.basename(filePath);
|
res.json(nasVaultStore.shapeRow(row, include));
|
||||||
const tempFilePath = path.join(os.tmpdir(), filename);
|
});
|
||||||
|
|
||||||
|
/** Create — POST /api/nas/media */
|
||||||
|
app.post('/api/nas/media', verifyNasSession, (req, res) => {
|
||||||
try {
|
try {
|
||||||
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
|
const { filename, mimeType, base64, metadata } = req.body || {};
|
||||||
res.download(tempFilePath, filename, () => {
|
if (!base64) return res.status(400).json({ error: 'Missing base64 in body' });
|
||||||
try { fs.unlinkSync(tempFilePath); } catch (_) {}
|
const created = nasVaultStore.create(req.vaultKey, { filename, mimeType, base64, metadata });
|
||||||
});
|
res.status(201).json(created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Download error:', err.message);
|
console.error('NAS create error:', err.message);
|
||||||
res.status(500).json({ error: 'Download failed: ' + err.message });
|
res.status(400).json({ error: err.message || 'Create failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sftp/upload', verifyToken, async (req, res) => {
|
/** Update — PUT /api/nas/media/:id */
|
||||||
const { remotePath } = req.body;
|
app.put('/api/nas/media/:id', verifyNasSession, (req, res) => {
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await file.mv(tempFilePath);
|
const updated = nasVaultStore.update(req.vaultKey, req.params.id, req.body || {});
|
||||||
await runCommand(
|
if (!updated) return res.status(404).json({ error: 'Media not found' });
|
||||||
'scp',
|
res.json(updated);
|
||||||
buildScpArgs(conn, tempFilePath, `${conn.username}@${conn.host}:${remotePath}`)
|
|
||||||
);
|
|
||||||
fs.unlinkSync(tempFilePath);
|
|
||||||
console.log(`Uploaded: ${remotePath}`);
|
|
||||||
res.json({ success: true, remotePath, message: 'File uploaded' });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload error:', err.message);
|
console.error('NAS update error:', err.message);
|
||||||
res.status(500).json({ error: 'Upload failed: ' + err.message });
|
res.status(400).json({ error: err.message || 'Update failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
|
/** Delete — DELETE /api/nas/media/:id */
|
||||||
const { path: dirPath } = req.body;
|
app.delete('/api/nas/media/:id', verifyNasSession, (req, res) => {
|
||||||
if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' });
|
const ok = nasVaultStore.remove(req.vaultKey, req.params.id);
|
||||||
|
if (!ok) return res.status(404).json({ error: 'Media not found' });
|
||||||
try {
|
res.status(204).send();
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sftp/delete', verifyToken, async (req, res) => {
|
/** End session (does not delete vault data) */
|
||||||
const { path: filePath } = req.body;
|
app.delete('/api/nas/sessions/current', verifyNasSession, (req, res) => {
|
||||||
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) => {
|
|
||||||
connections.delete(req.connectionId);
|
connections.delete(req.connectionId);
|
||||||
console.log(`Disconnected: ${req.connectionId}`);
|
res.json({ success: true, message: 'Session closed' });
|
||||||
res.json({ success: true, message: 'Disconnected from SFTP server' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
const { googleAccessToken, pageSize = 50, pageToken } = req.body;
|
||||||
if (!googleAccessToken) {
|
if (!googleAccessToken) {
|
||||||
return res.status(400).json({ error: 'Google access token is required' });
|
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) => {
|
/** Saves a Google image into the NAS vault as base64 with metadata (CRUD Create). */
|
||||||
const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
|
app.post('/api/google-photos/download-to-nas', verifyNasSession, async (req, res) => {
|
||||||
const conn = req.connection;
|
const { googleAccessToken, baseUrl, filename, metadata = {} } = req.body;
|
||||||
|
if (!googleAccessToken || !baseUrl) {
|
||||||
if (!googleAccessToken || !baseUrl || !remotePath) {
|
return res.status(400).json({ error: 'googleAccessToken and baseUrl are required' });
|
||||||
return res.status(400).json({ error: 'googleAccessToken, baseUrl, and remotePath are required' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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' });
|
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 safeName = filename || `gphoto_${Date.now()}.jpg`;
|
||||||
const tempFilePath = path.join(os.tmpdir(), safeName);
|
const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg';
|
||||||
fs.writeFileSync(tempFilePath, buffer);
|
|
||||||
|
|
||||||
const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`;
|
const created = nasVaultStore.create(req.vaultKey, {
|
||||||
await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath));
|
filename: safeName,
|
||||||
fs.unlinkSync(tempFilePath);
|
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}`);
|
console.log(`Saved Google Photo to NAS vault: ${created.id}`);
|
||||||
res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` });
|
res.json({ success: true, media: created, message: 'Photo stored in NAS vault' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Download-to-NAS error:', err.message);
|
console.error('Google → NAS error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to save photo to NAS: ' + 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 });
|
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\nShutting down...');
|
console.log('\nShutting down...');
|
||||||
connections.forEach((_, id) => connections.delete(id));
|
connections.clear();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,12 +244,10 @@ app.use((req, res) => {
|
|||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`\n SFTP Backend Server`);
|
console.log(`\n NAS REST backend (CRUDL + base64 vault)`);
|
||||||
console.log(` Port: ${PORT}`);
|
console.log(` Port: ${PORT}`);
|
||||||
console.log(` URL: http://localhost:${PORT}`);
|
console.log(` URL: http://localhost:${PORT}`);
|
||||||
console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`);
|
console.log(` Status: Ready\n`);
|
||||||
console.log(` Status: Ready\n`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|
||||||
+66
-29
@@ -1,7 +1,7 @@
|
|||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('\n--- NAS Backend API Test ---');
|
console.log('\n--- NAS REST backend test ---');
|
||||||
console.log(`Backend: ${BACKEND_URL}\n`);
|
console.log(`Backend: ${BACKEND_URL}\n`);
|
||||||
|
|
||||||
console.log('[1] Health check...');
|
console.log('[1] Health check...');
|
||||||
@@ -9,38 +9,31 @@ async function main() {
|
|||||||
const res = await fetch(`${BACKEND_URL}/api/health`);
|
const res = await fetch(`${BACKEND_URL}/api/health`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
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) {
|
} catch (err) {
|
||||||
console.log(` Failed: ${err.message}`);
|
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);
|
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 port = parseInt(process.env.NAS_PORT || '22', 10);
|
||||||
const username = process.env.NAS_USER;
|
const username = process.env.NAS_USER || 'demo';
|
||||||
const password = process.env.NAS_PASS;
|
const password = process.env.NAS_PASS || 'demo';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let connectionToken = null;
|
let connectionToken = null;
|
||||||
console.log(`[2] SFTP connect to ${host}:${port}...`);
|
console.log(`[2] POST /api/nas/sessions (${host}, ${username})...`);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/sftp/connect`, {
|
const res = await fetch(`${BACKEND_URL}/api/nas/sessions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ host, port, username, password }),
|
body: JSON.stringify({ host, port, username, password }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
const data = await res.json();
|
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;
|
connectionToken = data.connectionToken;
|
||||||
console.log(` Connected.\n`);
|
console.log(' Session OK.\n');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(` Failed: ${err.message}\n`);
|
console.log(` Failed: ${err.message}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -51,29 +44,73 @@ async function main() {
|
|||||||
Authorization: `Bearer ${connectionToken}`,
|
Authorization: `Bearer ${connectionToken}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[3] List directory "${folder}"...`);
|
console.log('[3] GET /api/nas/media (list)...');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
|
const res = await fetch(`${BACKEND_URL}/api/nas/media?limit=10&include=metadata`, {
|
||||||
method: 'POST',
|
headers: { Authorization: `Bearer ${connectionToken}` },
|
||||||
headers: authHeaders,
|
|
||||||
body: JSON.stringify({ path: folder }),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
console.log(` Found ${data.count || 0} items in ${data.path}`);
|
console.log(` total=${data.total}, returned=${(data.items || []).length}\n`);
|
||||||
(data.files || []).slice(0, 5).forEach((f, i) => {
|
|
||||||
console.log(` ${i + 1}. ${f.name} ${f.isDirectory ? '(dir)' : `(${f.size} bytes)`}`);
|
|
||||||
});
|
|
||||||
console.log();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(` Failed: ${err.message}\n`);
|
console.log(` Failed: ${err.message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[4] Disconnect...');
|
console.log('[4] POST /api/nas/media (tiny PNG create)...');
|
||||||
|
let createdId = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
|
const onePxPng =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/nas/media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders,
|
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"entryPoint": "./App.js",
|
|
||||||
"name": "MASS Photo App",
|
"name": "MASS Photo App",
|
||||||
"slug": "mass-expo",
|
"slug": "mass-expo",
|
||||||
"owner": "bobthebob",
|
"owner": "bobthebob",
|
||||||
|
|||||||
+30
-30
@@ -3,42 +3,42 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "App.js",
|
"main": "App.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "npx expo start",
|
||||||
"android": "expo run:android",
|
"android": "npx expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "npx expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "npx expo start --web",
|
||||||
"backend": "node ServerFiles/server.js"
|
"backend": "node ServerFiles/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-navigation/bottom-tabs": "^6.5.20",
|
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/native": "^7.2.2",
|
||||||
"@react-navigation/native-stack": "^6.9.26",
|
"@react-navigation/native-stack": "^7.14.11",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.15.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~55.0.15",
|
||||||
"expo-application": "~7.0.8",
|
"expo-application": "~55.0.14",
|
||||||
"expo-auth-session": "~7.0.10",
|
"expo-auth-session": "~55.0.14",
|
||||||
"expo-blur": "~15.0.8",
|
"expo-blur": "~55.0.14",
|
||||||
"expo-crypto": "~15.0.8",
|
"expo-crypto": "~55.0.14",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~55.0.16",
|
||||||
"expo-image-picker": "~17.0.10",
|
"expo-image-picker": "~55.0.18",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~55.0.13",
|
||||||
"expo-media-library": "~18.2.1",
|
"expo-media-library": "~55.0.14",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~55.0.13",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~55.0.15",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~55.0.5",
|
||||||
"expo-system-ui": "^55.0.9",
|
"expo-system-ui": "^55.0.15",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~55.0.14",
|
||||||
"react": "19.1.0",
|
"react": "19.2.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.83.4",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "4.2.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.0",
|
"@babel/core": "^7.29.0",
|
||||||
"babel-preset-expo": "~12.0.0"
|
"babel-preset-expo": "~55.0.17"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jscodeshift": "0.16.1",
|
"jscodeshift": "0.16.1",
|
||||||
|
|||||||
+184
-64
@@ -1,7 +1,33 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { File } from 'expo-file-system';
|
||||||
|
|
||||||
const DEFAULT_BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001';
|
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 {
|
class NASAPI {
|
||||||
constructor(backendUrl = DEFAULT_BACKEND_URL) {
|
constructor(backendUrl = DEFAULT_BACKEND_URL) {
|
||||||
this.backendUrl = backendUrl;
|
this.backendUrl = backendUrl;
|
||||||
@@ -10,6 +36,14 @@ class NASAPI {
|
|||||||
this.credentials = null;
|
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') {
|
async connect(host, port, username, password, photoFolder = '/Photos') {
|
||||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
||||||
|
|
||||||
@@ -17,107 +51,193 @@ class NASAPI {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${this.backendUrl}/api/sftp/connect`,
|
`${this.backendUrl}/api/nas/sessions`,
|
||||||
{ host, port: port || 22, username, password },
|
{ host, port: port || 22, username, password },
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
);
|
);
|
||||||
if (response.data?.success) {
|
if (response.data?.success && response.data.connectionToken) {
|
||||||
this.connectionToken = response.data.connectionToken;
|
this.connectionToken = response.data.connectionToken;
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
return { success: true, message: `Connected to ${host}` };
|
return { success: true, message: `Connected to NAS REST API (${host})` };
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
console.log('Backend not available, storing NAS credentials locally');
|
console.log('NAS backend not available, credentials stored locally only');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isConnected = true;
|
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() {
|
/** List — GET /api/nas/media */
|
||||||
if (!this.connectionToken) return {};
|
async listMedia({ offset = 0, limit = 100, include = 'metadata' } = {}) {
|
||||||
return { Authorization: `Bearer ${this.connectionToken}` };
|
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 = '/') {
|
/** Read — GET /api/nas/media/:id */
|
||||||
if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' };
|
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(
|
const response = await axios.post(
|
||||||
`${this.backendUrl}/api/sftp/list`, { path },
|
`${this.backendUrl}/api/nas/media`,
|
||||||
{ headers: this.getHeaders(), timeout: 15000 }
|
{ filename, mimeType, base64, metadata },
|
||||||
|
{ headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, timeout: 120000 }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPhotos(folder = null) {
|
/** Update — PUT /api/nas/media/:id */
|
||||||
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
|
async updateMedia(id, payload) {
|
||||||
if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
|
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 {
|
try {
|
||||||
const listing = await this.listDirectory(targetFolder);
|
const { items = [], total = 0 } = await this.listMedia({ offset: 0, limit: 200, include: 'full' });
|
||||||
const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
|
const mediaItems = items.map((row) => {
|
||||||
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
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 || [])
|
return { mediaItems, folder: targetFolder, total };
|
||||||
.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 };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get photos error:', error.message);
|
console.error('NAS getPhotos error:', error.message);
|
||||||
return { mediaItems: [], folder: targetFolder };
|
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) {
|
async uploadFile(localPath, remotePath) {
|
||||||
if (!this.connectionToken) throw new Error('Backend not connected — cannot upload');
|
const file = new File(localPath);
|
||||||
const formData = new FormData();
|
if (!file.exists) throw new Error('Local file not found');
|
||||||
formData.append('remotePath', remotePath);
|
const buf = await file.arrayBuffer();
|
||||||
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
|
const base64 = arrayBufferToBase64(buf);
|
||||||
|
const name = (remotePath && remotePath.split('/').pop()) || file.name || 'upload';
|
||||||
const response = await axios.post(
|
return this.createMedia({
|
||||||
`${this.backendUrl}/api/sftp/upload`, formData,
|
filename: name,
|
||||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
mimeType: guessMimeFromName(name),
|
||||||
);
|
base64,
|
||||||
return response.data;
|
metadata: { uploadedFrom: localPath },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDirectory(remotePath) {
|
async createDirectory(_remotePath) {
|
||||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
return { success: true, message: 'REST vault has no directories; use POST /api/nas/media' };
|
||||||
const response = await axios.post(
|
|
||||||
`${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
|
|
||||||
{ headers: this.getHeaders(), timeout: 10000 }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(remotePath) {
|
async deleteFile(mediaId) {
|
||||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
return this.deleteMedia(mediaId);
|
||||||
const response = await axios.post(
|
|
||||||
`${this.backendUrl}/api/sftp/delete`, { path: remotePath },
|
|
||||||
{ headers: this.getHeaders(), timeout: 10000 }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
if (this.connectionToken) {
|
if (this.connectionToken) {
|
||||||
try {
|
try {
|
||||||
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
|
await axios.delete(`${this.backendUrl}/api/nas/sessions/current`, {
|
||||||
} catch (_) {}
|
headers: this.getHeaders(),
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
/* session may already be invalid */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.connectionToken = null;
|
this.connectionToken = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
|
|||||||
+37
-31
@@ -3,6 +3,7 @@ import * as WebBrowser from 'expo-web-browser';
|
|||||||
import * as Application from 'expo-application';
|
import * as Application from 'expo-application';
|
||||||
import { File, Directory, Paths } from 'expo-file-system';
|
import { File, Directory, Paths } from 'expo-file-system';
|
||||||
import { AuthServiceBase } from './AuthServiceBase';
|
import { AuthServiceBase } from './AuthServiceBase';
|
||||||
|
import MediaMetadataService from '../services/MediaMetadataService';
|
||||||
import { logError } from '../services/ErrorHandler';
|
import { logError } from '../services/ErrorHandler';
|
||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
@@ -29,6 +30,8 @@ const PICKER_API = 'https://photospicker.googleapis.com/v1';
|
|||||||
const CACHE_DIR = new Directory(Paths.cache, 'google_photos');
|
const CACHE_DIR = new Directory(Paths.cache, 'google_photos');
|
||||||
|
|
||||||
const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`;
|
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 {
|
class GoogleAUTH extends AuthServiceBase {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -39,12 +42,26 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
this.email = null;
|
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() {
|
async authenticate() {
|
||||||
if (!ACTIVE_CLIENT_ID) {
|
if (!ACTIVE_CLIENT_ID) {
|
||||||
console.warn('Google Auth: no Android client ID — using placeholder mode');
|
console.warn('Google Auth: no Android client ID — using placeholder mode');
|
||||||
this.accessToken = 'placeholder_google_token_' + Date.now();
|
this.accessToken = 'placeholder_google_token_' + Date.now();
|
||||||
this.refreshToken = 'placeholder_refresh_' + 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';
|
this.email = 'google-user@placeholder.com';
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -99,9 +116,7 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.accessToken = tokenData.access_token;
|
this._setTokenState(tokenData);
|
||||||
this.refreshToken = tokenData.refresh_token || null;
|
|
||||||
this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
|
|
||||||
|
|
||||||
console.log('Google token granted scopes:', tokenData.scope || '(none returned)');
|
console.log('Google token granted scopes:', tokenData.scope || '(none returned)');
|
||||||
|
|
||||||
@@ -154,8 +169,7 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.accessToken = data.access_token;
|
this._setTokenState(data);
|
||||||
this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
|
|
||||||
return this.accessToken;
|
return this.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +215,13 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
return await res.json();
|
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) {
|
async _waitForSession(sessionId) {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const maxAttempts = 20;
|
const maxAttempts = 20;
|
||||||
@@ -245,13 +266,6 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +281,8 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
const creationTime = file.mediaFileMetadata?.creationTime
|
const creationTime = file.mediaFileMetadata?.creationTime
|
||||||
|| item.mediaFileMetadata?.creationTime
|
|| item.mediaFileMetadata?.creationTime
|
||||||
|| null;
|
|| null;
|
||||||
|
const mw = file.mediaFileMetadata?.width ?? item.mediaFileMetadata?.width;
|
||||||
|
const mh = file.mediaFileMetadata?.height ?? item.mediaFileMetadata?.height;
|
||||||
|
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
console.log(`Normalising item[0]: id=${item.id}, baseUrl=${baseUrl?.substring(0, 60)}…, filename=${filename}, mime=${mimeType}`);
|
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',
|
media_type: isVideo ? 'video' : 'photo',
|
||||||
mime_type: mimeType,
|
mime_type: mimeType,
|
||||||
created_date: creationTime,
|
created_date: creationTime,
|
||||||
|
width: mw,
|
||||||
|
height: mh,
|
||||||
};
|
};
|
||||||
});
|
}).map((m) => MediaMetadataService.enrichGooglePickerItem(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadPickerPhotos(items) {
|
async downloadPickerPhotos(items) {
|
||||||
@@ -312,10 +330,7 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_');
|
const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
const ext = item.mime_type?.includes('png') ? '.png'
|
const ext = this._parseFileExtension(item);
|
||||||
: item.mime_type?.includes('gif') ? '.gif'
|
|
||||||
: item.mime_type?.includes('webp') ? '.webp'
|
|
||||||
: '.jpg';
|
|
||||||
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
|
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
|
||||||
|
|
||||||
if (destFile.exists && destFile.size > 1024) {
|
if (destFile.exists && destFile.size > 1024) {
|
||||||
@@ -369,16 +384,10 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
const session = await this._createPickerSession();
|
const session = await this._createPickerSession();
|
||||||
|
|
||||||
if (session?.error) {
|
if (session?.error) {
|
||||||
return {
|
return this._buildPickerError(session.error.code, session.error.message);
|
||||||
mediaItems: [], nextPageToken: null, sessionId: null,
|
|
||||||
error: { code: session.error.code, message: session.error.message },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (!session?.id || !session?.pickerUri) {
|
if (!session?.id || !session?.pickerUri) {
|
||||||
return {
|
return this._buildPickerError(0, 'Failed to create Picker session');
|
||||||
mediaItems: [], nextPageToken: null, sessionId: null,
|
|
||||||
error: { code: 0, message: 'Failed to create Picker session' },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Picker session created:', session.id);
|
console.log('Picker session created:', session.id);
|
||||||
@@ -393,10 +402,7 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
|
|
||||||
const ready = await this._waitForSession(session.id);
|
const ready = await this._waitForSession(session.id);
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return {
|
return this._buildPickerError(0, 'No photos were selected or the picker was dismissed', session.id);
|
||||||
mediaItems: [], nextPageToken: null, sessionId: session.id,
|
|
||||||
error: { code: 0, message: 'No photos were selected or the picker was dismissed' },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this._fetchPickerMediaItems(session.id, 100, null);
|
const result = await this._fetchPickerMediaItems(session.id, 100, null);
|
||||||
@@ -413,7 +419,7 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Pick Photos', 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;
|
this.isConnected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureConnected() {
|
||||||
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
|
}
|
||||||
|
|
||||||
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
|
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
|
||||||
try {
|
try {
|
||||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
||||||
@@ -35,7 +39,7 @@ class NASAUTH extends AuthServiceBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAccessToken() {
|
async getAccessToken() {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
this.ensureConnected();
|
||||||
return { host: this.host, username: this.username, isConnected: this.isConnected };
|
return { host: this.host, username: this.username, isConnected: this.isConnected };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,39 +65,45 @@ class NASAUTH extends AuthServiceBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUserInfo() {
|
async getUserInfo() {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
this.ensureConnected();
|
||||||
return {
|
return {
|
||||||
host: this.host,
|
host: this.host,
|
||||||
username: this.username,
|
username: this.username,
|
||||||
port: this.port,
|
port: this.port,
|
||||||
photoFolder: this.photoFolder,
|
photoFolder: this.photoFolder,
|
||||||
connectedAt: new Date().toISOString(),
|
connectedAt: new Date().toISOString(),
|
||||||
serverType: 'NAS SFTP',
|
serverType: 'NAS REST (CRUDL)',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDirectory(remotePath = '/') {
|
async listDirectory(remotePath = '/') {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
this.ensureConnected();
|
||||||
return await this.nasAPI.listDirectory(remotePath);
|
return await this.nasAPI.listDirectory(remotePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(localPath, remotePath) {
|
async uploadFile(localPath, remotePath) {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
this.ensureConnected();
|
||||||
return await this.nasAPI.uploadFile(localPath, remotePath);
|
return await this.nasAPI.uploadFile(localPath, remotePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPhotos(photoFolder = null) {
|
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');
|
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) {
|
async createDirectory(remotePath) {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
this.ensureConnected();
|
||||||
return await this.nasAPI.createDirectory(remotePath);
|
return await this.nasAPI.createDirectory(remotePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(remotePath) {
|
async deleteFile(remotePath) {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
this.ensureConnected();
|
||||||
return await this.nasAPI.deleteFile(remotePath);
|
return await this.nasAPI.deleteFile(remotePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import {
|
|||||||
Dimensions, FlatList,
|
Dimensions, FlatList,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useTheme } from '../services/ThemeContext';
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
import MediaDetailDrawer from './MediaDetailDrawer';
|
||||||
|
|
||||||
|
const DRAWER_PEEK = 124;
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
const THUMB_SIZE = 60;
|
const THUMB_SIZE = 60;
|
||||||
const THUMB_GAP = 4;
|
const THUMB_GAP = 4;
|
||||||
|
const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata }) => {
|
||||||
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 { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mainListRef = useRef(null);
|
const mainListRef = useRef(null);
|
||||||
const thumbListRef = 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 }) => (
|
const renderMainItem = ({ item }) => (
|
||||||
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
|
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT, paddingBottom: DRAWER_PEEK }}>
|
||||||
{item.local_path ? (
|
{renderImageOrPlaceholder(item, 64)}
|
||||||
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
|
|
||||||
) : (
|
|
||||||
<View style={styles.fullPlaceholder}>
|
|
||||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,6 +106,8 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
|
|
||||||
const displayMedia = currentMedia || media;
|
const displayMedia = currentMedia || media;
|
||||||
|
|
||||||
|
if (!media) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
|
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
|
||||||
@@ -138,17 +141,13 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
style={styles.mainList}
|
style={styles.mainList}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
displayMedia.local_path ? (
|
<View style={{ flex: 1, paddingBottom: DRAWER_PEEK }}>
|
||||||
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
|
{renderImageOrPlaceholder(displayMedia, 64)}
|
||||||
) : (
|
</View>
|
||||||
<View style={styles.fullPlaceholder}>
|
|
||||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasCarousel && (
|
{hasCarousel && (
|
||||||
<View style={styles.thumbCarousel}>
|
<View style={[styles.thumbCarousel, { bottom: DRAWER_PEEK + 8 }]}>
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={thumbListRef}
|
ref={thumbListRef}
|
||||||
data={mediaList}
|
data={mediaList}
|
||||||
@@ -166,33 +165,12 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
|
<View style={styles.drawerLayer} pointerEvents="box-none">
|
||||||
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
|
<MediaDetailDrawer
|
||||||
<View style={styles.row}>
|
theme={theme}
|
||||||
<Text style={styles.label}>Source</Text>
|
displayMedia={displayMedia}
|
||||||
<Text style={styles.value}>
|
resolveNasMetadata={resolveNasMetadata}
|
||||||
{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>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -201,6 +179,12 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1 },
|
container: { flex: 1 },
|
||||||
|
drawerLayer: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
zIndex: 50,
|
||||||
|
elevation: 50,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
position: 'absolute', top: 44, right: 16, zIndex: 20,
|
position: 'absolute', top: 44, right: 16, zIndex: 20,
|
||||||
width: 36, height: 36, borderRadius: 18,
|
width: 36, height: 36, borderRadius: 18,
|
||||||
@@ -217,7 +201,10 @@ const styles = StyleSheet.create({
|
|||||||
fullImage: { flex: 1, width: '100%' },
|
fullImage: { flex: 1, width: '100%' },
|
||||||
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
thumbCarousel: {
|
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)',
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
},
|
},
|
||||||
@@ -231,14 +218,6 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%', height: '100%', backgroundColor: '#333',
|
width: '100%', height: '100%', backgroundColor: '#333',
|
||||||
justifyContent: 'center', alignItems: 'center',
|
justifyContent: 'center', alignItems: 'center',
|
||||||
},
|
},
|
||||||
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;
|
export default MediaViewer;
|
||||||
|
|||||||
@@ -23,53 +23,34 @@ const NASSetupInstructions = () => {
|
|||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<View style={styles.body}>
|
<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.stepTitle, { color: theme.primary }]}>Prerequisites</Text>
|
||||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||||
• An Ubuntu server (20.04 LTS or later) with SSH access enabled{'\n'}
|
• Node.js on the machine running the backend{'\n'}
|
||||||
• A user account with sudo access on the server{'\n'}
|
• A reachable URL for that backend (LAN IP or tunnel){'\n'}
|
||||||
• The server's IP address and SSH port (default: 22)
|
• Same host/username pair always maps to the same on-disk vault
|
||||||
</Text>
|
</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 }]}>
|
<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'}
|
From the ServerFiles folder:{'\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'}
|
|
||||||
npm install{'\n'}
|
npm install{'\n'}
|
||||||
node server.js{'\n\n'}
|
npm start{'\n\n'}
|
||||||
Ensure the backend URL in the app settings matches the server address (e.g. http://192.168.1.100:3001).
|
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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+134
-31
@@ -1,16 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
|
View, Text, FlatList, SectionList, TouchableOpacity, Image, Dimensions, StyleSheet,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useTheme } from '../services/ThemeContext';
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
import MediaMetadataService from '../services/MediaMetadataService';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const GRID_GAP = 2;
|
const GRID_GAP = 2;
|
||||||
|
const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
|
||||||
|
|
||||||
const PhotoGrid = ({
|
const PhotoGrid = ({
|
||||||
data,
|
data,
|
||||||
onItemPress,
|
timelineSections,
|
||||||
columns = 3,
|
columns = 3,
|
||||||
|
onItemPress,
|
||||||
|
onLongPressItem,
|
||||||
|
onToggleSelect,
|
||||||
|
selectionMode,
|
||||||
|
selectedKeys,
|
||||||
|
getItemKey,
|
||||||
refreshControl,
|
refreshControl,
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
ListEmptyComponent,
|
ListEmptyComponent,
|
||||||
@@ -21,7 +29,7 @@ const PhotoGrid = ({
|
|||||||
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
|
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
|
||||||
|
|
||||||
const getServiceIcon = (type) =>
|
const getServiceIcon = (type) =>
|
||||||
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
(SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE);
|
||||||
|
|
||||||
const renderSourceBadges = (item) => {
|
const renderSourceBadges = (item) => {
|
||||||
const sources = item.sources && item.sources.length > 0
|
const sources = item.sources && item.sources.length > 0
|
||||||
@@ -45,38 +53,113 @@ const PhotoGrid = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }) => (
|
const keyOf = (item) => (getItemKey ? getItemKey(item) : MediaMetadataService.mediaFingerprint(item));
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.gridItem, { width: itemSize, height: itemSize, backgroundColor: theme.chipBg }]}
|
const renderCell = (item) => {
|
||||||
onPress={() => onItemPress?.(item)}
|
if (!item) {
|
||||||
activeOpacity={0.8}
|
return (
|
||||||
>
|
<View
|
||||||
{item.local_path ? (
|
style={[styles.gridItem, { width: itemSize, height: itemSize, margin: GRID_GAP / 2 }]}
|
||||||
<Image
|
pointerEvents="none"
|
||||||
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>
|
const k = keyOf(item);
|
||||||
</View>
|
const selected = selectionMode && selectedKeys?.has(k);
|
||||||
)}
|
return (
|
||||||
{item.media_type === 'video' && (
|
<TouchableOpacity
|
||||||
<View style={styles.videoOverlay}>
|
style={[
|
||||||
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
|
styles.gridItem,
|
||||||
</View>
|
{
|
||||||
)}
|
width: itemSize,
|
||||||
{renderSourceBadges(item)}
|
height: itemSize,
|
||||||
</TouchableOpacity>
|
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 (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={renderItem}
|
renderItem={({ item }) => renderCell(item)}
|
||||||
keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()}
|
keyExtractor={(item, i) => keyOf(item) || i.toString()}
|
||||||
numColumns={columns}
|
numColumns={columns}
|
||||||
contentContainerStyle={styles.gridContent}
|
contentContainerStyle={styles.gridContent}
|
||||||
refreshControl={refreshControl}
|
refreshControl={refreshControl}
|
||||||
@@ -91,6 +174,19 @@ const PhotoGrid = ({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
gridContent: { paddingBottom: 20 },
|
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: {
|
gridItem: {
|
||||||
margin: GRID_GAP / 2,
|
margin: GRID_GAP / 2,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -112,7 +208,14 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
|
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
|
||||||
flexDirection: 'row', gap: 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;
|
export default PhotoGrid;
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { useTheme } from '../services/ThemeContext';
|
|||||||
|
|
||||||
const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => {
|
const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const profileInitial = currentUser?.username?.charAt(0)?.toUpperCase() || '?';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.topBar, { backgroundColor: theme.background }]}>
|
<View style={[styles.topBar, { backgroundColor: theme.background }]}>
|
||||||
@@ -18,9 +19,7 @@ const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) =>
|
|||||||
style={[styles.profileButton, { backgroundColor: theme.profileBg }]}
|
style={[styles.profileButton, { backgroundColor: theme.profileBg }]}
|
||||||
onPress={onProfilePress}
|
onPress={onProfilePress}
|
||||||
>
|
>
|
||||||
<Text style={styles.profileInitial}>
|
<Text style={styles.profileInitial}>{profileInitial}</Text>
|
||||||
{currentUser?.username?.charAt(0)?.toUpperCase() || '?'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.menuButton} onPress={onMenuPress}>
|
<TouchableOpacity style={styles.menuButton} onPress={onMenuPress}>
|
||||||
<Text style={[styles.menuDots, { color: theme.textSecondary }]}>⋮</Text>
|
<Text style={[styles.menuDots, { color: theme.textSecondary }]}>⋮</Text>
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useTheme } from '../services/ThemeContext';
|
import { useTheme } from '../services/ThemeContext';
|
||||||
import DatabaseService from '../services/DatabaseService';
|
import DatabaseService from '../services/DatabaseService';
|
||||||
|
import MediaMetadataService from '../services/MediaMetadataService';
|
||||||
import MediaViewer from '../components/MediaViewer';
|
import MediaViewer from '../components/MediaViewer';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const ALBUM_COLUMNS = 2;
|
const ALBUM_COLUMNS = 2;
|
||||||
const ALBUM_GAP = 12;
|
const ALBUM_GAP = 12;
|
||||||
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
|
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 CollectionsScreen = ({ route }) => {
|
||||||
const userId = route?.params?.userId;
|
const userId = route?.params?.userId;
|
||||||
@@ -30,8 +33,13 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
}, [userId])
|
}, [userId])
|
||||||
);
|
);
|
||||||
|
|
||||||
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
|
||||||
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
const getServiceName = (type) => SERVICE_NAMES[type] || type;
|
||||||
|
|
||||||
|
const refreshCollections = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
loadCollections();
|
||||||
|
};
|
||||||
|
|
||||||
const loadCollections = async () => {
|
const loadCollections = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +47,28 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
const allMedia = await DatabaseService.getMediaByUser(userId);
|
const allMedia = await DatabaseService.getMediaByUser(userId);
|
||||||
const sectionList = [];
|
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 = [
|
const utilityAlbums = [
|
||||||
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
|
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
|
||||||
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
|
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
|
||||||
@@ -175,7 +205,8 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
<Text style={[styles.headerTitle, { color: theme.text }]}>Collections</Text>
|
<Text style={[styles.headerTitle, { color: theme.text }]}>Collections</Text>
|
||||||
</View>
|
</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}>
|
<View style={styles.centerContainer}>
|
||||||
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
|
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
|
||||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
|
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
|
||||||
@@ -192,7 +223,7 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
stickySectionHeadersEnabled={false}
|
stickySectionHeadersEnabled={false}
|
||||||
refreshControl={
|
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 {
|
import {
|
||||||
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
|
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useTheme } from '../services/ThemeContext';
|
import { useTheme } from '../services/ThemeContext';
|
||||||
@@ -12,8 +12,28 @@ import { File } from 'expo-file-system';
|
|||||||
import TopBar from '../components/TopBar';
|
import TopBar from '../components/TopBar';
|
||||||
import PhotoGrid from '../components/PhotoGrid';
|
import PhotoGrid from '../components/PhotoGrid';
|
||||||
import MediaViewer from '../components/MediaViewer';
|
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 { 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 mergeMediaAcrossServices = (items) => {
|
||||||
const byFilename = new Map();
|
const byFilename = new Map();
|
||||||
const noFilename = [];
|
const noFilename = [];
|
||||||
@@ -36,6 +56,15 @@ const mergeMediaAcrossServices = (items) => {
|
|||||||
if (!existing.local_path && item.local_path) {
|
if (!existing.local_path && item.local_path) {
|
||||||
existing.local_path = item.local_path;
|
existing.local_path = item.local_path;
|
||||||
}
|
}
|
||||||
|
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 {
|
} else {
|
||||||
byFilename.set(key, { ...item, sources: [item.source_service] });
|
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.sources.includes(src)) existing.sources.push(src);
|
||||||
}
|
}
|
||||||
if (!existing.local_path && item.local_path) existing.local_path = item.local_path;
|
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 {
|
} else {
|
||||||
bySourceId.set(key, item);
|
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 HomeScreen = ({ navigation, route }) => {
|
||||||
const userId = route?.params?.userId;
|
const userId = route?.params?.userId;
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -75,6 +138,21 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const [googleSessionId, setGoogleSessionId] = useState(null);
|
const [googleSessionId, setGoogleSessionId] = useState(null);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [importing, setImporting] = 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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -84,9 +162,9 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) loadAllMedia();
|
if (currentUser) loadAllMedia();
|
||||||
}, [currentUser]);
|
}, [currentUser, linkedAccounts]);
|
||||||
|
|
||||||
useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]);
|
useEffect(() => { filterMedia(); }, [selectedFilter, allMedia, sortMode]);
|
||||||
|
|
||||||
const loadUserData = async () => {
|
const loadUserData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -94,6 +172,8 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
||||||
setLinkedAccounts(accounts);
|
setLinkedAccounts(accounts);
|
||||||
|
const albums = await DatabaseService.getUserAlbums(userId);
|
||||||
|
setUserAlbums(albums);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading user data:', err);
|
console.error('Error loading user data:', err);
|
||||||
}
|
}
|
||||||
@@ -108,6 +188,8 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const deviceResult = await DeviceMediaService.getPhotos(100);
|
const deviceResult = await DeviceMediaService.getPhotos(100);
|
||||||
if (deviceResult.mediaItems.length) {
|
if (deviceResult.mediaItems.length) {
|
||||||
newMedia.push(...deviceResult.mediaItems);
|
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);
|
setDeviceEndCursor(deviceResult.endCursor);
|
||||||
setDeviceHasMore(deviceResult.hasNextPage);
|
setDeviceHasMore(deviceResult.hasNextPage);
|
||||||
@@ -118,20 +200,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
try {
|
try {
|
||||||
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
||||||
if (googleMedia?.length) {
|
if (googleMedia?.length) {
|
||||||
const validated = googleMedia.map((item) => {
|
newMedia.push(...normalizeGoogleMedia(googleMedia));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('DB Google media fetch error:', 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 importGooglePhotos = async () => {
|
||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
if (!googleAccount) {
|
if (!googleAccount) {
|
||||||
@@ -176,10 +250,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const data = googleAccount.account_data || {};
|
const data = googleAccount.account_data || {};
|
||||||
const googleAuth = new GoogleAUTH();
|
const googleAuth = buildGoogleAuth(data);
|
||||||
googleAuth.accessToken = data.accessToken || null;
|
|
||||||
googleAuth.refreshToken = data.refreshToken || null;
|
|
||||||
googleAuth.expiresAt = data.expiresAt || 0;
|
|
||||||
|
|
||||||
let result = await googleAuth.pickPhotos();
|
let result = await googleAuth.pickPhotos();
|
||||||
|
|
||||||
@@ -199,9 +270,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logError('Import Google Photos', result.error.message);
|
logError('Import Google Photos', result.error.message);
|
||||||
} else if (result?.mediaItems?.length) {
|
} else if (result?.mediaItems?.length) {
|
||||||
for (const item of result.mediaItems) {
|
await Promise.all(result.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item)));
|
||||||
await DatabaseService.saveMediaItem(userId, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
setGoogleNextPage(result.nextPageToken || null);
|
setGoogleNextPage(result.nextPageToken || null);
|
||||||
setGoogleSessionId(result.sessionId || null);
|
setGoogleSessionId(result.sessionId || null);
|
||||||
@@ -234,8 +303,84 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
(m.sources && m.sources.includes(selectedFilter))
|
(m.sources && m.sources.includes(selectedFilter))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0));
|
setFilteredMedia(MediaMetadataService.sortMediaItems(filtered, sortMode));
|
||||||
setFilteredMedia(filtered);
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const loadMorePhotos = async () => {
|
||||||
@@ -259,17 +404,12 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
if (googleAccount) {
|
if (googleAccount) {
|
||||||
const data = googleAccount.account_data || {};
|
const data = googleAccount.account_data || {};
|
||||||
const googleAuth = new GoogleAUTH();
|
const googleAuth = buildGoogleAuth(data);
|
||||||
googleAuth.accessToken = data.accessToken || null;
|
|
||||||
googleAuth.refreshToken = data.refreshToken || null;
|
|
||||||
googleAuth.expiresAt = data.expiresAt || 0;
|
|
||||||
|
|
||||||
const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId);
|
const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId);
|
||||||
if (photos?.mediaItems?.length) {
|
if (photos?.mediaItems?.length) {
|
||||||
moreMedia.push(...photos.mediaItems);
|
moreMedia.push(...photos.mediaItems);
|
||||||
for (const item of photos.mediaItems) {
|
await Promise.all(photos.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item)));
|
||||||
await DatabaseService.saveMediaItem(userId, item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setGoogleNextPage(photos?.nextPageToken || null);
|
setGoogleNextPage(photos?.nextPageToken || null);
|
||||||
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
|
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 getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
|
||||||
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
const getServiceName = (type) => SERVICE_NAMES[type] || type;
|
||||||
|
|
||||||
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
|
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 }]}>
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<TopBar
|
<TopBar
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onSearchPress={() => navigation.navigate('Search')}
|
onSearchPress={() => navigation.navigate('Search', { userId })}
|
||||||
onProfilePress={() => navigation.navigate('Settings', { userId })}
|
onProfilePress={() => navigation.navigate('Settings', { userId })}
|
||||||
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterChips />
|
<FilterChips />
|
||||||
|
|
||||||
|
<MediaSortControls value={sortMode} onChange={setSortMode} />
|
||||||
|
|
||||||
|
<SelectionToolbar
|
||||||
|
visible={selectionMode}
|
||||||
|
selectedCount={selectedKeys.size}
|
||||||
|
onAddToAlbum={() => setAlbumModalVisible(true)}
|
||||||
|
onClear={clearSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
{hasGoogleAccount && (
|
{hasGoogleAccount && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.importButton, { backgroundColor: theme.primary }]}
|
style={[styles.importButton, { backgroundColor: theme.primary }]}
|
||||||
@@ -372,18 +521,23 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.retryButton, { backgroundColor: theme.primary }]}
|
style={[styles.retryButton, { backgroundColor: theme.primary }]}
|
||||||
onPress={() => { setRefreshing(true); loadAllMedia(); }}
|
onPress={refreshMedia}
|
||||||
>
|
>
|
||||||
<Text style={styles.retryButtonText}>Refresh</Text>
|
<Text style={styles.retryButtonText}>Refresh</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<PhotoGrid
|
<PhotoGrid
|
||||||
data={filteredMedia}
|
data={sortMode === 'date_taken' ? [] : filteredMedia}
|
||||||
|
timelineSections={timelineSections}
|
||||||
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
|
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
|
||||||
|
onLongPressItem={onLongPressItem}
|
||||||
|
onToggleSelect={toggleSelect}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
onEndReached={loadMorePhotos}
|
onEndReached={loadMorePhotos}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
|
<RefreshControl refreshing={refreshing} onRefresh={refreshMedia} />
|
||||||
}
|
}
|
||||||
ListFooterComponent={loadingMore ? (
|
ListFooterComponent={loadingMore ? (
|
||||||
<View style={{ padding: 16, alignItems: 'center' }}>
|
<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
|
<MediaViewer
|
||||||
visible={viewerVisible}
|
visible={viewerVisible}
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
allMedia={filteredMedia}
|
allMedia={filteredMedia}
|
||||||
onClose={() => setViewerVisible(false)}
|
onClose={() => setViewerVisible(false)}
|
||||||
|
resolveNasMetadata={resolveNasMetadata}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTheme } from '../services/ThemeContext';
|
|||||||
import DatabaseService from '../services/DatabaseService';
|
import DatabaseService from '../services/DatabaseService';
|
||||||
|
|
||||||
const APP_ICON = require('../../assets/icon.png');
|
const APP_ICON = require('../../assets/icon.png');
|
||||||
|
const isBlank = (value) => !value?.trim();
|
||||||
|
|
||||||
const LoginScreen = ({ navigation }) => {
|
const LoginScreen = ({ navigation }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -15,7 +16,7 @@ const LoginScreen = ({ navigation }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!identifier.trim() || !password.trim()) {
|
if (isBlank(identifier) || isBlank(password)) {
|
||||||
Alert.alert('Error', 'Please enter your username/email and password');
|
Alert.alert('Error', 'Please enter your username/email and password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
|
StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
|
||||||
ActivityIndicator, Switch, TextInput,
|
ActivityIndicator, Switch, TextInput,
|
||||||
@@ -10,9 +10,27 @@ import NASAUTH from '../AUTH/NASAUTH';
|
|||||||
import NASSetupInstructions from '../components/NASSetupInstructions';
|
import NASSetupInstructions from '../components/NASSetupInstructions';
|
||||||
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
|
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 OnboardingScreen = ({ navigation, route }) => {
|
||||||
const { userId } = route.params;
|
const userId = route?.params?.userId;
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId == null) {
|
||||||
|
navigation.replace('Login');
|
||||||
|
}
|
||||||
|
}, [userId, navigation]);
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -36,17 +54,11 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
const [autoBackupServices, setAutoBackupServices] = useState([]);
|
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 getConnectedServices = () => {
|
||||||
const services = [];
|
const services = [];
|
||||||
if (googleEnabled && googleConnected) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' });
|
if (googleEnabled && googleConnected) services.push(SERVICES.GOOGLE_PHOTOS);
|
||||||
if (iosEnabled && iosConnected) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' });
|
if (iosEnabled && iosConnected) services.push(SERVICES.ICLOUD_PHOTOS);
|
||||||
if (nasEnabled && nasConnected) services.push({ name: 'NAS Storage', type: 'NAS', icon: '💾' });
|
if (nasEnabled && nasConnected) services.push(SERVICES.NAS);
|
||||||
return services;
|
return services;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,14 +116,15 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setNasHealth('checking');
|
setNasHealth('checking');
|
||||||
|
const parsedNasPort = parseInt(nasPort, 10) || 22;
|
||||||
const nasAuth = new NASAUTH();
|
const nasAuth = new NASAUTH();
|
||||||
await nasAuth.authenticate(
|
await nasAuth.authenticate(
|
||||||
nasHost.trim(), parseInt(nasPort) || 22,
|
nasHost.trim(), parsedNasPort,
|
||||||
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
||||||
);
|
);
|
||||||
await DatabaseService.linkAccount(userId, 'NAS', {
|
await DatabaseService.linkAccount(userId, 'NAS', {
|
||||||
host: nasHost.trim(),
|
host: nasHost.trim(),
|
||||||
port: parseInt(nasPort) || 22,
|
port: parsedNasPort,
|
||||||
username: nasUsername.trim(),
|
username: nasUsername.trim(),
|
||||||
photoFolder: nasPhotoFolder.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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<View style={styles.progressBar}>
|
<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 key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {steps.length}</Text>
|
<Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {ONBOARDING_STEPS.length}</Text>
|
||||||
<Text style={[styles.title, { color: theme.text }]}>{steps[currentStep].title}</Text>
|
<Text style={[styles.title, { color: theme.text }]}>{ONBOARDING_STEPS[currentStep].title}</Text>
|
||||||
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
|
<Text style={[styles.description, { color: theme.textSecondary }]}>{ONBOARDING_STEPS[currentStep].description}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
@@ -332,7 +353,7 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
>
|
>
|
||||||
{loading ? <ActivityIndicator color="#fff" /> : (
|
{loading ? <ActivityIndicator color="#fff" /> : (
|
||||||
<Text style={styles.nextButtonText}>
|
<Text style={styles.nextButtonText}>
|
||||||
{currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
|
{currentStep === ONBOARDING_STEPS.length - 1 ? 'Get Started' : 'Next'}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useTheme } from '../services/ThemeContext';
|
|||||||
import DatabaseService from '../services/DatabaseService';
|
import DatabaseService from '../services/DatabaseService';
|
||||||
|
|
||||||
const APP_ICON = require('../../assets/icon.png');
|
const APP_ICON = require('../../assets/icon.png');
|
||||||
|
const isBlank = (value) => !value?.trim();
|
||||||
|
const isEmailValid = (value) => value.includes('@') && value.includes('.');
|
||||||
|
|
||||||
const RegisterScreen = ({ navigation }) => {
|
const RegisterScreen = ({ navigation }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -17,11 +19,11 @@ const RegisterScreen = ({ navigation }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleRegister = async () => {
|
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 (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 (isBlank(email)) { Alert.alert('Error', 'Please enter an email'); return; }
|
||||||
if (!email.includes('@') || !email.includes('.')) { Alert.alert('Error', 'Please enter a valid email address'); return; }
|
if (!isEmailValid(email)) { Alert.alert('Error', 'Please enter a valid email address'); return; }
|
||||||
if (!password.trim()) { Alert.alert('Error', 'Please enter a password'); 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.length < 6) { Alert.alert('Error', 'Password must be at least 6 characters'); return; }
|
||||||
if (password !== confirmPassword) { Alert.alert('Error', 'Passwords do not match'); return; }
|
if (password !== confirmPassword) { Alert.alert('Error', 'Passwords do not match'); return; }
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ const SearchScreen = ({ route }) => {
|
|||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Text style={styles.emptyIcon}>🔍</Text>
|
<Text style={styles.emptyIcon}>🔍</Text>
|
||||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
|
|||||||
import NASAUTH from '../AUTH/NASAUTH';
|
import NASAUTH from '../AUTH/NASAUTH';
|
||||||
import GoogleAUTH from '../AUTH/GoogleAUTH';
|
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 SettingsScreen = ({ navigation, route }) => {
|
||||||
const userId = route?.params?.userId;
|
const userId = route?.params?.userId;
|
||||||
const { theme, themeName, setThemeName } = useTheme();
|
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) => {
|
const recheckHealth = async (acc) => {
|
||||||
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' }));
|
updateServiceHealth(acc.service_type, 'checking');
|
||||||
const status = await checkSingleHealth(acc);
|
const status = await checkSingleHealth(acc);
|
||||||
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
|
updateServiceHealth(acc.service_type, status);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleService = async (accountId, currentEnabled) => {
|
const toggleService = async (accountId, currentEnabled) => {
|
||||||
@@ -188,13 +200,14 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const nasAuth = new NASAUTH();
|
const nasAuth = new NASAUTH();
|
||||||
|
const parsedNasPort = parseInt(nasPort, 10) || 22;
|
||||||
await nasAuth.authenticate(
|
await nasAuth.authenticate(
|
||||||
nasHost.trim(), parseInt(nasPort) || 22,
|
nasHost.trim(), parsedNasPort,
|
||||||
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
||||||
);
|
);
|
||||||
await DatabaseService.linkAccount(userId, 'NAS', {
|
await DatabaseService.linkAccount(userId, 'NAS', {
|
||||||
host: nasHost.trim(),
|
host: nasHost.trim(),
|
||||||
port: parseInt(nasPort) || 22,
|
port: parsedNasPort,
|
||||||
username: nasUsername.trim(),
|
username: nasUsername.trim(),
|
||||||
photoFolder: nasPhotoFolder.trim(),
|
photoFolder: nasPhotoFolder.trim(),
|
||||||
});
|
});
|
||||||
@@ -216,9 +229,9 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getServiceIcon = (type) =>
|
const getServiceIcon = (type) =>
|
||||||
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
(SERVICE_ICONS[type] || '📱');
|
||||||
const getServiceName = (type) =>
|
const getServiceName = (type) =>
|
||||||
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
(SERVICE_NAMES[type] || type);
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
|
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
|
||||||
@@ -379,11 +392,7 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
|
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
|
||||||
New photos from your device will be automatically uploaded to the selected services.
|
New photos from your device will be automatically uploaded to the selected services.
|
||||||
</Text>
|
</Text>
|
||||||
{[
|
{AUTO_BACKUP_SERVICES.map((svc) => {
|
||||||
{ type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' },
|
|
||||||
{ type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' },
|
|
||||||
{ type: 'NAS', name: 'NAS Storage', icon: '💾' },
|
|
||||||
].map((svc) => {
|
|
||||||
const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled);
|
const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -417,7 +426,9 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
style={[styles.checkRow, { borderBottomWidth: 0 }]}
|
style={[styles.checkRow, { borderBottomWidth: 0 }]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setAutoBackup([]);
|
setAutoBackup([]);
|
||||||
DatabaseService.setAutoBackupServices(userId, []);
|
DatabaseService.setAutoBackupServices(userId, []).catch((err) => {
|
||||||
|
console.error('Error saving auto backup:', err);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|||||||
+257
-35
@@ -1,9 +1,55 @@
|
|||||||
import * as SQLite from 'expo-sqlite';
|
import * as SQLite from 'expo-sqlite';
|
||||||
import CredentialEncryption from './CredentialEncryption';
|
import CredentialEncryption from './CredentialEncryption';
|
||||||
|
import MediaMetadataService from './MediaMetadataService';
|
||||||
import { logError } from './ErrorHandler';
|
import { logError } from './ErrorHandler';
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
let initPromise = 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 = {
|
const DatabaseService = {
|
||||||
initDatabase: async () => {
|
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 auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {}
|
||||||
try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
|
try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
|
||||||
|
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');
|
console.log('Database initialized successfully');
|
||||||
return true;
|
return true;
|
||||||
@@ -84,6 +155,11 @@ const DatabaseService = {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
runMediaInsert: async (params) => {
|
||||||
|
const result = await db.runAsync(MEDIA_INSERT_SQL, buildMediaInsertParams(params));
|
||||||
|
return result.lastInsertRowId;
|
||||||
|
},
|
||||||
|
|
||||||
createUser: async (username, email, password) => {
|
createUser: async (username, email, password) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return null;
|
if (!(await DatabaseService.ensureDb())) return null;
|
||||||
@@ -132,7 +208,7 @@ const DatabaseService = {
|
|||||||
getUserById: async (userId) => {
|
getUserById: async (userId) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return null;
|
if (!(await DatabaseService.ensureDb())) return null;
|
||||||
if (!userId && userId !== 0) {
|
if (!hasValue(userId)) {
|
||||||
logError('Get User', 'User ID is required');
|
logError('Get User', 'User ID is required');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -175,7 +251,7 @@ const DatabaseService = {
|
|||||||
getLinkedAccounts: async (userId) => {
|
getLinkedAccounts: async (userId) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return [];
|
if (!(await DatabaseService.ensureDb())) return [];
|
||||||
if (!userId && userId !== 0) {
|
if (!hasValue(userId)) {
|
||||||
logError('Get Linked Accounts', 'User ID is required');
|
logError('Get Linked Accounts', 'User ID is required');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -183,10 +259,7 @@ const DatabaseService = {
|
|||||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
|
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
return rows.map((row) => ({
|
return rows.map(withDecryptedAccount);
|
||||||
...row,
|
|
||||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Get Linked Accounts', error);
|
logError('Get Linked Accounts', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -201,7 +274,7 @@ const DatabaseService = {
|
|||||||
[userId, serviceType]
|
[userId, serviceType]
|
||||||
);
|
);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
|
return withDecryptedAccount(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Get Linked Account By Service', error);
|
logError('Get Linked Account By Service', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -226,7 +299,7 @@ const DatabaseService = {
|
|||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
|
return withDecryptedAccount(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Get Default Service', error);
|
logError('Get Default Service', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -261,10 +334,7 @@ const DatabaseService = {
|
|||||||
'SELECT * FROM linked_accounts WHERE user_id = ?',
|
'SELECT * FROM linked_accounts WHERE user_id = ?',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
return rows.map((row) => ({
|
return rows.map(withDecryptedAccount);
|
||||||
...row,
|
|
||||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Get All Linked Accounts', error);
|
logError('Get All Linked Accounts', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -278,13 +348,17 @@ const DatabaseService = {
|
|||||||
logError('Add Media', 'userId and sourceService are required');
|
logError('Add Media', 'userId and sourceService are required');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const result = await db.runAsync(
|
return await DatabaseService.runMediaInsert({
|
||||||
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
|
userId,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
sourceService,
|
||||||
[userId, sourceService, sourceId || null, title || null, description || null,
|
sourceId,
|
||||||
localPath || null, remotePath || null, mediaType || null, createdDate || null]
|
title,
|
||||||
);
|
description,
|
||||||
return result.lastInsertRowId;
|
localPath,
|
||||||
|
remotePath,
|
||||||
|
mediaType,
|
||||||
|
createdDate,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Add Media', error);
|
logError('Add Media', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -297,22 +371,59 @@ const DatabaseService = {
|
|||||||
if (!userId || !item?.source_service)
|
if (!userId || !item?.source_service)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
const incomingMeta = normalizeMetaString(item);
|
||||||
|
const mergedFromIncoming = incomingMeta
|
||||||
|
? MediaMetadataService.mergeMetadataJson('{}', incomingMeta)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (item.source_id) {
|
if (item.source_id) {
|
||||||
const existing = await db.getFirstAsync(
|
const existing = await db.getFirstAsync(
|
||||||
'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
|
'SELECT * FROM media WHERE user_id = ? AND source_id = ?',
|
||||||
[userId, item.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(
|
const insertRow = { ...item, userId, metadata_json: mergedFromIncoming };
|
||||||
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
|
const resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow);
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
if (resolvedInsert) insertRow.created_date = resolvedInsert;
|
||||||
[userId, item.source_service, item.source_id || null, item.title || null,
|
return await DatabaseService.runMediaInsert(insertRow);
|
||||||
item.description || null, item.local_path || null, item.remote_path || null,
|
|
||||||
item.media_type || null, item.created_date || null]
|
|
||||||
);
|
|
||||||
return result.lastInsertRowId;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Save Media Item', error);
|
logError('Save Media Item', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -322,12 +433,12 @@ const DatabaseService = {
|
|||||||
getMediaByUser: async (userId) => {
|
getMediaByUser: async (userId) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return [];
|
if (!(await DatabaseService.ensureDb())) return [];
|
||||||
if (!userId && userId !== 0) {
|
if (!hasValue(userId)) {
|
||||||
logError('Get Media', 'User ID is required');
|
logError('Get Media', 'User ID is required');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return await db.getAllAsync(
|
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]
|
[userId]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -352,17 +463,128 @@ const DatabaseService = {
|
|||||||
searchMedia: async (userId, query) => {
|
searchMedia: async (userId, query) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return [];
|
if (!(await DatabaseService.ensureDb())) return [];
|
||||||
const searchTerm = `%${query}%`;
|
const q = (query || '').trim();
|
||||||
return await db.getAllAsync(
|
if (!q) return [];
|
||||||
'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
|
const searchTerm = `%${q}%`;
|
||||||
[userId, searchTerm, searchTerm]
|
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) {
|
} catch (error) {
|
||||||
logError('Search Media', error);
|
logError('Search Media', error);
|
||||||
return [];
|
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) => {
|
getAutoBackupServices: async (userId) => {
|
||||||
try {
|
try {
|
||||||
if (!(await DatabaseService.ensureDb())) return [];
|
if (!(await DatabaseService.ensureDb())) return [];
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
|
import MediaMetadataService from './MediaMetadataService';
|
||||||
|
|
||||||
|
const EMPTY_MEDIA_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
|
||||||
|
|
||||||
const DeviceMediaService = {
|
const DeviceMediaService = {
|
||||||
requestPermission: async () => {
|
requestPermission: async () => {
|
||||||
@@ -13,7 +16,7 @@ const DeviceMediaService = {
|
|||||||
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
|
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
|
||||||
const hasPermission = await DeviceMediaService.requestPermission();
|
const hasPermission = await DeviceMediaService.requestPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
return { mediaItems: [], endCursor: null, hasNextPage: false };
|
return EMPTY_MEDIA_PAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaTypeFilter =
|
const mediaTypeFilter =
|
||||||
@@ -30,21 +33,9 @@ const DeviceMediaService = {
|
|||||||
|
|
||||||
const result = await MediaLibrary.getAssetsAsync(options);
|
const result = await MediaLibrary.getAssetsAsync(options);
|
||||||
|
|
||||||
const mediaItems = result.assets.map((asset) => ({
|
const mediaItems = await Promise.all(
|
||||||
source_service: 'DEVICE',
|
result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a))
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaItems,
|
mediaItems,
|
||||||
@@ -71,11 +62,11 @@ const DeviceMediaService = {
|
|||||||
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
|
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
|
||||||
const hasPermission = await DeviceMediaService.requestPermission();
|
const hasPermission = await DeviceMediaService.requestPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
return { mediaItems: [], endCursor: null, hasNextPage: false };
|
return EMPTY_MEDIA_PAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
const album = await MediaLibrary.getAlbumAsync(albumId);
|
const album = await MediaLibrary.getAlbumAsync(albumId);
|
||||||
if (!album) return { mediaItems: [], endCursor: null, hasNextPage: false };
|
if (!album) return EMPTY_MEDIA_PAGE;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
first: pageSize,
|
first: pageSize,
|
||||||
@@ -86,21 +77,9 @@ const DeviceMediaService = {
|
|||||||
|
|
||||||
const result = await MediaLibrary.getAssetsAsync(options);
|
const result = await MediaLibrary.getAssetsAsync(options);
|
||||||
|
|
||||||
const mediaItems = result.assets.map((asset) => ({
|
const mediaItems = await Promise.all(
|
||||||
source_service: 'DEVICE',
|
result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a))
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaItems,
|
mediaItems,
|
||||||
|
|||||||
Reference in new issue
Block a user