feat: cards come from articles, and Nextcloud is gone
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 43s
Tests / e2e (push) Has been cancelled

Two removals the user asked for.

No deck from a PDF section. A card should be written from an article —
text a person has read, edited and published — not from whatever
happened to be on pages 40-58 of a source document. POST /flashcards/
and the generate_flashcard_deck task are gone, with the Create Cards
button on the document page. What remains: POST /articles/{id}/ai-cards,
and POST /flashcards/manual for writing a deck by hand.

And no Nextcloud. It was a per-person cloud integration for a corpus one
person loads: a settings panel asking every educator for an app
password, a second tab on the upload page, and three endpoints. The
upload page now has one way in, which is the one anybody used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 16:03:04 +02:00
parent e2919e73c2
commit 99570fb433
10 changed files with 18 additions and 649 deletions

View file

@ -12,7 +12,7 @@ setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.api import errors as api_errors from app.api import errors as api_errors
from app.api.versioning import VERSIONED_ROOT, VersionAlias from app.api.versioning import VERSIONED_ROOT, VersionAlias
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams from app.routers import auth, documents, quizzes, attempts, admin, tts, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams
from app.routers import access from app.routers import access
from app.routers import feedback from app.routers import feedback
from app.routers import folders from app.routers import folders
@ -626,7 +626,6 @@ app.include_router(quizzes.router, prefix=f"{VERSIONED_ROOT}/quizzes", tags=["qu
app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"]) app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"])
app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"]) app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"]) app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"])
app.include_router(nextcloud.router, prefix=f"{VERSIONED_ROOT}/nextcloud", tags=["nextcloud"])
app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"]) app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"])
app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"]) app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"]) app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])

View file

@ -28,14 +28,6 @@ router = APIRouter()
# ── Schemas ────────────────────────────────────────────────────────── # ── Schemas ──────────────────────────────────────────────────────────
class FlashcardDeckCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int
title: str
model_id: str | None = None
class FlashcardDeckUpdate(BaseModel): class FlashcardDeckUpdate(BaseModel):
title: str | None = None title: str | None = None
category_id: int | None = None category_id: int | None = None
@ -133,50 +125,17 @@ def _own_deck_to_edit(deck_id: int, current_user: User, db: Session) -> Flashcar
# ── Deck endpoints ─────────────────────────────────────────────────── # ── Deck endpoints ───────────────────────────────────────────────────
@router.post("/")
def create_flashcard_deck(
data: FlashcardDeckCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Start async flashcard generation from a section. Returns {job_id} immediately."""
import uuid
section = db.query(Section).filter(Section.id == data.section_id).first()
if not section:
raise HTTPException(status_code=404, detail="Section not found")
job_id = str(uuid.uuid4())
try:
from app.tasks.quiz_tasks import generate_flashcard_deck
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
r.lpush(f"extraction:user_jobs:{current_user.id}", job_id)
r.expire(f"extraction:user_jobs:{current_user.id}", 86400)
r.set(f"extraction:job_title:{job_id}", data.title, ex=3600)
generate_flashcard_deck.delay(
job_id=job_id,
user_id=current_user.id,
section_id=data.section_id,
title=data.title,
model_id=data.model_id,
)
except Exception:
raise HTTPException(status_code=503, detail="Task queue unavailable")
return {"job_id": job_id, "status": "pending"}
class ManualDeckCreate(BaseModel): class ManualDeckCreate(BaseModel):
title: str title: str
category_id: int | None = None category_id: int | None = None
# No deck from a PDF section. Cards are written from an article — the text a
# person has already read, edited and published — rather than from whatever
# happened to be on pages 40-58 of a source document. The article path is
# POST /articles/{id}/ai-cards; a deck by hand is POST /flashcards/manual.
@router.post("/manual") @router.post("/manual")
def create_deck_manually( def create_deck_manually(
data: ManualDeckCreate, data: ManualDeckCreate,

View file

@ -1,158 +0,0 @@
"""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:
import posixpath
base = server.rstrip("/")
# Normalize to collapse any ../ sequences before building URL
p = posixpath.normpath("/" + 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}")
raw_name = req.path.split("/")[-1]
# Strip characters that would break the Content-Disposition header
safe_name = raw_name.replace('"', '').replace('\\', '').replace('\n', '').replace('\r', '') or "document.pdf"
return StreamingResponse(
io.BytesIO(resp.content),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{safe_name}"',
"Content-Length": str(len(resp.content))},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"Download failed: {e}")

