PunimTag Web Application - Major Feature Release #1

Open
tanyar09 wants to merge 106 commits from dev into master
5 changed files with 460 additions and 150 deletions
Showing only changes of commit 41bed0b680 - Show all commits

View File

@ -147,6 +147,15 @@ export const photosApi = {
return data
},
browseDirectory: async (path: string): Promise<BrowseDirectoryResponse> => {
// Axios automatically URL-encodes query parameters
const { data } = await apiClient.get<BrowseDirectoryResponse>(
'/api/v1/photos/browse-directory',
{ params: { path } }
)
return data
},
getPhotoImageBlob: async (photoId: number): Promise<string> => {
// 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[]
}

View File

@ -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<BrowseDirectoryResponse['items']>([])
const [parentPath, setParentPath] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={(e) => {
// Close modal when clicking backdrop
if (e.target === e.currentTarget) {
onClose()
}
}}
>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col m-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Select Folder
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 focus:outline-none"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/* Path Input */}
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50">
<form onSubmit={handlePathInputSubmit} className="flex gap-2">
<input
type="text"
value={pathInput}
onChange={(e) => 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"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go
</button>
</form>
</div>
{/* Breadcrumb Navigation */}
<div className="px-6 py-2 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-1 text-sm">
<button
onClick={() => loadDirectory('/')}
className="px-2 py-1 text-blue-600 hover:text-blue-800 hover:underline"
>
Root
</button>
{pathSegments.map((segment, index) => (
<span key={index} className="flex items-center gap-1">
<span className="text-gray-400">/</span>
<button
onClick={() => loadDirectory(breadcrumbPaths[index])}
className="px-2 py-1 text-blue-600 hover:text-blue-800 hover:underline"
>
{segment}
</button>
</span>
))}
</div>
</div>
{/* Error Message */}
{error && (
<div className="px-6 py-3 bg-red-50 border-b border-red-200">
<p className="text-sm text-red-800">{String(error)}</p>
</div>
)}
{/* Directory Listing */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500">Loading...</div>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500">Directory is empty</div>
</div>
) : (
<div className="space-y-1">
{parentPath && (
<button
onClick={handleParentClick}
className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none focus:bg-gray-100 flex items-center gap-2"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
<span className="text-gray-700 font-medium">.. (Parent)</span>
</button>
)}
{items.map((item) => (
<button
key={item.path}
onClick={() => handleItemClick(item)}
className={`w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none focus:bg-gray-100 flex items-center gap-2 ${
item.is_directory ? 'cursor-pointer' : 'cursor-default opacity-60'
}`}
disabled={!item.is_directory}
>
{item.is_directory ? (
<svg
className="w-5 h-5 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
) : (
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
)}
<span className="text-gray-700">{item.name}</span>
</button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
<div className="text-sm text-gray-600">
<span className="font-medium">Current path:</span>{' '}
<span className="font-mono">{currentPath}</span>
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
<button
onClick={handleSelectCurrentPath}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Select This Folder
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -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<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(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() {
<button
type="button"
onClick={handleFolderBrowse}
disabled={isImporting || isBrowsing}
disabled={isImporting}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isBrowsing ? 'Opening...' : 'Browse'}
Browse
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
@ -455,6 +316,15 @@ export default function Scan() {
</div>
)}
</div>
{/* Folder Browser Modal */}
{showFolderBrowser && (
<FolderBrowser
onSelectPath={handleFolderSelect}
initialPath={folderPath || '/'}
onClose={() => setShowFolderBrowser(false)}
/>
)}
</div>
)
}

View File

@ -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.

View File

@ -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")