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:
2
.env
2
.env
@@ -9,7 +9,7 @@
|
|||||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||||
PHOTO_DIRS=./photos
|
PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest
|
||||||
|
|
||||||
# Redis configuration
|
# Redis configuration
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -70,33 +70,33 @@ docker compose up -d
|
|||||||
|
|
||||||
You don't need to touch `mulita.yml` or the API to get started.
|
You don't need to touch `mulita.yml` or the API to get started.
|
||||||
|
|
||||||
### How "mounted folders" and "source folders" relate
|
### How libraries are managed
|
||||||
|
|
||||||
There are two layers, and confusing them is the most common source of
|
Mulita is **config-driven**: the host directory you mount via
|
||||||
"why doesn't this work" questions:
|
`PHOTO_DIRS` becomes your library, and the backend automatically
|
||||||
|
registers it as a source root on startup. There is no UI for adding
|
||||||
|
or removing source roots — to change what Mulita scans, edit `.env`
|
||||||
|
(or `docker-compose.yml` for multi-mount setups) and restart the
|
||||||
|
stack.
|
||||||
|
|
||||||
| Layer | Lives in | What it controls |
|
This keeps the model simple: **the docker mount IS the library**.
|
||||||
|---|---|---|
|
No two layers, no confusion about which view to use.
|
||||||
| **Mount** | `docker-compose.yml` (`${PHOTO_DIRS}:/photos:rw`) | What's *visible* inside the container |
|
|
||||||
| **Source root** | Database (managed by the UI) | What the scanner *walks and indexes* |
|
|
||||||
|
|
||||||
Both are required. A folder that isn't mounted is invisible to the
|
### Changing or adding libraries
|
||||||
container regardless of what the database says, and a folder that's
|
|
||||||
mounted but not registered as a source root won't be scanned.
|
|
||||||
|
|
||||||
In practice, the default flow handles this for you: you mount one host
|
To point at a different library:
|
||||||
directory via `PHOTO_DIRS`, and the bootstrap automatically registers it
|
1. Edit `PHOTO_DIRS` in `.env`
|
||||||
as a source root. If you want to add a *subfolder* of your library as a
|
2. `docker compose down`
|
||||||
separate source root (so it shows up as its own item in the sidebar),
|
3. (Optional, for a clean slate) `docker volume rm muleimage_db_data muleimage_thumbs_data muleimage_proxies_data`
|
||||||
use the **Add Source Folder** button — the dialog is a directory browser
|
4. `docker compose up -d`
|
||||||
restricted to what's mounted, so you can only add things the container
|
|
||||||
can actually see.
|
|
||||||
|
|
||||||
### Adding more libraries
|
The new library shows up automatically. Without step 3 the old
|
||||||
|
library's metadata stays in the DB and you'll see a warning at
|
||||||
|
startup that the old source root's path is missing on disk —
|
||||||
|
that's a hint to clean up.
|
||||||
|
|
||||||
Today the compose file mounts a single host directory as `/photos`.
|
For multiple libraries, edit `docker-compose.yml` and add additional
|
||||||
If you want multiple libraries from different host paths, edit
|
mount lines:
|
||||||
`docker-compose.yml` and add additional mount lines, e.g.:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
@@ -104,8 +104,9 @@ volumes:
|
|||||||
- /Volumes/Archive:/archive:rw # additional library
|
- /Volumes/Archive:/archive:rw # additional library
|
||||||
```
|
```
|
||||||
|
|
||||||
Then in the UI, click **Add Source Folder**, browse to `/archive`, and
|
Each mounted directory will need a corresponding source root row in
|
||||||
add it. (Multi-mount support via env vars is on the roadmap.)
|
the DB; today that means `POST /api/v1/folders` via curl, or wait
|
||||||
|
for the multi-mount auto-registration that's on the roadmap.
|
||||||
|
|
||||||
### Read-only libraries
|
### Read-only libraries
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Folders API router
|
Folders API router. Source roots are config-driven (PHOTO_DIRS in .env →
|
||||||
|
backend bootstrap on startup); this router only exposes read access and a
|
||||||
|
manual rescan trigger. Adding/removing source roots happens by editing
|
||||||
|
docker-compose.yml + .env and restarting the stack.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import List, Optional
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Folder, SourceRoot
|
from app.models import Folder, SourceRoot
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
class FolderCreate(BaseModel):
|
|
||||||
path: str
|
|
||||||
recursive: bool = True
|
|
||||||
watch: bool = False
|
|
||||||
|
|
||||||
class FolderResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
path: str
|
|
||||||
photo_count: int
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get all source folders"""
|
"""Get all source folders"""
|
||||||
@@ -50,39 +39,6 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
return {"folders": folders_list}
|
return {"folders": folders_list}
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def create_folder(folder: FolderCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Add a new source folder"""
|
|
||||||
# Check if path exists
|
|
||||||
if not os.path.exists(folder.path):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Path does not exist: {folder.path}")
|
|
||||||
|
|
||||||
# Check if path is already added
|
|
||||||
result = await db.execute(select(SourceRoot).where(SourceRoot.path == folder.path))
|
|
||||||
existing = result.scalar_one_or_none()
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=400, detail="Path already added as source folder")
|
|
||||||
|
|
||||||
# Create source root
|
|
||||||
source_root = SourceRoot(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
name=os.path.basename(folder.path),
|
|
||||||
path=folder.path
|
|
||||||
)
|
|
||||||
db.add(source_root)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Automatically trigger a scan for the new folder
|
|
||||||
from app.tasks.celery import celery_app
|
|
||||||
celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": source_root.id,
|
|
||||||
"name": source_root.name,
|
|
||||||
"path": source_root.path,
|
|
||||||
"photo_count": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/scan")
|
@router.post("/{folder_id}/scan")
|
||||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Trigger manual re-scan of source root folder"""
|
"""Trigger manual re-scan of source root folder"""
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Library API router for stats, scanning, and directory browsing
|
Library API router for stats and scanning
|
||||||
"""
|
"""
|
||||||
import os
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo, SourceRoot
|
from app.models import Photo
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# Always-allowed root for the directory browser. Whatever the user mounts
|
|
||||||
# as PHOTO_DIRS in .env shows up here.
|
|
||||||
DEFAULT_LIBRARY_ROOT = "/photos"
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get library statistics"""
|
"""Get library statistics"""
|
||||||
@@ -44,89 +38,6 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
|||||||
"total_size_gb": round(size / (1024**3), 2) if size else 0
|
"total_size_gb": round(size / (1024**3), 2) if size else 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/browse")
|
|
||||||
async def browse_directory(
|
|
||||||
path: str = DEFAULT_LIBRARY_ROOT,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""List the immediate child directories of `path` so the frontend can
|
|
||||||
render a folder picker. The path is validated to live under one of the
|
|
||||||
allowed roots so this can't be used to enumerate the container
|
|
||||||
filesystem:
|
|
||||||
|
|
||||||
- The default library mount (/photos)
|
|
||||||
- Any active SourceRoot the user has already added (and its subtree)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"path": str, # canonical (normalized) path
|
|
||||||
"parent": str | null, # parent path if still inside an allowed root
|
|
||||||
"is_existing_root": bool # whether `path` is itself a SourceRoot
|
|
||||||
"children": [
|
|
||||||
{ "name", "path", "is_existing_root" }, ...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# Build the allowed-roots set: default mount + every active SourceRoot.
|
|
||||||
sr_result = await db.execute(
|
|
||||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
|
||||||
)
|
|
||||||
source_roots = sr_result.scalars().all()
|
|
||||||
sr_paths = [os.path.normpath(sr.path) for sr in source_roots]
|
|
||||||
allowed_roots = {os.path.normpath(DEFAULT_LIBRARY_ROOT), *sr_paths}
|
|
||||||
|
|
||||||
canonical = os.path.normpath(path)
|
|
||||||
|
|
||||||
# Path must live under (or be) one of the allowed roots — prevents
|
|
||||||
# browsing /etc, /data/db, etc.
|
|
||||||
def under_allowed(p: str) -> bool:
|
|
||||||
for root in allowed_roots:
|
|
||||||
if p == root or p.startswith(root + os.sep):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not under_allowed(canonical):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Path is outside the allowed photo roots",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not os.path.isdir(canonical):
|
|
||||||
raise HTTPException(status_code=404, detail=f"Not a directory: {canonical}")
|
|
||||||
|
|
||||||
# Build the child list — only directories, hidden entries (dotfiles)
|
|
||||||
# excluded.
|
|
||||||
sr_path_set = set(sr_paths)
|
|
||||||
try:
|
|
||||||
entries = sorted(os.listdir(canonical))
|
|
||||||
except OSError as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Cannot read directory: {e}")
|
|
||||||
|
|
||||||
children = []
|
|
||||||
for entry in entries:
|
|
||||||
if entry.startswith('.'):
|
|
||||||
continue
|
|
||||||
child_path = os.path.join(canonical, entry)
|
|
||||||
if not os.path.isdir(child_path):
|
|
||||||
continue
|
|
||||||
children.append({
|
|
||||||
"name": entry,
|
|
||||||
"path": child_path,
|
|
||||||
"is_existing_root": child_path in sr_path_set,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Compute parent path if it's still inside an allowed root.
|
|
||||||
parent = os.path.normpath(os.path.dirname(canonical))
|
|
||||||
parent_in_scope = parent != canonical and under_allowed(parent)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"path": canonical,
|
|
||||||
"parent": parent if parent_in_scope else None,
|
|
||||||
"is_existing_root": canonical in sr_path_set,
|
|
||||||
"children": children,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan")
|
@router.post("/scan")
|
||||||
async def trigger_scan():
|
async def trigger_scan():
|
||||||
"""Trigger full library re-scan"""
|
"""Trigger full library re-scan"""
|
||||||
|
|||||||
@@ -119,6 +119,26 @@ async def _recompute_folder_counts(session: AsyncSession) -> None:
|
|||||||
f.photo_count = int(count_result.scalar() or 0)
|
f.photo_count = int(count_result.scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _warn_stale_source_roots(session: AsyncSession) -> int:
|
||||||
|
"""Log a warning for any active source root whose path no longer exists
|
||||||
|
on disk. Doesn't delete — a missing path could be a temporarily
|
||||||
|
unmounted drive, and silently dropping user data is worse than
|
||||||
|
surfacing a noisy log line.
|
||||||
|
"""
|
||||||
|
result = await session.execute(select(SourceRoot))
|
||||||
|
rows = result.scalars().all()
|
||||||
|
stale = 0
|
||||||
|
for sr in rows:
|
||||||
|
if not os.path.isdir(sr.path):
|
||||||
|
stale += 1
|
||||||
|
logger.warning(
|
||||||
|
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
|
||||||
|
f"— is the docker mount still in place? "
|
||||||
|
f"(Edit docker-compose.yml or PHOTO_DIRS in .env to fix.)"
|
||||||
|
)
|
||||||
|
return stale
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_data_integrity() -> dict:
|
async def cleanup_data_integrity() -> dict:
|
||||||
"""Top-level entry point. Runs the dedupe + count refresh in a single
|
"""Top-level entry point. Runs the dedupe + count refresh in a single
|
||||||
transaction. Returns a small summary dict for logging."""
|
transaction. Returns a small summary dict for logging."""
|
||||||
@@ -127,10 +147,12 @@ async def cleanup_data_integrity() -> dict:
|
|||||||
sr_deleted = await _dedupe_source_roots(session)
|
sr_deleted = await _dedupe_source_roots(session)
|
||||||
f_deleted = await _dedupe_folders(session)
|
f_deleted = await _dedupe_folders(session)
|
||||||
await _recompute_folder_counts(session)
|
await _recompute_folder_counts(session)
|
||||||
|
stale = await _warn_stale_source_roots(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
summary = {
|
summary = {
|
||||||
"source_roots_merged": sr_deleted,
|
"source_roots_merged": sr_deleted,
|
||||||
"folders_merged": f_deleted,
|
"folders_merged": f_deleted,
|
||||||
|
"source_roots_stale": stale,
|
||||||
}
|
}
|
||||||
if sr_deleted or f_deleted:
|
if sr_deleted or f_deleted:
|
||||||
logger.info(f"Cleanup merged duplicates: {summary}")
|
logger.info(f"Cleanup merged duplicates: {summary}")
|
||||||
|
|||||||
@@ -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,
|
Image,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
|
||||||
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
@@ -32,7 +30,6 @@ interface TreeItem {
|
|||||||
export function LeftSidebar() {
|
export function LeftSidebar() {
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
||||||
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -119,32 +116,11 @@ export function LeftSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders from API
|
// Fetch folders from API
|
||||||
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
const { data: foldersData } = useQuery({
|
||||||
queryKey: ['folders'],
|
queryKey: ['folders'],
|
||||||
queryFn: sourceFolders.list,
|
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
|
// Mutation for scanning all folders
|
||||||
const scanLibraryMutation = useMutation({
|
const scanLibraryMutation = useMutation({
|
||||||
mutationFn: library.scan,
|
mutationFn: library.scan,
|
||||||
@@ -165,10 +141,6 @@ export function LeftSidebar() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleAddFolder = async (path: string, recursive: boolean) => {
|
|
||||||
await addFolderMutation.mutateAsync({ path, recursive })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScanAll = () => {
|
const handleScanAll = () => {
|
||||||
scanLibraryMutation.mutate()
|
scanLibraryMutation.mutate()
|
||||||
}
|
}
|
||||||
@@ -320,18 +292,6 @@ export function LeftSidebar() {
|
|||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Render Children */}
|
{/* Render Children */}
|
||||||
@@ -361,32 +321,18 @@ export function LeftSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
<div className="border-t border-border p-3 space-y-2">
|
{foldersData?.folders?.length > 0 && (
|
||||||
<button
|
<div className="border-t border-border p-3">
|
||||||
onClick={() => setShowAddFolderDialog(true)}
|
<button
|
||||||
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
|
|
||||||
onClick={handleScanAll}
|
onClick={handleScanAll}
|
||||||
disabled={isScanning}
|
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"
|
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")} />
|
<RefreshCw className={clsx('h-4 w-4', isScanning && 'animate-spin')} />
|
||||||
{isScanning ? 'Scanning...' : 'Scan All Folders'}
|
{isScanning ? 'Scanning...' : 'Scan all folders'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</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 = {
|
export const sourceFolders = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
const response = await api.get('/folders')
|
const response = await api.get('/folders')
|
||||||
return response.data
|
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) => {
|
scan: async (folderId: string) => {
|
||||||
const response = await api.post(`/folders/${folderId}/scan`)
|
const response = await api.post(`/folders/${folderId}/scan`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (folderId: string) => {
|
|
||||||
const response = await api.delete(`/folders/${folderId}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photos API
|
// Photos API
|
||||||
@@ -113,18 +100,6 @@ export const photos = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Library API
|
// 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 = {
|
export const library = {
|
||||||
scan: async () => {
|
scan: async () => {
|
||||||
const response = await api.post('/library/scan')
|
const response = await api.post('/library/scan')
|
||||||
@@ -140,15 +115,6 @@ export const library = {
|
|||||||
const response = await api.get('/library/stats')
|
const response = await api.get('/library/stats')
|
||||||
return response.data
|
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
|
// Heaps API
|
||||||
|
|||||||
Reference in New Issue
Block a user