View file

@ -831,123 +831,6 @@ def regenerate_embeddings(self, job_id: str, user_id: int, stale_only: bool = Tr
db.close() db.close()
@celery_app.task(name="generate_flashcard_deck", bind=True)
def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
title: str, model_id: str | None = None):
"""Generate flashcards from a document section using AI."""
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
try:
from app.models.section import Section
from app.models.pdf_document import PDFDocument
from app.services import vector_service
from app.services import extraction_modes
section = db.query(Section).filter(Section.id == section_id).first()
if not section:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "Section not found")
return
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
from app.services.ai_service import get_model_for_task
ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
if model_id:
ai_model_id = model_id
total_pages = section.end_page - section.start_page + 1
_push_step(r, job_id, "start", f"Generating flashcards from {total_pages} pages…")
all_cards = []
if total_pages <= CHUNK_PAGES:
content = vector_service.get_pages_text(section.document_id, section.start_page, section.end_page)
if content:
_push_step(r, job_id, "ai", f"Generating flashcards from pages {section.start_page}{section.end_page}")
cards = extraction_modes.generate_flashcards(
content, f"{section.start_page}{section.end_page}",
section.start_page, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Generated {len(cards)} cards")
else:
n_chunks = (total_pages + CHUNK_PAGES - 1) // CHUNK_PAGES
_push_step(r, job_id, "ai", f"Large section: splitting into {n_chunks} chunks")
for chunk_idx in range(1, n_chunks + 1):
start_p = section.start_page + (chunk_idx - 1) * CHUNK_PAGES
end_p = min(start_p + CHUNK_PAGES - 1, section.end_page)
content = vector_service.get_pages_text(section.document_id, start_p, end_p)
if not content or len(content.strip()) < 100:
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: no text, skipping")
continue
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
cards = extraction_modes.generate_flashcards(
content, f"{start_p}{end_p}", start_p, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
if not all_cards:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "No flashcards could be generated")
return
# Refresh DB connection for save phase
from sqlalchemy import text as _text
try:
db.execute(_text("SELECT 1"))
except Exception:
db.rollback()
db.close()
db = SessionLocal()
_push_step(r, job_id, "save", f"Saving {len(all_cards)} flashcards…")
from app.models.flashcard import FlashcardDeck, Flashcard
deck = FlashcardDeck(
title=title,
section_id=section_id,
user_id=user_id,
card_count=len(all_cards),
)
db.add(deck)
db.flush()
for c in all_cards:
card = Flashcard(
deck_id=deck.id,
front=c["front"],
back=c["back"],
page_reference=c.get("page_reference"),
)
db.add(card)
db.commit()
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
r.set(f"extraction:deck_id:{job_id}", str(deck.id), ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
except Exception as e:
logger.exception(f"Flashcard generation failed: {e}")
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
#: The three readings of a topic the reader offers, and what each one is for.
#: Written into the prompt because a model that is not told about them writes
#: one article and the other two tabs stay empty — which is what happened to
#: every generated article until now.
#: Room for the whole thing. An article with a long view, a high-yield view and
#: a clinical one runs well past four thousand tokens, and a reply that stops
#: mid-string is not a JSON document.
ARTICLE_MAX_TOKENS = 16000 ARTICLE_MAX_TOKENS = 16000

View file

@ -2308,14 +2308,6 @@
"422" "422"
] ]
}, },
"POST /api/v1/flashcards/": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/flashcards/cards/{card_id}/review": { "POST /api/v1/flashcards/cards/{card_id}/review": {
"body": true, "body": true,
"params": [ "params": [
@ -2408,30 +2400,6 @@
"422" "422"
] ]
}, },
"POST /api/v1/nextcloud/download": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/nextcloud/files": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/nextcloud/test": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/question-categories/": { "POST /api/v1/question-categories/": {
"body": true, "body": true,
"params": [], "params": [],

View file

