@oxyhq/services
Version:
Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀
1,290 lines (1,260 loc) • 82.2 kB
JavaScript
"use strict";
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator, Platform, RefreshControl, Modal, TextInput, Image } from 'react-native';
import { useOxy } from '../context/OxyContext';
import { fontFamilies } from '../styles/fonts';
import { toast } from '../../lib/sonner';
import { Ionicons } from '@expo/vector-icons';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
const FileManagementScreen = ({
onClose,
theme,
goBack,
navigate,
userId,
containerWidth = 400 // Fallback for when not provided by the router
}) => {
const {
user,
oxyServices
} = useOxy();
// Debug: log the actual container width
useEffect(() => {
console.log('[FileManagementScreen] Container width (full):', containerWidth);
// Padding structure:
// - containerWidth = full bottom sheet container width (measured from OxyProvider)
// - photoScrollContainer adds padding: 16 (32px total horizontal padding)
// - Available content width = containerWidth - 32
const availableContentWidth = containerWidth - 32;
console.log('[FileManagementScreen] Available content width:', availableContentWidth);
console.log('[FileManagementScreen] Spacing fix applied: 4px uniform gap both horizontal and vertical');
}, [containerWidth]);
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(null);
const [deleting, setDeleting] = useState(null);
const [selectedFile, setSelectedFile] = useState(null);
const [showFileDetails, setShowFileDetails] = useState(false);
const [openedFile, setOpenedFile] = useState(null);
const [fileContent, setFileContent] = useState(null);
const [loadingFileContent, setLoadingFileContent] = useState(false);
const [showFileDetailsInViewer, setShowFileDetailsInViewer] = useState(false);
const [viewMode, setViewMode] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState([]);
const [isDragging, setIsDragging] = useState(false);
const [photoDimensions, setPhotoDimensions] = useState({});
const [loadingDimensions, setLoadingDimensions] = useState(false);
const [hoveredPreview, setHoveredPreview] = useState(null);
const isDarkTheme = theme === 'dark';
const textColor = isDarkTheme ? '#FFFFFF' : '#000000';
const backgroundColor = isDarkTheme ? '#121212' : '#f2f2f2';
const secondaryBackgroundColor = isDarkTheme ? '#222222' : '#FFFFFF';
const borderColor = isDarkTheme ? '#444444' : '#E0E0E0';
const primaryColor = '#007AFF';
const dangerColor = '#FF3B30';
const successColor = '#34C759';
const targetUserId = userId || user?.id;
const loadFiles = useCallback(async (isRefresh = false) => {
if (!targetUserId) return;
try {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
const response = await oxyServices.listUserFiles(targetUserId);
setFiles(response.files || []);
} catch (error) {
console.error('Failed to load files:', error);
toast.error(error.message || 'Failed to load files');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [targetUserId, oxyServices]);
// Filter files based on search query and view mode
useEffect(() => {
let filteredByMode = files;
// Filter by view mode first
if (viewMode === 'photos') {
filteredByMode = files.filter(file => file.contentType.startsWith('image/'));
}
// Then filter by search query
if (!searchQuery.trim()) {
setFilteredFiles(filteredByMode);
} else {
const query = searchQuery.toLowerCase();
const filtered = filteredByMode.filter(file => file.filename.toLowerCase().includes(query) || file.contentType.toLowerCase().includes(query) || file.metadata?.description && file.metadata.description.toLowerCase().includes(query));
setFilteredFiles(filtered);
}
}, [files, searchQuery, viewMode]);
// Load photo dimensions for justified grid
const loadPhotoDimensions = useCallback(async photos => {
if (photos.length === 0) return;
setLoadingDimensions(true);
const newDimensions = {
...photoDimensions
};
let hasNewDimensions = false;
// Only load dimensions for photos we don't have yet
const photosToLoad = photos.filter(photo => !newDimensions[photo.id]);
if (photosToLoad.length === 0) {
setLoadingDimensions(false);
return;
}
try {
await Promise.all(photosToLoad.map(async photo => {
try {
const downloadUrl = oxyServices.getFileDownloadUrl(photo.id);
if (Platform.OS === 'web') {
const img = new window.Image();
await new Promise((resolve, reject) => {
img.onload = () => {
newDimensions[photo.id] = {
width: img.naturalWidth,
height: img.naturalHeight
};
hasNewDimensions = true;
resolve();
};
img.onerror = () => {
// Fallback dimensions for failed loads
newDimensions[photo.id] = {
width: 1,
height: 1
};
hasNewDimensions = true;
resolve();
};
img.src = downloadUrl;
});
} else {
// For mobile, use Image.getSize from react-native
await new Promise(resolve => {
Image.getSize(downloadUrl, (width, height) => {
newDimensions[photo.id] = {
width,
height
};
hasNewDimensions = true;
resolve();
}, () => {
// Fallback dimensions
newDimensions[photo.id] = {
width: 1,
height: 1
};
hasNewDimensions = true;
resolve();
});
});
}
} catch (error) {
// Fallback dimensions for any errors
newDimensions[photo.id] = {
width: 1,
height: 1
};
hasNewDimensions = true;
}
}));
if (hasNewDimensions) {
setPhotoDimensions(newDimensions);
}
} catch (error) {
console.error('Error loading photo dimensions:', error);
} finally {
setLoadingDimensions(false);
}
}, [oxyServices, photoDimensions]);
// Create justified rows from photos with responsive algorithm
const createJustifiedRows = useCallback(photos => {
if (photos.length === 0) return [];
const rows = [];
const photosPerRow = 3; // Fixed 3 photos per row for consistency
for (let i = 0; i < photos.length; i += photosPerRow) {
const rowPhotos = photos.slice(i, i + photosPerRow);
rows.push(rowPhotos);
}
return rows;
}, []);
const processFileUploads = async selectedFiles => {
if (selectedFiles.length === 0) return;
try {
// Show initial progress
setUploadProgress({
current: 0,
total: selectedFiles.length
});
// Validate file sizes (example: 50MB limit per file)
const maxSize = 50 * 1024 * 1024; // 50MB
const oversizedFiles = selectedFiles.filter(file => file.size > maxSize);
if (oversizedFiles.length > 0) {
const fileList = oversizedFiles.map(f => f.name).join('\n');
window.alert(`File Size Limit\n\nThe following files are too large (max 50MB):\n${fileList}`);
return;
}
// Option 1: Bulk upload (faster, all-or-nothing) for 5 or fewer files
if (selectedFiles.length <= 5) {
const filenames = selectedFiles.map(f => f.name);
const response = await oxyServices.uploadFiles(selectedFiles, filenames, {
userId: targetUserId,
uploadDate: new Date().toISOString()
});
toast.success(`${response.files.length} file(s) uploaded successfully`);
// Small delay to ensure backend processing is complete
setTimeout(async () => {
await loadFiles();
}, 500);
} else {
// Option 2: Individual uploads for better progress and error handling
let successCount = 0;
let failureCount = 0;
const errors = [];
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
setUploadProgress({
current: i + 1,
total: selectedFiles.length
});
try {
await oxyServices.uploadFile(file, file.name, {
userId: targetUserId,
uploadDate: new Date().toISOString()
});
successCount++;
} catch (error) {
failureCount++;
errors.push(`${file.name}: ${error.message || 'Upload failed'}`);
}
}
// Show results summary
if (successCount > 0) {
toast.success(`${successCount} file(s) uploaded successfully`);
}
if (failureCount > 0) {
const errorMessage = `${failureCount} file(s) failed to upload${errors.length > 0 ? ':\n' + errors.slice(0, 3).join('\n') + (errors.length > 3 ? '\n...' : '') : ''}`;
toast.error(errorMessage);
}
// Small delay to ensure backend processing is complete
setTimeout(async () => {
await loadFiles();
}, 500);
}
} catch (error) {
console.error('Upload error:', error);
toast.error(error.message || 'Failed to upload files');
} finally {
setUploadProgress(null);
}
};
const handleFileUpload = async () => {
try {
setUploading(true);
setUploadProgress(null);
if (Platform.OS === 'web') {
// Web file picker implementation
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '*/*';
input.onchange = async e => {
const selectedFiles = Array.from(e.target.files);
await processFileUploads(selectedFiles);
};
input.click();
} else {
// Mobile - show info that file picker can be added
const installCommand = 'npm install expo-document-picker';
const message = `Mobile File Upload\n\nTo enable file uploads on mobile, install expo-document-picker:\n\n${installCommand}\n\nThen import and use DocumentPicker.getDocumentAsync() in this method.`;
if (window.confirm(`${message}\n\nWould you like to copy the install command?`)) {
toast.info(`Install: ${installCommand}`);
} else {
toast.info('Mobile file upload requires expo-document-picker');
}
}
} catch (error) {
toast.error(error.message || 'Failed to upload file');
} finally {
setUploading(false);
setUploadProgress(null);
}
};
const handleFileDelete = async (fileId, filename) => {
// Use web-compatible confirmation dialog
const confirmed = window.confirm(`Are you sure you want to delete "${filename}"? This action cannot be undone.`);
if (!confirmed) {
console.log('Delete cancelled by user');
return;
}
try {
console.log('Deleting file:', {
fileId,
filename
});
console.log('Target user ID:', targetUserId);
console.log('Current user ID:', user?.id);
setDeleting(fileId);
const result = await oxyServices.deleteFile(fileId);
console.log('Delete result:', result);
toast.success('File deleted successfully');
// Reload files after successful deletion
setTimeout(async () => {
await loadFiles();
}, 500);
} catch (error) {
console.error('Delete error:', error);
console.error('Error details:', error.response?.data || error.message);
// Provide specific error messages
if (error.message?.includes('File not found') || error.message?.includes('404')) {
toast.error('File not found. It may have already been deleted.');
// Still reload files to refresh the list
setTimeout(async () => {
await loadFiles();
}, 500);
} else if (error.message?.includes('permission') || error.message?.includes('403')) {
toast.error('You do not have permission to delete this file.');
} else {
toast.error(error.message || 'Failed to delete file');
}
} finally {
setDeleting(null);
}
};
// Drag and drop handlers for web
const handleDragOver = e => {
if (Platform.OS === 'web' && user?.id === targetUserId) {
e.preventDefault();
setIsDragging(true);
}
};
const handleDragLeave = e => {
if (Platform.OS === 'web') {
e.preventDefault();
setIsDragging(false);
}
};
const handleDrop = async e => {
if (Platform.OS === 'web' && user?.id === targetUserId) {
e.preventDefault();
setIsDragging(false);
setUploading(true);
try {
const files = Array.from(e.dataTransfer.files);
await processFileUploads(files);
} catch (error) {
toast.error(error.message || 'Failed to upload files');
} finally {
setUploading(false);
}
}
};
const handleFileDownload = async (fileId, filename) => {
try {
if (Platform.OS === 'web') {
console.log('Downloading file:', {
fileId,
filename
});
// Use the public download URL method
const downloadUrl = oxyServices.getFileDownloadUrl(fileId);
console.log('Download URL:', downloadUrl);
try {
// Method 1: Try simple link download first
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('File download started');
} catch (linkError) {
console.warn('Link download failed, trying fetch method:', linkError);
// Method 2: Fallback to fetch download
const response = await fetch(downloadUrl);
if (!response.ok) {
if (response.status === 404) {
throw new Error('File not found. It may have been deleted.');
} else {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the blob URL
window.URL.revokeObjectURL(url);
toast.success('File downloaded successfully');
}
} else {
toast.info('File download not implemented for mobile yet');
}
} catch (error) {
console.error('Download error:', error);
toast.error(error.message || 'Failed to download file');
}
};
const formatFileSize = bytes => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = contentType => {
if (contentType.startsWith('image/')) return 'image';
if (contentType.startsWith('video/')) return 'videocam';
if (contentType.startsWith('audio/')) return 'musical-notes';
if (contentType.includes('pdf')) return 'document-text';
if (contentType.includes('word') || contentType.includes('doc')) return 'document';
if (contentType.includes('excel') || contentType.includes('sheet')) return 'grid';
if (contentType.includes('zip') || contentType.includes('archive')) return 'archive';
return 'document-outline';
};
const handleFileOpen = async file => {
try {
setLoadingFileContent(true);
setOpenedFile(file);
// For text files, images, and other viewable content, try to load the content
if (file.contentType.startsWith('text/') || file.contentType.includes('json') || file.contentType.includes('xml') || file.contentType.includes('javascript') || file.contentType.includes('typescript') || file.contentType.startsWith('image/') || file.contentType.includes('pdf') || file.contentType.startsWith('video/') || file.contentType.startsWith('audio/')) {
try {
const downloadUrl = oxyServices.getFileDownloadUrl(file.id);
const response = await fetch(downloadUrl);
if (response.ok) {
if (file.contentType.startsWith('image/') || file.contentType.includes('pdf') || file.contentType.startsWith('video/') || file.contentType.startsWith('audio/')) {
// For images, PDFs, videos, and audio, we'll use the URL directly
setFileContent(downloadUrl);
} else {
// For text files, get the content
const content = await response.text();
setFileContent(content);
}
} else {
if (response.status === 404) {
toast.error('File not found. It may have been deleted.');
} else {
toast.error(`Failed to load file: ${response.status} ${response.statusText}`);
}
setFileContent(null);
}
} catch (error) {
console.error('Failed to load file content:', error);
if (error.message?.includes('404') || error.message?.includes('not found')) {
toast.error('File not found. It may have been deleted.');
} else {
toast.error('Failed to load file content');
}
setFileContent(null);
}
} else {
// For non-viewable files, don't load content
setFileContent(null);
}
} catch (error) {
console.error('Failed to open file:', error);
toast.error(error.message || 'Failed to open file');
} finally {
setLoadingFileContent(false);
}
};
const handleCloseFile = () => {
setOpenedFile(null);
setFileContent(null);
setShowFileDetailsInViewer(false);
// Don't reset view mode when closing a file
};
const showFileDetailsModal = file => {
setSelectedFile(file);
setShowFileDetails(true);
};
const renderSimplePhotoItem = useCallback((photo, index) => {
const downloadUrl = oxyServices.getFileDownloadUrl(photo.id);
// Calculate photo item width based on actual container size from bottom sheet
let itemsPerRow = 3; // Default for mobile
if (containerWidth > 768) itemsPerRow = 4; // Desktop/tablet
else if (containerWidth > 480) itemsPerRow = 3; // Large mobile
// Account for the photoScrollContainer padding (16px on each side = 32px total)
const scrollContainerPadding = 32; // Total horizontal padding from photoScrollContainer
const gaps = (itemsPerRow - 1) * 4; // Gap between items (4px)
const availableWidth = containerWidth - scrollContainerPadding;
const itemWidth = (availableWidth - gaps) / itemsPerRow;
return /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.simplePhotoItem, {
width: itemWidth,
height: itemWidth,
marginRight: (index + 1) % itemsPerRow === 0 ? 0 : 4
}],
onPress: () => handleFileOpen(photo),
activeOpacity: 0.8,
children: /*#__PURE__*/_jsx(View, {
style: styles.simplePhotoContainer,
children: Platform.OS === 'web' ? /*#__PURE__*/_jsx("img", {
src: downloadUrl,
alt: photo.filename,
style: {
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 8,
transition: 'transform 0.2s ease'
},
loading: "lazy",
onError: e => {
console.error('Photo failed to load:', e);
},
onMouseEnter: e => {
e.currentTarget.style.transform = 'scale(1.05)';
},
onMouseLeave: e => {
e.currentTarget.style.transform = 'scale(1)';
}
}) : /*#__PURE__*/_jsx(Image, {
source: {
uri: downloadUrl
},
style: styles.simplePhotoImage,
resizeMode: "cover",
onError: e => {
console.error('Photo failed to load:', e);
}
})
})
}, photo.id);
}, [oxyServices, containerWidth]);
const renderJustifiedPhotoItem = useCallback((photo, width, height, isLast) => {
const downloadUrl = oxyServices.getFileDownloadUrl(photo.id);
return /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.justifiedPhotoItem, {
width,
height
}],
onPress: () => handleFileOpen(photo),
activeOpacity: 0.8,
children: /*#__PURE__*/_jsx(View, {
style: styles.justifiedPhotoContainer,
children: Platform.OS === 'web' ? /*#__PURE__*/_jsx("img", {
src: downloadUrl,
alt: photo.filename,
style: {
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 6,
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
},
loading: "lazy",
onError: e => {
console.error('Photo failed to load:', e);
},
onMouseEnter: e => {
e.currentTarget.style.transform = 'scale(1.02)';
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.15)';
e.currentTarget.style.zIndex = '10';
},
onMouseLeave: e => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
e.currentTarget.style.zIndex = '1';
}
}) : /*#__PURE__*/_jsx(Image, {
source: {
uri: downloadUrl
},
style: styles.justifiedPhotoImage,
resizeMode: "cover",
onError: e => {
console.error('Photo failed to load:', e);
}
})
})
}, photo.id);
}, [oxyServices]);
useEffect(() => {
loadFiles();
}, [loadFiles]);
const renderFileItem = file => {
const isImage = file.contentType.startsWith('image/');
const isPDF = file.contentType.includes('pdf');
const isVideo = file.contentType.startsWith('video/');
const isAudio = file.contentType.startsWith('audio/');
const hasPreview = isImage || isPDF || isVideo;
return /*#__PURE__*/_jsxs(View, {
style: [styles.fileItem, {
backgroundColor: secondaryBackgroundColor,
borderColor
}],
children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
style: styles.fileContent,
onPress: () => handleFileOpen(file),
children: [/*#__PURE__*/_jsx(View, {
style: styles.filePreviewContainer,
children: hasPreview ? /*#__PURE__*/_jsxs(View, {
style: styles.filePreview,
...(Platform.OS === 'web' && {
onMouseEnter: () => setHoveredPreview(file.id),
onMouseLeave: () => setHoveredPreview(null)
}),
children: [isImage && (Platform.OS === 'web' ? /*#__PURE__*/_jsx("img", {
src: oxyServices.getFileDownloadUrl(file.id),
style: {
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 8,
transition: 'transform 0.2s ease',
transform: hoveredPreview === file.id ? 'scale(1.05)' : 'scale(1)'
},
onError: e => {
// Show fallback icon if image fails to load
e.currentTarget.style.display = 'none';
const fallbackElement = e.currentTarget.parentElement?.querySelector('[data-fallback="true"]');
if (fallbackElement) {
fallbackElement.style.display = 'flex';
}
}
}) : /*#__PURE__*/_jsx(Image, {
source: {
uri: oxyServices.getFileDownloadUrl(file.id)
},
style: styles.previewImage,
resizeMode: "cover",
onError: () => {
// For React Native, you might want to set an error state
console.warn('Failed to load image preview for file:', file.id);
}
})), isPDF && /*#__PURE__*/_jsxs(View, {
style: styles.pdfPreview,
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "document",
size: 32,
color: primaryColor
}), /*#__PURE__*/_jsx(Text, {
style: [styles.pdfLabel, {
color: primaryColor
}],
children: "PDF"
})]
}), isVideo && /*#__PURE__*/_jsxs(View, {
style: styles.videoPreview,
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "play-circle",
size: 32,
color: primaryColor
}), /*#__PURE__*/_jsx(Text, {
style: [styles.videoLabel, {
color: primaryColor
}],
children: "VIDEO"
})]
}), /*#__PURE__*/_jsx(View, {
style: [styles.fallbackIcon, {
display: isImage ? 'none' : 'flex'
}],
...(Platform.OS === 'web' && {
'data-fallback': 'true'
}),
children: /*#__PURE__*/_jsx(Ionicons, {
name: getFileIcon(file.contentType),
size: 32,
color: primaryColor
})
}), Platform.OS === 'web' && hoveredPreview === file.id && isImage && /*#__PURE__*/_jsx(View, {
style: styles.previewOverlay,
children: /*#__PURE__*/_jsx(Ionicons, {
name: "eye",
size: 24,
color: "#FFFFFF"
})
})]
}) : /*#__PURE__*/_jsx(View, {
style: styles.fileIconContainer,
children: /*#__PURE__*/_jsx(Ionicons, {
name: getFileIcon(file.contentType),
size: 32,
color: primaryColor
})
})
}), /*#__PURE__*/_jsxs(View, {
style: styles.fileInfo,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.fileName, {
color: textColor
}],
numberOfLines: 1,
children: file.filename
}), /*#__PURE__*/_jsxs(Text, {
style: [styles.fileDetails, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: [formatFileSize(file.length), " \u2022 ", new Date(file.uploadDate).toLocaleDateString()]
}), file.metadata?.description && /*#__PURE__*/_jsx(Text, {
style: [styles.fileDescription, {
color: isDarkTheme ? '#AAAAAA' : '#888888'
}],
numberOfLines: 2,
children: file.metadata.description
})]
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.fileActions,
children: [hasPreview && /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.actionButton, {
backgroundColor: isDarkTheme ? '#333333' : '#F0F0F0'
}],
onPress: () => handleFileOpen(file),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "eye",
size: 20,
color: primaryColor
})
}), /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.actionButton, {
backgroundColor: isDarkTheme ? '#333333' : '#F0F0F0'
}],
onPress: () => handleFileDownload(file.id, file.filename),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "download",
size: 20,
color: primaryColor
})
}), /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.actionButton, {
backgroundColor: isDarkTheme ? '#400000' : '#FFEBEE'
}],
onPress: () => {
handleFileDelete(file.id, file.filename);
},
disabled: deleting === file.id,
children: deleting === file.id ? /*#__PURE__*/_jsx(ActivityIndicator, {
size: "small",
color: dangerColor
}) : /*#__PURE__*/_jsx(Ionicons, {
name: "trash",
size: 20,
color: dangerColor
})
})]
})]
}, file.id);
};
const renderPhotoGrid = useCallback(() => {
const photos = filteredFiles.filter(file => file.contentType.startsWith('image/'));
if (photos.length === 0) {
return /*#__PURE__*/_jsxs(View, {
style: styles.emptyState,
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "images-outline",
size: 64,
color: isDarkTheme ? '#666666' : '#CCCCCC'
}), /*#__PURE__*/_jsx(Text, {
style: [styles.emptyStateTitle, {
color: textColor
}],
children: "No Photos Yet"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.emptyStateDescription, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: user?.id === targetUserId ? `Upload photos to get started. You can select multiple photos at once${Platform.OS === 'web' ? ' or drag & drop them here.' : '.'}` : "This user hasn't uploaded any photos yet"
}), user?.id === targetUserId && /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.emptyStateButton, {
backgroundColor: primaryColor
}],
onPress: handleFileUpload,
disabled: uploading,
children: uploading ? /*#__PURE__*/_jsx(ActivityIndicator, {
size: "small",
color: "#FFFFFF"
}) : /*#__PURE__*/_jsxs(_Fragment, {
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "cloud-upload",
size: 20,
color: "#FFFFFF"
}), /*#__PURE__*/_jsx(Text, {
style: styles.emptyStateButtonText,
children: "Upload Photos"
})]
})
})]
});
}
return /*#__PURE__*/_jsxs(ScrollView, {
style: styles.scrollView,
contentContainerStyle: styles.photoScrollContainer,
refreshControl: /*#__PURE__*/_jsx(RefreshControl, {
refreshing: refreshing,
onRefresh: () => loadFiles(true),
tintColor: primaryColor
}),
showsVerticalScrollIndicator: false,
children: [loadingDimensions && /*#__PURE__*/_jsxs(View, {
style: styles.dimensionsLoadingIndicator,
children: [/*#__PURE__*/_jsx(ActivityIndicator, {
size: "small",
color: primaryColor
}), /*#__PURE__*/_jsx(Text, {
style: [styles.dimensionsLoadingText, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Loading photo layout..."
})]
}), /*#__PURE__*/_jsx(JustifiedPhotoGrid, {
photos: photos,
photoDimensions: photoDimensions,
loadPhotoDimensions: loadPhotoDimensions,
createJustifiedRows: createJustifiedRows,
renderJustifiedPhotoItem: renderJustifiedPhotoItem,
renderSimplePhotoItem: renderPhotoItem,
textColor: textColor,
containerWidth: containerWidth
})]
});
}, [filteredFiles, isDarkTheme, textColor, user?.id, targetUserId, uploading, primaryColor, handleFileUpload, refreshing, loadFiles, loadingDimensions, photoDimensions, loadPhotoDimensions, createJustifiedRows, renderJustifiedPhotoItem]);
// Separate component for the photo grid to optimize rendering
const JustifiedPhotoGrid = /*#__PURE__*/React.memo(({
photos,
photoDimensions,
loadPhotoDimensions,
createJustifiedRows,
renderJustifiedPhotoItem,
renderSimplePhotoItem,
textColor,
containerWidth
}) => {
// Load dimensions for new photos
React.useEffect(() => {
loadPhotoDimensions(photos);
}, [photos.map(p => p.id).join(','), loadPhotoDimensions]);
// Group photos by date
const photosByDate = React.useMemo(() => {
return photos.reduce((groups, photo) => {
const date = new Date(photo.uploadDate).toDateString();
if (!groups[date]) {
groups[date] = [];
}
groups[date].push(photo);
return groups;
}, {});
}, [photos]);
const sortedDates = React.useMemo(() => {
return Object.keys(photosByDate).sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
}, [photosByDate]);
return /*#__PURE__*/_jsx(_Fragment, {
children: sortedDates.map(date => {
const dayPhotos = photosByDate[date];
const justifiedRows = createJustifiedRows(dayPhotos, containerWidth);
return /*#__PURE__*/_jsxs(View, {
style: styles.photoDateSection,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.photoDateHeader, {
color: textColor
}],
children: new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}), /*#__PURE__*/_jsx(View, {
style: styles.justifiedPhotoGrid,
children: justifiedRows.map((row, rowIndex) => {
// Calculate row height based on available width
const gap = 4;
let totalAspectRatio = 0;
// Calculate total aspect ratio for this row
row.forEach(photo => {
const dimensions = photoDimensions[photo.id];
const aspectRatio = dimensions ? dimensions.width / dimensions.height : 1.33; // Default 4:3 ratio
totalAspectRatio += aspectRatio;
});
// Calculate the height that makes the row fill the available width
// Account for photoScrollContainer padding (32px total) and gaps between photos
const scrollContainerPadding = 32;
const availableWidth = containerWidth - scrollContainerPadding - gap * (row.length - 1);
const calculatedHeight = availableWidth / totalAspectRatio;
// Clamp height for visual consistency
const rowHeight = Math.max(120, Math.min(calculatedHeight, 300));
return /*#__PURE__*/_jsx(View, {
style: [styles.justifiedPhotoRow, {
height: rowHeight,
maxWidth: containerWidth - 32,
// Account for scroll container padding
gap: 4 // Add horizontal gap between photos in row
}],
children: row.map((photo, photoIndex) => {
const dimensions = photoDimensions[photo.id];
const aspectRatio = dimensions ? dimensions.width / dimensions.height : 1.33; // Default 4:3 ratio
const photoWidth = rowHeight * aspectRatio;
const isLast = photoIndex === row.length - 1;
return renderJustifiedPhotoItem(photo, photoWidth, rowHeight, isLast);
})
}, `row-${rowIndex}`);
})
})]
}, date);
})
});
});
const renderPhotoItem = (photo, index) => {
const downloadUrl = oxyServices.getFileDownloadUrl(photo.id);
// Calculate photo item width based on actual container size from bottom sheet
let itemsPerRow = 3; // Default for mobile
if (containerWidth > 768) itemsPerRow = 6; // Tablet/Desktop
else if (containerWidth > 480) itemsPerRow = 4; // Large mobile
// Account for the photoScrollContainer padding (16px on each side = 32px total)
const scrollContainerPadding = 32; // Total horizontal padding from photoScrollContainer
const gaps = (itemsPerRow - 1) * 4; // Gap between items
const availableWidth = containerWidth - scrollContainerPadding;
const itemWidth = (availableWidth - gaps) / itemsPerRow;
return /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.photoItem, {
width: itemWidth,
height: itemWidth
}],
onPress: () => handleFileOpen(photo),
activeOpacity: 0.8,
children: /*#__PURE__*/_jsx(View, {
style: styles.photoContainer,
children: Platform.OS === 'web' ? /*#__PURE__*/_jsx("img", {
src: downloadUrl,
alt: photo.filename,
style: {
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 8,
transition: 'transform 0.2s ease'
},
loading: "lazy",
onError: e => {
console.error('Photo failed to load:', e);
// Could replace with placeholder image
},
onMouseEnter: e => {
e.currentTarget.style.transform = 'scale(1.02)';
},
onMouseLeave: e => {
e.currentTarget.style.transform = 'scale(1)';
}
}) : /*#__PURE__*/_jsx(Image, {
source: {
uri: downloadUrl
},
style: styles.photoImage,
resizeMode: "cover",
onError: e => {
console.error('Photo failed to load:', e);
}
})
})
}, photo.id);
};
const renderFileDetailsModal = () => /*#__PURE__*/_jsx(Modal, {
visible: showFileDetails,
animationType: "slide",
presentationStyle: "pageSheet",
onRequestClose: () => setShowFileDetails(false),
children: /*#__PURE__*/_jsxs(View, {
style: [styles.modalContainer, {
backgroundColor
}],
children: [/*#__PURE__*/_jsxs(View, {
style: [styles.modalHeader, {
borderBottomColor: borderColor
}],
children: [/*#__PURE__*/_jsx(TouchableOpacity, {
style: styles.modalCloseButton,
onPress: () => setShowFileDetails(false),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "close",
size: 24,
color: textColor
})
}), /*#__PURE__*/_jsx(Text, {
style: [styles.modalTitle, {
color: textColor
}],
children: "File Details"
}), /*#__PURE__*/_jsx(View, {
style: styles.modalPlaceholder
})]
}), selectedFile && /*#__PURE__*/_jsx(ScrollView, {
style: styles.modalContent,
children: /*#__PURE__*/_jsxs(View, {
style: [styles.fileDetailCard, {
backgroundColor: secondaryBackgroundColor,
borderColor
}],
children: [/*#__PURE__*/_jsx(View, {
style: styles.fileDetailIcon,
children: /*#__PURE__*/_jsx(Ionicons, {
name: getFileIcon(selectedFile.contentType),
size: 64,
color: primaryColor
})
}), /*#__PURE__*/_jsx(Text, {
style: [styles.fileDetailName, {
color: textColor
}],
children: selectedFile.filename
}), /*#__PURE__*/_jsxs(View, {
style: styles.fileDetailInfo,
children: [/*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Size:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: formatFileSize(selectedFile.length)
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Type:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: selectedFile.contentType
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Uploaded:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: new Date(selectedFile.uploadDate).toLocaleString()
})]
}), selectedFile.metadata?.description && /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Description:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: selectedFile.metadata.description
})]
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.modalActions,
children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
style: [styles.modalActionButton, {
backgroundColor: primaryColor
}],
onPress: () => {
handleFileDownload(selectedFile.id, selectedFile.filename);
setShowFileDetails(false);
},
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "download",
size: 20,
color: "#FFFFFF"
}), /*#__PURE__*/_jsx(Text, {
style: styles.modalActionText,
children: "Download"
})]
}), user?.id === targetUserId && /*#__PURE__*/_jsxs(TouchableOpacity, {
style: [styles.modalActionButton, {
backgroundColor: dangerColor
}],
onPress: () => {
setShowFileDetails(false);
handleFileDelete(selectedFile.id, selectedFile.filename);
},
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "trash",
size: 20,
color: "#FFFFFF"
}), /*#__PURE__*/_jsx(Text, {
style: styles.modalActionText,
children: "Delete"
})]
})]
})]
})
})]
})
});
const renderFileViewer = () => {
if (!openedFile) return null;
const isImage = openedFile.contentType.startsWith('image/');
const isText = openedFile.contentType.startsWith('text/') || openedFile.contentType.includes('json') || openedFile.contentType.includes('xml') || openedFile.contentType.includes('javascript') || openedFile.contentType.includes('typescript');
const isPDF = openedFile.contentType.includes('pdf');
const isVideo = openedFile.contentType.startsWith('video/');
const isAudio = openedFile.contentType.startsWith('audio/');
return /*#__PURE__*/_jsxs(View, {
style: [styles.fileViewerContainer, {
backgroundColor
}],
children: [/*#__PURE__*/_jsxs(View, {
style: [styles.fileViewerHeader, {
borderBottomColor: borderColor
}],
children: [/*#__PURE__*/_jsx(TouchableOpacity, {
style: styles.backButton,
onPress: handleCloseFile,
children: /*#__PURE__*/_jsx(Ionicons, {
name: "arrow-back",
size: 24,
color: textColor
})
}), /*#__PURE__*/_jsxs(View, {
style: styles.fileViewerTitleContainer,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.fileViewerTitle, {
color: textColor
}],
numberOfLines: 1,
children: openedFile.filename
}), /*#__PURE__*/_jsxs(Text, {
style: [styles.fileViewerSubtitle, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: [formatFileSize(openedFile.length), " \u2022 ", openedFile.contentType]
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.fileViewerActions,
children: [/*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.actionButton, {
backgroundColor: isDarkTheme ? '#333333' : '#F0F0F0'
}],
onPress: () => handleFileDownload(openedFile.id, openedFile.filename),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "download",
size: 20,
color: primaryColor
})
}), /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.actionButton, {
backgroundColor: showFileDetailsInViewer ? primaryColor : isDarkTheme ? '#333333' : '#F0F0F0'
}],
onPress: () => setShowFileDetailsInViewer(!showFileDetailsInViewer),
children: /*#__PURE__*/_jsx(Ionicons, {
name: showFileDetailsInViewer ? "chevron-up" : "information-circle",
size: 20,
color: showFileDetailsInViewer ? "#FFFFFF" : primaryColor
})
})]
})]
}), showFileDetailsInViewer && /*#__PURE__*/_jsxs(View, {
style: [styles.fileDetailsSection, {
backgroundColor: secondaryBackgroundColor,
borderColor
}],
children: [/*#__PURE__*/_jsxs(View, {
style: styles.fileDetailsSectionHeader,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.fileDetailsSectionTitle, {
color: textColor
}],
children: "File Details"
}), /*#__PURE__*/_jsx(TouchableOpacity, {
style: styles.fileDetailsSectionToggle,
onPress: () => setShowFileDetailsInViewer(false),
children: /*#__PURE__*/_jsx(Ionicons, {
name: "chevron-up",
size: 20,
color: isDarkTheme ? '#BBBBBB' : '#666666'
})
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.fileDetailInfo,
children: [/*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "File Name:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: openedFile.filename
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Size:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: formatFileSize(openedFile.length)
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Type:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: openedFile.contentType
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Uploaded:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: new Date(openedFile.uploadDate).toLocaleString()
})]
}), openedFile.metadata?.description && /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "Description:"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.detailValue, {
color: textColor
}],
children: openedFile.metadata.description
})]
}), /*#__PURE__*/_jsxs(View, {
style: styles.detailRow,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.detailLabel, {
color: isDarkTheme ? '#BBBBBB' : '#666666'
}],
children: "File ID:"
}), /*#__PURE__*/_jsx(Tex