Add Nextcloud directory browser to backup_path field in Settings

The Backup Directory field in the Nextcloud integration card gains a
Browse button that opens an inline panel with: breadcrumb navigation,
scrollable directory list, and a Select button to pick the current path.
Falls back to manual text entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 11:09:47 +00:00
parent 3cfa092f73
commit 8a8c4073e5

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { CheckCircle, XCircle, RefreshCw, ChevronDown, ChevronUp, Clock } from 'lucide-react' import { CheckCircle, XCircle, RefreshCw, ChevronDown, ChevronUp, Clock, Folder, FolderOpen, ChevronRight } from 'lucide-react'
import { Sidebar } from '../components/Sidebar' import { Sidebar } from '../components/Sidebar'
import type { User } from '../types' import type { User } from '../types'
@ -113,6 +113,11 @@ function IntegrationCard({ integration, onSaved }: { integration: Integration; o
const [wfLocations, setWfLocations] = useState<{ id: string; name: string; short_name: string | null }[]>([]) const [wfLocations, setWfLocations] = useState<{ id: string; name: string; short_name: string | null }[]>([])
const [fetchingLocs, setFetchingLocs] = useState(false) const [fetchingLocs, setFetchingLocs] = useState(false)
const [locError, setLocError] = useState<string | null>(null) const [locError, setLocError] = useState<string | null>(null)
const [ncBrowseOpen, setNcBrowseOpen] = useState(false)
const [ncBrowsePath, setNcBrowsePath] = useState('/')
const [ncBrowseDirs, setNcBrowseDirs] = useState<{ name: string; path: string }[]>([])
const [ncBrowseLoading, setNcBrowseLoading] = useState(false)
const [ncBrowseError, setNcBrowseError] = useState<string | null>(null)
function openForm() { function openForm() {
const initial: Record<string, string> = {} const initial: Record<string, string> = {}
@ -120,9 +125,28 @@ function IntegrationCard({ integration, onSaved }: { integration: Integration; o
setForm(initial) setForm(initial)
setChanging(new Set()) setChanging(new Set())
setTestResult(null) setTestResult(null)
setNcBrowseOpen(false)
setOpen(true) setOpen(true)
} }
async function ncBrowse(path: string) {
setNcBrowseLoading(true)
setNcBrowseError(null)
setNcBrowsePath(path)
try {
const res = await fetch(`/settings/api/integrations/nextcloud/browse?path=${encodeURIComponent(path)}`, {
credentials: 'include',
})
const data = await res.json()
if (!res.ok) { setNcBrowseError(data.error || `Error ${res.status}`); return }
setNcBrowseDirs(data.dirs)
} catch {
setNcBrowseError('Network error')
} finally {
setNcBrowseLoading(false)
}
}
function setField(key: string, value: string) { function setField(key: string, value: string) {
setForm(prev => ({ ...prev, [key]: value })) setForm(prev => ({ ...prev, [key]: value }))
} }
@ -193,6 +217,137 @@ function IntegrationCard({ integration, onSaved }: { integration: Integration; o
{/* Config fields (plaintext) */} {/* Config fields (plaintext) */}
{Object.entries(integration.config).map(([k]) => { {Object.entries(integration.config).map(([k]) => {
if (integration.slug === 'nextcloud' && k === 'backup_path') {
const pathSegments = ncBrowsePath === '/' ? [] : ncBrowsePath.split('/').filter(Boolean)
return (
<div key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
Backup Directory
</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input value={form[k] ?? ''} onChange={e => setField(k, e.target.value)}
placeholder="e.g. HNF-Backups" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => {
if (ncBrowseOpen) { setNcBrowseOpen(false); return }
setNcBrowseOpen(true)
setNcBrowsePath('/')
ncBrowse('/')
}}
style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.78rem',
cursor: 'pointer', color: 'var(--text-dark)',
display: 'flex', alignItems: 'center', gap: '0.35rem', whiteSpace: 'nowrap',
}}
>
<Folder size={13} strokeWidth={1.75} />
Browse
</button>
</div>
{ncBrowseOpen && (
<div style={{
border: '1px solid var(--card-border)', borderRadius: '6px',
background: 'var(--body-bg)', overflow: 'hidden',
}}>
{/* Breadcrumb */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.2rem', flexWrap: 'wrap',
padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--card-border)',
fontSize: '0.78rem',
}}>
<button onClick={() => ncBrowse('/')}
style={{ background: 'none', border: 'none', cursor: 'pointer',
color: ncBrowsePath === '/' ? 'var(--text-dark)' : 'var(--gold)',
fontSize: '0.78rem', fontWeight: 600, padding: 0 }}>
/
</button>
{pathSegments.map((seg, i) => {
const segPath = '/' + pathSegments.slice(0, i + 1).join('/')
const isLast = i === pathSegments.length - 1
return (
<span key={segPath} style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
<ChevronRight size={11} strokeWidth={2} style={{ color: 'var(--text-mid)' }} />
<button onClick={() => !isLast && ncBrowse(segPath)}
style={{ background: 'none', border: 'none', cursor: isLast ? 'default' : 'pointer',
color: isLast ? 'var(--text-dark)' : 'var(--gold)',
fontSize: '0.78rem', fontWeight: isLast ? 600 : 400, padding: 0 }}>
{seg}
</button>
</span>
)
})}
</div>
{/* Directory list */}
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
{ncBrowseLoading && (
<div style={{ padding: '0.75rem', fontSize: '0.78rem', color: 'var(--text-mid)' }}>
Loading
</div>
)}
{ncBrowseError && (
<div style={{ padding: '0.75rem', fontSize: '0.78rem', color: 'var(--danger)' }}>
{ncBrowseError}
</div>
)}
{!ncBrowseLoading && !ncBrowseError && ncBrowseDirs.length === 0 && (
<div style={{ padding: '0.75rem', fontSize: '0.78rem', color: 'var(--text-mid)' }}>
No subdirectories here
</div>
)}
{!ncBrowseLoading && ncBrowseDirs.map(dir => (
<button
key={dir.path}
onClick={() => ncBrowse(dir.path)}
style={{
width: '100%', background: 'none', border: 'none',
borderBottom: '1px solid var(--card-border)',
padding: '0.45rem 0.75rem', fontSize: '0.82rem',
color: 'var(--text-dark)', cursor: 'pointer', textAlign: 'left',
display: 'flex', alignItems: 'center', gap: '0.5rem',
}}
>
<FolderOpen size={13} strokeWidth={1.75} style={{ color: 'var(--gold)', flexShrink: 0 }} />
{dir.name}
</button>
))}
</div>
{/* Footer: select current or cancel */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.5rem 0.75rem', borderTop: '1px solid var(--card-border)',
background: 'var(--card-bg)',
}}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
{ncBrowsePath}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={() => setNcBrowseOpen(false)}
style={{ background: 'none', border: '1px solid var(--card-border)',
borderRadius: '5px', padding: '0.25rem 0.65rem', fontSize: '0.75rem',
cursor: 'pointer', color: 'var(--text-mid)' }}>
Cancel
</button>
<button
onClick={() => {
setField(k, ncBrowsePath.replace(/^\//, ''))
setNcBrowseOpen(false)
}}
style={{ background: 'var(--navy)', color: '#fff', border: 'none',
borderRadius: '5px', padding: '0.25rem 0.65rem', fontSize: '0.75rem',
fontWeight: 600, cursor: 'pointer' }}>
Select
</button>
</div>
</div>
</div>
)}
</div>
)
}
if (integration.slug === 'workforce' && k === 'location_id') { if (integration.slug === 'workforce' && k === 'location_id') {
return ( return (
<div key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}> <div key={k} style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>