refactor: config-driven libraries; drop folder-add UI
The frontend AddSourceFolderDialog let users register source roots from inside the app, but with the bootstrap auto-creating one for the /photos mount on first boot, the dialog was redundant in the common case and confusing in every other (users had to know which container path corresponded to their host directory). Going config-driven matches Plex/Photoprism/Immich and matches the mental model "the docker mount IS the library". Frontend - Deleted components/dialogs/AddSourceFolderDialog.tsx entirely. - LeftSidebar drops the "+ Add Source Folder" button + bottom-bar layout, the addFolderMutation, the dead Plus action button on the (no-longer-existing) folders/heaps tree headers, and the Plus icon import. - api.ts: removed sourceFolders.add(), library.browse(), and the BrowseChild / BrowseResponse types. The remaining sourceFolders surface is read-only (list + manual scan). - LeftSidebar bottom strip is now just the "Scan all folders" button when there's at least one source root. Backend - Dropped POST /folders (no consumers) along with FolderCreate / FolderResponse pydantic models. The folders router header now documents the config-driven approach. - Dropped GET /library/browse (no consumers). Removed the unused os/HTTPException/SourceRoot imports it brought in. - cleanup_data_integrity now also walks the source roots and logs a warning for any whose path is missing on disk. Doesn't auto- delete (a missing path could be a temporarily unmounted drive) but surfaces enough hint to fix it. Returns the count in the summary dict alongside merged-duplicates. Docs - README "How libraries are managed" section rewritten to spell out that mounts ARE source roots, edit .env + restart, no UI for managing source roots. New "Changing or adding libraries" section walks through the typical edit-restart loop including the optional volume-nuke for a clean slate. - "Adding more libraries" subsection covers multi-mount via edited compose with a note that auto-registration is roadmap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,219 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
X,
|
||||
FolderPlus,
|
||||
AlertCircle,
|
||||
ChevronUp,
|
||||
Folder,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { library, type BrowseResponse } from '../../services/api'
|
||||
|
||||
interface AddSourceFolderDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onAdd: (path: string, recursive: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory-browser dialog for adding a source folder.
|
||||
*
|
||||
* Users can't reasonably know which container path corresponds to their
|
||||
* host directory, so the dialog instead lets them click through the mounted
|
||||
* library tree (rooted at /photos by default). The list comes from
|
||||
* GET /library/browse, which is server-side restricted to allowed roots.
|
||||
*
|
||||
* "Add this folder" registers whatever directory is currently shown.
|
||||
*/
|
||||
export function AddSourceFolderDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: AddSourceFolderDialogProps) {
|
||||
const [browse, setBrowse] = useState<BrowseResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
|
||||
// Load the root directory (/photos) on first open. Subsequent navigation
|
||||
// (clicking a child or the parent button) calls loadPath directly.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
loadPath(undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
const loadPath = async (path?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await library.browse(path)
|
||||
setBrowse(data)
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
e?.response?.data?.detail ||
|
||||
e?.message ||
|
||||
'Failed to load directory'
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!browse) return
|
||||
setAdding(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onAdd(browse.path, recursive)
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
e?.response?.data?.detail ||
|
||||
e?.message ||
|
||||
'Failed to add source folder'
|
||||
)
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (adding) return
|
||||
setBrowse(null)
|
||||
setError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-lg rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderPlus className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-text">
|
||||
Add source folder
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={adding}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current path + parent nav */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => browse?.parent && loadPath(browse.parent)}
|
||||
disabled={!browse?.parent || loading || adding}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-30"
|
||||
title="Up one folder"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<code className="flex-1 truncate rounded bg-surface-2 px-2 py-1 font-mono text-xs text-text">
|
||||
{browse?.path ?? '…'}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Children list */}
|
||||
<div className="mb-4 h-64 overflow-y-auto rounded border border-border bg-bg">
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{!loading && browse && browse.children.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
No subfolders here
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
browse &&
|
||||
browse.children.map((child) => (
|
||||
<button
|
||||
key={child.path}
|
||||
onClick={() => loadPath(child.path)}
|
||||
disabled={adding}
|
||||
className="flex w-full items-center gap-2 border-b border-border px-3 py-1.5 text-left text-sm text-text last:border-b-0 hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
<Folder className="h-4 w-4 text-text-muted" />
|
||||
<span className="flex-1 truncate">{child.name}</span>
|
||||
{child.is_existing_root && (
|
||||
<span className="flex items-center gap-1 rounded bg-primary/20 px-1.5 py-0.5 text-[10px] text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
Added
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recursive toggle */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<input
|
||||
id="recursive"
|
||||
type="checkbox"
|
||||
checked={recursive}
|
||||
onChange={(e) => setRecursive(e.target.checked)}
|
||||
disabled={adding}
|
||||
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
|
||||
/>
|
||||
<label htmlFor="recursive" className="text-sm text-text">
|
||||
Include subfolders
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex-1 text-xs text-text-muted">
|
||||
{browse?.is_existing_root
|
||||
? 'This folder is already a source root.'
|
||||
: 'Adds the folder shown above as a source root.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={adding}
|
||||
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={
|
||||
!browse || adding || loading || browse.is_existing_root
|
||||
}
|
||||
className={clsx(
|
||||
'rounded bg-primary px-4 py-2 text-sm text-white',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{adding ? 'Adding…' : 'Add this folder'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
Image,
|
||||
Star,
|
||||
Trash2,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
||||
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../ToastContainer'
|
||||
@@ -32,7 +30,6 @@ interface TreeItem {
|
||||
export function LeftSidebar() {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
||||
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
@@ -119,32 +116,11 @@ export function LeftSidebar() {
|
||||
}
|
||||
|
||||
// Fetch folders from API
|
||||
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
||||
const { data: foldersData } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: sourceFolders.list,
|
||||
})
|
||||
|
||||
// Mutation for adding folders
|
||||
const addFolderMutation = useMutation({
|
||||
mutationFn: async ({ path, recursive }: { path: string; recursive: boolean }) => {
|
||||
// Add the folder
|
||||
const folder = await sourceFolders.add(path, recursive)
|
||||
// Trigger scan for the new folder
|
||||
await sourceFolders.scan(folder.id)
|
||||
return folder
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
toast.success('Folder Added', `Scanning ${folder.name || folder.path}...`)
|
||||
// Refetch folders list
|
||||
refetchFolders()
|
||||
// Refetch photos to show new ones
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error('Failed to Add Folder', error.message || 'An error occurred')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// Mutation for scanning all folders
|
||||
const scanLibraryMutation = useMutation({
|
||||
mutationFn: library.scan,
|
||||
@@ -165,10 +141,6 @@ export function LeftSidebar() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddFolder = async (path: string, recursive: boolean) => {
|
||||
await addFolderMutation.mutateAsync({ path, recursive })
|
||||
}
|
||||
|
||||
const handleScanAll = () => {
|
||||
scanLibraryMutation.mutate()
|
||||
}
|
||||
@@ -320,18 +292,6 @@ export function LeftSidebar() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions (shown on hover) */}
|
||||
{(item.id === 'folders' || item.id === 'heaps') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Handle add folder/heap
|
||||
}}
|
||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Render Children */}
|
||||
@@ -361,32 +321,18 @@ export function LeftSidebar() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="border-t border-border p-3 space-y-2">
|
||||
<button
|
||||
onClick={() => setShowAddFolderDialog(true)}
|
||||
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Source Folder
|
||||
</button>
|
||||
{foldersData?.folders?.length > 0 && (
|
||||
<button
|
||||
{foldersData?.folders?.length > 0 && (
|
||||
<div className="border-t border-border p-3">
|
||||
<button
|
||||
onClick={handleScanAll}
|
||||
disabled={isScanning}
|
||||
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx("h-4 w-4", isScanning && "animate-spin")} />
|
||||
{isScanning ? 'Scanning...' : 'Scan All Folders'}
|
||||
<RefreshCw className={clsx('h-4 w-4', isScanning && 'animate-spin')} />
|
||||
{isScanning ? 'Scanning...' : 'Scan all folders'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Source Folder Dialog */}
|
||||
<AddSourceFolderDialog
|
||||
isOpen={showAddFolderDialog}
|
||||
onClose={() => setShowAddFolderDialog(false)}
|
||||
onAdd={handleAddFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,31 +9,18 @@ const api = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Source Folders API
|
||||
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
||||
// .env → bootstrap on backend startup), so the UI only reads them.
|
||||
export const sourceFolders = {
|
||||
list: async () => {
|
||||
const response = await api.get('/folders')
|
||||
return response.data
|
||||
},
|
||||
|
||||
add: async (path: string, recursive: boolean = true) => {
|
||||
const response = await api.post('/folders', {
|
||||
path,
|
||||
recursive,
|
||||
watch: false, // Can be made configurable later
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
scan: async (folderId: string) => {
|
||||
const response = await api.post(`/folders/${folderId}/scan`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (folderId: string) => {
|
||||
const response = await api.delete(`/folders/${folderId}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// Photos API
|
||||
@@ -113,18 +100,6 @@ export const photos = {
|
||||
}
|
||||
|
||||
// Library API
|
||||
export interface BrowseChild {
|
||||
name: string
|
||||
path: string
|
||||
is_existing_root: boolean
|
||||
}
|
||||
export interface BrowseResponse {
|
||||
path: string
|
||||
parent: string | null
|
||||
is_existing_root: boolean
|
||||
children: BrowseChild[]
|
||||
}
|
||||
|
||||
export const library = {
|
||||
scan: async () => {
|
||||
const response = await api.post('/library/scan')
|
||||
@@ -140,15 +115,6 @@ export const library = {
|
||||
const response = await api.get('/library/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** List immediate child directories of `path` for the source-folder
|
||||
* picker. Defaults to the library root (/photos). */
|
||||
browse: async (path?: string): Promise<BrowseResponse> => {
|
||||
const response = await api.get('/library/browse', {
|
||||
params: path ? { path } : undefined,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// Heaps API
|
||||
|
||||
Reference in New Issue
Block a user