mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
feat: update NAS configuration and enhance UI components for better user experience
This commit is contained in:
1 parent
8e6fa5270b
commit
6167418074
23 files changed
+2147
-702
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.config/
|
||||||
|
*.log
|
||||||
|
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
const readline = require('readline');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const config = require('./config');
|
||||||
|
const passwordManager = require('./password_manager');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ask(question) {
|
||||||
|
return new Promise(resolve => rl.question(question, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreen() {
|
||||||
|
process.stdout.write(process.platform === 'win32' ? '\x1Bc' : '\033[2J\033[H');
|
||||||
|
}
|
||||||
|
|
||||||
|
let detectedPlatform = null;
|
||||||
|
|
||||||
|
function printBanner() {
|
||||||
|
const user = os.userInfo().username;
|
||||||
|
const photosPath = config.getPhotosPath() || 'NOT SET';
|
||||||
|
const shortPath = photosPath.length > 25 ? '...' + photosPath.slice(-22) : photosPath;
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('╔══════════════════════════════════════════════╗');
|
||||||
|
console.log('║ NAS Photo Server - CLI Client ║');
|
||||||
|
console.log('╠══════════════════════════════════════════════╣');
|
||||||
|
console.log(`║ User: ${user.padEnd(32)}║`);
|
||||||
|
|
||||||
|
if (passwordManager.hasStoredPassword()) {
|
||||||
|
console.log('║ Password: [STORED - Press P to view] ║');
|
||||||
|
} else {
|
||||||
|
console.log('║ Password: [NOT SET] ║');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`║ Platform: ${(detectedPlatform || 'Unknown').padEnd(32)}║`);
|
||||||
|
console.log(`║ Photos: ${shortPath.padEnd(32)}║`);
|
||||||
|
console.log(`║ Port: ${String(config.DEFAULT_PORT).padEnd(32)}║`);
|
||||||
|
console.log('╚══════════════════════════════════════════════╝');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectOrAskPlatform() {
|
||||||
|
const plat = process.platform;
|
||||||
|
if (plat === 'win32') {
|
||||||
|
detectedPlatform = 'Windows';
|
||||||
|
console.log('[*] Detected platform: Windows');
|
||||||
|
return 'windows';
|
||||||
|
} else if (plat === 'linux') {
|
||||||
|
detectedPlatform = 'Linux (Ubuntu)';
|
||||||
|
console.log('[*] Detected platform: Linux (assuming Ubuntu)');
|
||||||
|
return 'linux';
|
||||||
|
} else {
|
||||||
|
console.log('');
|
||||||
|
console.log('What operating system are you running?');
|
||||||
|
console.log(' [1] Windows');
|
||||||
|
console.log(' [2] Linux (Ubuntu)');
|
||||||
|
const choice = await ask('Select (1/2): ');
|
||||||
|
if (choice.trim() === '1') {
|
||||||
|
detectedPlatform = 'Windows';
|
||||||
|
return 'windows';
|
||||||
|
} else {
|
||||||
|
detectedPlatform = 'Linux (Ubuntu)';
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDependencies() {
|
||||||
|
console.log('[*] Checking dependencies...');
|
||||||
|
try {
|
||||||
|
execSync('node --version', { stdio: 'ignore' });
|
||||||
|
console.log(' [OK] Node.js found');
|
||||||
|
} catch {
|
||||||
|
console.log(' [!!] Node.js not found. Please install Node.js.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeModules = path.join(__dirname, 'node_modules');
|
||||||
|
if (!fs.existsSync(nodeModules)) {
|
||||||
|
console.log('[*] Installing dependencies...');
|
||||||
|
try {
|
||||||
|
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
|
||||||
|
console.log(' [OK] Dependencies installed');
|
||||||
|
} catch {
|
||||||
|
console.log(' [!!] Failed to install dependencies. Run "npm install" manually in ServerFiles/');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(' [OK] Dependencies already installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupPhotosPath(platform) {
|
||||||
|
const current = config.getPhotosPath();
|
||||||
|
if (current) {
|
||||||
|
console.log(`[*] Current photos path: ${current}`);
|
||||||
|
const change = await ask('Change photos path? (Y/N): ');
|
||||||
|
if (change.trim().toUpperCase() !== 'Y') return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultPath;
|
||||||
|
if (platform === 'windows') {
|
||||||
|
defaultPath = path.join(os.homedir(), 'NASPhotos');
|
||||||
|
} else {
|
||||||
|
defaultPath = path.join(os.homedir(), 'nas-photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Default path: ${defaultPath}`);
|
||||||
|
const inputPath = await ask('Enter photos storage path (or press Enter for default): ');
|
||||||
|
const finalPath = inputPath.trim() || defaultPath;
|
||||||
|
|
||||||
|
if (!fs.existsSync(finalPath)) {
|
||||||
|
console.log(`[*] Creating directory: ${finalPath}`);
|
||||||
|
fs.mkdirSync(finalPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
config.setPhotosPath(finalPath);
|
||||||
|
console.log(`[OK] Photos path set to: ${finalPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePassword() {
|
||||||
|
console.log('');
|
||||||
|
console.log('╔══════════════════════════════════════════════╗');
|
||||||
|
console.log('║ Password Management ║');
|
||||||
|
console.log('╠══════════════════════════════════════════════╣');
|
||||||
|
|
||||||
|
if (passwordManager.hasStoredPassword()) {
|
||||||
|
console.log('║ A password is currently stored. ║');
|
||||||
|
console.log('║ [V] View password ║');
|
||||||
|
console.log('║ [C] Change password ║');
|
||||||
|
console.log('║ [D] Delete password ║');
|
||||||
|
console.log('║ [B] Back ║');
|
||||||
|
console.log('╚══════════════════════════════════════════════╝');
|
||||||
|
|
||||||
|
const choice = await ask('Select option: ');
|
||||||
|
switch (choice.trim().toUpperCase()) {
|
||||||
|
case 'V': {
|
||||||
|
const pw = passwordManager.retrievePassword();
|
||||||
|
if (pw) {
|
||||||
|
console.log('');
|
||||||
|
console.log(` User: ${os.userInfo().username}`);
|
||||||
|
console.log(` Password: ${pw}`);
|
||||||
|
console.log('');
|
||||||
|
} else {
|
||||||
|
console.log('[!!] Could not decrypt password. It may have been created on another machine.');
|
||||||
|
}
|
||||||
|
await ask('Press Enter to continue...');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'C': {
|
||||||
|
const newPw = await ask('Enter new password: ');
|
||||||
|
if (newPw.trim().length > 0) {
|
||||||
|
passwordManager.storePassword(newPw.trim());
|
||||||
|
console.log('[OK] Password updated.');
|
||||||
|
console.log(` New password: ${newPw.trim()}`);
|
||||||
|
} else {
|
||||||
|
console.log('[!] Empty password not saved.');
|
||||||
|
}
|
||||||
|
await ask('Press Enter to continue...');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'D': {
|
||||||
|
const confirm = await ask('Are you sure? (Y/N): ');
|
||||||
|
if (confirm.trim().toUpperCase() === 'Y') {
|
||||||
|
passwordManager.deletePassword();
|
||||||
|
console.log('[OK] Password deleted.');
|
||||||
|
}
|
||||||
|
await ask('Press Enter to continue...');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('║ No password is stored. ║');
|
||||||
|
console.log('╚══════════════════════════════════════════════╝');
|
||||||
|
const store = await ask('Do you want to store a password? (Y/N): ');
|
||||||
|
if (store.trim().toUpperCase() === 'Y') {
|
||||||
|
const pw = await ask('Enter password: ');
|
||||||
|
if (pw.trim().length > 0) {
|
||||||
|
passwordManager.storePassword(pw.trim());
|
||||||
|
console.log(`[OK] Password stored successfully.`);
|
||||||
|
console.log(` Your password: ${pw.trim()}`);
|
||||||
|
} else {
|
||||||
|
console.log('[!] Empty password not saved.');
|
||||||
|
}
|
||||||
|
await ask('Press Enter to continue...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConnectionInfo() {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
const addresses = [];
|
||||||
|
|
||||||
|
for (const [name, nets] of Object.entries(interfaces)) {
|
||||||
|
for (const net of nets) {
|
||||||
|
if (net.family === 'IPv4' && !net.internal) {
|
||||||
|
addresses.push({ name, address: net.address });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('╔══════════════════════════════════════════════╗');
|
||||||
|
console.log('║ Connection Information ║');
|
||||||
|
console.log('╠══════════════════════════════════════════════╣');
|
||||||
|
console.log(`║ User: ${os.userInfo().username.padEnd(35)}║`);
|
||||||
|
|
||||||
|
if (passwordManager.hasStoredPassword()) {
|
||||||
|
console.log('║ Pass: [STORED] ║');
|
||||||
|
} else {
|
||||||
|
console.log('║ Pass: [NOT SET] ║');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`║ Port: ${String(config.DEFAULT_PORT).padEnd(35)}║`);
|
||||||
|
console.log('║ ║');
|
||||||
|
|
||||||
|
if (addresses.length > 0) {
|
||||||
|
console.log('║ Network Addresses: ║');
|
||||||
|
for (const addr of addresses) {
|
||||||
|
const line = ` ${addr.name}: http://${addr.address}:${config.DEFAULT_PORT}`;
|
||||||
|
console.log(`║${line.padEnd(46)}║`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('║ No network interfaces found ║');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('╚══════════════════════════════════════════════╝');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServerInteractive() {
|
||||||
|
const photosPath = config.getPhotosPath();
|
||||||
|
if (!photosPath) {
|
||||||
|
console.log('[!!] Photos path not set. Please configure it first.');
|
||||||
|
await ask('Press Enter to continue...');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portInput = await ask(`Enter port (default ${config.DEFAULT_PORT}): `);
|
||||||
|
const port = parseInt(portInput.trim()) || config.DEFAULT_PORT;
|
||||||
|
|
||||||
|
console.log(`[*] Starting NAS server on port ${port}...`);
|
||||||
|
console.log(`[*] Photos path: ${photosPath}`);
|
||||||
|
console.log('[*] Press Ctrl+C to stop the server.');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const { startServer } = require('./server');
|
||||||
|
startServer(port);
|
||||||
|
return true; // Server takes over the process
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mainMenu(platform) {
|
||||||
|
while (true) {
|
||||||
|
clearScreen();
|
||||||
|
printBanner();
|
||||||
|
console.log(' [1] Start NAS Server');
|
||||||
|
console.log(' [2] Configure Photos Path');
|
||||||
|
console.log(' [3] Password Management (P)');
|
||||||
|
console.log(' [4] Show Connection Info');
|
||||||
|
console.log(' [5] Exit');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const choice = await ask('Select option: ');
|
||||||
|
|
||||||
|
switch (choice.trim().toUpperCase()) {
|
||||||
|
case '1': {
|
||||||
|
const started = await startServerInteractive();
|
||||||
|
if (started) return; // Server is now running
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '2':
|
||||||
|
await setupPhotosPath(platform);
|
||||||
|
break;
|
||||||
|
case '3':
|
||||||
|
case 'P':
|
||||||
|
await handlePassword();
|
||||||
|
break;
|
||||||
|
case '4':
|
||||||
|
showConnectionInfo();
|
||||||
|
await ask('\nPress Enter to continue...');
|
||||||
|
break;
|
||||||
|
case '5':
|
||||||
|
console.log('Goodbye!');
|
||||||
|
rl.close();
|
||||||
|
process.exit(0);
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
clearScreen();
|
||||||
|
console.log('Welcome to NAS Photo Server Setup');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const platform = await detectOrAskPlatform();
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (!checkDependencies()) {
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// First-time setup: photos path
|
||||||
|
if (!config.getPhotosPath()) {
|
||||||
|
console.log('[!] No photos path configured. Let\'s set one up.');
|
||||||
|
await setupPhotosPath(platform);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask about password on first run
|
||||||
|
if (!passwordManager.hasStoredPassword()) {
|
||||||
|
const storePw = await ask('Do you want to store a password? (Y/N): ');
|
||||||
|
if (storePw.trim().toUpperCase() === 'Y') {
|
||||||
|
const pw = await ask('Enter password: ');
|
||||||
|
if (pw.trim().length > 0) {
|
||||||
|
passwordManager.storePassword(pw.trim());
|
||||||
|
console.log(`[OK] Password stored. Your password: ${pw.trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mainMenu(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const CONFIG_DIR = path.join(__dirname, '.config');
|
||||||
|
const PHOTOS_PATH_FILE = path.join(__dirname, 'nas_config.txt');
|
||||||
|
const DB_PATH = path.join(CONFIG_DIR, 'nas.db');
|
||||||
|
const PASSWORD_FILE = path.join(CONFIG_DIR, '.credentials');
|
||||||
|
const DEFAULT_PORT = 9500;
|
||||||
|
|
||||||
|
function ensureConfigDir() {
|
||||||
|
if (!fs.existsSync(CONFIG_DIR)) {
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhotosPath() {
|
||||||
|
if (!fs.existsSync(PHOTOS_PATH_FILE)) return null;
|
||||||
|
|
||||||
|
let raw = fs.readFileSync(PHOTOS_PATH_FILE, 'utf-8');
|
||||||
|
|
||||||
|
// Strip UTF-8 BOM
|
||||||
|
raw = raw.replace(/^\uFEFF/, '');
|
||||||
|
// Strip ALL control characters (CR, LF, null, tabs, zero-width spaces, etc.)
|
||||||
|
// except forward-slash and backslash which are path separators
|
||||||
|
raw = raw.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\r\n]/g, '');
|
||||||
|
// Strip zero-width / non-breaking unicode junk that can sneak in from copy-paste
|
||||||
|
raw = raw.replace(/[\u200B\u200C\u200D\uFEFF\u00A0]/g, '');
|
||||||
|
raw = raw.trim();
|
||||||
|
|
||||||
|
// Take only the first line in case there are multiple lines
|
||||||
|
raw = raw.split('\n')[0].trim();
|
||||||
|
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// Return the path exactly as written — do NOT use path.resolve()
|
||||||
|
// because it mangles Linux paths on Windows and vice versa
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPhotosPath(p) {
|
||||||
|
// Clean and write as plain UTF-8 with unix line ending, no BOM
|
||||||
|
const clean = p.replace(/[\r\n\x00]/g, '').trim();
|
||||||
|
fs.writeFileSync(PHOTOS_PATH_FILE, clean, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CONFIG_DIR,
|
||||||
|
PHOTOS_PATH_FILE,
|
||||||
|
DB_PATH,
|
||||||
|
PASSWORD_FILE,
|
||||||
|
DEFAULT_PORT,
|
||||||
|
ensureConfigDir,
|
||||||
|
getPhotosPath,
|
||||||
|
setPhotosPath,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
if (db) return db;
|
||||||
|
config.ensureConfigDir();
|
||||||
|
db = new Database(config.DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
initSchema();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSchema() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
filepath TEXT NOT NULL,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
indexed_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
UNIQUE(user_id, sha256_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS albums (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS album_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
album_id INTEGER NOT NULL,
|
||||||
|
photo_id INTEGER NOT NULL,
|
||||||
|
added_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(album_id, photo_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_hash ON photos(sha256_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_albums_user ON albums(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_album_photos_album ON album_photos(album_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUser(username) {
|
||||||
|
const d = getDb();
|
||||||
|
const existing = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const result = d.prepare('INSERT INTO users (username) VALUES (?)').run(username);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashFile(filePath) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeType(ext) {
|
||||||
|
const map = {
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||||
|
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
|
||||||
|
'.heic': 'image/heic', '.heif': 'image/heif',
|
||||||
|
'.tiff': 'image/tiff', '.tif': 'image/tiff', '.svg': 'image/svg+xml',
|
||||||
|
};
|
||||||
|
return map[ext.toLowerCase()] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.heif', '.tiff', '.tif', '.svg'];
|
||||||
|
|
||||||
|
function indexPhotosForUser(username, photosBasePath) {
|
||||||
|
const d = getDb();
|
||||||
|
const userId = ensureUser(username);
|
||||||
|
|
||||||
|
const userDir = path.join(photosBasePath, username);
|
||||||
|
if (!fs.existsSync(userDir)) {
|
||||||
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
|
return { indexed: 0, skipped: 0, errors: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = getAllMediaFiles(userDir);
|
||||||
|
|
||||||
|
let indexed = 0, skipped = 0, errors = 0;
|
||||||
|
|
||||||
|
const insertStmt = d.prepare(`
|
||||||
|
INSERT OR IGNORE INTO photos (user_id, filename, filepath, sha256_hash, file_size, mime_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const fullPath of files) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
const hash = hashFile(fullPath);
|
||||||
|
const ext = path.extname(fullPath);
|
||||||
|
const mime = getMimeType(ext);
|
||||||
|
const filename = path.basename(fullPath);
|
||||||
|
|
||||||
|
const result = insertStmt.run(userId, filename, fullPath, hash, stat.size, mime);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
indexed++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { indexed, skipped, errors, total: files.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively find all media files in a directory
|
||||||
|
function getAllMediaFiles(dir) {
|
||||||
|
let results = [];
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results = results.concat(getAllMediaFiles(fullPath));
|
||||||
|
} else {
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
if (SUPPORTED_EXTS.includes(ext)) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhotoIndex(username) {
|
||||||
|
const d = getDb();
|
||||||
|
const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
|
if (!user) return [];
|
||||||
|
return d.prepare(`
|
||||||
|
SELECT id, filename, sha256_hash, file_size, mime_type, indexed_at
|
||||||
|
FROM photos WHERE user_id = ? ORDER BY indexed_at DESC
|
||||||
|
`).all(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhotoById(photoId) {
|
||||||
|
const d = getDb();
|
||||||
|
return d.prepare(`
|
||||||
|
SELECT p.*, u.username FROM photos p
|
||||||
|
JOIN users u ON p.user_id = u.id
|
||||||
|
WHERE p.id = ?
|
||||||
|
`).get(photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhotoByHash(hash) {
|
||||||
|
const d = getDb();
|
||||||
|
return d.prepare(`
|
||||||
|
SELECT p.*, u.username FROM photos p
|
||||||
|
JOIN users u ON p.user_id = u.id
|
||||||
|
WHERE p.sha256_hash = ?
|
||||||
|
`).get(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllUsers() {
|
||||||
|
const d = getDb();
|
||||||
|
return d.prepare('SELECT * FROM users ORDER BY username').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOrphanedEntries(username, photosBasePath) {
|
||||||
|
const d = getDb();
|
||||||
|
const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
|
if (!user) return 0;
|
||||||
|
|
||||||
|
const photos = d.prepare('SELECT id, filepath FROM photos WHERE user_id = ?').all(user.id);
|
||||||
|
let removed = 0;
|
||||||
|
const deleteStmt = d.prepare('DELETE FROM photos WHERE id = ?');
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
if (!fs.existsSync(photo.filepath)) {
|
||||||
|
deleteStmt.run(photo.id);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePhoto(photoId) {
|
||||||
|
const d = getDb();
|
||||||
|
const photo = getPhotoById(photoId);
|
||||||
|
if (!photo) return { success: false, error: 'Photo not found' };
|
||||||
|
|
||||||
|
// Remove from disk
|
||||||
|
if (fs.existsSync(photo.filepath)) {
|
||||||
|
fs.unlinkSync(photo.filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from album_photos first (cascade might not work with all builds)
|
||||||
|
d.prepare('DELETE FROM album_photos WHERE photo_id = ?').run(photoId);
|
||||||
|
d.prepare('DELETE FROM photos WHERE id = ?').run(photoId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Album operations ───
|
||||||
|
|
||||||
|
function createAlbum(username, albumName) {
|
||||||
|
const d = getDb();
|
||||||
|
const userId = ensureUser(username);
|
||||||
|
try {
|
||||||
|
const result = d.prepare('INSERT INTO albums (user_id, name) VALUES (?, ?)').run(userId, albumName);
|
||||||
|
return { success: true, albumId: Number(result.lastInsertRowid) };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('UNIQUE')) {
|
||||||
|
return { success: false, error: 'Album already exists' };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbums(username) {
|
||||||
|
const d = getDb();
|
||||||
|
const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
const albums = d.prepare('SELECT * FROM albums WHERE user_id = ? ORDER BY created_at DESC').all(user.id);
|
||||||
|
return albums.map(album => {
|
||||||
|
const count = d.prepare('SELECT COUNT(*) as cnt FROM album_photos WHERE album_id = ?').get(album.id);
|
||||||
|
const cover = d.prepare(`
|
||||||
|
SELECT p.id, p.filename, p.sha256_hash FROM album_photos ap
|
||||||
|
JOIN photos p ON ap.photo_id = p.id
|
||||||
|
WHERE ap.album_id = ? ORDER BY ap.added_at DESC LIMIT 1
|
||||||
|
`).get(album.id);
|
||||||
|
return { ...album, photoCount: count.cnt, coverPhoto: cover || null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbumPhotos(albumId) {
|
||||||
|
const d = getDb();
|
||||||
|
return d.prepare(`
|
||||||
|
SELECT p.id, p.filename, p.sha256_hash, p.file_size, p.mime_type, p.indexed_at
|
||||||
|
FROM album_photos ap
|
||||||
|
JOIN photos p ON ap.photo_id = p.id
|
||||||
|
WHERE ap.album_id = ?
|
||||||
|
ORDER BY ap.added_at DESC
|
||||||
|
`).all(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPhotoToAlbum(albumId, photoId) {
|
||||||
|
const d = getDb();
|
||||||
|
try {
|
||||||
|
d.prepare('INSERT OR IGNORE INTO album_photos (album_id, photo_id) VALUES (?, ?)').run(albumId, photoId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhotoFromAlbum(albumId, photoId) {
|
||||||
|
const d = getDb();
|
||||||
|
d.prepare('DELETE FROM album_photos WHERE album_id = ? AND photo_id = ?').run(albumId, photoId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAlbum(albumId) {
|
||||||
|
const d = getDb();
|
||||||
|
d.prepare('DELETE FROM album_photos WHERE album_id = ?').run(albumId);
|
||||||
|
d.prepare('DELETE FROM albums WHERE id = ?').run(albumId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameAlbum(albumId, newName) {
|
||||||
|
const d = getDb();
|
||||||
|
try {
|
||||||
|
d.prepare('UPDATE albums SET name = ? WHERE id = ?').run(newName, albumId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDb() {
|
||||||
|
if (db) {
|
||||||
|
db.close();
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDb,
|
||||||
|
ensureUser,
|
||||||
|
hashFile,
|
||||||
|
indexPhotosForUser,
|
||||||
|
getPhotoIndex,
|
||||||
|
getPhotoById,
|
||||||
|
getPhotoByHash,
|
||||||
|
getAllUsers,
|
||||||
|
removeOrphanedEntries,
|
||||||
|
deletePhoto,
|
||||||
|
createAlbum,
|
||||||
|
getAlbums,
|
||||||
|
getAlbumPhotos,
|
||||||
|
addPhotoToAlbum,
|
||||||
|
removePhotoFromAlbum,
|
||||||
|
deleteAlbum,
|
||||||
|
renameAlbum,
|
||||||
|
closeDb,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
/home/user/photos
|
/home/bob/photos
|
||||||
|
|
||||||
+10
-17
@@ -1,31 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "media-app-sftp-backend",
|
"name": "nas-photo-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "SFTP Backend API for Media Aggregation App",
|
"description": "Self-contained NAS Photo Server with REST API and CLI",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js",
|
"cli": "node cli.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"dev": "node cli.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"sftp",
|
"nas",
|
||||||
"backend",
|
"photo-server",
|
||||||
"api",
|
"rest-api",
|
||||||
"express"
|
"base64"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^2.2.2",
|
"better-sqlite3": "^11.7.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^16.6.1",
|
"express": "^4.22.1"
|
||||||
"express": "^4.22.1",
|
|
||||||
"express-fileupload": "^1.4.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"node-fetch": "^2.7.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^3.0.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
const SALT_LENGTH = 32;
|
||||||
|
|
||||||
|
function deriveKey(passphrase, salt) {
|
||||||
|
return crypto.pbkdf2Sync(passphrase, salt, 100000, KEY_LENGTH, 'sha512');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMachineId() {
|
||||||
|
const os = require('os');
|
||||||
|
const raw = `${os.hostname()}-${os.platform()}-${os.arch()}-${os.userInfo().username}`;
|
||||||
|
return crypto.createHash('sha256').update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(plaintext) {
|
||||||
|
const machineKey = getMachineId();
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||||
|
const key = deriveKey(machineKey, salt);
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(plaintext, 'utf-8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return {
|
||||||
|
salt: salt.toString('hex'),
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
authTag: authTag.toString('hex'),
|
||||||
|
data: encrypted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(encObj) {
|
||||||
|
const machineKey = getMachineId();
|
||||||
|
const salt = Buffer.from(encObj.salt, 'hex');
|
||||||
|
const key = deriveKey(machineKey, salt);
|
||||||
|
const iv = Buffer.from(encObj.iv, 'hex');
|
||||||
|
const authTag = Buffer.from(encObj.authTag, 'hex');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encObj.data, 'hex', 'utf-8');
|
||||||
|
decrypted += decipher.final('utf-8');
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storePassword(password) {
|
||||||
|
config.ensureConfigDir();
|
||||||
|
const encrypted = encrypt(password);
|
||||||
|
const content = JSON.stringify(encrypted);
|
||||||
|
|
||||||
|
fs.writeFileSync(config.PASSWORD_FILE, content, { encoding: 'utf-8', mode: 0o600 });
|
||||||
|
|
||||||
|
// On Windows, hide the file with attrib
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
try {
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
execSync(`attrib +h "${config.PASSWORD_FILE}"`, { stdio: 'ignore' });
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrievePassword() {
|
||||||
|
if (!fs.existsSync(config.PASSWORD_FILE)) return null;
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(config.PASSWORD_FILE, 'utf-8');
|
||||||
|
const encObj = JSON.parse(content);
|
||||||
|
return decrypt(encObj);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStoredPassword() {
|
||||||
|
return fs.existsSync(config.PASSWORD_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePassword() {
|
||||||
|
if (fs.existsSync(config.PASSWORD_FILE)) {
|
||||||
|
fs.unlinkSync(config.PASSWORD_FILE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
storePassword,
|
||||||
|
retrievePassword,
|
||||||
|
hasStoredPassword,
|
||||||
|
deletePassword,
|
||||||
|
};
|
||||||
|
|
||||||
+326
-318
@@ -1,376 +1,384 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const util = require('util');
|
const path = require('path');
|
||||||
const { execFile } = require('child_process');
|
const crypto = require('crypto');
|
||||||
const fileUpload = require('express-fileupload');
|
const config = require('./config');
|
||||||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
const db = require('./db');
|
||||||
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const execFileAsync = util.promisify(execFile);
|
|
||||||
|
|
||||||
const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
|
|
||||||
|
|
||||||
// Read the default NAS photo directory from the config file
|
|
||||||
const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt');
|
|
||||||
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 (_) {
|
|
||||||
// Config file missing — keep the hard-coded default
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSH/SFTP shell helpers
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Express setup
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
app.use(fileUpload());
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '100mb' }));
|
||||||
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
|
||||||
const connections = new Map();
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// JWT verification middleware
|
function getPhotosPath() {
|
||||||
|
const p = config.getPhotosPath();
|
||||||
function verifyToken(req, res, next) {
|
if (!p) throw new Error('Photos path not configured. Set it in nas_config.txt');
|
||||||
const authHeader = req.headers.authorization;
|
if (!fs.existsSync(p)) {
|
||||||
const token = authHeader?.split(' ')[1];
|
fs.mkdirSync(p, { recursive: true });
|
||||||
if (!token) return res.status(401).json({ error: 'No token provided' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, SECRET_KEY);
|
|
||||||
req.connectionId = decoded.connectionId;
|
|
||||||
req.connection = connections.get(decoded.connectionId);
|
|
||||||
if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' });
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
res.status(401).json({ error: 'Invalid token: ' + err.message });
|
|
||||||
}
|
}
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── REST Endpoints ──────────────────────────────────────────────
|
function sanitizeUsername(username) {
|
||||||
|
return username.trim().replace(/[^a-zA-Z0-9_\-.]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
// Health check
|
// ─── Health ──────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
activeConnections: connections.size,
|
|
||||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the current NAS photo directory read from nas_config.txt
|
// ─── User Registration ──────────────────────────────────────
|
||||||
|
// Device sends its username; NAS registers and creates user folder
|
||||||
app.get('/api/config/photo-dir', (req, res) => {
|
|
||||||
res.json({ photoDir: DEFAULT_PHOTO_DIR });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Establish an SFTP connection (stores credentials, returns a JWT)
|
|
||||||
|
|
||||||
app.post('/api/sftp/connect', (req, res) => {
|
|
||||||
const { host, port = 22, username, password } = req.body;
|
|
||||||
if (!host || !username || !password) {
|
|
||||||
return res.status(400).json({ error: 'Missing required fields: host, username, password' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
connections.set(connectionId, { host, port, username, password, createdAt: new Date() });
|
|
||||||
|
|
||||||
const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' });
|
|
||||||
console.log(`Connection prepared: ${host}:${port} (${connectionId})`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
connectionToken: token,
|
|
||||||
message: `Connection prepared for ${host}:${port}`,
|
|
||||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// List directory contents
|
|
||||||
|
|
||||||
app.post('/api/sftp/list', verifyToken, async (req, res) => {
|
|
||||||
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
|
|
||||||
const conn = req.connection;
|
|
||||||
|
|
||||||
|
app.post('/api/register', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]);
|
const { username } = req.body;
|
||||||
const files = result.stdout
|
if (!username || typeof username !== 'string' || username.trim().length === 0) {
|
||||||
.split('\n')
|
return res.status(400).json({ error: 'Username is required' });
|
||||||
.filter(Boolean)
|
}
|
||||||
.map((line) => {
|
const sanitized = sanitizeUsername(username);
|
||||||
const parts = line.split(/\s+/);
|
const userId = db.ensureUser(sanitized);
|
||||||
return {
|
const photosPath = getPhotosPath();
|
||||||
name: parts.pop(),
|
const userDir = path.join(photosPath, sanitized);
|
||||||
isDirectory: parts[0]?.charAt(0) === 'd',
|
if (!fs.existsSync(userDir)) {
|
||||||
size: parseInt(parts[4], 10),
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
modifyTime: new Date(`${parts[5]} ${parts[6]} ${parts[7]} ${parts[8]}`),
|
}
|
||||||
permissions: parts[0],
|
res.json({ success: true, userId, username: sanitized });
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ path: dirPath, files, count: files.length });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('List directory error:', err.message);
|
res.status(500).json({ error: err.message });
|
||||||
res.status(500).json({ error: 'Failed to list directory: ' + err.message });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download a file from the NAS
|
// ─── Index (NAS builds its database) ────────────────────────
|
||||||
|
|
||||||
app.get('/api/sftp/download', verifyToken, async (req, res) => {
|
|
||||||
const { path: filePath } = req.query;
|
|
||||||
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
|
||||||
|
|
||||||
const conn = req.connection;
|
|
||||||
const filename = path.basename(filePath);
|
|
||||||
const tempFilePath = path.join(os.tmpdir(), filename);
|
|
||||||
|
|
||||||
|
app.post('/api/index', (req, res) => {
|
||||||
try {
|
try {
|
||||||
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
|
const { username } = req.body;
|
||||||
res.download(tempFilePath, filename, () => {
|
if (!username) return res.status(400).json({ error: 'Username is required' });
|
||||||
try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ }
|
|
||||||
|
const photosPath = getPhotosPath();
|
||||||
|
const sanitized = sanitizeUsername(username);
|
||||||
|
|
||||||
|
const removed = db.removeOrphanedEntries(sanitized, photosPath);
|
||||||
|
const result = db.indexPhotosForUser(sanitized, photosPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
username: sanitized,
|
||||||
|
indexed: result.indexed,
|
||||||
|
skipped: result.skipped,
|
||||||
|
errors: result.errors,
|
||||||
|
totalFiles: result.total,
|
||||||
|
orphansRemoved: removed,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Download error:', err.message);
|
res.status(500).json({ error: err.message });
|
||||||
res.status(500).json({ error: 'Download failed: ' + err.message });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload a file to the NAS
|
// ─── Photo List (POST fetch cycle) ─────────────────────────
|
||||||
|
|
||||||
app.post('/api/sftp/upload', verifyToken, async (req, res) => {
|
|
||||||
const { remotePath } = req.body;
|
|
||||||
if (!remotePath || !req.files?.file) {
|
|
||||||
return res.status(400).json({ error: 'Missing remotePath or file' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = req.files.file;
|
|
||||||
const conn = req.connection;
|
|
||||||
const tempFilePath = path.join(os.tmpdir(), file.name);
|
|
||||||
|
|
||||||
|
app.post('/api/photos', (req, res) => {
|
||||||
try {
|
try {
|
||||||
await file.mv(tempFilePath);
|
const { username } = req.body;
|
||||||
await runCommand(
|
if (!username) return res.status(400).json({ error: 'Username is required' });
|
||||||
'scp',
|
|
||||||
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) {
|
|
||||||
console.error('Upload error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Upload failed: ' + err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a directory on the NAS
|
const sanitized = sanitizeUsername(username);
|
||||||
|
const photos = db.getPhotoIndex(sanitized);
|
||||||
|
|
||||||
app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
|
res.json({
|
||||||
const { path: dirPath } = req.body;
|
success: true,
|
||||||
if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' });
|
username: sanitized,
|
||||||
|
count: photos.length,
|
||||||
try {
|
photos: photos.map(p => ({
|
||||||
await runSftpBatch(req.connection, [`mkdir ${dirPath}`]);
|
id: p.id,
|
||||||
console.log(`Created directory: ${dirPath}`);
|
filename: p.filename,
|
||||||
res.json({ success: true, path: dirPath, message: 'Directory created' });
|
sha256Hash: p.sha256_hash,
|
||||||
} catch (err) {
|
fileSize: p.file_size,
|
||||||
console.error('Mkdir error:', err.message);
|
mimeType: p.mime_type,
|
||||||
res.status(500).json({ error: 'Failed to create directory: ' + err.message });
|
indexedAt: p.indexed_at,
|
||||||
}
|
})),
|
||||||
});
|
|
||||||
|
|
||||||
// Delete a file on the NAS
|
|
||||||
|
|
||||||
app.post('/api/sftp/delete', verifyToken, async (req, res) => {
|
|
||||||
const { path: filePath } = req.body;
|
|
||||||
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runSftpBatch(req.connection, [`rm ${filePath}`]);
|
|
||||||
console.log(`Deleted: ${filePath}`);
|
|
||||||
res.json({ success: true, path: filePath, message: 'File deleted' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Failed to delete file: ' + err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disconnect from the NAS
|
|
||||||
|
|
||||||
app.post('/api/sftp/disconnect', verifyToken, (req, res) => {
|
|
||||||
connections.delete(req.connectionId);
|
|
||||||
console.log(`Disconnected: ${req.connectionId}`);
|
|
||||||
res.json({ success: true, message: 'Disconnected from SFTP server' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Google Photos → NAS Proxy Endpoints ─────────────────────────
|
|
||||||
|
|
||||||
// Fetch media items from Google Photos on behalf of the mobile client
|
|
||||||
|
|
||||||
app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
|
||||||
const { googleAccessToken, pageSize = 50, pageToken } = req.body;
|
|
||||||
if (!googleAccessToken) {
|
|
||||||
return res.status(400).json({ error: 'Google access token is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ pageSize: String(pageSize) });
|
|
||||||
if (pageToken) params.append('pageToken', pageToken);
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${googleAccessToken}` } }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errText = await response.text();
|
|
||||||
return res.status(response.status).json({ error: 'Google API error', details: errText });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const mediaItems = (data.mediaItems || []).map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
filename: item.filename,
|
|
||||||
mimeType: item.mimeType,
|
|
||||||
baseUrl: item.baseUrl,
|
|
||||||
productUrl: item.productUrl,
|
|
||||||
creationTime: item.mediaMetadata?.creationTime,
|
|
||||||
width: item.mediaMetadata?.width,
|
|
||||||
height: item.mediaMetadata?.height,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`Fetched ${mediaItems.length} Google Photos items via NAS proxy`);
|
|
||||||
res.json({ success: true, mediaItems, nextPageToken: data.nextPageToken || null, count: mediaItems.length });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Google Photos fetch error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch Google Photos: ' + err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Download a Google Photos item and save it directly to the NAS
|
|
||||||
|
|
||||||
app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => {
|
|
||||||
const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
|
|
||||||
const conn = req.connection;
|
|
||||||
|
|
||||||
if (!googleAccessToken || !baseUrl || !remotePath) {
|
|
||||||
return res.status(400).json({ error: 'googleAccessToken, baseUrl, and remotePath are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const imageResponse = await fetch(`${baseUrl}=d`, {
|
|
||||||
headers: { Authorization: `Bearer ${googleAccessToken}` },
|
|
||||||
});
|
});
|
||||||
if (!imageResponse.ok) {
|
|
||||||
return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await imageResponse.buffer();
|
|
||||||
const safeName = filename || `gphoto_${Date.now()}.jpg`;
|
|
||||||
const tempFilePath = path.join(os.tmpdir(), safeName);
|
|
||||||
fs.writeFileSync(tempFilePath, buffer);
|
|
||||||
|
|
||||||
const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`;
|
|
||||||
await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath));
|
|
||||||
fs.unlinkSync(tempFilePath);
|
|
||||||
|
|
||||||
console.log(`Saved Google Photo to NAS: ${remotePath}/${safeName}`);
|
|
||||||
res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Download-to-NAS error:', err.message);
|
res.status(500).json({ error: err.message });
|
||||||
res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync status (placeholder for future batch sync)
|
// ─── Fetch Single Photo as Base64 with hash ─────────────────
|
||||||
|
|
||||||
app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
|
app.post('/api/photo/fetch', (req, res) => {
|
||||||
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
|
try {
|
||||||
|
const { photoId, hash } = req.body;
|
||||||
|
if (!photoId && !hash) {
|
||||||
|
return res.status(400).json({ error: 'photoId or hash is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let photo;
|
||||||
|
if (photoId) {
|
||||||
|
photo = db.getPhotoById(photoId);
|
||||||
|
} else {
|
||||||
|
photo = db.getPhotoByHash(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
if (!fs.existsSync(photo.filepath)) {
|
||||||
|
return res.status(404).json({ error: 'Photo file missing from disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(photo.filepath);
|
||||||
|
const base64Data = fileBuffer.toString('base64');
|
||||||
|
const transportHash = crypto.createHash('sha256').update(base64Data).digest('hex');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
photo: {
|
||||||
|
id: photo.id,
|
||||||
|
filename: photo.filename,
|
||||||
|
mimeType: photo.mime_type,
|
||||||
|
fileSize: photo.file_size,
|
||||||
|
sha256Hash: photo.sha256_hash,
|
||||||
|
transportHash,
|
||||||
|
base64: base64Data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Lifecycle ───────────────────────────────────────────────────
|
// ─── Upload Photo from Device to NAS ────────────────────────
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
app.post('/api/photo/upload', (req, res) => {
|
||||||
console.log('\nShutting down...');
|
try {
|
||||||
connections.forEach((_, id) => connections.delete(id));
|
const { username, filename, base64, transportHash } = req.body;
|
||||||
process.exit(0);
|
if (!username || !filename || !base64) {
|
||||||
|
return res.status(400).json({ error: 'username, filename, and base64 are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transportHash) {
|
||||||
|
const computed = crypto.createHash('sha256').update(base64).digest('hex');
|
||||||
|
if (computed !== transportHash) {
|
||||||
|
return res.status(400).json({ error: 'Transport integrity check failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeUsername(username);
|
||||||
|
const photosPath = getPhotosPath();
|
||||||
|
const userDir = path.join(photosPath, sanitized);
|
||||||
|
if (!fs.existsSync(userDir)) {
|
||||||
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeFilename = filename.replace(/[^a-zA-Z0-9_\-.]/g, '_');
|
||||||
|
const destPath = path.join(userDir, safeFilename);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(base64, 'base64');
|
||||||
|
fs.writeFileSync(destPath, buffer);
|
||||||
|
|
||||||
|
db.ensureUser(sanitized);
|
||||||
|
const result = db.indexPhotosForUser(sanitized, photosPath);
|
||||||
|
|
||||||
|
res.json({ success: true, filename: safeFilename, indexed: result.indexed });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Delete Photo from NAS ──────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/api/photo/delete', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.body;
|
||||||
|
if (!photoId) return res.status(400).json({ error: 'photoId is required' });
|
||||||
|
|
||||||
|
const result = db.deletePhoto(photoId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Refresh (re-index + return list) ───────────────────────
|
||||||
|
|
||||||
|
app.post('/api/refresh', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.body;
|
||||||
|
if (!username) return res.status(400).json({ error: 'Username is required' });
|
||||||
|
|
||||||
|
const sanitized = sanitizeUsername(username);
|
||||||
|
const photosPath = getPhotosPath();
|
||||||
|
|
||||||
|
db.ensureUser(sanitized);
|
||||||
|
db.removeOrphanedEntries(sanitized, photosPath);
|
||||||
|
db.indexPhotosForUser(sanitized, photosPath);
|
||||||
|
const photos = db.getPhotoIndex(sanitized);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
username: sanitized,
|
||||||
|
count: photos.length,
|
||||||
|
photos: photos.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
filename: p.filename,
|
||||||
|
sha256Hash: p.sha256_hash,
|
||||||
|
fileSize: p.file_size,
|
||||||
|
mimeType: p.mime_type,
|
||||||
|
indexedAt: p.indexed_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Album Endpoints ────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/api/albums', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.body;
|
||||||
|
if (!username) return res.status(400).json({ error: 'Username is required' });
|
||||||
|
const sanitized = sanitizeUsername(username);
|
||||||
|
const albums = db.getAlbums(sanitized);
|
||||||
|
res.json({ success: true, albums });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/album/create', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, name } = req.body;
|
||||||
|
if (!username || !name) return res.status(400).json({ error: 'username and name are required' });
|
||||||
|
const sanitized = sanitizeUsername(username);
|
||||||
|
const result = db.createAlbum(sanitized, name.trim());
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/album/photos', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { albumId } = req.body;
|
||||||
|
if (!albumId) return res.status(400).json({ error: 'albumId is required' });
|
||||||
|
const photos = db.getAlbumPhotos(albumId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
photos: photos.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
filename: p.filename,
|
||||||
|
sha256Hash: p.sha256_hash,
|
||||||
|
fileSize: p.file_size,
|
||||||
|
mimeType: p.mime_type,
|
||||||
|
indexedAt: p.indexed_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/album/add-photo', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { albumId, photoId } = req.body;
|
||||||
|
if (!albumId || !photoId) return res.status(400).json({ error: 'albumId and photoId are required' });
|
||||||
|
const result = db.addPhotoToAlbum(albumId, photoId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/album/remove-photo', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { albumId, photoId } = req.body;
|
||||||
|
if (!albumId || !photoId) return res.status(400).json({ error: 'albumId and photoId are required' });
|
||||||
|
const result = db.removePhotoFromAlbum(albumId, photoId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/album/delete', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { albumId } = req.body;
|
||||||
|
if (!albumId) return res.status(400).json({ error: 'albumId is required' });
|
||||||
|
const result = db.deleteAlbum(albumId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/album/rename', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { albumId, name } = req.body;
|
||||||
|
if (!albumId || !name) return res.status(400).json({ error: 'albumId and name are required' });
|
||||||
|
const result = db.renameAlbum(albumId, name.trim());
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Users ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/users', (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = db.getAllUsers();
|
||||||
|
res.json({ success: true, users });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Error handling ─────────────────────────────────────────
|
||||||
|
|
||||||
app.use((err, req, res, _next) => {
|
app.use((err, req, res, _next) => {
|
||||||
console.error('Server error:', err.message);
|
console.error('Server error:', err.message);
|
||||||
res.status(500).json({
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
error: 'Internal server error',
|
|
||||||
message: process.env.NODE_ENV === 'development' ? err.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ error: 'Not found', path: req.path });
|
res.status(404).json({ error: 'Not found', path: req.path });
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
// ─── Lifecycle ──────────────────────────────────────────────
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`\n SFTP Backend Server`);
|
function startServer(port) {
|
||||||
console.log(` Port: ${PORT}`);
|
const p = port || config.DEFAULT_PORT;
|
||||||
console.log(` URL: http://localhost:${PORT}`);
|
app.listen(p, '0.0.0.0', () => {
|
||||||
console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`);
|
console.log(`[NAS Server] Running on http://0.0.0.0:${p}`);
|
||||||
console.log(` Status: Ready\n`);
|
console.log(`[NAS Server] Photos path: ${config.getPhotosPath() || 'NOT SET'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const photosPath = config.getPhotosPath();
|
||||||
|
if (!photosPath) {
|
||||||
|
console.error('ERROR: Photos path not set. Write the path to your photos directory in nas_config.txt');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// Auto-create the photos directory if it doesn't exist yet
|
||||||
|
if (!fs.existsSync(photosPath)) {
|
||||||
|
fs.mkdirSync(photosPath, { recursive: true });
|
||||||
|
console.log(`[NAS Server] Created photos directory: ${photosPath}`);
|
||||||
|
}
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
db.closeDb();
|
||||||
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = { app, startServer };
|
||||||
|
|
||||||
+164
-101
@@ -1,135 +1,198 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
// HTTP client that communicates with the SFTP backend (ServerFiles/server.js)
|
// REST client that communicates with the NAS Photo Server (ServerFiles/server.js)
|
||||||
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
|
const DEFAULT_PORT = '9500';
|
||||||
|
|
||||||
class NASAPI {
|
class NASAPI {
|
||||||
constructor(backendUrl = DEFAULT_BACKEND_URL) {
|
constructor() {
|
||||||
this.backendUrl = backendUrl;
|
this.baseUrl = null;
|
||||||
this.connectionToken = null;
|
this.username = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.credentials = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Establish a connection via the backend; falls back to local-only config if the backend is unreachable
|
configure(host, port, username) {
|
||||||
async connect(host, port, username, password, photoFolder = '/Photos') {
|
this.baseUrl = `http://${host}:${port || DEFAULT_PORT}`;
|
||||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
this.credentials = { host, port: port || 22, username, photoFolder };
|
async connect(host, port, username) {
|
||||||
|
if (!host || !username) throw new Error('Host and username are required');
|
||||||
|
|
||||||
|
this.configure(host, port, username);
|
||||||
|
|
||||||
|
// Register user on NAS — NAS creates user folder and DB entry
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/register`,
|
||||||
|
{ username: this.username },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
this.isConnected = true;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Connected to NAS at ${host}`,
|
||||||
|
userId: response.data.userId,
|
||||||
|
username: response.data.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Failed to register with NAS');
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck() {
|
||||||
|
if (!this.baseUrl) return false;
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.get(`${this.baseUrl}/api/health`, { timeout: 5000 });
|
||||||
`${this.backendUrl}/api/sftp/connect`,
|
return response.data?.status === 'ok';
|
||||||
{ host, port: port || 22, username, password },
|
} catch {
|
||||||
{ timeout: 10000 }
|
return false;
|
||||||
);
|
|
||||||
if (response.data?.success) {
|
|
||||||
this.connectionToken = response.data.connectionToken;
|
|
||||||
this.isConnected = true;
|
|
||||||
return { success: true, message: `Connected to ${host}` };
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
console.log('Backend not available, storing NAS credentials locally');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isConnected = true;
|
|
||||||
return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials };
|
|
||||||
}
|
|
||||||
|
|
||||||
getHeaders() {
|
|
||||||
if (!this.connectionToken) return {};
|
|
||||||
return { Authorization: `Bearer ${this.connectionToken}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
async listDirectory(path = '/') {
|
|
||||||
if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' };
|
|
||||||
const response = await axios.post(
|
|
||||||
`${this.backendUrl}/api/sftp/list`, { path },
|
|
||||||
{ headers: this.getHeaders(), timeout: 15000 }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter directory listing for supported photo/video files
|
|
||||||
async getPhotos(folder = null) {
|
|
||||||
const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
|
|
||||||
if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const listing = await this.listDirectory(targetFolder);
|
|
||||||
const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
|
|
||||||
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
|
||||||
|
|
||||||
const mediaItems = (listing.files || [])
|
|
||||||
.filter((file) => {
|
|
||||||
const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
|
|
||||||
return 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) {
|
|
||||||
console.error('Get photos error:', error.message);
|
|
||||||
return { mediaItems: [], folder: targetFolder };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(localPath, remotePath) {
|
// Full refresh cycle: POST to re-index + fetch updated photo list
|
||||||
if (!this.connectionToken) throw new Error('Backend not connected — cannot upload');
|
async refreshPhotos() {
|
||||||
const formData = new FormData();
|
if (!this.username) throw new Error('Not connected');
|
||||||
formData.append('remotePath', remotePath);
|
|
||||||
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
|
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${this.backendUrl}/api/sftp/upload`, formData,
|
`${this.baseUrl}/api/refresh`,
|
||||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
{ username: this.username },
|
||||||
|
{ timeout: 30000 },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDirectory(remotePath) {
|
// Fetch photo index only (no re-index)
|
||||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
async getPhotos() {
|
||||||
|
if (!this.username) throw new Error('Not connected');
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
|
`${this.baseUrl}/api/photos`,
|
||||||
{ headers: this.getHeaders(), timeout: 10000 }
|
{ username: this.username },
|
||||||
|
{ timeout: 15000 },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(remotePath) {
|
// Fetch a single photo as base64 with hash verification
|
||||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
async fetchPhoto(photoId) {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${this.backendUrl}/api/sftp/delete`, { path: remotePath },
|
`${this.baseUrl}/api/photo/fetch`,
|
||||||
{ headers: this.getHeaders(), timeout: 10000 }
|
{ photoId },
|
||||||
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
return response.data;
|
if (!response.data?.success || !response.data?.photo) {
|
||||||
}
|
throw new Error('Failed to fetch photo');
|
||||||
|
|
||||||
async disconnect() {
|
|
||||||
if (this.connectionToken) {
|
|
||||||
try {
|
|
||||||
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
|
|
||||||
} catch (_) { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
this.connectionToken = null;
|
return response.data.photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload a photo from device to NAS as base64
|
||||||
|
async uploadPhoto(filename, base64Data) {
|
||||||
|
if (!this.username) throw new Error('Not connected');
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/photo/upload`,
|
||||||
|
{ username: this.username, filename, base64: base64Data },
|
||||||
|
{ timeout: 120000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a photo from NAS
|
||||||
|
async deletePhoto(photoId) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/photo/delete`,
|
||||||
|
{ photoId },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-index only
|
||||||
|
async reIndex() {
|
||||||
|
if (!this.username) throw new Error('Not connected');
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/index`,
|
||||||
|
{ username: this.username },
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Album operations ───
|
||||||
|
|
||||||
|
async getAlbums() {
|
||||||
|
if (!this.username) throw new Error('Not connected');
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/albums`,
|
||||||
|
{ username: this.username },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlbum(name) {
|
||||||
|
if (!this.username) throw new Error('Not connected');
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/album/create`,
|
||||||
|
{ username: this.username, name },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumPhotos(albumId) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/album/photos`,
|
||||||
|
{ albumId },
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPhotoToAlbum(albumId, photoId) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/album/add-photo`,
|
||||||
|
{ albumId, photoId },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePhotoFromAlbum(albumId, photoId) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/album/remove-photo`,
|
||||||
|
{ albumId, photoId },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAlbum(albumId) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/album/delete`,
|
||||||
|
{ albumId },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameAlbum(albumId, name) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.baseUrl}/api/album/rename`,
|
||||||
|
{ albumId, name },
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.baseUrl = null;
|
||||||
|
this.username = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.credentials = null;
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
getConnectionInfo() {
|
getConnectionInfo() {
|
||||||
return this.credentials ? { ...this.credentials, isConnected: this.isConnected } : null;
|
if (!this.baseUrl) return null;
|
||||||
|
return { baseUrl: this.baseUrl, username: this.username, isConnected: this.isConnected };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-28
@@ -1,33 +1,34 @@
|
|||||||
import { AuthServiceBase } from './AuthServiceBase';
|
import { AuthServiceBase } from './AuthServiceBase';
|
||||||
import NASAPI from '../API/NASAPI';
|
import NASAPI from '../API/NASAPI';
|
||||||
|
|
||||||
// NAS SFTP authentication — connects to the backend server via NASAPI
|
// NAS authentication — connects to the NAS REST server via NASAPI
|
||||||
class NASAUTH extends AuthServiceBase {
|
class NASAUTH extends AuthServiceBase {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.nasAPI = new NASAPI();
|
this.nasAPI = new NASAPI();
|
||||||
this.host = null;
|
this.host = null;
|
||||||
this.port = 22;
|
this.port = null;
|
||||||
this.username = null;
|
this.username = null;
|
||||||
this.password = null;
|
|
||||||
this.photoFolder = null;
|
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
|
async authenticate(host, port = 9500, username) {
|
||||||
try {
|
try {
|
||||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
if (!host || !username) throw new Error('Host and username are required');
|
||||||
|
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.port = port || 22;
|
this.port = port || 9500;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.password = password;
|
|
||||||
this.photoFolder = photoFolder;
|
|
||||||
|
|
||||||
await this.nasAPI.connect(host, this.port, username, password, photoFolder);
|
await this.nasAPI.connect(host, this.port, username);
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
|
|
||||||
return { success: true, message: `NAS configured: ${host}`, host: this.host, username: this.username };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `NAS connected: ${host}`,
|
||||||
|
host: this.host,
|
||||||
|
username: this.username,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('NAS authentication error:', error.message);
|
console.error('NAS authentication error:', error.message);
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
@@ -41,23 +42,21 @@ class NASAUTH extends AuthServiceBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken() {
|
async refreshToken() {
|
||||||
if (this.host && this.username && this.password) {
|
if (this.host && this.username) {
|
||||||
return await this.authenticate(this.host, this.port, this.username, this.password, this.photoFolder);
|
return await this.authenticate(this.host, this.port, this.username);
|
||||||
}
|
}
|
||||||
throw new Error('No NAS credentials available for reconnection');
|
throw new Error('No NAS credentials available for reconnection');
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeAccess() {
|
async revokeAccess() {
|
||||||
try {
|
try {
|
||||||
await this.nasAPI.disconnect();
|
this.nasAPI.disconnect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Disconnect error:', error.message);
|
console.error('Disconnect error:', error.message);
|
||||||
}
|
}
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.host = null;
|
this.host = null;
|
||||||
this.username = null;
|
this.username = null;
|
||||||
this.password = null;
|
|
||||||
this.photoFolder = null;
|
|
||||||
return { success: true, message: 'NAS connection closed' };
|
return { success: true, message: 'NAS connection closed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,35 +66,75 @@ class NASAUTH extends AuthServiceBase {
|
|||||||
host: this.host,
|
host: this.host,
|
||||||
username: this.username,
|
username: this.username,
|
||||||
port: this.port,
|
port: this.port,
|
||||||
photoFolder: this.photoFolder,
|
|
||||||
connectedAt: new Date().toISOString(),
|
connectedAt: new Date().toISOString(),
|
||||||
serverType: 'NAS SFTP',
|
serverType: 'NAS REST',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDirectory(remotePath = '/') {
|
// Full refresh: re-index on NAS + return photo list
|
||||||
|
async refreshPhotos() {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
return await this.nasAPI.listDirectory(remotePath);
|
return await this.nasAPI.refreshPhotos();
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(localPath, remotePath) {
|
// Fetch photo list only
|
||||||
|
async getPhotos() {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
return await this.nasAPI.uploadFile(localPath, remotePath);
|
return await this.nasAPI.getPhotos();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPhotos(photoFolder = null) {
|
// Fetch single photo as base64 data URI
|
||||||
|
async fetchPhoto(photoId) {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos');
|
return await this.nasAPI.fetchPhoto(photoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDirectory(remotePath) {
|
// Upload photo
|
||||||
|
async uploadPhoto(filename, base64Data) {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
return await this.nasAPI.createDirectory(remotePath);
|
return await this.nasAPI.uploadPhoto(filename, base64Data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(remotePath) {
|
// Delete photo from NAS
|
||||||
|
async deletePhoto(photoId) {
|
||||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
return await this.nasAPI.deleteFile(remotePath);
|
return await this.nasAPI.deletePhoto(photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
async healthCheck() {
|
||||||
|
return await this.nasAPI.healthCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album operations
|
||||||
|
async getAlbums() {
|
||||||
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
|
return await this.nasAPI.getAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlbum(name) {
|
||||||
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
|
return await this.nasAPI.createAlbum(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumPhotos(albumId) {
|
||||||
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
|
return await this.nasAPI.getAlbumPhotos(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPhotoToAlbum(albumId, photoId) {
|
||||||
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
|
return await this.nasAPI.addPhotoToAlbum(albumId, photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePhotoFromAlbum(albumId, photoId) {
|
||||||
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
|
return await this.nasAPI.removePhotoFromAlbum(albumId, photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAlbum(albumId) {
|
||||||
|
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||||
|
return await this.nasAPI.deleteAlbum(albumId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
|
||||||
|
// Reusable empty-state placeholder shown when a list has no items
|
||||||
|
const EmptyState = ({ icon = '📭', title, message, actionLabel, onAction }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.icon}>{icon}</Text>
|
||||||
|
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||||
|
{message ? (
|
||||||
|
<Text style={[styles.message, { color: theme.textSecondary }]}>{message}</Text>
|
||||||
|
) : null}
|
||||||
|
{actionLabel && onAction ? (
|
||||||
|
<TouchableOpacity style={[styles.button, { backgroundColor: theme.primary }]} onPress={onAction}>
|
||||||
|
<Text style={styles.buttonText}>{actionLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, paddingTop: 60 },
|
||||||
|
icon: { fontSize: 56, marginBottom: 16 },
|
||||||
|
title: { fontSize: 18, fontWeight: '600', marginBottom: 6, textAlign: 'center' },
|
||||||
|
message: { fontSize: 13, textAlign: 'center', lineHeight: 20, marginBottom: 20 },
|
||||||
|
button: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
|
||||||
|
buttonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||||
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
|
||||||
|
// Reusable horizontal filter chip row — used in HomeScreen, AlbumsScreen, etc.
|
||||||
|
const FilterChips = ({ filters, selectedKey, onSelect }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.row}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.chip, { backgroundColor: selectedKey === 'all' ? theme.chipActiveBg : theme.chipBg }]}
|
||||||
|
onPress={() => onSelect('all')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, selectedKey === 'all' && { color: theme.primary, fontWeight: '600' }]}>
|
||||||
|
All
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{filters.map((f) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f.key}
|
||||||
|
style={[styles.chip, { backgroundColor: selectedKey === f.key ? theme.chipActiveBg : theme.chipBg }]}
|
||||||
|
onPress={() => onSelect(f.key)}
|
||||||
|
>
|
||||||
|
{f.icon ? <Text style={styles.chipIcon}>{f.icon}</Text> : null}
|
||||||
|
<Text style={[styles.chipText, selectedKey === f.key && { color: theme.primary, fontWeight: '600' }]}>
|
||||||
|
{f.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { maxHeight: 40 },
|
||||||
|
row: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
|
||||||
|
chip: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4,
|
||||||
|
borderRadius: 16, gap: 4, height: 28,
|
||||||
|
},
|
||||||
|
chipIcon: { fontSize: 11 },
|
||||||
|
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FilterChips;
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
|
||||||
|
// Reusable inline header bar with back arrow and title
|
||||||
|
const ScreenHeader = ({ title, subtitle, onBack, rightAction }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.header, { backgroundColor: theme.background, borderBottomColor: theme.border }]}>
|
||||||
|
{onBack ? (
|
||||||
|
<TouchableOpacity onPress={onBack} style={styles.backButton}>
|
||||||
|
<Text style={[styles.backArrow, { color: theme.text }]}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.backButton} />
|
||||||
|
)}
|
||||||
|
<View style={styles.titleWrap}>
|
||||||
|
<Text style={[styles.title, { color: theme.text }]} numberOfLines={1}>{title}</Text>
|
||||||
|
{subtitle ? <Text style={[styles.subtitle, { color: theme.textSecondary }]}>{subtitle}</Text> : null}
|
||||||
|
</View>
|
||||||
|
{rightAction || <View style={styles.backButton} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 52,
|
||||||
|
paddingBottom: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
backButton: { width: 32 },
|
||||||
|
backArrow: { fontSize: 24 },
|
||||||
|
titleWrap: { flex: 1, alignItems: 'center' },
|
||||||
|
title: { fontSize: 18, fontWeight: '700' },
|
||||||
|
subtitle: { fontSize: 12, marginTop: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ScreenHeader;
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Shared service helpers — avoids duplicating icon/name maps across components and screens
|
||||||
|
|
||||||
|
export const getServiceIcon = (type) =>
|
||||||
|
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||||
|
|
||||||
|
export const getServiceName = (type) =>
|
||||||
|
({
|
||||||
|
DEVICE: 'On Device',
|
||||||
|
GOOGLE_PHOTOS: 'Google Photos',
|
||||||
|
ICLOUD_PHOTOS: 'iCloud Photos',
|
||||||
|
NAS: 'NAS Storage',
|
||||||
|
}[type] || type);
|
||||||
|
|
||||||
@@ -4,26 +4,25 @@ import {
|
|||||||
Dimensions, FlatList,
|
Dimensions, FlatList,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useTheme } from '../services/ThemeContext';
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
|
||||||
|
|
||||||
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 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);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fullscreen media viewer with thumbnail carousel.
|
* Fullscreen media viewer with thumbnail carousel and action toolbar.
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* visible – boolean to show/hide the modal
|
* visible – boolean to show/hide the modal
|
||||||
* media – currently selected media item
|
* media – currently selected media item
|
||||||
* allMedia – (optional) full array of media items for carousel navigation
|
* allMedia – (optional) full array of media items for carousel navigation
|
||||||
* onClose – callback when user dismisses the viewer
|
* onClose – callback when user dismisses the viewer
|
||||||
|
* onDelete – (optional) callback(item) to delete the current item
|
||||||
|
* onAddToAlbum – (optional) callback(item) to add current item to an album
|
||||||
|
* onShare – (optional) callback(item) to share current item
|
||||||
*/
|
*/
|
||||||
const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum, onShare }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mainListRef = useRef(null);
|
const mainListRef = useRef(null);
|
||||||
const thumbListRef = useRef(null);
|
const thumbListRef = useRef(null);
|
||||||
@@ -185,6 +184,28 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
|
|
||||||
{/* Info panel */}
|
{/* Info panel */}
|
||||||
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
|
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
|
||||||
|
{/* Action toolbar */}
|
||||||
|
<View style={styles.actionBar}>
|
||||||
|
{onShare && (
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={() => onShare(displayMedia)}>
|
||||||
|
<Text style={styles.actionEmoji}>📤</Text>
|
||||||
|
<Text style={styles.actionText}>Share</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{onAddToAlbum && (
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={() => onAddToAlbum(displayMedia)}>
|
||||||
|
<Text style={styles.actionEmoji}>📁</Text>
|
||||||
|
<Text style={styles.actionText}>Album</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<TouchableOpacity style={styles.actionBtn} onPress={() => onDelete(displayMedia)}>
|
||||||
|
<Text style={styles.actionEmoji}>🗑️</Text>
|
||||||
|
<Text style={[styles.actionText, { color: '#f28b82' }]}>Delete</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
|
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.label}>Source</Text>
|
<Text style={styles.label}>Source</Text>
|
||||||
@@ -211,6 +232,24 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{displayMedia.sha256Hash && (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>SHA-256</Text>
|
||||||
|
<Text style={[styles.value, { fontSize: 10, fontFamily: 'monospace' }]} numberOfLines={1}>
|
||||||
|
{displayMedia.sha256Hash.substring(0, 24)}…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{displayMedia.fileSize > 0 && (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Size</Text>
|
||||||
|
<Text style={styles.value}>
|
||||||
|
{displayMedia.fileSize > 1048576
|
||||||
|
? (displayMedia.fileSize / 1048576).toFixed(1) + ' MB'
|
||||||
|
: (displayMedia.fileSize / 1024).toFixed(1) + ' KB'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -255,6 +294,13 @@ const styles = StyleSheet.create({
|
|||||||
padding: 16, paddingBottom: 32,
|
padding: 16, paddingBottom: 32,
|
||||||
borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
||||||
},
|
},
|
||||||
|
actionBar: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-around', marginBottom: 12,
|
||||||
|
paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.15)',
|
||||||
|
},
|
||||||
|
actionBtn: { alignItems: 'center', paddingHorizontal: 16 },
|
||||||
|
actionEmoji: { fontSize: 20, marginBottom: 4 },
|
||||||
|
actionText: { color: '#e8eaed', fontSize: 11, fontWeight: '500' },
|
||||||
title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 },
|
title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 },
|
||||||
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
||||||
label: { fontSize: 12, color: '#9aa0a6' },
|
label: { fontSize: 12, color: '#9aa0a6' },
|
||||||
|
|||||||
@@ -25,51 +25,41 @@ const NASSetupInstructions = () => {
|
|||||||
<View style={styles.body}>
|
<View style={styles.body}>
|
||||||
<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'}
|
• A Windows or Ubuntu machine to run the NAS server{'\n'}
|
||||||
• A user account with sudo access on the server{'\n'}
|
• Node.js installed on the NAS machine{'\n'}
|
||||||
• The server's IP address and SSH port (default: 22)
|
• The NAS machine's IP address on your local network
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 1: Enable SSH</Text>
|
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 1: Copy the ServerFiles folder</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'}
|
The ServerFiles folder is self-contained. Copy it to any machine you want to use as a NAS.
|
||||||
sudo apt update && sudo apt install openssh-server{'\n'}
|
|
||||||
sudo systemctl enable ssh && sudo systemctl start ssh
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 2: Create a Photos Directory</Text>
|
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 2: Run the CLI client</Text>
|
||||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||||
Create a dedicated folder for your photo storage:{'\n\n'}
|
Open a terminal in the ServerFiles folder and run:{'\n\n'}
|
||||||
mkdir -p ~/photos{'\n'}
|
node cli.js{'\n\n'}
|
||||||
chmod 755 ~/photos
|
The CLI will ask whether you are on Windows or Linux (Ubuntu), set a photos storage path, and optionally store a password.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 3: Configure Firewall (if needed)</Text>
|
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 3: Start the server</Text>
|
||||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||||
If your server uses UFW, allow SSH connections:{'\n\n'}
|
From the CLI menu, select "Start NAS Server". The server starts on port 9500 by default.{'\n\n'}
|
||||||
sudo ufw allow ssh{'\n'}
|
Alternatively run: node server.js
|
||||||
sudo ufw enable
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 4: Find Your Server IP</Text>
|
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 4: Connect from the App</Text>
|
||||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
||||||
Run this on your server to find its local IP:{'\n\n'}
|
Enter the NAS machine IP, port (default 9500), and your username.{'\n\n'}
|
||||||
hostname -I{'\n\n'}
|
No password or photo folder path is needed — the NAS manages storage location and user folders automatically.
|
||||||
Use that IP address in the "Server Address" field above.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 5: Connect from the App</Text>
|
<Text style={[styles.stepTitle, { color: theme.primary }]}>How it works</Text>
|
||||||
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
|
<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'}
|
• Your username is sent to the NAS, which creates a folder for you{'\n'}
|
||||||
The app's backend server will handle the SFTP connection securely.
|
• The NAS indexes all photos in your folder and stores SHA-256 hashes{'\n'}
|
||||||
</Text>
|
• Photos are sent to the app as base64-encoded data with integrity hashes{'\n'}
|
||||||
|
• Pull-to-refresh triggers a full re-index on the NAS
|
||||||
<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'}
|
|
||||||
node server.js{'\n\n'}
|
|
||||||
Ensure the backend URL in the app settings matches the server address (e.g. http://192.168.1.100:3001).
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -98,4 +88,3 @@ const styles = StyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default NASSetupInstructions;
|
export default NASSetupInstructions;
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action sheet overlay for photo operations (delete, add to album, info, etc.)
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* visible – boolean
|
||||||
|
* onClose – dismiss callback
|
||||||
|
* actions – array of { label, icon, onPress, destructive? }
|
||||||
|
*/
|
||||||
|
const PhotoActionSheet = ({ visible, onClose, actions = [] }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={onClose}>
|
||||||
|
<View style={[styles.sheet, { backgroundColor: theme.card }]}>
|
||||||
|
<View style={[styles.handle, { backgroundColor: theme.border }]} />
|
||||||
|
{actions.map((action, i) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={action.label + i}
|
||||||
|
style={[styles.actionRow, i < actions.length - 1 && { borderBottomColor: theme.borderLight, borderBottomWidth: StyleSheet.hairlineWidth }]}
|
||||||
|
onPress={() => { onClose(); action.onPress?.(); }}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
{action.icon ? <Text style={styles.actionIcon}>{action.icon}</Text> : null}
|
||||||
|
<Text style={[styles.actionLabel, { color: action.destructive ? theme.danger : theme.text }]}>
|
||||||
|
{action.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity style={[styles.cancelRow, { backgroundColor: theme.chipBg }]} onPress={onClose}>
|
||||||
|
<Text style={[styles.cancelText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
paddingBottom: 30,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
handle: {
|
||||||
|
width: 36, height: 4, borderRadius: 2, alignSelf: 'center', marginBottom: 8,
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
|
actionIcon: { fontSize: 20 },
|
||||||
|
actionLabel: { fontSize: 16, fontWeight: '500' },
|
||||||
|
cancelRow: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
cancelText: { fontSize: 16, fontWeight: '600' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PhotoActionSheet;
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
|
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useTheme } from '../services/ThemeContext';
|
import { useTheme } from '../services/ThemeContext';
|
||||||
|
import { getServiceIcon } from '../UI/ServiceHelpers';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const GRID_GAP = 2;
|
const GRID_GAP = 2;
|
||||||
@@ -10,6 +11,7 @@ const GRID_GAP = 2;
|
|||||||
const PhotoGrid = ({
|
const PhotoGrid = ({
|
||||||
data,
|
data,
|
||||||
onItemPress,
|
onItemPress,
|
||||||
|
onItemLongPress,
|
||||||
columns = 3,
|
columns = 3,
|
||||||
refreshControl,
|
refreshControl,
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
@@ -20,8 +22,6 @@ const PhotoGrid = ({
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
|
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
|
||||||
|
|
||||||
const getServiceIcon = (type) =>
|
|
||||||
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
|
||||||
|
|
||||||
const renderSourceBadges = (item) => {
|
const renderSourceBadges = (item) => {
|
||||||
const sources = item.sources && item.sources.length > 0
|
const sources = item.sources && item.sources.length > 0
|
||||||
@@ -51,6 +51,7 @@ const PhotoGrid = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.gridItem, { width: itemSize, height: itemSize, backgroundColor: theme.chipBg }]}
|
style={[styles.gridItem, { width: itemSize, height: itemSize, backgroundColor: theme.chipBg }]}
|
||||||
onPress={() => onItemPress?.(item)}
|
onPress={() => onItemPress?.(item)}
|
||||||
|
onLongPress={() => onItemLongPress?.(item)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{item.local_path ? (
|
{item.local_path ? (
|
||||||
|
|||||||
+286
-50
@@ -1,12 +1,17 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity,
|
StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity,
|
||||||
Dimensions, Image, ActivityIndicator, RefreshControl,
|
Dimensions, Image, ActivityIndicator, RefreshControl, Alert, TextInput, Modal,
|
||||||
} 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';
|
||||||
import DatabaseService from '../services/DatabaseService';
|
import DatabaseService from '../services/DatabaseService';
|
||||||
|
import NASAUTH from '../AUTH/NASAUTH';
|
||||||
import MediaViewer from '../components/MediaViewer';
|
import MediaViewer from '../components/MediaViewer';
|
||||||
|
import PhotoActionSheet from '../components/PhotoActionSheet';
|
||||||
|
import ScreenHeader from '../UI/ScreenHeader';
|
||||||
|
import EmptyState from '../UI/EmptyState';
|
||||||
|
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const ALBUM_COLUMNS = 2;
|
const ALBUM_COLUMNS = 2;
|
||||||
@@ -24,14 +29,30 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
const [selectedMedia, setSelectedMedia] = useState(null);
|
const [selectedMedia, setSelectedMedia] = useState(null);
|
||||||
const [viewerVisible, setViewerVisible] = useState(false);
|
const [viewerVisible, setViewerVisible] = useState(false);
|
||||||
|
|
||||||
|
// Create album modal
|
||||||
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||||
|
const [newAlbumName, setNewAlbumName] = useState('');
|
||||||
|
|
||||||
|
|
||||||
|
// Action sheet
|
||||||
|
const [actionSheetVisible, setActionSheetVisible] = useState(false);
|
||||||
|
const [actionTarget, setActionTarget] = useState(null);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (userId) loadCollections();
|
if (userId) loadCollections();
|
||||||
}, [userId])
|
}, [userId])
|
||||||
);
|
);
|
||||||
|
|
||||||
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
const getNasAuth = async () => {
|
||||||
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
||||||
|
const nasAccount = accounts.find((a) => a.service_type === 'NAS');
|
||||||
|
if (!nasAccount) return null;
|
||||||
|
const auth = new NASAUTH();
|
||||||
|
const d = nasAccount.account_data;
|
||||||
|
await auth.authenticate(d.host, d.port, d.username);
|
||||||
|
return auth;
|
||||||
|
};
|
||||||
|
|
||||||
const loadCollections = async () => {
|
const loadCollections = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +60,7 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
const allMedia = await DatabaseService.getMediaByUser(userId);
|
const allMedia = await DatabaseService.getMediaByUser(userId);
|
||||||
const sectionList = [];
|
const sectionList = [];
|
||||||
|
|
||||||
// ─── Utility albums (top) ───
|
// ─── Utility albums ───
|
||||||
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' },
|
||||||
@@ -68,6 +89,36 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── NAS Albums (from NAS server) ───
|
||||||
|
const nasAccount = accounts.find((a) => a.service_type === 'NAS');
|
||||||
|
if (nasAccount) {
|
||||||
|
try {
|
||||||
|
const auth = await getNasAuth();
|
||||||
|
if (auth) {
|
||||||
|
const result = await auth.getAlbums();
|
||||||
|
if (result?.albums?.length) {
|
||||||
|
const nasAlbumEntries = result.albums.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
icon: '📁',
|
||||||
|
count: a.photoCount || 0,
|
||||||
|
coverImage: null,
|
||||||
|
media: [],
|
||||||
|
type: 'NAS_ALBUM',
|
||||||
|
nasAlbumId: a.id,
|
||||||
|
}));
|
||||||
|
sectionList.push({
|
||||||
|
title: 'NAS Albums',
|
||||||
|
icon: '💾',
|
||||||
|
data: [{ albums: nasAlbumEntries }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await auth.revokeAccess();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('NAS album fetch error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSections(sectionList);
|
setSections(sectionList);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading collections:', err);
|
console.error('Error loading collections:', err);
|
||||||
@@ -77,27 +128,171 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Album detail view
|
const openAlbum = async (album) => {
|
||||||
|
setSelectedAlbum(album);
|
||||||
|
|
||||||
|
if (album.type === 'NAS_ALBUM' && album.nasAlbumId) {
|
||||||
|
// Fetch NAS album photos
|
||||||
|
try {
|
||||||
|
const auth = await getNasAuth();
|
||||||
|
if (auth) {
|
||||||
|
const result = await auth.getAlbumPhotos(album.nasAlbumId);
|
||||||
|
const items = (result?.photos || []).map((p) => ({
|
||||||
|
source_service: 'NAS',
|
||||||
|
source_id: `nas_${p.id}`,
|
||||||
|
title: p.filename,
|
||||||
|
media_type: 'photo',
|
||||||
|
sha256Hash: p.sha256Hash,
|
||||||
|
fileSize: p.fileSize,
|
||||||
|
nas_photo_id: p.id,
|
||||||
|
local_path: null,
|
||||||
|
}));
|
||||||
|
setAlbumMedia(items);
|
||||||
|
await auth.revokeAccess();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('NAS album photos error:', err);
|
||||||
|
setAlbumMedia([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAlbumMedia(album.media || []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Create NAS Album ───
|
||||||
|
const handleCreateAlbum = async () => {
|
||||||
|
if (!newAlbumName.trim()) {
|
||||||
|
Alert.alert('Error', 'Please enter an album name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const auth = await getNasAuth();
|
||||||
|
if (auth) {
|
||||||
|
const result = await auth.createAlbum(newAlbumName.trim());
|
||||||
|
await auth.revokeAccess();
|
||||||
|
if (result?.success) {
|
||||||
|
setCreateModalVisible(false);
|
||||||
|
setNewAlbumName('');
|
||||||
|
loadCollections();
|
||||||
|
Alert.alert('Created', `Album "${newAlbumName.trim()}" created on NAS`);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', result?.error || 'Failed to create album');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Error', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Delete NAS Album ───
|
||||||
|
const handleDeleteAlbum = (album) => {
|
||||||
|
if (!album.nasAlbumId) return;
|
||||||
|
Alert.alert('Delete Album', `Delete "${album.name}"? Photos will not be deleted.`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete', style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const auth = await getNasAuth();
|
||||||
|
if (auth) {
|
||||||
|
await auth.deleteAlbum(album.nasAlbumId);
|
||||||
|
await auth.revokeAccess();
|
||||||
|
setSelectedAlbum(null);
|
||||||
|
loadCollections();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Error', err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Remove photo from NAS album ───
|
||||||
|
const handleRemoveFromAlbum = (item) => {
|
||||||
|
if (!selectedAlbum?.nasAlbumId || !item.nas_photo_id) return;
|
||||||
|
Alert.alert('Remove from Album', `Remove "${item.title}" from this album?`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove', style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const auth = await getNasAuth();
|
||||||
|
if (auth) {
|
||||||
|
await auth.removePhotoFromAlbum(selectedAlbum.nasAlbumId, item.nas_photo_id);
|
||||||
|
await auth.revokeAccess();
|
||||||
|
setAlbumMedia((prev) => prev.filter((m) => m.nas_photo_id !== item.nas_photo_id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Error', err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Delete photo from NAS entirely ───
|
||||||
|
const handleDeletePhoto = (item) => {
|
||||||
|
if (!item.nas_photo_id) return;
|
||||||
|
Alert.alert('Delete Photo', `Permanently delete "${item.title}" from NAS?`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete', style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const auth = await getNasAuth();
|
||||||
|
if (auth) {
|
||||||
|
await auth.nasAPI.deletePhoto(item.nas_photo_id);
|
||||||
|
await auth.revokeAccess();
|
||||||
|
setAlbumMedia((prev) => prev.filter((m) => m.nas_photo_id !== item.nas_photo_id));
|
||||||
|
setViewerVisible(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Error', err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoLongPress = (item) => {
|
||||||
|
setActionTarget(item);
|
||||||
|
setActionSheetVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPhotoActions = () => {
|
||||||
|
if (!actionTarget) return [];
|
||||||
|
const acts = [
|
||||||
|
{ label: 'View', icon: '👁️', onPress: () => { setSelectedMedia(actionTarget); setViewerVisible(true); } },
|
||||||
|
];
|
||||||
|
if (selectedAlbum?.nasAlbumId && actionTarget.nas_photo_id) {
|
||||||
|
acts.push({ label: 'Remove from Album', icon: '📤', onPress: () => handleRemoveFromAlbum(actionTarget) });
|
||||||
|
}
|
||||||
|
if (actionTarget.nas_photo_id) {
|
||||||
|
acts.push({ label: 'Delete from NAS', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionTarget) });
|
||||||
|
}
|
||||||
|
return acts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Album detail view ───
|
||||||
if (selectedAlbum) {
|
if (selectedAlbum) {
|
||||||
const gridItemSize = (width - 6) / 3;
|
const gridItemSize = (width - 6) / 3;
|
||||||
|
const isNasAlbum = !!selectedAlbum.nasAlbumId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<View style={[styles.albumHeader, { borderBottomColor: theme.border }]}>
|
<ScreenHeader
|
||||||
<TouchableOpacity onPress={() => { setSelectedAlbum(null); setAlbumMedia([]); }}>
|
title={selectedAlbum.name}
|
||||||
<Text style={[styles.backArrow, { color: theme.text }]}>←</Text>
|
subtitle={`${albumMedia.length} item${albumMedia.length !== 1 ? 's' : ''}`}
|
||||||
</TouchableOpacity>
|
onBack={() => { setSelectedAlbum(null); setAlbumMedia([]); }}
|
||||||
<View style={styles.albumHeaderInfo}>
|
rightAction={isNasAlbum ? (
|
||||||
<Text style={[styles.albumHeaderTitle, { color: theme.text }]}>{selectedAlbum.name}</Text>
|
<TouchableOpacity onPress={() => handleDeleteAlbum(selectedAlbum)}>
|
||||||
<Text style={[styles.albumHeaderCount, { color: theme.textSecondary }]}>
|
<Text style={{ color: theme.danger, fontSize: 13, fontWeight: '600' }}>Delete</Text>
|
||||||
{selectedAlbum.count} item{selectedAlbum.count !== 1 ? 's' : ''}
|
</TouchableOpacity>
|
||||||
</Text>
|
) : undefined}
|
||||||
</View>
|
/>
|
||||||
</View>
|
|
||||||
{albumMedia.length === 0 ? (
|
{albumMedia.length === 0 ? (
|
||||||
<View style={styles.centerContainer}>
|
<EmptyState icon="📭" title="No items in this collection" />
|
||||||
<Text style={{ fontSize: 48, marginBottom: 12 }}>📭</Text>
|
|
||||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>No items in this collection</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={albumMedia}
|
data={albumMedia}
|
||||||
@@ -105,6 +300,7 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.gridItem, { width: gridItemSize, height: gridItemSize, backgroundColor: theme.chipBg }]}
|
style={[styles.gridItem, { width: gridItemSize, height: gridItemSize, backgroundColor: theme.chipBg }]}
|
||||||
onPress={() => { setSelectedMedia(item); setViewerVisible(true); }}
|
onPress={() => { setSelectedMedia(item); setViewerVisible(true); }}
|
||||||
|
onLongPress={() => handlePhotoLongPress(item)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{item.local_path ? (
|
{item.local_path ? (
|
||||||
@@ -116,16 +312,28 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
|
keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()}
|
||||||
numColumns={3}
|
numColumns={3}
|
||||||
contentContainerStyle={styles.gridContent}
|
contentContainerStyle={styles.gridContent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MediaViewer visible={viewerVisible} media={selectedMedia} allMedia={albumMedia} onClose={() => setViewerVisible(false)} />
|
<MediaViewer
|
||||||
|
visible={viewerVisible}
|
||||||
|
media={selectedMedia}
|
||||||
|
allMedia={albumMedia}
|
||||||
|
onClose={() => setViewerVisible(false)}
|
||||||
|
onDelete={handleDeletePhoto}
|
||||||
|
/>
|
||||||
|
<PhotoActionSheet
|
||||||
|
visible={actionSheetVisible}
|
||||||
|
onClose={() => setActionSheetVisible(false)}
|
||||||
|
actions={getPhotoActions()}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Loading ───
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
|
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
|
||||||
@@ -135,13 +343,15 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Album card renderer ───
|
||||||
const renderAlbumRow = ({ item }) => (
|
const renderAlbumRow = ({ item }) => (
|
||||||
<View style={styles.albumRow}>
|
<View style={styles.albumRow}>
|
||||||
{item.albums.map((album, idx) => (
|
{item.albums.map((album, idx) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={album.name + idx}
|
key={album.name + idx}
|
||||||
style={[styles.albumCard, { width: albumSize }]}
|
style={[styles.albumCard, { width: albumSize }]}
|
||||||
onPress={() => { setSelectedAlbum(album); setAlbumMedia(album.media); }}
|
onPress={() => openAlbum(album)}
|
||||||
|
onLongPress={() => album.nasAlbumId && handleDeleteAlbum(album)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<View style={[styles.albumCover, { width: albumSize, height: albumSize, backgroundColor: theme.chipBg }]}>
|
<View style={[styles.albumCover, { width: albumSize, height: albumSize, backgroundColor: theme.chipBg }]}>
|
||||||
@@ -172,20 +382,28 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasNas = sections.some((s) => s.title === 'NAS Storage' || s.title === 'NAS Albums');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<View style={[styles.header, { backgroundColor: theme.background }]}>
|
<View style={[styles.header, { backgroundColor: theme.background }]}>
|
||||||
<Text style={[styles.headerTitle, { color: theme.text }]}>Collections</Text>
|
<Text style={[styles.headerTitle, { color: theme.text }]}>Collections</Text>
|
||||||
|
{hasNas && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.createBtn, { backgroundColor: theme.primary }]}
|
||||||
|
onPress={() => setCreateModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.createBtnText}>+ Album</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? (
|
{sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? (
|
||||||
<View style={styles.centerContainer}>
|
<EmptyState
|
||||||
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
|
icon="📁"
|
||||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
|
title="No Collections"
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
message="Collections from your connected services will appear here"
|
||||||
Collections from your connected services will appear here
|
/>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<SectionList
|
<SectionList
|
||||||
sections={sections}
|
sections={sections}
|
||||||
@@ -199,6 +417,31 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create NAS Album Modal */}
|
||||||
|
<Modal visible={createModalVisible} transparent animationType="fade" onRequestClose={() => setCreateModalVisible(false)}>
|
||||||
|
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setCreateModalVisible(false)}>
|
||||||
|
<View style={[styles.modalCard, { backgroundColor: theme.card }]}>
|
||||||
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Create NAS Album</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.modalInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
||||||
|
placeholder="Album name"
|
||||||
|
placeholderTextColor={theme.textTertiary}
|
||||||
|
value={newAlbumName}
|
||||||
|
onChangeText={setNewAlbumName}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity style={[styles.modalBtn, { borderColor: theme.border }]} onPress={() => setCreateModalVisible(false)}>
|
||||||
|
<Text style={[styles.modalBtnText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.modalBtn, { backgroundColor: theme.primary }]} onPress={handleCreateAlbum}>
|
||||||
|
<Text style={[styles.modalBtnText, { color: '#fff' }]}>Create</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -206,42 +449,35 @@ const CollectionsScreen = ({ route }) => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1 },
|
container: { flex: 1 },
|
||||||
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
||||||
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
|
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
|
||||||
headerTitle: { fontSize: 24, fontWeight: '700' },
|
headerTitle: { fontSize: 24, fontWeight: '700' },
|
||||||
|
createBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 16 },
|
||||||
|
createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
|
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
|
||||||
// Section headers
|
sectionHeader: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8 },
|
||||||
sectionHeader: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8,
|
|
||||||
},
|
|
||||||
sectionIcon: { fontSize: 20 },
|
sectionIcon: { fontSize: 20 },
|
||||||
sectionTitle: { fontSize: 16, fontWeight: '700' },
|
sectionTitle: { fontSize: 16, fontWeight: '700' },
|
||||||
// Album grid
|
|
||||||
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
|
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
|
||||||
albumCard: {},
|
albumCard: {},
|
||||||
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
|
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
|
||||||
albumCoverImage: { width: '100%', height: '100%' },
|
albumCoverImage: { width: '100%', height: '100%' },
|
||||||
albumCoverPlaceholder: {
|
albumCoverPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
|
||||||
width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center',
|
|
||||||
},
|
|
||||||
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
|
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
|
||||||
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
|
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
|
||||||
// Album detail
|
// Album detail
|
||||||
albumHeader: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
|
|
||||||
paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1,
|
|
||||||
},
|
|
||||||
backArrow: { fontSize: 24 },
|
|
||||||
albumHeaderInfo: { flex: 1 },
|
|
||||||
albumHeaderTitle: { fontSize: 20, fontWeight: '700' },
|
|
||||||
albumHeaderCount: { fontSize: 12 },
|
|
||||||
gridContent: { paddingBottom: 20 },
|
gridContent: { paddingBottom: 20 },
|
||||||
gridItem: { margin: 1 },
|
gridItem: { margin: 1 },
|
||||||
gridImage: { width: '100%', height: '100%' },
|
gridImage: { width: '100%', height: '100%' },
|
||||||
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
|
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
|
||||||
// Empty / Loading
|
|
||||||
loadingText: { marginTop: 12, fontSize: 14 },
|
loadingText: { marginTop: 12, fontSize: 14 },
|
||||||
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
// Create album modal
|
||||||
emptyText: { fontSize: 13, textAlign: 'center' },
|
modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||||
|
modalCard: { width: '80%', borderRadius: 16, padding: 20 },
|
||||||
|
modalTitle: { fontSize: 18, fontWeight: '700', marginBottom: 16 },
|
||||||
|
modalInput: { borderWidth: 1, borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 16 },
|
||||||
|
modalActions: { flexDirection: 'row', gap: 10 },
|
||||||
|
modalBtn: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', borderWidth: 1, borderColor: 'transparent' },
|
||||||
|
modalBtnText: { fontSize: 14, fontWeight: '600' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export default CollectionsScreen;
|
export default CollectionsScreen;
|
||||||
+134
-96
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
|
StyleSheet, View, Text, ActivityIndicator, RefreshControl, Alert,
|
||||||
} 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,6 +12,10 @@ 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 PhotoActionSheet from '../components/PhotoActionSheet';
|
||||||
|
import FilterChips from '../UI/FilterChips';
|
||||||
|
import EmptyState from '../UI/EmptyState';
|
||||||
|
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
|
||||||
import { logError } from '../services/ErrorHandler';
|
import { logError } from '../services/ErrorHandler';
|
||||||
import { TouchableOpacity } from 'react-native';
|
import { TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
@@ -19,11 +23,8 @@ import { TouchableOpacity } from 'react-native';
|
|||||||
* Merge media items from different services.
|
* Merge media items from different services.
|
||||||
* Items with the same filename (title) are merged into a single entry
|
* Items with the same filename (title) are merged into a single entry
|
||||||
* with a `sources` array listing every service it appears in.
|
* with a `sources` array listing every service it appears in.
|
||||||
* The best local_path is kept (prefer file:// from DEVICE over a cached
|
|
||||||
* Picker download, since device URIs are always fresh).
|
|
||||||
*/
|
*/
|
||||||
const mergeMediaAcrossServices = (items) => {
|
const mergeMediaAcrossServices = (items) => {
|
||||||
// First pass: group by normalised filename
|
|
||||||
const byFilename = new Map();
|
const byFilename = new Map();
|
||||||
const noFilename = [];
|
const noFilename = [];
|
||||||
|
|
||||||
@@ -36,15 +37,12 @@ const mergeMediaAcrossServices = (items) => {
|
|||||||
|
|
||||||
if (byFilename.has(key)) {
|
if (byFilename.has(key)) {
|
||||||
const existing = byFilename.get(key);
|
const existing = byFilename.get(key);
|
||||||
// Add this service to the sources list
|
|
||||||
if (!existing.sources.includes(item.source_service)) {
|
if (!existing.sources.includes(item.source_service)) {
|
||||||
existing.sources.push(item.source_service);
|
existing.sources.push(item.source_service);
|
||||||
}
|
}
|
||||||
// Prefer a local device path over a cached download
|
|
||||||
if (item.source_service === 'DEVICE' && item.local_path) {
|
if (item.source_service === 'DEVICE' && item.local_path) {
|
||||||
existing.local_path = item.local_path;
|
existing.local_path = item.local_path;
|
||||||
}
|
}
|
||||||
// Keep the best local_path if the existing one is missing
|
|
||||||
if (!existing.local_path && item.local_path) {
|
if (!existing.local_path && item.local_path) {
|
||||||
existing.local_path = item.local_path;
|
existing.local_path = item.local_path;
|
||||||
}
|
}
|
||||||
@@ -53,7 +51,6 @@ const mergeMediaAcrossServices = (items) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: also deduplicate by source_id for items with different titles
|
|
||||||
const bySourceId = new Map();
|
const bySourceId = new Map();
|
||||||
for (const item of [...byFilename.values(), ...noFilename]) {
|
for (const item of [...byFilename.values(), ...noFilename]) {
|
||||||
const key = item.source_id || item.id || item.local_path || Math.random().toString();
|
const key = item.source_id || item.id || item.local_path || Math.random().toString();
|
||||||
@@ -89,6 +86,9 @@ 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);
|
||||||
|
// Action sheet state
|
||||||
|
const [actionSheetVisible, setActionSheetVisible] = useState(false);
|
||||||
|
const [actionSheetTarget, setActionSheetTarget] = useState(null);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -118,7 +118,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
try {
|
try {
|
||||||
const newMedia = [];
|
const newMedia = [];
|
||||||
|
|
||||||
// ─── Device photos (local camera roll, screenshots, etc.) ───
|
// ─── Device photos ───
|
||||||
try {
|
try {
|
||||||
const deviceResult = await DeviceMediaService.getPhotos(100);
|
const deviceResult = await DeviceMediaService.getPhotos(100);
|
||||||
if (deviceResult.mediaItems.length) {
|
if (deviceResult.mediaItems.length) {
|
||||||
@@ -130,18 +130,15 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
console.error('Device media fetch error:', err);
|
console.error('Device media fetch error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Previously imported Google Photos (stored in DB) ───
|
// ─── Previously imported Google Photos ───
|
||||||
try {
|
try {
|
||||||
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
||||||
if (googleMedia?.length) {
|
if (googleMedia?.length) {
|
||||||
// Validate cached files still exist — clear stale paths
|
|
||||||
const validated = googleMedia.map((item) => {
|
const validated = googleMedia.map((item) => {
|
||||||
if (item.local_path) {
|
if (item.local_path) {
|
||||||
try {
|
try {
|
||||||
const f = new File(item.local_path);
|
const f = new File(item.local_path);
|
||||||
if (!f.exists || f.size === 0) {
|
if (!f.exists || f.size === 0) return { ...item, local_path: null };
|
||||||
return { ...item, local_path: null };
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return { ...item, local_path: null };
|
return { ...item, local_path: null };
|
||||||
}
|
}
|
||||||
@@ -154,15 +151,29 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
console.error('DB Google media fetch error:', err);
|
console.error('DB Google media fetch error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── NAS fetch ───
|
// ─── NAS fetch (REST refresh cycle) ───
|
||||||
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
||||||
if (nasAccount) {
|
if (nasAccount) {
|
||||||
try {
|
try {
|
||||||
const nasAuth = new NASAUTH();
|
|
||||||
const nasData = nasAccount.account_data;
|
const nasData = nasAccount.account_data;
|
||||||
await nasAuth.authenticate(nasData.host, nasData.port, nasData.username, nasData.password, nasData.photoFolder);
|
const nasAuth = new NASAUTH();
|
||||||
const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder);
|
await nasAuth.authenticate(nasData.host, nasData.port, nasData.username);
|
||||||
if (nasPhotos?.mediaItems) newMedia.push(...nasPhotos.mediaItems);
|
const result = await nasAuth.refreshPhotos();
|
||||||
|
if (result?.photos?.length) {
|
||||||
|
const nasItems = result.photos.map((p) => ({
|
||||||
|
source_service: 'NAS',
|
||||||
|
source_id: `nas_${p.id}`,
|
||||||
|
title: p.filename,
|
||||||
|
media_type: p.mimeType?.startsWith('image/') ? 'photo' : 'video',
|
||||||
|
created_date: p.indexedAt,
|
||||||
|
size: p.fileSize,
|
||||||
|
sha256Hash: p.sha256Hash,
|
||||||
|
nas_photo_id: p.id,
|
||||||
|
// No local_path — NAS photos are fetched on-demand as base64
|
||||||
|
local_path: null,
|
||||||
|
}));
|
||||||
|
newMedia.push(...nasItems);
|
||||||
|
}
|
||||||
await nasAuth.revokeAccess();
|
await nasAuth.revokeAccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('NAS fetch error:', err);
|
console.error('NAS fetch error:', err);
|
||||||
@@ -175,9 +186,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
console.log('iCloud: placeholder — would fetch photos here');
|
console.log('iCloud: placeholder — would fetch photos here');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Merge & cross-reference across services ───
|
|
||||||
// Match items across sources by filename so we can show "this photo
|
|
||||||
// exists on Device AND Google Photos" etc.
|
|
||||||
const merged = mergeMediaAcrossServices(newMedia);
|
const merged = mergeMediaAcrossServices(newMedia);
|
||||||
setAllMedia(merged);
|
setAllMedia(merged);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -188,7 +196,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Import from Google Photos (opens picker in browser) ───
|
// ─── Import from Google Photos ───
|
||||||
const importGooglePhotos = async () => {
|
const importGooglePhotos = async () => {
|
||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
if (!googleAccount) {
|
if (!googleAccount) {
|
||||||
@@ -206,7 +214,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
let result = await googleAuth.pickPhotos();
|
let result = await googleAuth.pickPhotos();
|
||||||
|
|
||||||
// If 403 (stale scopes), auto re-auth and retry
|
|
||||||
if (result?.error?.code === 403) {
|
if (result?.error?.code === 403) {
|
||||||
console.log('Stale token — re-authenticating…');
|
console.log('Stale token — re-authenticating…');
|
||||||
await googleAuth.revokeAccess();
|
await googleAuth.revokeAccess();
|
||||||
@@ -223,21 +230,15 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
logError('Import Google Photos', result.error.message);
|
logError('Import Google Photos', result.error.message);
|
||||||
} else if (result?.mediaItems?.length) {
|
} else if (result?.mediaItems?.length) {
|
||||||
// Save imported items to DB so they persist across app restarts
|
|
||||||
for (const item of result.mediaItems) {
|
for (const item of result.mediaItems) {
|
||||||
await DatabaseService.saveMediaItem(userId, item);
|
await DatabaseService.saveMediaItem(userId, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
setGoogleNextPage(result.nextPageToken || null);
|
setGoogleNextPage(result.nextPageToken || null);
|
||||||
setGoogleSessionId(result.sessionId || null);
|
setGoogleSessionId(result.sessionId || null);
|
||||||
|
|
||||||
// Merge into current view with cross-service matching
|
|
||||||
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
|
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
|
||||||
|
|
||||||
console.log(`Imported ${result.mediaItems.length} photos from Google`);
|
console.log(`Imported ${result.mediaItems.length} photos from Google`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist refreshed tokens
|
|
||||||
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
|
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
|
||||||
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
||||||
accessToken: googleAuth.accessToken,
|
accessToken: googleAuth.accessToken,
|
||||||
@@ -256,7 +257,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const filterMedia = () => {
|
const filterMedia = () => {
|
||||||
let filtered = allMedia;
|
let filtered = allMedia;
|
||||||
if (selectedFilter !== 'all') {
|
if (selectedFilter !== 'all') {
|
||||||
// Check both source_service and the sources array for cross-service items
|
|
||||||
filtered = allMedia.filter((m) =>
|
filtered = allMedia.filter((m) =>
|
||||||
m.source_service === selectedFilter ||
|
m.source_service === selectedFilter ||
|
||||||
(m.sources && m.sources.includes(selectedFilter))
|
(m.sources && m.sources.includes(selectedFilter))
|
||||||
@@ -314,11 +314,93 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
// ─── Photo actions ───
|
||||||
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
|
||||||
|
const handleDeletePhoto = (item) => {
|
||||||
|
const sources = item.sources || [item.source_service];
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
if (sources.includes('DEVICE')) {
|
||||||
|
actions.push('your device');
|
||||||
|
}
|
||||||
|
if (sources.includes('NAS') || item.nas_photo_id) {
|
||||||
|
actions.push('the NAS');
|
||||||
|
}
|
||||||
|
if (sources.includes('GOOGLE_PHOTOS')) {
|
||||||
|
actions.push('Google Photos (DB record)');
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Photo',
|
||||||
|
`Delete "${item.title || 'this photo'}" from ${actions.join(' and ')}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
// Delete from NAS if applicable
|
||||||
|
if (item.nas_photo_id) {
|
||||||
|
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
||||||
|
if (nasAccount) {
|
||||||
|
const nasAuth = new NASAUTH();
|
||||||
|
const nasData = nasAccount.account_data;
|
||||||
|
await nasAuth.authenticate(nasData.host, nasData.port, nasData.username);
|
||||||
|
await nasAuth.deletePhoto(item.nas_photo_id);
|
||||||
|
await nasAuth.revokeAccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove from local DB
|
||||||
|
if (item.id && typeof item.id === 'number') {
|
||||||
|
await DatabaseService.deleteMediaItem?.(item.id);
|
||||||
|
}
|
||||||
|
// Remove from state
|
||||||
|
setAllMedia((prev) => prev.filter((m) =>
|
||||||
|
(m.source_id || m.id) !== (item.source_id || item.id)
|
||||||
|
));
|
||||||
|
setViewerVisible(false);
|
||||||
|
Alert.alert('Deleted', 'Photo has been removed.');
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Error', 'Failed to delete: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLongPress = (item) => {
|
||||||
|
setActionSheetTarget(item);
|
||||||
|
setActionSheetVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionSheetActions = () => {
|
||||||
|
if (!actionSheetTarget) return [];
|
||||||
|
const actions = [
|
||||||
|
{ label: 'View Details', icon: 'ℹ️', onPress: () => { setSelectedMedia(actionSheetTarget); setViewerVisible(true); } },
|
||||||
|
];
|
||||||
|
if (actionSheetTarget.nas_photo_id || actionSheetTarget.source_service === 'NAS') {
|
||||||
|
actions.push({ label: 'Delete from NAS', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionSheetTarget) });
|
||||||
|
}
|
||||||
|
if (actionSheetTarget.source_service === 'DEVICE') {
|
||||||
|
actions.push({ label: 'Delete from Device', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionSheetTarget) });
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
|
|
||||||
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
|
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
|
|
||||||
|
// Build filter list
|
||||||
|
const sourceFilters = [
|
||||||
|
{ key: 'DEVICE', icon: getServiceIcon('DEVICE'), name: getServiceName('DEVICE') },
|
||||||
|
...linkedAccounts.map((acc) => ({
|
||||||
|
key: acc.service_type,
|
||||||
|
icon: getServiceIcon(acc.service_type),
|
||||||
|
name: getServiceName(acc.service_type),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
if (loading && allMedia.length === 0) {
|
if (loading && allMedia.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
|
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
|
||||||
@@ -328,39 +410,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the list of available source filters
|
|
||||||
const sourceFilters = [
|
|
||||||
{ key: 'DEVICE', icon: '📱', name: 'On Device' },
|
|
||||||
...linkedAccounts.map((acc) => ({
|
|
||||||
key: acc.service_type,
|
|
||||||
icon: getServiceIcon(acc.service_type),
|
|
||||||
name: getServiceName(acc.service_type),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const FilterChips = () => (
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow} style={styles.filterContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.chip, { backgroundColor: selectedFilter === 'all' ? theme.chipActiveBg : theme.chipBg }]}
|
|
||||||
onPress={() => setSelectedFilter('all')}
|
|
||||||
>
|
|
||||||
<Text style={[styles.chipText, selectedFilter === 'all' && { color: theme.primary, fontWeight: '600' }]}>All</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{sourceFilters.map((f) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={f.key}
|
|
||||||
style={[styles.chip, { backgroundColor: selectedFilter === f.key ? theme.chipActiveBg : theme.chipBg }]}
|
|
||||||
onPress={() => setSelectedFilter(f.key)}
|
|
||||||
>
|
|
||||||
<Text style={styles.chipIcon}>{f.icon}</Text>
|
|
||||||
<Text style={[styles.chipText, selectedFilter === f.key && { color: theme.primary, fontWeight: '600' }]}>
|
|
||||||
{f.name}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<TopBar
|
<TopBar
|
||||||
@@ -370,7 +419,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterChips />
|
<FilterChips filters={sourceFilters} selectedKey={selectedFilter} onSelect={setSelectedFilter} />
|
||||||
|
|
||||||
{/* Import from Google Photos button */}
|
{/* Import from Google Photos button */}
|
||||||
{hasGoogleAccount && (
|
{hasGoogleAccount && (
|
||||||
@@ -392,25 +441,22 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredMedia.length === 0 ? (
|
{filteredMedia.length === 0 ? (
|
||||||
<View style={styles.centerContainer}>
|
<EmptyState
|
||||||
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
|
icon="📸"
|
||||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
|
title="No Photos Yet"
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
message={
|
||||||
{hasGoogleAccount
|
hasGoogleAccount
|
||||||
? 'Tap "Import from Google Photos" above to select cloud photos,\nor grant media library permission to see on-device photos.'
|
? 'Tap "Import from Google Photos" above to select cloud photos,\nor grant media library permission to see on-device photos.'
|
||||||
: 'Grant media library permission or take some photos to get started'}
|
: 'Grant media library permission or take some photos to get started'
|
||||||
</Text>
|
}
|
||||||
<TouchableOpacity
|
actionLabel="Refresh"
|
||||||
style={[styles.retryButton, { backgroundColor: theme.primary }]}
|
onAction={() => { setRefreshing(true); loadAllMedia(); }}
|
||||||
onPress={() => { setRefreshing(true); loadAllMedia(); }}
|
/>
|
||||||
>
|
|
||||||
<Text style={styles.retryButtonText}>Refresh</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<PhotoGrid
|
<PhotoGrid
|
||||||
data={filteredMedia}
|
data={filteredMedia}
|
||||||
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
|
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
|
||||||
|
onItemLongPress={handleLongPress}
|
||||||
onEndReached={loadMorePhotos}
|
onEndReached={loadMorePhotos}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
|
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
|
||||||
@@ -428,6 +474,13 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
allMedia={filteredMedia}
|
allMedia={filteredMedia}
|
||||||
onClose={() => setViewerVisible(false)}
|
onClose={() => setViewerVisible(false)}
|
||||||
|
onDelete={handleDeletePhoto}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotoActionSheet
|
||||||
|
visible={actionSheetVisible}
|
||||||
|
onClose={() => setActionSheetVisible(false)}
|
||||||
|
actions={getActionSheetActions()}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -437,16 +490,6 @@ const styles = StyleSheet.create({
|
|||||||
container: { flex: 1 },
|
container: { flex: 1 },
|
||||||
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
||||||
loadingText: { marginTop: 12, fontSize: 14 },
|
loadingText: { marginTop: 12, fontSize: 14 },
|
||||||
// Filter chips
|
|
||||||
filterContainer: { maxHeight: 40 },
|
|
||||||
filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
|
|
||||||
chip: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4,
|
|
||||||
borderRadius: 16, gap: 4, height: 28,
|
|
||||||
},
|
|
||||||
chipIcon: { fontSize: 11 },
|
|
||||||
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
|
|
||||||
// Import button
|
|
||||||
importButton: {
|
importButton: {
|
||||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
|
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
|
||||||
@@ -454,11 +497,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
importIcon: { fontSize: 16 },
|
importIcon: { fontSize: 16 },
|
||||||
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||||
// Empty
|
|
||||||
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
|
||||||
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
|
|
||||||
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
|
|
||||||
retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default HomeScreen;
|
export default HomeScreen;
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
// NAS config
|
// NAS config
|
||||||
const [nasHost, setNasHost] = useState('');
|
const [nasHost, setNasHost] = useState('');
|
||||||
const [nasPort, setNasPort] = useState('22');
|
const [nasPort, setNasPort] = useState('9500');
|
||||||
const [nasUsername, setNasUsername] = useState('');
|
const [nasUsername, setNasUsername] = useState('');
|
||||||
const [nasPassword, setNasPassword] = useState('');
|
|
||||||
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
|
|
||||||
|
|
||||||
// Auto-backup checklist (multi-select)
|
// Auto-backup checklist (multi-select)
|
||||||
const [autoBackupServices, setAutoBackupServices] = useState([]);
|
const [autoBackupServices, setAutoBackupServices] = useState([]);
|
||||||
@@ -102,8 +100,8 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNasAuth = async () => {
|
const handleNasAuth = async () => {
|
||||||
if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
|
if (!nasHost.trim() || !nasUsername.trim()) {
|
||||||
Alert.alert('Error', 'Please fill in all NAS connection fields');
|
Alert.alert('Error', 'Please fill in the NAS host and username');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -111,14 +109,13 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
setNasHealth('checking');
|
setNasHealth('checking');
|
||||||
const nasAuth = new NASAUTH();
|
const nasAuth = new NASAUTH();
|
||||||
await nasAuth.authenticate(
|
await nasAuth.authenticate(
|
||||||
nasHost.trim(), parseInt(nasPort) || 22,
|
nasHost.trim(), parseInt(nasPort) || 9500,
|
||||||
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
nasUsername.trim()
|
||||||
);
|
);
|
||||||
await DatabaseService.linkAccount(userId, 'NAS', {
|
await DatabaseService.linkAccount(userId, 'NAS', {
|
||||||
host: nasHost.trim(),
|
host: nasHost.trim(),
|
||||||
port: parseInt(nasPort) || 22,
|
port: parseInt(nasPort) || 9500,
|
||||||
username: nasUsername.trim(),
|
username: nasUsername.trim(),
|
||||||
photoFolder: nasPhotoFolder.trim(),
|
|
||||||
});
|
});
|
||||||
setNasConnected(true);
|
setNasConnected(true);
|
||||||
setNasHealth('healthy');
|
setNasHealth('healthy');
|
||||||
@@ -189,7 +186,7 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
description="Access your iCloud photo library (placeholder)"
|
description="Access your iCloud photo library (placeholder)"
|
||||||
enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} />
|
enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} />
|
||||||
<ServiceToggleCard theme={theme} icon="💾" name="NAS Storage"
|
<ServiceToggleCard theme={theme} icon="💾" name="NAS Storage"
|
||||||
description="Connect to your Ubuntu server via SFTP"
|
description="Connect to your NAS photo server (REST API)"
|
||||||
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
|
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
|
||||||
|
|
||||||
{/* NAS setup instructions dropdown */}
|
{/* NAS setup instructions dropdown */}
|
||||||
@@ -254,19 +251,11 @@ const OnboardingScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
|
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
||||||
placeholder="22" value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor={theme.textTertiary} />
|
placeholder="9500" value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor={theme.textTertiary} />
|
||||||
|
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
|
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
||||||
placeholder="SSH username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
|
placeholder="Your username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
|
||||||
|
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Password</Text>
|
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
|
||||||
placeholder="SSH password" value={nasPassword} onChangeText={setNasPassword} editable={!loading} secureTextEntry autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
|
|
||||||
|
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Photos Folder</Text>
|
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
|
||||||
placeholder="/home/user/photos" value={nasPhotoFolder} onChangeText={setNasPhotoFolder} editable={!loading} placeholderTextColor={theme.textTertiary} />
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.authButton, { backgroundColor: theme.accent }, loading && styles.buttonDisabled]}
|
style={[styles.authButton, { backgroundColor: theme.accent }, loading && styles.buttonDisabled]}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import NASSetupInstructions from '../components/NASSetupInstructions';
|
|||||||
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
|
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';
|
||||||
|
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
|
||||||
|
|
||||||
const SettingsScreen = ({ navigation, route }) => {
|
const SettingsScreen = ({ navigation, route }) => {
|
||||||
const userId = route?.params?.userId;
|
const userId = route?.params?.userId;
|
||||||
@@ -25,10 +26,8 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
// NAS edit fields
|
// NAS edit fields
|
||||||
const [editingNas, setEditingNas] = useState(false);
|
const [editingNas, setEditingNas] = useState(false);
|
||||||
const [nasHost, setNasHost] = useState('');
|
const [nasHost, setNasHost] = useState('');
|
||||||
const [nasPort, setNasPort] = useState('22');
|
const [nasPort, setNasPort] = useState('9500');
|
||||||
const [nasUsername, setNasUsername] = useState('');
|
const [nasUsername, setNasUsername] = useState('');
|
||||||
const [nasPassword, setNasPassword] = useState('');
|
|
||||||
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -46,9 +45,8 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
const nasAcc = allAccounts.find((a) => a.service_type === 'NAS');
|
const nasAcc = allAccounts.find((a) => a.service_type === 'NAS');
|
||||||
if (nasAcc?.account_data) {
|
if (nasAcc?.account_data) {
|
||||||
setNasHost(nasAcc.account_data.host || '');
|
setNasHost(nasAcc.account_data.host || '');
|
||||||
setNasPort(String(nasAcc.account_data.port || 22));
|
setNasPort(String(nasAcc.account_data.port || 9500));
|
||||||
setNasUsername(nasAcc.account_data.username || '');
|
setNasUsername(nasAcc.account_data.username || '');
|
||||||
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check health for each enabled account
|
// Check health for each enabled account
|
||||||
@@ -88,9 +86,10 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
if (acc.service_type === 'NAS') {
|
if (acc.service_type === 'NAS') {
|
||||||
const nasAuth = new NASAUTH();
|
const nasAuth = new NASAUTH();
|
||||||
const d = acc.account_data || {};
|
const d = acc.account_data || {};
|
||||||
await nasAuth.authenticate(d.host, d.port, d.username, d.password, d.photoFolder);
|
await nasAuth.authenticate(d.host, d.port, d.username);
|
||||||
|
const healthy = await nasAuth.healthCheck();
|
||||||
await nasAuth.revokeAccess();
|
await nasAuth.revokeAccess();
|
||||||
return 'healthy';
|
return healthy ? 'healthy' : 'error';
|
||||||
}
|
}
|
||||||
if (acc.service_type === 'ICLOUD_PHOTOS') {
|
if (acc.service_type === 'ICLOUD_PHOTOS') {
|
||||||
// Placeholder – always "healthy" since it's a stub
|
// Placeholder – always "healthy" since it's a stub
|
||||||
@@ -191,22 +190,21 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveNasConfig = async () => {
|
const saveNasConfig = async () => {
|
||||||
if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
|
if (!nasHost.trim() || !nasUsername.trim()) {
|
||||||
Alert.alert('Error', 'Please fill in all NAS fields');
|
Alert.alert('Error', 'Please fill in the NAS host and username');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const nasAuth = new NASAUTH();
|
const nasAuth = new NASAUTH();
|
||||||
await nasAuth.authenticate(
|
await nasAuth.authenticate(
|
||||||
nasHost.trim(), parseInt(nasPort) || 22,
|
nasHost.trim(), parseInt(nasPort) || 9500,
|
||||||
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
|
nasUsername.trim()
|
||||||
);
|
);
|
||||||
await DatabaseService.linkAccount(userId, 'NAS', {
|
await DatabaseService.linkAccount(userId, 'NAS', {
|
||||||
host: nasHost.trim(),
|
host: nasHost.trim(),
|
||||||
port: parseInt(nasPort) || 22,
|
port: parseInt(nasPort) || 9500,
|
||||||
username: nasUsername.trim(),
|
username: nasUsername.trim(),
|
||||||
photoFolder: nasPhotoFolder.trim(),
|
|
||||||
});
|
});
|
||||||
setEditingNas(false);
|
setEditingNas(false);
|
||||||
await loadData();
|
await loadData();
|
||||||
@@ -225,11 +223,6 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getServiceIcon = (type) =>
|
|
||||||
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
|
||||||
const getServiceName = (type) =>
|
|
||||||
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
|
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
|
||||||
{ key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' },
|
{ key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' },
|
||||||
@@ -348,16 +341,10 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
value={nasHost} onChangeText={setNasHost} placeholder="192.168.1.100" placeholderTextColor={theme.textTertiary} />
|
value={nasHost} onChangeText={setNasHost} placeholder="192.168.1.100" placeholderTextColor={theme.textTertiary} />
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
|
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</Text>
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
||||||
value={nasPort} onChangeText={setNasPort} keyboardType="numeric" placeholderTextColor={theme.textTertiary} />
|
value={nasPort} onChangeText={setNasPort} keyboardType="numeric" placeholder="9500" placeholderTextColor={theme.textTertiary} />
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
|
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Username</Text>
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
||||||
value={nasUsername} onChangeText={setNasUsername} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
|
value={nasUsername} onChangeText={setNasUsername} autoCapitalize="none" placeholder="Your username" placeholderTextColor={theme.textTertiary} />
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Password</Text>
|
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
|
||||||
value={nasPassword} onChangeText={setNasPassword} secureTextEntry autoCapitalize="none" placeholder="Enter password" placeholderTextColor={theme.textTertiary} />
|
|
||||||
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Photos Folder</Text>
|
|
||||||
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
|
|
||||||
value={nasPhotoFolder} onChangeText={setNasPhotoFolder} placeholderTextColor={theme.textTertiary} />
|
|
||||||
<TouchableOpacity style={[styles.saveButton, { backgroundColor: theme.primary }]} onPress={saveNasConfig} disabled={loading}>
|
<TouchableOpacity style={[styles.saveButton, { backgroundColor: theme.primary }]} onPress={saveNasConfig} disabled={loading}>
|
||||||
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveButtonText}>Save Configuration</Text>}
|
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveButtonText}>Save Configuration</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -384,6 +384,17 @@ const DatabaseService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteMediaItem: async (mediaId) => {
|
||||||
|
try {
|
||||||
|
if (!(await DatabaseService.ensureDb())) return false;
|
||||||
|
await db.runAsync('DELETE FROM media WHERE id = ?', [mediaId]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logError('Delete Media Item', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ─── Settings ──────────────────────────────────────────────
|
// ─── Settings ──────────────────────────────────────────────
|
||||||
|
|
||||||
getAutoBackupServices: async (userId) => {
|
getAutoBackupServices: async (userId) => {
|
||||||
|
|||||||
Reference in new issue
Block a user