@ -182,29 +182,6 @@ export default function DocumentDetailPage() {
} }
} }
const generateFlashcards = async (sectionId, sectionName) => {
setGenerating(sectionId)
setError('')
try {
const title = quizTitle || `Cards: ${sectionName}`
const res = await api.post('/flashcards/', {
section_id: sectionId,
title,
model_id: selectedModelId || null,
})
if (res.data.job_id) {
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
stored.unshift({ jobId: res.data.job_id, title, status: 'running', lastStep: 'Starting…', ts: Date.now() })
localStorage.setItem('pedquiz_jobs', JSON.stringify(stored.slice(0, 10)))
setActiveJob({ jobId: res.data.job_id, sectionName, type: 'flashcard' })
}
} catch (err) {
setError(err.response?.data?.detail || 'Failed to start card generation. Check AI model config.')
} finally {
setGenerating(null)
}
}
const generateQuiz = async (sectionId, sectionName) => { const generateQuiz = async (sectionId, sectionName) => {
setGenerating(sectionId) setGenerating(sectionId)
setError('') setError('')
@ -499,13 +476,6 @@ export default function DocumentDetailPage() {
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</> <><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
) : 'Extract Quiz'} ) : 'Extract Quiz'}
</button> </button>
<button
className="btn btn-secondary btn-sm"
onClick={() => generateFlashcards(section.id, section.name)}
disabled={generating === section.id}
>
Create Cards
</button>
<ConfirmButton <ConfirmButton
onConfirm={() => deleteSection(section.id)} onConfirm={() => deleteSection(section.id)}
label="Delete section" label="Delete section"

View file

@ -129,101 +129,6 @@ function StudySection() {
) )
} }
function NextcloudSection() {
const [server, setServer] = useState('https://cloud.danvics.com')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [status, setStatus] = useState(null)
const [statusMsg, setStatusMsg] = useState('')
const [loaded, setLoaded] = useState(false)
// Load from server on mount
useEffect(() => {
api.get('/auth/me/settings').then(res => {
const s = res.data
if (s.nc_server) setServer(s.nc_server)
if (s.nc_username) setUsername(s.nc_username)
if (s.nc_password) setPassword(s.nc_password)
// Also sync to localStorage for UploadPage
if (s.nc_server) localStorage.setItem('nc_server', s.nc_server)
if (s.nc_username) localStorage.setItem('nc_username', s.nc_username)
if (s.nc_password) localStorage.setItem('nc_password', s.nc_password)
}).catch(() => {}).finally(() => setLoaded(true))
}, [])
const save = async () => {
// Save to both server (cross-browser) and localStorage (for UploadPage)
localStorage.setItem('nc_server', server)
localStorage.setItem('nc_username', username)
localStorage.setItem('nc_password', password)
try {
await api.put('/auth/me/settings', { nc_server: server, nc_username: username, nc_password: password })
} catch { }
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 = async () => {
localStorage.removeItem('nc_server')
localStorage.removeItem('nc_username')
localStorage.removeItem('nc_password')
try { await api.put('/auth/me/settings', {}) } catch { }
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 to import PDFs directly from Upload page.
</p>
{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" />
</div>
<div className="form-group">
<label>Username</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="your-username" autoComplete="off" />
</div>
<div className="form-group">
<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, 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 }}>
Use an App Password (Nextcloud Settings Security), not your account password.
</p>
</Section>
)
}
/**
* The places that are their own pages for good reason a taxonomy tree, an
* editorial queue, a job log. Everything that is a *setting* now lives in this
* page's own sections rather than behind a link to a second dashboard.
*/
function ToolsSection() { function ToolsSection() {
// Editorial is not here: it has its own entry in the section bar, and a card // Editorial is not here: it has its own entry in the section bar, and a card
// pointing at it would be a second door to the same room. // pointing at it would be a second door to the same room.
@ -399,7 +304,7 @@ function SitePolicySection() {
* Settings as a set of places, each with its own address. * Settings as a set of places, each with its own address.
* *
* It was one 600px column holding the account form, the theme picker, a * It was one 600px column holding the account form, the theme picker, a
* Nextcloud integration, a document list and a grid of links one of which * A document list and a grid of links one of which
* went to a second dashboard with a second row of tabs and a second visual * went to a second dashboard with a second row of tabs and a second visual
* language. There is one place to configure the site now: the admin sections * language. There is one place to configure the site now: the admin sections
* are rendered here, under headings that say who they are for, and the section * are rendered here, under headings that say who they are for, and the section
@ -436,7 +341,6 @@ export default function SettingsPage() {
<DocumentsSection /> <DocumentsSection />
{/* One person loads the corpus. This was offered to every learner {/* One person loads the corpus. This was offered to every learner
as though each had a cloud to connect. */} as though each had a cloud to connect. */}
{isAdmin && <NextcloudSection />}
</> </>
) }, ) },
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools', { key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',

