Add Nextcloud integration, TTS key fix, Settings/Upload improvements
- Nextcloud backend proxy (WebDAV PROPFIND/GET) avoiding CORS
- /api/nextcloud/test — credential validation
- /api/nextcloud/files — browse folders and list PDFs
- /api/nextcloud/download — stream file for upload
- UploadPage: Local/Nextcloud tabs, inline Nextcloud file browser
- SettingsPage: Test Connection button with live feedback
- AWS Polly Joanna (neural) set as default TTS voice
- TTS stops on question navigation AND answer selection (key prop fix)
- Dashboard: Good morning/afternoon/evening greeting with first name
- Favicon: 🩺 emoji SVG
- Navbar: cleaned up — username removed, ⚙ Settings link added
- manage.py CLI: reset-password, list-users, reembed commands
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
799b5b3ee5
commit
84f5927e5d
4 changed files with 357 additions and 66 deletions
|
|
@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
|||
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
|
@ -209,6 +209,7 @@ app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
|
|||
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(tts.router, prefix="/api/tts", tags=["tts"])
|
||||
app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
154
backend/app/routers/nextcloud.py
Normal file
154
backend/app/routers/nextcloud.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""Nextcloud WebDAV proxy — avoids browser CORS issues."""
|
||||
import io
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.user import User
|
||||
from app.utils.auth import require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DAV_PROPFIND = b"""<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontenttype/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
|
||||
class NCRequest(BaseModel):
|
||||
server: str
|
||||
username: str
|
||||
password: str
|
||||
path: str = "/"
|
||||
|
||||
|
||||
def _dav_url(server: str, username: str, path: str) -> str:
|
||||
base = server.rstrip("/")
|
||||
p = path.lstrip("/")
|
||||
return f"{base}/remote.php/dav/files/{username}/{p}"
|
||||
|
||||
|
||||
def _parse_propfind(xml_bytes: bytes, base_path: str) -> list[dict]:
|
||||
"""Parse WebDAV PROPFIND response into a list of file/folder dicts."""
|
||||
ns = {"d": "DAV:"}
|
||||
tree = ET.fromstring(xml_bytes)
|
||||
items = []
|
||||
for response in tree.findall("d:response", ns):
|
||||
href = (response.findtext("d:href", "", ns) or "").rstrip("/")
|
||||
# Skip the directory itself
|
||||
props = response.find("d:propstat/d:prop", ns)
|
||||
if props is None:
|
||||
continue
|
||||
name = props.findtext("d:displayname", "", ns) or href.split("/")[-1]
|
||||
content_type = props.findtext("d:getcontenttype", "", ns) or ""
|
||||
size = props.findtext("d:getcontentlength", "0", ns) or "0"
|
||||
is_dir = props.find("d:resourcetype/d:collection", ns) is not None
|
||||
|
||||
# Build clean path from href
|
||||
dav_prefix = "/remote.php/dav/files/"
|
||||
if dav_prefix in href:
|
||||
clean = href[href.index(dav_prefix) + len(dav_prefix):]
|
||||
# Remove username prefix
|
||||
parts = clean.split("/", 1)
|
||||
item_path = "/" + (parts[1] if len(parts) > 1 else "")
|
||||
else:
|
||||
item_path = "/" + name
|
||||
|
||||
if is_dir:
|
||||
items.append({"name": name, "path": item_path, "type": "dir", "size": 0})
|
||||
elif "pdf" in content_type.lower() or name.lower().endswith(".pdf"):
|
||||
items.append({"name": name, "path": item_path, "type": "pdf", "size": int(size)})
|
||||
|
||||
# Sort: dirs first, then PDFs
|
||||
items.sort(key=lambda x: (0 if x["type"] == "dir" else 1, x["name"].lower()))
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def test_connection(req: NCRequest, _: User = Depends(require_moderator)):
|
||||
"""Test Nextcloud credentials by listing the root."""
|
||||
url = _dav_url(req.server, req.username, "/")
|
||||
try:
|
||||
resp = httpx.request(
|
||||
"PROPFIND", url,
|
||||
auth=(req.username, req.password),
|
||||
headers={"Depth": "0", "Content-Type": "application/xml"},
|
||||
content=DAV_PROPFIND,
|
||||
timeout=10,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code in (200, 207):
|
||||
return {"ok": True, "message": "Connected successfully"}
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
raise HTTPException(status_code=400, detail=f"Nextcloud returned {resp.status_code}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Connection failed: {e}")
|
||||
|
||||
|
||||
@router.post("/files")
|
||||
def list_files(req: NCRequest, _: User = Depends(require_moderator)):
|
||||
"""List PDFs and folders at the given path."""
|
||||
url = _dav_url(req.server, req.username, req.path)
|
||||
try:
|
||||
resp = httpx.request(
|
||||
"PROPFIND", url,
|
||||
auth=(req.username, req.password),
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=DAV_PROPFIND,
|
||||
timeout=15,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if resp.status_code not in (200, 207):
|
||||
raise HTTPException(status_code=400, detail=f"Nextcloud error {resp.status_code}")
|
||||
|
||||
items = _parse_propfind(resp.content, req.path)
|
||||
# Remove the current directory entry itself
|
||||
items = [i for i in items if i["path"] != req.path]
|
||||
return {"path": req.path, "items": items}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to list files: {e}")
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
def download_file(req: NCRequest, _: User = Depends(require_moderator)):
|
||||
"""Download a file from Nextcloud and stream it back for upload."""
|
||||
if not req.path.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Only PDF files can be imported")
|
||||
url = _dav_url(req.server, req.username, req.path)
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
auth=(req.username, req.password),
|
||||
timeout=120,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail=f"Download failed: {resp.status_code}")
|
||||
filename = req.path.split("/")[-1]
|
||||
return StreamingResponse(
|
||||
io.BytesIO(resp.content),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(resp.content))},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Download failed: {e}")
|
||||
|
|
@ -112,28 +112,46 @@ function NextcloudSection() {
|
|||
const [server, setServer] = useState(localStorage.getItem('nc_server') || 'https://cloud.danvics.com')
|
||||
const [username, setUsername] = useState(localStorage.getItem('nc_username') || '')
|
||||
const [password, setPassword] = useState(localStorage.getItem('nc_password') || '')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [status, setStatus] = useState(null) // null | 'testing' | 'ok' | 'error'
|
||||
const [statusMsg, setStatusMsg] = useState('')
|
||||
|
||||
const save = () => {
|
||||
localStorage.setItem('nc_server', server)
|
||||
localStorage.setItem('nc_username', username)
|
||||
localStorage.setItem('nc_password', password)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus(null), 2000)
|
||||
}
|
||||
|
||||
const test = async () => {
|
||||
setStatus('testing')
|
||||
setStatusMsg('')
|
||||
try {
|
||||
const res = await api.post('/nextcloud/test', { server, username, password })
|
||||
setStatus('ok')
|
||||
setStatusMsg(res.data.message)
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setStatusMsg(err.response?.data?.detail || 'Connection failed')
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
localStorage.removeItem('nc_server')
|
||||
localStorage.removeItem('nc_username')
|
||||
localStorage.removeItem('nc_password')
|
||||
setServer('https://cloud.danvics.com'); setUsername(''); setPassword('')
|
||||
setStatus(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Nextcloud Integration">
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
|
||||
Connect your Nextcloud account to import PDFs directly when uploading.
|
||||
Connect your Nextcloud to import PDFs directly from Upload page.
|
||||
</p>
|
||||
{saved && <div className="alert alert-success">Nextcloud settings saved</div>}
|
||||
{status === 'ok' && <div className="alert alert-success">✓ {statusMsg}</div>}
|
||||
{status === 'error' && <div className="alert alert-error">✗ {statusMsg}</div>}
|
||||
{status === 'saved' && <div className="alert alert-success">Settings saved</div>}
|
||||
<div className="form-group">
|
||||
<label>Server URL</label>
|
||||
<input type="url" value={server} onChange={e => setServer(e.target.value)} placeholder="https://cloud.example.com" />
|
||||
|
|
@ -146,12 +164,15 @@ function NextcloudSection() {
|
|||
<label>App Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={save}>Save</button>
|
||||
<button className="btn btn-secondary" onClick={test} disabled={!username || !password || status === 'testing'}>
|
||||
{status === 'testing' ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{username && <button className="btn btn-secondary" onClick={clear}>Disconnect</button>}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 10 }}>
|
||||
Credentials are stored locally in your browser. Use an App Password, not your account password.
|
||||
Use an App Password (Nextcloud → Settings → Security), not your account password.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,19 +2,134 @@ import { useState, useRef } from 'react'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
function NextcloudBrowser({ onFile }) {
|
||||
const ncServer = localStorage.getItem('nc_server') || ''
|
||||
const ncUser = localStorage.getItem('nc_username') || ''
|
||||
const ncPass = localStorage.getItem('nc_password') || ''
|
||||
|
||||
const [path, setPath] = useState('/')
|
||||
const [items, setItems] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [downloading, setDownloading] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const browse = async (p = path) => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const res = await api.post('/nextcloud/files', { server: ncServer, username: ncUser, password: ncPass, path: p })
|
||||
setItems(res.data.items)
|
||||
setPath(p)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to load files')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const importFile = async (item) => {
|
||||
setDownloading(item.path)
|
||||
try {
|
||||
const res = await api.post('/nextcloud/download',
|
||||
{ server: ncServer, username: ncUser, password: ncPass, path: item.path },
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const file = new File([res.data], item.name, { type: 'application/pdf' })
|
||||
onFile(file)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Download failed')
|
||||
} finally { setDownloading(null) }
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
const parts = path.replace(/\/$/, '').split('/')
|
||||
parts.pop()
|
||||
browse(parts.join('/') || '/')
|
||||
}
|
||||
|
||||
if (!ncUser) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}>☁️</div>
|
||||
No Nextcloud account configured.{' '}
|
||||
<a href="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</a> to add one.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: '0.85rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>☁️ {ncServer}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>→</span>
|
||||
<code style={{ color: 'var(--text)', fontSize: '0.82rem' }}>{path}</code>
|
||||
{path !== '/' && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={goUp}>↑ Up</button>
|
||||
)}
|
||||
{items === null && (
|
||||
<button className="btn btn-sm btn-primary" onClick={() => browse('/')} disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Browse'}
|
||||
</button>
|
||||
)}
|
||||
{items !== null && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => browse(path)} disabled={loading}>
|
||||
{loading ? '...' : '↻'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items !== null && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', maxHeight: 300, overflowY: 'auto' }}>
|
||||
{items.length === 0 && (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
No PDFs in this folder
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
|
||||
borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
background: 'var(--card-bg)',
|
||||
}}>
|
||||
<span style={{ fontSize: '1rem' }}>{item.type === 'dir' ? '📁' : '📄'}</span>
|
||||
<span style={{ flex: 1, fontSize: '0.875rem', color: 'var(--text)' }}>
|
||||
{item.name}
|
||||
{item.type === 'pdf' && (
|
||||
<span style={{ marginLeft: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
{(item.size / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.type === 'dir' ? (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => browse(item.path)}>Open</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-primary"
|
||||
onClick={() => importFile(item)}
|
||||
disabled={downloading === item.path}>
|
||||
{downloading === item.path ? 'Downloading...' : 'Import'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [tab, setTab] = useState('local') // 'local' | 'nextcloud'
|
||||
const fileRef = useRef()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const hasNextcloud = !!localStorage.getItem('nc_username')
|
||||
|
||||
const handleFile = (f) => {
|
||||
if (f && f.type === 'application/pdf') {
|
||||
setFile(f)
|
||||
setError('')
|
||||
setFile(f); setError(''); setTab('local')
|
||||
} else {
|
||||
setError('Please select a PDF file')
|
||||
}
|
||||
|
|
@ -22,91 +137,91 @@ export default function UploadPage() {
|
|||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
|
||||
setUploading(true); setError('')
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await api.post('/documents/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setProgress(Math.round((e.loaded / e.total) * 100))
|
||||
},
|
||||
onUploadProgress: (e) => { if (e.total) setProgress(Math.round((e.loaded / e.total) * 100)) },
|
||||
})
|
||||
navigate(`/documents/${res.data.id}`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
} finally { setUploading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h2>Upload PDF Document</h2>
|
||||
<p style={{ color: '#64748b', marginBottom: 20 }}>
|
||||
Upload a PDF file (up to 500MB) to generate interactive quizzes from its content.
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: 20, fontSize: '0.9rem' }}>
|
||||
Upload a PDF file (up to 500MB) to generate interactive quizzes.
|
||||
</p>
|
||||
|
||||
{/* Tab selector */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20 }}>
|
||||
<button className={`btn btn-sm ${tab === 'local' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTab('local')}>
|
||||
💻 Local File
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === 'nextcloud' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTab('nextcloud')}>
|
||||
☁️ Nextcloud{!hasNextcloud && ' (not set up)'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div
|
||||
className={`upload-area ${dragging ? 'dragging' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
handleFile(e.dataTransfer.files[0])
|
||||
}}
|
||||
>
|
||||
{file ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 600 }}>{file.name}</div>
|
||||
<div style={{ color: '#64748b', marginTop: 4 }}>
|
||||
{(file.size / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
{tab === 'local' && (
|
||||
<>
|
||||
<div
|
||||
className={`upload-area ${dragging ? 'dragging' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files[0]) }}
|
||||
>
|
||||
{file ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 600 }}>{file.name}</div>
|
||||
<div style={{ color: 'var(--text-muted)', marginTop: 4 }}>{(file.size / 1024 / 1024).toFixed(1)} MB</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>PDF</div>
|
||||
<div>Click or drag a PDF file here</div>
|
||||
<div style={{ color: 'var(--text-subtle)', fontSize: '0.85rem', marginTop: 4 }}>Supports files up to 500MB</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>PDF</div>
|
||||
<div>Click or drag a PDF file here</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.85rem', marginTop: 4 }}>
|
||||
Supports files up to 500MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept=".pdf" hidden onChange={(e) => handleFile(e.target.files[0])} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
hidden
|
||||
onChange={(e) => handleFile(e.target.files[0])}
|
||||
/>
|
||||
{tab === 'nextcloud' && (
|
||||
<NextcloudBrowser onFile={handleFile} />
|
||||
)}
|
||||
|
||||
{/* Selected file from Nextcloud */}
|
||||
{tab === 'nextcloud' && file && (
|
||||
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)', fontSize: '0.875rem' }}>
|
||||
Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(1)} MB)
|
||||
<button onClick={() => setFile(null)} style={{ marginLeft: 12, background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="progress-bar">
|
||||
<div className="fill" style={{ width: `${progress}%` }}></div>
|
||||
<div className="progress-bar" style={{ marginTop: 12 }}>
|
||||
<div className="fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
>
|
||||
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
|
||||
{uploading ? `Uploading ${progress}%...` : 'Upload & Process'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/')}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue