From 99570fb4330f91c136697ea27a55e5360185235c Mon Sep 17 00:00:00 2001
From: Daniel
Date: Sun, 13 Sep 2026 16:03:04 +0200
Subject: [PATCH] feat: cards come from articles, and Nextcloud is gone
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
---
backend/app/main.py | 3 +-
backend/app/routers/flashcards.py | 53 +-------
backend/app/routers/nextcloud.py | 158 ----------------------
backend/app/tasks/quiz_tasks.py | 117 ----------------
backend/tests/api-contract.json | 32 -----
frontend/src/pages/DocumentDetailPage.jsx | 30 ----
frontend/src/pages/SettingsPage.jsx | 98 +-------------
frontend/src/pages/ToolsPage.jsx | 14 --
frontend/src/pages/UploadPage.jsx | 149 +-------------------
frontend/src/pages/UploadPage.test.jsx | 13 +-
10 files changed, 18 insertions(+), 649 deletions(-)
delete mode 100644 backend/app/routers/nextcloud.py
diff --git a/backend/app/main.py b/backend/app/main.py
index 4bdc985..e844261 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -12,7 +12,7 @@ setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.api import errors as api_errors
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 feedback
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(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
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(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])
diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py
index 8970082..87397a9 100644
--- a/backend/app/routers/flashcards.py
+++ b/backend/app/routers/flashcards.py
@@ -28,14 +28,6 @@ router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────
-class FlashcardDeckCreate(BaseModel):
- model_config = {"protected_namespaces": ()}
-
- section_id: int
- title: str
- model_id: str | None = None
-
-
class FlashcardDeckUpdate(BaseModel):
title: str | 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 ───────────────────────────────────────────────────
-@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):
title: str
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")
def create_deck_manually(
data: ManualDeckCreate,
diff --git a/backend/app/routers/nextcloud.py b/backend/app/routers/nextcloud.py
deleted file mode 100644
index 39be305..0000000
--- a/backend/app/routers/nextcloud.py
+++ /dev/null
@@ -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"""
-
-
-
-
-
-
-
- """
-
-
-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}")
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 5033961..342ad6a 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -831,123 +831,6 @@ def regenerate_embeddings(self, job_id: str, user_id: int, stale_only: bool = Tr
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
diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json
index 7364150..dd0131e 100644
--- a/backend/tests/api-contract.json
+++ b/backend/tests/api-contract.json
@@ -2308,14 +2308,6 @@
"422"
]
},
- "POST /api/v1/flashcards/": {
- "body": true,
- "params": [],
- "responses": [
- "200",
- "422"
- ]
- },
"POST /api/v1/flashcards/cards/{card_id}/review": {
"body": true,
"params": [
@@ -2408,30 +2400,6 @@
"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/": {
"body": true,
"params": [],
diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx
index af93562..741f956 100644
--- a/frontend/src/pages/DocumentDetailPage.jsx
+++ b/frontend/src/pages/DocumentDetailPage.jsx
@@ -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) => {
setGenerating(sectionId)
setError('')
@@ -499,13 +476,6 @@ export default function DocumentDetailPage() {
<> Extracting...>
) : 'Extract Quiz'}
- generateFlashcards(section.id, section.name)}
- disabled={generating === section.id}
- >
- Create Cards
-
deleteSection(section.id)}
label="Delete section"
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
index d56d622..1e09e2c 100644
--- a/frontend/src/pages/SettingsPage.jsx
+++ b/frontend/src/pages/SettingsPage.jsx
@@ -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 (
-
-
- Connect your Nextcloud to import PDFs directly from Upload page.
-
- {status === 'ok' && ✓ {statusMsg}
}
- {status === 'error' && ✗ {statusMsg}
}
- {status === 'saved' && Settings saved
}
-
- Server URL
- setServer(e.target.value)} placeholder="https://cloud.example.com" />
-
-
- Username
- setUsername(e.target.value)} placeholder="your-username" autoComplete="off" />
-
-
- App Password
- setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
-
-
- Save
-
- {status === 'testing' ? 'Testing...' : 'Test Connection'}
-
- {username && Disconnect }
-
-
- Use an App Password (Nextcloud → Settings → Security), not your account password.
-
-
- )
-}
-
-/**
- * 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() {
// 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.
@@ -399,7 +304,7 @@ function SitePolicySection() {
* Settings as a set of places, each with its own address.
*
* 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
* 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
@@ -436,7 +341,6 @@ export default function SettingsPage() {
{/* One person loads the corpus. This was offered to every learner
as though each had a cloud to connect. */}
- {isAdmin && }
>
) },
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',
diff --git a/frontend/src/pages/ToolsPage.jsx b/frontend/src/pages/ToolsPage.jsx
index 871abd6..bb4c8cb 100644
--- a/frontend/src/pages/ToolsPage.jsx
+++ b/frontend/src/pages/ToolsPage.jsx
@@ -185,20 +185,6 @@ export default function ToolsPage() {
)}
- {user?.role === 'admin' && (
-
-
-
Import
-
-
- {/* 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. Connect it.
-
-
- )}
)
}
diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx
index b1c7309..2f263f7 100644
--- a/frontend/src/pages/UploadPage.jsx
+++ b/frontend/src/pages/UploadPage.jsx
@@ -2,130 +2,15 @@ import { useState, useRef } from 'react'
import { Link, 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, 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 (
-
-
☁️
- No Nextcloud account configured.{' '}
-
Go to Settings to add one.
-
- )
- }
-
- return (
-
- {error &&
{error}
}
-
- ☁️ {ncServer}
- →
- {path}
- {path !== '/' && (
- ↑ Up
- )}
- {items === null && (
- browse('/')} disabled={loading}>
- {loading ? 'Loading...' : 'Browse'}
-
- )}
- {items !== null && (
- browse(path)} disabled={loading}>
- {loading ? '...' : '↻'}
-
- )}
-
-
- {items !== null && (
-
- {items.length === 0 && (
-
- No PDFs in this folder
-
- )}
- {items.map((item, i) => (
-
- {item.type === 'dir' ? '📁' : '📄'}
-
- {item.name}
- {item.type === 'pdf' && (
-
- {(item.size / 1024 / 1024).toFixed(1)} MB
-
- )}
-
- {item.type === 'dir' ? (
- browse(item.path)}>Open
- ) : (
- importFile(item)}
- disabled={downloading === item.path}>
- {downloading === item.path ? 'Downloading...' : 'Import'}
-
- )}
-
- ))}
-
- )}
-
- )
-}
-
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 [uploadName, setUploadName] = useState('')
const [stage, setStage] = useState('uploading') // 'uploading' | 'processing'
@@ -154,11 +39,9 @@ export default function UploadPage() {
} finally { clearInterval(timer); setUploading(false) }
}
- const handleFile = (f, fromNextcloud = false) => {
+ const handleFile = (f) => {
if (f && f.type === 'application/pdf') {
setFile(f); setError('')
- if (!fromNextcloud) setTab('local')
- if (fromNextcloud) doUpload(f)
} else {
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.
- {/* Tab selector */}
-
- setTab('local')}>
- 💻 Local File
-
- setTab('nextcloud')}>
- ☁️ Nextcloud{!hasNextcloud && ' (not set up)'}
-
-
-
{error && {error}
}
- {tab === 'local' && (
- <>
+ <>
fileRef.current?.click()}
@@ -211,20 +81,7 @@ export default function UploadPage() {
)}
handleFile(e.target.files[0])} />
- >
- )}
-
- {tab === 'nextcloud' && (
-
- )}
-
- {/* Selected file from Nextcloud */}
- {tab === 'nextcloud' && file && (
-
- Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
- setFile(null)} style={{ marginLeft: 12, background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>✕
-
- )}
+ >
{uploading && (
diff --git a/frontend/src/pages/UploadPage.test.jsx b/frontend/src/pages/UploadPage.test.jsx
index a7d9ce6..9938bd8 100644
--- a/frontend/src/pages/UploadPage.test.jsx
+++ b/frontend/src/pages/UploadPage.test.jsx
@@ -16,16 +16,17 @@ describe('UploadPage', () => {
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(
)
-
- await userEvent.click(screen.getByRole('button', { name: '☁️ Nextcloud (not set up)' }))
-
- const settingsLink = screen.getByRole('link', { name: 'Go to Settings' })
- expect(settingsLink).toHaveAttribute('href', '/settings')
+ expect(screen.queryByRole('button', { name: /Nextcloud/ })).toBeNull()
+ expect(screen.queryByRole('button', { name: /Local File/ })).toBeNull()
+ expect(screen.getByText(/drag a PDF file here/i)).toBeInTheDocument()
})
})