feat: update NAS configuration and enhance UI components for better user experience

This commit is contained in:
bobbert committed 2026-03-04 21:52:52 +00:00
1 parent 8e6fa5270b
commit 6167418074
23 files changed
+2147 -702

No files matched your search

+164 -101
View File
@@ -1,135 +1,198 @@
import axios from 'axios';
// HTTP client that communicates with the SFTP backend (ServerFiles/server.js)
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
// REST client that communicates with the NAS Photo Server (ServerFiles/server.js)
const DEFAULT_PORT = '9500';
class NASAPI {
constructor(backendUrl = DEFAULT_BACKEND_URL) {
this.backendUrl = backendUrl;
this.connectionToken = null;
constructor() {
this.baseUrl = null;
this.username = null;
this.isConnected = false;
this.credentials = null;
}
// Establish a connection via the backend; falls back to local-only config if the backend is unreachable
async connect(host, port, username, password, photoFolder = '/Photos') {
if (!host || !username || !password) throw new Error('Host, username, and password are required');
configure(host, port, username) {
this.baseUrl = `http://${host}:${port || DEFAULT_PORT}`;
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 {
const response = await axios.post(
`${this.backendUrl}/api/sftp/connect`,
{ host, port: port || 22, username, password },
{ timeout: 10000 }
);
if (response.data?.success) {
this.connectionToken = response.data.connectionToken;
this.isConnected = true;
return { success: true, message: `Connected to ${host}` };
}
} 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 };
const response = await axios.get(`${this.baseUrl}/api/health`, { timeout: 5000 });
return response.data?.status === 'ok';
} catch {
return false;
}
}
async uploadFile(localPath, remotePath) {
if (!this.connectionToken) throw new Error('Backend not connected — cannot upload');
const formData = new FormData();
formData.append('remotePath', remotePath);
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
// Full refresh cycle: POST to re-index + fetch updated photo list
async refreshPhotos() {
if (!this.username) throw new Error('Not connected');
const response = await axios.post(
`${this.backendUrl}/api/sftp/upload`, formData,
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
`${this.baseUrl}/api/refresh`,
{ username: this.username },
{ timeout: 30000 },
);
return response.data;
}
async createDirectory(remotePath) {
if (!this.connectionToken) throw new Error('Backend not connected');
// Fetch photo index only (no re-index)
async getPhotos() {
if (!this.username) throw new Error('Not connected');
const response = await axios.post(
`${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
`${this.baseUrl}/api/photos`,
{ username: this.username },
{ timeout: 15000 },
);
return response.data;
}
async deleteFile(remotePath) {
if (!this.connectionToken) throw new Error('Backend not connected');
// Fetch a single photo as base64 with hash verification
async fetchPhoto(photoId) {
const response = await axios.post(
`${this.backendUrl}/api/sftp/delete`, { path: remotePath },
{ headers: this.getHeaders(), timeout: 10000 }
`${this.baseUrl}/api/photo/fetch`,
{ photoId },
{ timeout: 60000 },
);
return response.data;
}
async disconnect() {
if (this.connectionToken) {
try {
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
} catch (_) { /* ignore */ }
if (!response.data?.success || !response.data?.photo) {
throw new Error('Failed to fetch photo');
}
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.credentials = null;
return { success: true };
}
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
View File
@@ -1,33 +1,34 @@
import { AuthServiceBase } from './AuthServiceBase';
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 {
constructor() {
super();
this.nasAPI = new NASAPI();
this.host = null;
this.port = 22;
this.port = null;
this.username = null;
this.password = null;
this.photoFolder = null;
this.isConnected = false;
}
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
async authenticate(host, port = 9500, username) {
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.port = port || 22;
this.port = port || 9500;
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;
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) {
console.error('NAS authentication error:', error.message);
this.isConnected = false;
@@ -41,23 +42,21 @@ class NASAUTH extends AuthServiceBase {
}
async refreshToken() {
if (this.host && this.username && this.password) {
return await this.authenticate(this.host, this.port, this.username, this.password, this.photoFolder);
if (this.host && this.username) {
return await this.authenticate(this.host, this.port, this.username);
}
throw new Error('No NAS credentials available for reconnection');
}
async revokeAccess() {
try {
await this.nasAPI.disconnect();
this.nasAPI.disconnect();
} catch (error) {
console.error('Disconnect error:', error.message);
}
this.isConnected = false;
this.host = null;
this.username = null;
this.password = null;
this.photoFolder = null;
return { success: true, message: 'NAS connection closed' };
}
@@ -67,35 +66,75 @@ class NASAUTH extends AuthServiceBase {
host: this.host,
username: this.username,
port: this.port,
photoFolder: this.photoFolder,
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');
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');
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');
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');
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');
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);
}
}
+35
View File
@@ -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;
+52
View File
@@ -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;
+45
View File
@@ -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;
+13
View File
@@ -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);
+57 -11
View File
@@ -4,26 +4,25 @@ import {
Dimensions, FlatList,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const THUMB_SIZE = 60;
const THUMB_GAP = 4;
const getServiceIcon = (type) =>
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) =>
({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
/**
* Fullscreen media viewer with thumbnail carousel.
* Fullscreen media viewer with thumbnail carousel and action toolbar.
*
* Props:
* visible – boolean to show/hide the modal
* media – currently selected media item
* allMedia – (optional) full array of media items for carousel navigation
* onClose – callback when user dismisses the viewer
* visible – boolean to show/hide the modal
* media – currently selected media item
* allMedia – (optional) full array of media items for carousel navigation
* onClose – callback when user dismisses the viewer
* 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 mainListRef = useRef(null);
const thumbListRef = useRef(null);
@@ -185,6 +184,28 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
{/* Info panel */}
<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>
<View style={styles.row}>
<Text style={styles.label}>Source</Text>
@@ -211,6 +232,24 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
</Text>
</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>
</Modal>
@@ -255,6 +294,13 @@ const styles = StyleSheet.create({
padding: 16, paddingBottom: 32,
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 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
label: { fontSize: 12, color: '#9aa0a6' },
+20 -31
View File
@@ -25,51 +25,41 @@ const NASSetupInstructions = () => {
<View style={styles.body}>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Prerequisites</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
• An Ubuntu server (20.04 LTS or later) with SSH access enabled{'\n'}
• A user account with sudo access on the server{'\n'}
• The server's IP address and SSH port (default: 22)
• A Windows or Ubuntu machine to run the NAS server{'\n'}
• Node.js installed on the NAS machine{'\n'}
• The NAS machine's IP address on your local network
</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 }]}>
If SSH is not already enabled on your Ubuntu server, open a terminal on the server and run:{'\n\n'}
sudo apt update && sudo apt install openssh-server{'\n'}
sudo systemctl enable ssh && sudo systemctl start ssh
The ServerFiles folder is self-contained. Copy it to any machine you want to use as a NAS.
</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 }]}>
Create a dedicated folder for your photo storage:{'\n\n'}
mkdir -p ~/photos{'\n'}
chmod 755 ~/photos
Open a terminal in the ServerFiles folder and run:{'\n\n'}
node cli.js{'\n\n'}
The CLI will ask whether you are on Windows or Linux (Ubuntu), set a photos storage path, and optionally store a password.
</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 }]}>
If your server uses UFW, allow SSH connections:{'\n\n'}
sudo ufw allow ssh{'\n'}
sudo ufw enable
From the CLI menu, select "Start NAS Server". The server starts on port 9500 by default.{'\n\n'}
Alternatively run: node server.js
</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 }]}>
Run this on your server to find its local IP:{'\n\n'}
hostname -I{'\n\n'}
Use that IP address in the "Server Address" field above.
Enter the NAS machine IP, port (default 9500), and your username.{'\n\n'}
No password or photo folder path is needed — the NAS manages storage location and user folders automatically.
</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 }]}>
Enter the server IP, SSH port (22), your username, password, and the photos folder path (e.g. /home/youruser/photos).{'\n\n'}
The app's backend server will handle the SFTP connection securely.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 6: Run the Backend Server</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
The NAS connection uses a backend server. Navigate to the ServerFiles folder and run:{'\n\n'}
npm install{'\n'}
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).
• Your username is sent to the NAS, which creates a folder for you{'\n'}
• The NAS indexes all photos in your folder and stores SHA-256 hashes{'\n'}
• 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>
</View>
)}
@@ -98,4 +88,3 @@ const styles = StyleSheet.create({
});
export default NASSetupInstructions;
+78
View File
@@ -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 -2
View File
@@ -3,6 +3,7 @@ import {
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
import { getServiceIcon } from '../UI/ServiceHelpers';
const { width } = Dimensions.get('window');
const GRID_GAP = 2;
@@ -10,6 +11,7 @@ const GRID_GAP = 2;
const PhotoGrid = ({
data,
onItemPress,
onItemLongPress,
columns = 3,
refreshControl,
ListHeaderComponent,
@@ -20,8 +22,6 @@ const PhotoGrid = ({
const { theme } = useTheme();
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
const getServiceIcon = (type) =>
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const renderSourceBadges = (item) => {
const sources = item.sources && item.sources.length > 0
@@ -51,6 +51,7 @@ const PhotoGrid = ({
<TouchableOpacity
style={[styles.gridItem, { width: itemSize, height: itemSize, backgroundColor: theme.chipBg }]}
onPress={() => onItemPress?.(item)}
onLongPress={() => onItemLongPress?.(item)}
activeOpacity={0.8}
>
{item.local_path ? (
+286 -50
View File
@@ -1,12 +1,17 @@
import React, { useState, useCallback } from 'react';
import {
StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity,
Dimensions, Image, ActivityIndicator, RefreshControl,
Dimensions, Image, ActivityIndicator, RefreshControl, Alert, TextInput, Modal,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import NASAUTH from '../AUTH/NASAUTH';
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 ALBUM_COLUMNS = 2;
@@ -24,14 +29,30 @@ const CollectionsScreen = ({ route }) => {
const [selectedMedia, setSelectedMedia] = useState(null);
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(
useCallback(() => {
if (userId) loadCollections();
}, [userId])
);
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
const getNasAuth = async () => {
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 () => {
try {
@@ -39,7 +60,7 @@ const CollectionsScreen = ({ route }) => {
const allMedia = await DatabaseService.getMediaByUser(userId);
const sectionList = [];
// ─── Utility albums (top) ───
// ─── Utility albums ───
const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, 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);
} catch (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) {
const gridItemSize = (width - 6) / 3;
const isNasAlbum = !!selectedAlbum.nasAlbumId;
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.albumHeader, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => { setSelectedAlbum(null); setAlbumMedia([]); }}>
<Text style={[styles.backArrow, { color: theme.text }]}>←</Text>
</TouchableOpacity>
<View style={styles.albumHeaderInfo}>
<Text style={[styles.albumHeaderTitle, { color: theme.text }]}>{selectedAlbum.name}</Text>
<Text style={[styles.albumHeaderCount, { color: theme.textSecondary }]}>
{selectedAlbum.count} item{selectedAlbum.count !== 1 ? 's' : ''}
</Text>
</View>
</View>
<ScreenHeader
title={selectedAlbum.name}
subtitle={`${albumMedia.length} item${albumMedia.length !== 1 ? 's' : ''}`}
onBack={() => { setSelectedAlbum(null); setAlbumMedia([]); }}
rightAction={isNasAlbum ? (
<TouchableOpacity onPress={() => handleDeleteAlbum(selectedAlbum)}>
<Text style={{ color: theme.danger, fontSize: 13, fontWeight: '600' }}>Delete</Text>
</TouchableOpacity>
) : undefined}
/>
{albumMedia.length === 0 ? (
<View style={styles.centerContainer}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📭</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No items in this collection</Text>
</View>
<EmptyState icon="📭" title="No items in this collection" />
) : (
<FlatList
data={albumMedia}
@@ -105,6 +300,7 @@ const CollectionsScreen = ({ route }) => {
<TouchableOpacity
style={[styles.gridItem, { width: gridItemSize, height: gridItemSize, backgroundColor: theme.chipBg }]}
onPress={() => { setSelectedMedia(item); setViewerVisible(true); }}
onLongPress={() => handlePhotoLongPress(item)}
activeOpacity={0.8}
>
{item.local_path ? (
@@ -116,16 +312,28 @@ const CollectionsScreen = ({ route }) => {
)}
</TouchableOpacity>
)}
keyExtractor={(item, i) => item.id?.toString() || i.toString()}
keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()}
numColumns={3}
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>
);
}
// ─── Loading ───
if (loading) {
return (
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
@@ -135,13 +343,15 @@ const CollectionsScreen = ({ route }) => {
);
}
// ─── Album card renderer ───
const renderAlbumRow = ({ item }) => (
<View style={styles.albumRow}>
{item.albums.map((album, idx) => (
<TouchableOpacity
key={album.name + idx}
style={[styles.albumCard, { width: albumSize }]}
onPress={() => { setSelectedAlbum(album); setAlbumMedia(album.media); }}
onPress={() => openAlbum(album)}
onLongPress={() => album.nasAlbumId && handleDeleteAlbum(album)}
activeOpacity={0.8}
>
<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 (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={[styles.header, { backgroundColor: theme.background }]}>
<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>
{sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? (
<View style={styles.centerContainer}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Collections from your connected services will appear here
</Text>
</View>
<EmptyState
icon="📁"
title="No Collections"
message="Collections from your connected services will appear here"
/>
) : (
<SectionList
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>
);
};
@@ -206,42 +449,35 @@ const CollectionsScreen = ({ route }) => {
const styles = StyleSheet.create({
container: { flex: 1 },
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' },
createBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 16 },
createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
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 },
sectionTitle: { fontSize: 16, fontWeight: '700' },
// Album grid
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
albumCard: {},
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
albumCoverImage: { width: '100%', height: '100%' },
albumCoverPlaceholder: {
width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center',
},
albumCoverPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
// 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 },
gridItem: { margin: 1 },
gridImage: { width: '100%', height: '100%' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
// Empty / Loading
loadingText: { marginTop: 12, fontSize: 14 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' },
// Create album modal
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;
+134 -96
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
StyleSheet, View, Text, ActivityIndicator, RefreshControl, Alert,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
@@ -12,6 +12,10 @@ import { File } from 'expo-file-system';
import TopBar from '../components/TopBar';
import PhotoGrid from '../components/PhotoGrid';
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 { TouchableOpacity } from 'react-native';
@@ -19,11 +23,8 @@ import { TouchableOpacity } from 'react-native';
* Merge media items from different services.
* Items with the same filename (title) are merged into a single entry
* with a `sources` array listing every service it appears in.
* The best local_path is kept (prefer file:// from DEVICE over a cached
* Picker download, since device URIs are always fresh).
*/
const mergeMediaAcrossServices = (items) => {
// First pass: group by normalised filename
const byFilename = new Map();
const noFilename = [];
@@ -36,15 +37,12 @@ const mergeMediaAcrossServices = (items) => {
if (byFilename.has(key)) {
const existing = byFilename.get(key);
// Add this service to the sources list
if (!existing.sources.includes(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) {
existing.local_path = item.local_path;
}
// Keep the best local_path if the existing one is missing
if (!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();
for (const item of [...byFilename.values(), ...noFilename]) {
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 [loadingMore, setLoadingMore] = useState(false);
const [importing, setImporting] = useState(false);
// Action sheet state
const [actionSheetVisible, setActionSheetVisible] = useState(false);
const [actionSheetTarget, setActionSheetTarget] = useState(null);
useFocusEffect(
useCallback(() => {
@@ -118,7 +118,7 @@ const HomeScreen = ({ navigation, route }) => {
try {
const newMedia = [];
// ─── Device photos (local camera roll, screenshots, etc.) ───
// ─── Device photos ───
try {
const deviceResult = await DeviceMediaService.getPhotos(100);
if (deviceResult.mediaItems.length) {
@@ -130,18 +130,15 @@ const HomeScreen = ({ navigation, route }) => {
console.error('Device media fetch error:', err);
}
// ─── Previously imported Google Photos (stored in DB) ───
// ─── Previously imported Google Photos ───
try {
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
if (googleMedia?.length) {
// Validate cached files still exist — clear stale paths
const validated = googleMedia.map((item) => {
if (item.local_path) {
try {
const f = new File(item.local_path);
if (!f.exists || f.size === 0) {
return { ...item, local_path: null };
}
if (!f.exists || f.size === 0) return { ...item, local_path: null };
} catch (_) {
return { ...item, local_path: null };
}
@@ -154,15 +151,29 @@ const HomeScreen = ({ navigation, route }) => {
console.error('DB Google media fetch error:', err);
}
// ─── NAS fetch ───
// ─── NAS fetch (REST refresh cycle) ───
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasAccount) {
try {
const nasAuth = new NASAUTH();
const nasData = nasAccount.account_data;
await nasAuth.authenticate(nasData.host, nasData.port, nasData.username, nasData.password, nasData.photoFolder);
const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder);
if (nasPhotos?.mediaItems) newMedia.push(...nasPhotos.mediaItems);
const nasAuth = new NASAUTH();
await nasAuth.authenticate(nasData.host, nasData.port, nasData.username);
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();
} catch (err) {
console.error('NAS fetch error:', err);
@@ -175,9 +186,6 @@ const HomeScreen = ({ navigation, route }) => {
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);
setAllMedia(merged);
} 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 googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
if (!googleAccount) {
@@ -206,7 +214,6 @@ const HomeScreen = ({ navigation, route }) => {
let result = await googleAuth.pickPhotos();
// If 403 (stale scopes), auto re-auth and retry
if (result?.error?.code === 403) {
console.log('Stale token — re-authenticating…');
await googleAuth.revokeAccess();
@@ -223,21 +230,15 @@ const HomeScreen = ({ navigation, route }) => {
if (result?.error) {
logError('Import Google Photos', result.error.message);
} else if (result?.mediaItems?.length) {
// Save imported items to DB so they persist across app restarts
for (const item of result.mediaItems) {
await DatabaseService.saveMediaItem(userId, item);
}
setGoogleNextPage(result.nextPageToken || null);
setGoogleSessionId(result.sessionId || null);
// Merge into current view with cross-service matching
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
console.log(`Imported ${result.mediaItems.length} photos from Google`);
}
// Persist refreshed tokens
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: googleAuth.accessToken,
@@ -256,7 +257,6 @@ const HomeScreen = ({ navigation, route }) => {
const filterMedia = () => {
let filtered = allMedia;
if (selectedFilter !== 'all') {
// Check both source_service and the sources array for cross-service items
filtered = allMedia.filter((m) =>
m.source_service === 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] || '📱');
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
// ─── Photo actions ───
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');
// 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) {
return (
<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 (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<TopBar
@@ -370,7 +419,7 @@ const HomeScreen = ({ navigation, route }) => {
onMenuPress={() => navigation.navigate('Settings', { userId })}
/>
<FilterChips />
<FilterChips filters={sourceFilters} selectedKey={selectedFilter} onSelect={setSelectedFilter} />
{/* Import from Google Photos button */}
{hasGoogleAccount && (
@@ -392,25 +441,22 @@ const HomeScreen = ({ navigation, route }) => {
)}
{filteredMedia.length === 0 ? (
<View style={styles.centerContainer}>
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
{hasGoogleAccount
<EmptyState
icon="📸"
title="No Photos Yet"
message={
hasGoogleAccount
? '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'}
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: theme.primary }]}
onPress={() => { setRefreshing(true); loadAllMedia(); }}
>
<Text style={styles.retryButtonText}>Refresh</Text>
</TouchableOpacity>
</View>
: 'Grant media library permission or take some photos to get started'
}
actionLabel="Refresh"
onAction={() => { setRefreshing(true); loadAllMedia(); }}
/>
) : (
<PhotoGrid
data={filteredMedia}
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
onItemLongPress={handleLongPress}
onEndReached={loadMorePhotos}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
@@ -428,6 +474,13 @@ const HomeScreen = ({ navigation, route }) => {
media={selectedMedia}
allMedia={filteredMedia}
onClose={() => setViewerVisible(false)}
onDelete={handleDeletePhoto}
/>
<PhotoActionSheet
visible={actionSheetVisible}
onClose={() => setActionSheetVisible(false)}
actions={getActionSheetActions()}
/>
</View>
);
@@ -437,16 +490,6 @@ const styles = StyleSheet.create({
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
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: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
@@ -454,11 +497,6 @@ const styles = StyleSheet.create({
},
importIcon: { fontSize: 16 },
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;
+9 -20
View File
@@ -33,10 +33,8 @@ const OnboardingScreen = ({ navigation, route }) => {
// NAS config
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22');
const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Auto-backup checklist (multi-select)
const [autoBackupServices, setAutoBackupServices] = useState([]);
@@ -102,8 +100,8 @@ const OnboardingScreen = ({ navigation, route }) => {
};
const handleNasAuth = async () => {
if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
Alert.alert('Error', 'Please fill in all NAS connection fields');
if (!nasHost.trim() || !nasUsername.trim()) {
Alert.alert('Error', 'Please fill in the NAS host and username');
return;
}
try {
@@ -111,14 +109,13 @@ const OnboardingScreen = ({ navigation, route }) => {
setNasHealth('checking');
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
nasHost.trim(), parseInt(nasPort) || 22,
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
nasHost.trim(), parseInt(nasPort) || 9500,
nasUsername.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
port: parseInt(nasPort) || 22,
port: parseInt(nasPort) || 9500,
username: nasUsername.trim(),
photoFolder: nasPhotoFolder.trim(),
});
setNasConnected(true);
setNasHealth('healthy');
@@ -189,7 +186,7 @@ const OnboardingScreen = ({ navigation, route }) => {
description="Access your iCloud photo library (placeholder)"
enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} />
<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)} />
{/* NAS setup instructions dropdown */}
@@ -254,19 +251,11 @@ const OnboardingScreen = ({ navigation, route }) => {
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</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>
<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} />
<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} />
placeholder="Your username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
<TouchableOpacity
style={[styles.authButton, { backgroundColor: theme.accent }, loading && styles.buttonDisabled]}
+13 -26
View File
@@ -10,6 +10,7 @@ import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import NASAUTH from '../AUTH/NASAUTH';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
const SettingsScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
@@ -25,10 +26,8 @@ const SettingsScreen = ({ navigation, route }) => {
// NAS edit fields
const [editingNas, setEditingNas] = useState(false);
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22');
const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
useFocusEffect(
useCallback(() => {
@@ -46,9 +45,8 @@ const SettingsScreen = ({ navigation, route }) => {
const nasAcc = allAccounts.find((a) => a.service_type === 'NAS');
if (nasAcc?.account_data) {
setNasHost(nasAcc.account_data.host || '');
setNasPort(String(nasAcc.account_data.port || 22));
setNasPort(String(nasAcc.account_data.port || 9500));
setNasUsername(nasAcc.account_data.username || '');
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
}
// Check health for each enabled account
@@ -88,9 +86,10 @@ const SettingsScreen = ({ navigation, route }) => {
if (acc.service_type === 'NAS') {
const nasAuth = new NASAUTH();
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();
return 'healthy';
return healthy ? 'healthy' : 'error';
}
if (acc.service_type === 'ICLOUD_PHOTOS') {
// Placeholder – always "healthy" since it's a stub
@@ -191,22 +190,21 @@ const SettingsScreen = ({ navigation, route }) => {
};
const saveNasConfig = async () => {
if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
Alert.alert('Error', 'Please fill in all NAS fields');
if (!nasHost.trim() || !nasUsername.trim()) {
Alert.alert('Error', 'Please fill in the NAS host and username');
return;
}
setLoading(true);
try {
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
nasHost.trim(), parseInt(nasPort) || 22,
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
nasHost.trim(), parseInt(nasPort) || 9500,
nasUsername.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
port: parseInt(nasPort) || 22,
port: parseInt(nasPort) || 9500,
username: nasUsername.trim(),
photoFolder: nasPhotoFolder.trim(),
});
setEditingNas(false);
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 = [
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
{ 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} />
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>Port</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>
<TextInput style={[styles.fieldInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={nasUsername} onChangeText={setNasUsername} 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 }]}
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} />
value={nasUsername} onChangeText={setNasUsername} autoCapitalize="none" placeholder="Your username" placeholderTextColor={theme.textTertiary} />
<TouchableOpacity style={[styles.saveButton, { backgroundColor: theme.primary }]} onPress={saveNasConfig} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveButtonText}>Save Configuration</Text>}
</TouchableOpacity>
+11
View File
@@ -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 ──────────────────────────────────────────────
getAutoBackupServices: async (userId) => {