From f4bdb5d9b3d0f459badcb7dac6ed5c44f9d837a9 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 30 Jan 2026 16:09:24 +0000 Subject: [PATCH] feat: Implement directory browsing functionality - Add `browseDirectory` API endpoint to list directory contents. - Create `FolderBrowser` component for user interface to navigate directories. - Update `Scan` page to integrate folder browsing feature. - Define `DirectoryItem` and `BrowseDirectoryResponse` schemas for API responses. --- admin-frontend/src/api/photos.ts | 22 ++ .../src/components/FolderBrowser.tsx | 293 ++++++++++++++++++ admin-frontend/src/pages/Scan.tsx | 170 ++-------- backend/api/photos.py | 108 +++++++ backend/schemas/photos.py | 17 + 5 files changed, 460 insertions(+), 150 deletions(-) create mode 100644 admin-frontend/src/components/FolderBrowser.tsx diff --git a/admin-frontend/src/api/photos.ts b/admin-frontend/src/api/photos.ts index 8813558..8bad433 100644 --- a/admin-frontend/src/api/photos.ts +++ b/admin-frontend/src/api/photos.ts @@ -147,6 +147,15 @@ export const photosApi = { return data }, + browseDirectory: async (path: string): Promise => { + // Axios automatically URL-encodes query parameters + const { data } = await apiClient.get( + '/api/v1/photos/browse-directory', + { params: { path } } + ) + return data + }, + getPhotoImageBlob: async (photoId: number): Promise => { // Fetch image as blob with authentication const response = await apiClient.get( @@ -182,3 +191,16 @@ export interface SearchPhotosResponse { total: number } +export interface DirectoryItem { + name: string + path: string + is_directory: boolean + is_file: boolean +} + +export interface BrowseDirectoryResponse { + current_path: string + parent_path: string | null + items: DirectoryItem[] +} + diff --git a/admin-frontend/src/components/FolderBrowser.tsx b/admin-frontend/src/components/FolderBrowser.tsx new file mode 100644 index 0000000..8343a7d --- /dev/null +++ b/admin-frontend/src/components/FolderBrowser.tsx @@ -0,0 +1,293 @@ +import { useState, useEffect, useCallback } from 'react' +import { photosApi, BrowseDirectoryResponse } from '../api/photos' + +interface FolderBrowserProps { + onSelectPath: (path: string) => void + initialPath?: string + onClose: () => void +} + +export default function FolderBrowser({ + onSelectPath, + initialPath = '/', + onClose, +}: FolderBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath) + const [items, setItems] = useState([]) + const [parentPath, setParentPath] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [pathInput, setPathInput] = useState(initialPath) + + const loadDirectory = useCallback(async (path: string) => { + setLoading(true) + setError(null) + try { + console.log('Loading directory:', path) + const data = await photosApi.browseDirectory(path) + console.log('Directory loaded:', data) + setCurrentPath(data.current_path) + setPathInput(data.current_path) + setParentPath(data.parent_path) + setItems(data.items) + } catch (err: any) { + console.error('Error loading directory:', err) + console.error('Error response:', err?.response) + console.error('Error status:', err?.response?.status) + console.error('Error data:', err?.response?.data) + + // Handle FastAPI validation errors (422) - they have a different structure + let errorMsg = 'Failed to load directory' + if (err?.response?.data) { + const data = err.response.data + // FastAPI validation errors have detail as an array + if (Array.isArray(data.detail)) { + errorMsg = data.detail.map((d: any) => d.msg || JSON.stringify(d)).join(', ') + } else if (typeof data.detail === 'string') { + errorMsg = data.detail + } else if (data.message) { + errorMsg = data.message + } else if (typeof data === 'string') { + errorMsg = data + } + } else if (err?.message) { + errorMsg = err.message + } + + setError(errorMsg) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + console.log('FolderBrowser mounted, loading initial path:', initialPath) + loadDirectory(initialPath) + }, [initialPath, loadDirectory]) + + const handleItemClick = (item: BrowseDirectoryResponse['items'][0]) => { + if (item.is_directory) { + loadDirectory(item.path) + } + } + + const handleParentClick = () => { + if (parentPath) { + loadDirectory(parentPath) + } + } + + const handlePathInputSubmit = (e: React.FormEvent) => { + e.preventDefault() + loadDirectory(pathInput) + } + + const handleSelectCurrentPath = () => { + onSelectPath(currentPath) + onClose() + } + + // Build breadcrumb path segments + const pathSegments = currentPath.split('/').filter(Boolean) + const breadcrumbPaths: string[] = [] + pathSegments.forEach((_segment, index) => { + const path = '/' + pathSegments.slice(0, index + 1).join('/') + breadcrumbPaths.push(path) + }) + + console.log('FolderBrowser render - loading:', loading, 'error:', error, 'items:', items.length) + + return ( +
{ + // Close modal when clicking backdrop + if (e.target === e.currentTarget) { + onClose() + } + }} + > +
e.stopPropagation()} + > + {/* Header */} +
+
+

+ Select Folder +

+ +
+
+ + {/* Path Input */} +
+
+ setPathInput(e.target.value)} + placeholder="Enter or navigate to folder path" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> + +
+
+ + {/* Breadcrumb Navigation */} +
+
+ + {pathSegments.map((segment, index) => ( + + / + + + ))} +
+
+ + {/* Error Message */} + {error && ( +
+

{String(error)}

+
+ )} + + {/* Directory Listing */} +
+ {loading ? ( +
+
Loading...
+
+ ) : items.length === 0 ? ( +
+
Directory is empty
+
+ ) : ( +
+ {parentPath && ( + + )} + {items.map((item) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+
+ Current path:{' '} + {currentPath} +
+
+ + +
+
+
+
+ ) +} + diff --git a/admin-frontend/src/pages/Scan.tsx b/admin-frontend/src/pages/Scan.tsx index 28d1f68..068f54e 100644 --- a/admin-frontend/src/pages/Scan.tsx +++ b/admin-frontend/src/pages/Scan.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from 'react' import { photosApi, PhotoImportRequest } from '../api/photos' import { jobsApi, JobResponse, JobStatus } from '../api/jobs' +import FolderBrowser from '../components/FolderBrowser' interface JobProgress { id: string @@ -15,7 +16,7 @@ export default function Scan() { const [folderPath, setFolderPath] = useState('') const [recursive, setRecursive] = useState(true) const [isImporting, setIsImporting] = useState(false) - const [isBrowsing, setIsBrowsing] = useState(false) + const [showFolderBrowser, setShowFolderBrowser] = useState(false) const [currentJob, setCurrentJob] = useState(null) const [jobProgress, setJobProgress] = useState(null) const [importResult, setImportResult] = useState<{ @@ -35,154 +36,14 @@ export default function Scan() { } }, []) - const handleFolderBrowse = async () => { - setIsBrowsing(true) + const handleFolderBrowse = () => { + setError(null) + setShowFolderBrowser(true) + } + + const handleFolderSelect = (selectedPath: string) => { + setFolderPath(selectedPath) setError(null) - - // Try backend API first (uses tkinter for native folder picker with full path) - try { - console.log('Attempting to open native folder picker...') - const result = await photosApi.browseFolder() - console.log('Backend folder picker result:', result) - - if (result.success && result.path) { - // Ensure we have a valid absolute path (not just folder name) - const path = result.path.trim() - if (path && path.length > 0) { - // Verify it looks like an absolute path: - // - Unix/Linux: starts with / (includes mounted network shares like /mnt/...) - // - Windows local: starts with drive letter like C:\ - // - Windows UNC: starts with \\ (network paths like \\server\share\folder) - const isUnixPath = path.startsWith('/') - const isWindowsLocalPath = /^[A-Za-z]:[\\/]/.test(path) - const isWindowsUncPath = path.startsWith('\\\\') || path.startsWith('//') - - if (isUnixPath || isWindowsLocalPath || isWindowsUncPath) { - setFolderPath(path) - setIsBrowsing(false) - return - } else { - // Backend validated it, so trust it even if it doesn't match our patterns - // (might be a valid path format we didn't account for) - console.warn('Backend returned path with unexpected format:', path) - setFolderPath(path) - setIsBrowsing(false) - return - } - } - } - // If we get here, result.success was false or path was empty - console.warn('Backend folder picker returned no path:', result) - if (result.success === false && result.message) { - setError(result.message || 'No folder was selected. Please try again.') - } else { - setError('No folder was selected. Please try again.') - } - setIsBrowsing(false) - } catch (err: any) { - // Backend API failed, fall back to browser picker - console.warn('Backend folder picker unavailable, using browser fallback:', err) - - // Extract error message from various possible locations - const errorMsg = err?.response?.data?.detail || - err?.response?.data?.message || - err?.message || - String(err) || - '' - - console.log('Error details:', { - status: err?.response?.status, - detail: err?.response?.data?.detail, - message: err?.message, - fullError: err - }) - - // Check if it's a display/availability issue - if (errorMsg.includes('display') || errorMsg.includes('DISPLAY') || errorMsg.includes('tkinter')) { - // Show user-friendly message about display issue - setError('Native folder picker unavailable. Using browser fallback.') - } else if (err?.response?.status === 503) { - // 503 Service Unavailable - likely tkinter or display issue - setError('Native folder picker unavailable (tkinter/display issue). Using browser fallback.') - } else { - // Other error - log it but continue to browser fallback - console.error('Error calling backend folder picker:', err) - setError('Native folder picker unavailable. Using browser fallback.') - } - } - - // Fallback: Use browser-based folder picker - // This code runs if backend API failed or returned no path - console.log('Attempting browser fallback folder picker...') - - // Use File System Access API if available (modern browsers) - if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) { - try { - console.log('Using File System Access API...') - const directoryHandle = await (window as any).showDirectoryPicker() - // Get the folder name from the handle - const folderName = directoryHandle.name - // Note: Browsers don't expose full absolute paths for security reasons - console.log('Selected folder name:', folderName) - - // Browser picker only gives folder name, not full path - // Set the folder name and show helpful message - setFolderPath(folderName) - setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.') - } catch (err: any) { - // User cancelled the picker - if (err.name !== 'AbortError') { - console.error('Error selecting folder:', err) - setError('Error opening folder picker: ' + err.message) - } else { - // User cancelled - clear any previous error - setError(null) - } - } finally { - setIsBrowsing(false) - } - } else { - // Fallback: use a hidden directory input - // Note: This will show a browser confirmation dialog that cannot be removed - console.log('Using file input fallback...') - const input = document.createElement('input') - input.type = 'file' - input.setAttribute('webkitdirectory', '') - input.setAttribute('directory', '') - input.setAttribute('multiple', '') - input.style.display = 'none' - - input.onchange = (e: any) => { - const files = e.target.files - if (files && files.length > 0) { - const firstFile = files[0] - const relativePath = firstFile.webkitRelativePath - const pathParts = relativePath.split('/') - const rootFolder = pathParts[0] - // Note: Browsers don't expose full absolute paths for security reasons - console.log('Selected folder name:', rootFolder) - - // Browser picker only gives folder name, not full path - // Set the folder name and show helpful message - setFolderPath(rootFolder) - setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.') - } - if (document.body.contains(input)) { - document.body.removeChild(input) - } - setIsBrowsing(false) - } - - input.oncancel = () => { - if (document.body.contains(input)) { - document.body.removeChild(input) - } - setIsBrowsing(false) - } - - document.body.appendChild(input) - input.click() - } } const handleScanFolder = async () => { @@ -332,10 +193,10 @@ export default function Scan() {

@@ -455,6 +316,15 @@ export default function Scan() { )} + + {/* Folder Browser Modal */} + {showFolderBrowser && ( + setShowFolderBrowser(false)} + /> + )} ) } diff --git a/backend/api/photos.py b/backend/api/photos.py index 0e9faaa..578c77e 100644 --- a/backend/api/photos.py +++ b/backend/api/photos.py @@ -29,6 +29,8 @@ from backend.schemas.photos import ( BulkDeletePhotosResponse, BulkRemoveFavoritesRequest, BulkRemoveFavoritesResponse, + BrowseDirectoryResponse, + DirectoryItem, ) from backend.schemas.search import ( PhotoSearchResult, @@ -436,6 +438,112 @@ async def upload_photos( } +@router.get("/browse-directory", response_model=BrowseDirectoryResponse) +def browse_directory( + current_user: Annotated[dict, Depends(get_current_user)], + path: str = Query("/", description="Directory path to list"), +) -> BrowseDirectoryResponse: + """List directories and files in a given path. + + No GUI required - uses os.listdir() to read filesystem. + Returns JSON with directory structure for web-based folder browser. + + Args: + path: Directory path to list (can be relative or absolute) + + Returns: + BrowseDirectoryResponse with current path, parent path, and items list + + Raises: + HTTPException: If path doesn't exist, is not a directory, or access is denied + """ + import os + from pathlib import Path + + try: + # Convert to absolute path + abs_path = os.path.abspath(path) + + # Normalize path separators + abs_path = os.path.normpath(abs_path) + + # Security: Optional - restrict to certain base paths + # For now, allow any path (server admin should configure file permissions) + # You can uncomment and customize this for production: + # allowed_bases = ["/home", "/mnt", "/opt/punimtag", "/media"] + # if not any(abs_path.startswith(base) for base in allowed_bases): + # raise HTTPException( + # status_code=status.HTTP_403_FORBIDDEN, + # detail=f"Path not allowed: {abs_path}" + # ) + + if not os.path.exists(abs_path): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Path does not exist: {abs_path}", + ) + + if not os.path.isdir(abs_path): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Path is not a directory: {abs_path}", + ) + + # Read directory contents + items = [] + try: + for item in os.listdir(abs_path): + item_path = os.path.join(abs_path, item) + full_path = os.path.abspath(item_path) + + # Skip if we can't access it (permission denied) + try: + is_dir = os.path.isdir(full_path) + is_file = os.path.isfile(full_path) + except (OSError, PermissionError): + # Skip items we can't access + continue + + items.append( + DirectoryItem( + name=item, + path=full_path, + is_directory=is_dir, + is_file=is_file, + ) + ) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied reading directory: {abs_path}", + ) + + # Sort: directories first, then files, both alphabetically + items.sort(key=lambda x: (not x.is_directory, x.name.lower())) + + # Get parent path (None if at root) + parent_path = None + if abs_path != "/" and abs_path != os.path.dirname(abs_path): + parent_path = os.path.dirname(abs_path) + # Normalize parent path + parent_path = os.path.normpath(parent_path) + + return BrowseDirectoryResponse( + current_path=abs_path, + parent_path=parent_path, + items=items, + ) + + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error reading directory: {str(e)}", + ) + + @router.post("/browse-folder") def browse_folder() -> dict: """Open native folder picker dialog and return selected folder path. diff --git a/backend/schemas/photos.py b/backend/schemas/photos.py index 253d224..8059216 100644 --- a/backend/schemas/photos.py +++ b/backend/schemas/photos.py @@ -91,3 +91,20 @@ class BulkDeletePhotosResponse(BaseModel): description="Photo IDs that were requested but not found", ) + +class DirectoryItem(BaseModel): + """Directory item (file or folder) in a directory listing.""" + + name: str = Field(..., description="Name of the item") + path: str = Field(..., description="Full absolute path to the item") + is_directory: bool = Field(..., description="Whether this is a directory") + is_file: bool = Field(..., description="Whether this is a file") + + +class BrowseDirectoryResponse(BaseModel): + """Response for directory browsing.""" + + current_path: str = Field(..., description="Current directory path") + parent_path: Optional[str] = Field(None, description="Parent directory path (None if at root)") + items: List[DirectoryItem] = Field(..., description="List of items in the directory") + -- 2.49.1