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'} - 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
} -
- - setServer(e.target.value)} placeholder="https://cloud.example.com" /> -
-
- - setUsername(e.target.value)} placeholder="your-username" autoComplete="off" /> -
-
- - setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" /> -
-
- - - {username && } -
-

- 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 !== '/' && ( - - )} - {items === null && ( - - )} - {items !== null && ( - - )} -
- - {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' ? ( - - ) : ( - - )} -
- ))} -
- )} -
- ) -} - 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 */} -
- - -
- {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) - -
- )} + {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() }) })