View file

@ -185,20 +185,6 @@ export default function ToolsPage() {
</section> </section>
)} )}
{user?.role === 'admin' && (
<section className="tools-section">
<div className="tools-section-head">
<h2>Import</h2>
</div>
<p className="tools-note">
{/* One person loads the corpus. It was in everyone's settings as
though each learner had a cloud to connect, which none of them
has and none of them needs. */}
Nextcloud is an import path for whoever loads the corpus, not a
per-learner integration. <Link to="/settings?s=library">Connect it</Link>.
</p>
</section>
)}
</div> </div>
) )
} }

View file

@ -2,130 +2,15 @@ import { useState, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client' 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, true)
} 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.{' '}
<Link to="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</Link> 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() { export default function UploadPage() {
const [file, setFile] = useState(null) const [file, setFile] = useState(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const [error, setError] = useState('') const [error, setError] = useState('')
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const [tab, setTab] = useState('local') // 'local' | 'nextcloud'
const fileRef = useRef() const fileRef = useRef()
const navigate = useNavigate() const navigate = useNavigate()
const hasNextcloud = !!localStorage.getItem('nc_username')
const [uploadName, setUploadName] = useState('') const [uploadName, setUploadName] = useState('')
const [stage, setStage] = useState('uploading') // 'uploading' | 'processing' const [stage, setStage] = useState('uploading') // 'uploading' | 'processing'
@ -154,11 +39,9 @@ export default function UploadPage() {
} finally { clearInterval(timer); setUploading(false) } } finally { clearInterval(timer); setUploading(false) }
} }
const handleFile = (f, fromNextcloud = false) => { const handleFile = (f) => {
if (f && f.type === 'application/pdf') { if (f && f.type === 'application/pdf') {
setFile(f); setError('') setFile(f); setError('')
if (!fromNextcloud) setTab('local')
if (fromNextcloud) doUpload(f)
} else { } else {
setError('Please select a PDF file') setError('Please select a PDF file')
} }
@ -174,22 +57,9 @@ export default function UploadPage() {
Upload a PDF file (up to 500MB) to generate interactive quizzes. Upload a PDF file (up to 500MB) to generate interactive quizzes.
</p> </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>} {error && <div className="alert alert-error">{error}</div>}
{tab === 'local' && ( <>
<>
<div <div
className={`upload-area ${dragging ? 'dragging' : ''}`} className={`upload-area ${dragging ? 'dragging' : ''}`}
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
@ -211,20 +81,7 @@ export default function UploadPage() {
)} )}
</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 && ( {uploading && (
<div style={{ marginTop: 16, padding: '16px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)' }}> <div style={{ marginTop: 16, padding: '16px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)' }}>

View file

@ -16,16 +16,17 @@ describe('UploadPage', () => {
localStorage.clear() localStorage.clear()
}) })
it('uses client-side navigation for the settings link', async () => { it('offers one way in: a file from this machine', async () => {
// The Nextcloud tab is gone. It was a per-person cloud integration on a
// page only an educator reaches, for a corpus one person loads and it
// asked every one of them for an app password.
render( render(
<MemoryRouter> <MemoryRouter>
<UploadPage /> <UploadPage />
</MemoryRouter> </MemoryRouter>
) )
expect(screen.queryByRole('button', { name: /Nextcloud/ })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: '☁️ Nextcloud (not set up)' })) expect(screen.queryByRole('button', { name: /Local File/ })).toBeNull()
expect(screen.getByText(/drag a PDF file here/i)).toBeInTheDocument()
const settingsLink = screen.getByRole('link', { name: 'Go to Settings' })
expect(settingsLink).toHaveAttribute('href', '/settings')
}) })
}) })