feat: simplify folder setup — single mount, auto bootstrap, browser dialog
Cleans up the maze of overlapping ways folders entered the app, plus
removes the dead trash plumbing left over from the soft-discard
refactor.
Setup model (now)
- ONE env var: PHOTO_DIRS in .env, set to the host path of your
library. Compose mounts that at /photos. That's the entire setup.
- On first boot, the backend auto-creates a SourceRoot row named
"Library" pointing at /photos so the user sees their photos
immediately without configuring anything.
- Source roots and discard live in the database; mulita.yml only
carries operational settings (thumbnails, scanner, performance).
- The "Add Source Folder" dialog is now a directory browser
restricted server-side to /photos and any existing source root —
the user clicks through actual mounted directories instead of
typing container paths they can't possibly know.
Backend
- New services/scanner.bootstrap_default_source_root(): if no
SourceRoot rows exist and /photos is mounted, create one. Wired
into the lifespan handler before cleanup + initial scan.
- New GET /library/browse?path= returning the immediate child
directories of `path`, validated to live under one of the allowed
roots (default mount + every active SourceRoot). Hidden entries
are filtered. Children are tagged with is_existing_root so the UI
can show an "Added" badge. Returns parent path for up-nav, or
null when at the top of the allowed scope.
- scan_all_source_roots now reads from the DB instead of the YAML
config so DB-managed source roots are honoured by initial scan.
- Dropped the placeholder source_roots block from mulita.yml — the
paths /photos/main and /photos/iphone never existed and just
produced startup warnings.
- Dropped TrashSettings, settings.trash, settings.source_roots,
and the SourceRoot pydantic model from config.py. Soft discard
has owned this for a while; it was dead code.
Compose
- Single ${PHOTO_DIRS:-./photos}:/photos:rw mount in both backend
and worker.
- Removed the hardcoded ~/Pictures:/host/Pictures:rw mount — the
PHOTO_DIRS variable is the single source of truth now.
- Removed the trash_data named volume + mounts (no consumers).
- backend/Dockerfile no longer creates /data/trash; it now creates
/data/proxies (which the proxy endpoint actually uses).
Frontend
- AddSourceFolderDialog rewritten as a directory tree picker:
loads /library/browse on open, lets the user navigate up via a
ChevronUp button or down by clicking subfolders, shows the
current path inline, and adds whatever directory is currently
shown. Existing source roots are tagged "Added" so the user
knows what's already registered. Errors from the backend (e.g.
trying to navigate outside the allowed scope) surface inline.
- New library.browse() helper + BrowseChild / BrowseResponse types
in services/api.ts.
Docs
- README Quick Start rewritten around the single PHOTO_DIRS env
var, with macOS/Linux/Windows examples.
- New "How mounted folders and source folders relate" section that
spells out the two-layer model (mount = visibility, source root
= scanning) so the most common confusion is addressed up front.
- Added a "Read-only libraries" subsection that lists exactly which
endpoints fail under :ro.
- "Configuration" section reframed: source roots are managed by the
UI/API now, mulita.yml is operational settings only.
- .env file now has examples for the common host paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { X, FolderPlus, AlertCircle } from 'lucide-react'
|
||||
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
|
||||
@@ -8,147 +16,204 @@ interface AddSourceFolderDialogProps {
|
||||
onAdd: (path: string, recursive: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolderDialogProps) {
|
||||
const [folderPath, setFolderPath] = useState('')
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
/**
|
||||
* 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)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!folderPath.trim()) {
|
||||
setError('Please enter a folder path')
|
||||
return
|
||||
}
|
||||
// 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])
|
||||
|
||||
setIsLoading(true)
|
||||
const loadPath = async (path?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onAdd(folderPath.trim(), recursive)
|
||||
setFolderPath('')
|
||||
setRecursive(true)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add source folder')
|
||||
const data = await library.browse(path)
|
||||
setBrowse(data)
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
e?.response?.data?.detail ||
|
||||
e?.message ||
|
||||
'Failed to load directory'
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
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 (!isLoading) {
|
||||
setFolderPath('')
|
||||
setError(null)
|
||||
onClose()
|
||||
}
|
||||
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">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative z-10 w-full max-w-md rounded-lg bg-surface border border-border p-6 shadow-xl">
|
||||
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold text-text">
|
||||
Add source folder
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
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>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Path Input */}
|
||||
<div>
|
||||
<label htmlFor="folderPath" className="mb-1 block text-sm text-text-muted">
|
||||
Folder Path
|
||||
</label>
|
||||
<input
|
||||
id="folderPath"
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder="/host/Pictures/your-folder"
|
||||
disabled={isLoading}
|
||||
className={clsx(
|
||||
'w-full rounded border bg-bg px-3 py-2 text-sm text-text placeholder-text-faint',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
error ? 'border-reject' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p className="text-xs text-text-muted">
|
||||
Use container paths. Your Pictures folder is available at:
|
||||
</p>
|
||||
<code className="block text-xs bg-surface-2 px-2 py-1 rounded text-primary">
|
||||
/host/Pictures/
|
||||
</code>
|
||||
<p className="text-xs text-text-faint">
|
||||
Example: /host/Pictures/MulitaTest
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Recursive Checkbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="recursive"
|
||||
type="checkbox"
|
||||
checked={recursive}
|
||||
onChange={(e) => setRecursive(e.target.checked)}
|
||||
disabled={isLoading}
|
||||
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 Message */}
|
||||
{error && (
|
||||
<div className="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>
|
||||
{/* 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>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !folderPath.trim()}
|
||||
className="rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Add Folder'}
|
||||
</button>
|
||||
{/* 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>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,18 @@ 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')
|
||||
@@ -128,6 +140,15 @@ 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