This is a Docker volume issue, not a code bug. The backend container only sees paths explicitly mounted in docker-compose.yml. A new mount on the Linux host (e.g. NAS share, USB drive) is invisible to the container until added as a volume — restarting the container alone doesn't help, and restarting just the host doesn't either. Backend: New GET /api/filebrowser/diagnose endpoint reads /proc/self/mountinfo and returns the actual bind/nfs/cifs mounts the container sees, plus a check of common candidate roots (/audiofiles, /mnt, /media, /srv, /home, /app/data) showing whether they exist and have content. Frontend: Info icon in FileBrowser header toggles a diagnose panel showing mounts and root candidates. Quick-access buttons now built dynamically from candidate roots that actually exist. On 'path not found' error: helpful inline explanation including the exact .env variable and docker-compose command needed to add a new mount. docker-compose.yml: New EXTRA_AUDIO_PATH env variable. Mounts a second host path 1:1 (same path inside container, like AUDIOFILES_PATH does). Defaults to ./data → /extra_audio_unused when unset, which is a no-op. .env.example: Documents EXTRA_AUDIO_PATH usage with example. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
201 lines
8.9 KiB
TypeScript
201 lines
8.9 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
||
import { Folder, ChevronRight, ChevronUp, X, Check, Loader2, FileAudio, AlertCircle, Info } from 'lucide-react'
|
||
import api from '../../api/client'
|
||
|
||
interface Entry { name: string; path: string; isDir: boolean }
|
||
interface MountInfo { mountPoint: string; fsType: string; source: string }
|
||
interface RootInfo { path: string; exists: boolean; entryCount?: number | null; permError?: boolean }
|
||
|
||
interface Props {
|
||
initialPath?: string
|
||
onSelect: (path: string) => void
|
||
onClose: () => void
|
||
}
|
||
|
||
export default function FileBrowser({ initialPath = '/', onSelect, onClose }: Props) {
|
||
const [path, setPath] = useState(initialPath)
|
||
const [entries, setEntries] = useState<Entry[]>([])
|
||
const [parent, setParent] = useState<string | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [showDiagnose, setShowDiagnose] = useState(false)
|
||
const [diagnose, setDiagnose] = useState<{ mounts: MountInfo[]; candidateRoots: RootInfo[]; hint: string } | null>(null)
|
||
|
||
const load = async (p: string) => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const r = await api.get('/api/filebrowser', { params: { path: p } })
|
||
setPath(r.data.path)
|
||
setParent(r.data.parent)
|
||
setEntries(r.data.entries)
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.detail || 'Fehler beim Laden')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadDiagnose = async () => {
|
||
try {
|
||
const r = await api.get('/api/filebrowser/diagnose')
|
||
setDiagnose(r.data)
|
||
} catch {}
|
||
}
|
||
|
||
useEffect(() => { load(initialPath) }, [])
|
||
useEffect(() => { loadDiagnose() }, [])
|
||
|
||
const visibleRoots = diagnose?.candidateRoots?.filter((r) => r.exists) || []
|
||
const quickPaths = ['/audiofiles', ...visibleRoots.map((r) => r.path).filter((p) => p !== '/audiofiles'), '/']
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||
<div
|
||
className="bg-surface border border-divider rounded-xl w-full max-w-lg flex flex-col"
|
||
style={{ maxHeight: '85vh' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-divider flex-shrink-0">
|
||
<h3 className="text-ink" style={{ fontSize: '13px', fontWeight: 600 }}>Ordner auswählen</h3>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setShowDiagnose(!showDiagnose)}
|
||
className={`p-1 transition-colors ${showDiagnose ? 'text-primary' : 'text-muted hover:text-ink'}`}
|
||
title="Container-Mounts anzeigen"
|
||
>
|
||
<Info size={14} />
|
||
</button>
|
||
<button onClick={onClose} className="text-muted hover:text-ink transition-colors">
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Diagnose-Panel */}
|
||
{showDiagnose && diagnose && (
|
||
<div className="px-4 py-3 border-b border-divider bg-card flex-shrink-0 overflow-y-auto" style={{ maxHeight: '40vh' }}>
|
||
<p className="text-muted mb-2" style={{ fontSize: '11px', fontWeight: 600 }}>Aktive Volume-Mounts im Container:</p>
|
||
{diagnose.mounts.length === 0 ? (
|
||
<p className="text-muted" style={{ fontSize: '11px' }}>Keine Mounts gefunden</p>
|
||
) : (
|
||
<div className="space-y-0.5 mb-3">
|
||
{diagnose.mounts.map((m, i) => (
|
||
<div key={i} className="font-mono text-ink truncate" style={{ fontSize: '10px' }}>
|
||
<span className="text-primary">{m.mountPoint}</span>
|
||
<span className="text-muted"> ({m.fsType})</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p className="text-muted mb-1" style={{ fontSize: '11px', fontWeight: 600 }}>Top-Level-Verzeichnisse:</p>
|
||
<div className="space-y-0.5 mb-3">
|
||
{diagnose.candidateRoots.map((r) => (
|
||
<div key={r.path} className="flex items-center gap-2 font-mono" style={{ fontSize: '10px' }}>
|
||
{r.exists ? (
|
||
<span className="text-primary">✓</span>
|
||
) : (
|
||
<span className="text-muted2">×</span>
|
||
)}
|
||
<span className={r.exists ? 'text-ink' : 'text-muted2 line-through'}>{r.path}</span>
|
||
{r.exists && r.entryCount !== null && r.entryCount !== undefined && (
|
||
<span className="text-muted">({r.entryCount} Einträge)</span>
|
||
)}
|
||
{r.permError && <span className="text-yellow-400">(kein Zugriff)</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="text-muted leading-relaxed" style={{ fontSize: '10px' }}>{diagnose.hint}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick access */}
|
||
<div className="flex gap-1 px-3 py-2 border-b border-divider flex-shrink-0 overflow-x-auto">
|
||
{quickPaths.map((p) => (
|
||
<button key={p} onClick={() => load(p)}
|
||
className={`flex-shrink-0 px-2 py-1 rounded transition-colors ${path === p ? 'bg-primary-dim text-primary' : 'text-muted hover:text-ink hover:bg-card'}`}
|
||
style={{ fontSize: '11px' }}>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Current path */}
|
||
<div className="px-4 py-2 bg-card border-b border-divider flex items-center gap-2 flex-shrink-0">
|
||
{parent && (
|
||
<button onClick={() => load(parent)} className="text-muted hover:text-ink flex-shrink-0 transition-colors">
|
||
<ChevronUp size={16} />
|
||
</button>
|
||
)}
|
||
<p className="text-muted font-mono truncate" style={{ fontSize: '11px' }}>{path}</p>
|
||
</div>
|
||
|
||
{/* Entry list */}
|
||
<div className="overflow-y-auto flex-1">
|
||
{loading ? (
|
||
<div className="flex justify-center py-10">
|
||
<Loader2 size={24} className="text-primary animate-spin" />
|
||
</div>
|
||
) : error ? (
|
||
<div className="px-4 py-6">
|
||
<div className="flex items-start gap-2 mb-3">
|
||
<AlertCircle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
|
||
<p className="text-red-400" style={{ fontSize: '13px' }}>{error}</p>
|
||
</div>
|
||
<p className="text-muted" style={{ fontSize: '11px' }}>
|
||
Falls dein Mount auf der Host-Maschine existiert, aber hier nicht sichtbar ist: er muss
|
||
in <span className="font-mono text-ink">docker-compose.yml</span> als Volume gemountet werden.
|
||
Setze <span className="font-mono text-ink">EXTRA_AUDIO_PATH=/dein/pfad</span> in der <span className="font-mono text-ink">.env</span> und
|
||
starte mit <span className="font-mono text-ink">docker-compose down && docker-compose up -d</span> neu.
|
||
Klick oben auf <Info size={11} className="inline" /> für Details.
|
||
</p>
|
||
</div>
|
||
) : entries.length === 0 ? (
|
||
<p className="text-muted px-4 py-6" style={{ fontSize: '13px' }}>Ordner ist leer</p>
|
||
) : (
|
||
entries.map((e) =>
|
||
e.isDir ? (
|
||
<button
|
||
key={e.path}
|
||
onClick={() => load(e.path)}
|
||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-card text-muted hover:text-ink text-left transition-colors"
|
||
style={{ fontSize: '13px' }}
|
||
>
|
||
<Folder size={16} className="text-yellow-500 flex-shrink-0" />
|
||
<span className="flex-1 truncate">{e.name}</span>
|
||
<ChevronRight size={14} className="flex-shrink-0 text-muted2" />
|
||
</button>
|
||
) : (
|
||
<div
|
||
key={e.path}
|
||
className="flex items-center gap-3 px-4 py-2 text-muted2"
|
||
style={{ fontSize: '12px' }}
|
||
>
|
||
<FileAudio size={14} className="flex-shrink-0" />
|
||
<span className="truncate">{e.name}</span>
|
||
</div>
|
||
)
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-divider flex-shrink-0">
|
||
<button onClick={onClose} className="text-muted hover:text-ink transition-colors" style={{ fontSize: '13px' }}>
|
||
Abbrechen
|
||
</button>
|
||
<button
|
||
onClick={() => { onSelect(path); onClose() }}
|
||
className="flex items-center gap-2 bg-primary text-black px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity"
|
||
style={{ fontSize: '13px' }}
|
||
>
|
||
<Check size={14} />
|
||
Auswählen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|