diff --git a/app/database.py b/app/database.py index 716f770..5d4e366 100644 --- a/app/database.py +++ b/app/database.py @@ -62,10 +62,121 @@ async def init_db(): updated_at TEXT NOT NULL ) """) - + + # Bookmarks (#94) + await db.execute(""" + CREATE TABLE IF NOT EXISTS bookmarks ( + user_id TEXT NOT NULL, + drucksache TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, drucksache) + ) + """) + + # Kommentare (#94) + await db.execute(""" + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + user_name TEXT DEFAULT '', + drucksache TEXT NOT NULL, + text TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'all', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + await db.execute( + "CREATE INDEX IF NOT EXISTS idx_comments_drucksache ON comments(drucksache)" + ) + await db.commit() +# ─── Bookmark-Functions (#94) ─────────────────────────────────────────────── + +async def toggle_bookmark(user_id: str, drucksache: str) -> bool: + """Toggle bookmark. Returns True if bookmarked, False if removed.""" + async with aiosqlite.connect(settings.db_path) as db: + row = await db.execute( + "SELECT 1 FROM bookmarks WHERE user_id=? AND drucksache=?", + (user_id, drucksache), + ) + exists = await row.fetchone() + if exists: + await db.execute( + "DELETE FROM bookmarks WHERE user_id=? AND drucksache=?", + (user_id, drucksache), + ) + await db.commit() + return False + else: + await db.execute( + "INSERT INTO bookmarks (user_id, drucksache) VALUES (?, ?)", + (user_id, drucksache), + ) + await db.commit() + return True + + +async def get_bookmarks(user_id: str) -> list[str]: + """Get all bookmarked drucksache IDs for a user.""" + async with aiosqlite.connect(settings.db_path) as db: + rows = await db.execute( + "SELECT drucksache FROM bookmarks WHERE user_id=? ORDER BY created_at DESC", + (user_id,), + ) + return [r[0] for r in await rows.fetchall()] + + +# ─── Comment-Functions (#94) ──────────────────────────────────────────────── + +async def add_comment(user_id: str, user_name: str, drucksache: str, + text: str, visibility: str = "all") -> dict: + """Add a comment. Returns the new comment dict.""" + now = datetime.utcnow().isoformat() + async with aiosqlite.connect(settings.db_path) as db: + cursor = await db.execute( + "INSERT INTO comments (user_id, user_name, drucksache, text, visibility, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (user_id, user_name, drucksache, text, visibility, now), + ) + await db.commit() + return { + "id": cursor.lastrowid, + "user_id": user_id, + "user_name": user_name, + "drucksache": drucksache, + "text": text, + "visibility": visibility, + "created_at": now, + } + + +async def get_comments(drucksache: str, user_id: Optional[str] = None) -> list[dict]: + """Get comments for a drucksache. Filters by visibility: + - 'all' comments are always visible + - group-scoped comments only visible if user is in that group (TODO) + """ + async with aiosqlite.connect(settings.db_path) as db: + db.row_factory = aiosqlite.Row + rows = await db.execute( + "SELECT * FROM comments WHERE drucksache=? ORDER BY created_at", + (drucksache,), + ) + return [dict(r) for r in await rows.fetchall()] + + +async def delete_comment(comment_id: int, user_id: str) -> bool: + """Delete a comment (only by its author).""" + async with aiosqlite.connect(settings.db_path) as db: + cursor = await db.execute( + "DELETE FROM comments WHERE id=? AND user_id=?", + (comment_id, user_id), + ) + await db.commit() + return cursor.rowcount > 0 + + async def create_job( job_id: str, input_preview: str, diff --git a/app/main.py b/app/main.py index b6977bf..b82e0cd 100644 --- a/app/main.py +++ b/app/main.py @@ -35,6 +35,7 @@ from .database import ( get_all_assessments, get_assessment, delete_assessment, upsert_assessment, import_json_assessments, search_assessments, + toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment, ) from .parlamente import get_adapter, ADAPTERS from .bundeslaender import alle_bundeslaender @@ -354,6 +355,55 @@ async def auth_login_url(request: Request, redirect: str = "/"): return {"enabled": True, "url": url} +# ─── Bookmarks + Comments (#94) ───────────────────────────────────────────── + +@app.post("/api/bookmark") +async def bookmark_toggle( + drucksache: str = Form(...), + user: dict = Depends(require_auth), +): + """Toggle bookmark für einen Antrag.""" + is_bookmarked = await toggle_bookmark(user["sub"], drucksache) + return {"bookmarked": is_bookmarked, "drucksache": drucksache} + + +@app.get("/api/bookmarks") +async def bookmarks_list(user=Depends(get_current_user)): + """Liste aller Bookmarks des aktuellen Users.""" + if not user: + return [] + return await get_bookmarks(user["sub"]) + + +@app.post("/api/comment") +async def comment_add( + drucksache: str = Form(...), + text: str = Form(...), + visibility: str = Form("all"), + user: dict = Depends(require_auth), +): + """Kommentar hinzufügen.""" + if len(text) > 2000: + raise HTTPException(status_code=400, detail="Kommentar zu lang (max 2000 Zeichen)") + return await add_comment(user["sub"], user.get("name", ""), drucksache, text, visibility) + + +@app.get("/api/comments") +async def comments_list(drucksache: str, user=Depends(get_current_user)): + """Kommentare für einen Antrag.""" + user_id = user["sub"] if user else None + return await get_comments(drucksache, user_id) + + +@app.delete("/api/comment/{comment_id}") +async def comment_delete(comment_id: int, user: dict = Depends(require_auth)): + """Eigenen Kommentar löschen.""" + deleted = await delete_comment(comment_id, user["sub"]) + if not deleted: + raise HTTPException(status_code=404, detail="Kommentar nicht gefunden oder nicht Ihr Kommentar") + return {"status": "deleted"} + + # API: Load assessments from database @app.get("/api/assessments") async def list_assessments(bundesland: Optional[str] = None): diff --git a/app/templates/index.html b/app/templates/index.html index 42785a0..0d7465e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1260,6 +1260,67 @@ } } + // ─── Bookmarks + Comments (#94) ───────────────────────────── + async function toggleBookmark(drucksache, btn) { + const resp = await fetch('/api/bookmark', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: `drucksache=${encodeURIComponent(drucksache)}` + }); + if (resp.ok) { + const data = await resp.json(); + btn.textContent = data.bookmarked ? '⭐ Gemerkt' : '🔖 Merken'; + btn.style.background = data.bookmarked ? '#fff3cd' : 'none'; + } + } + + async function loadBookmarkState(drucksache) { + if (!currentUser) return; + const bookmarks = await fetch('/api/bookmarks').then(r => r.json()); + const btn = document.getElementById('bookmark-btn-' + drucksache.replace('/', '-')); + if (btn && bookmarks.includes(drucksache)) { + btn.textContent = '⭐ Gemerkt'; + btn.style.background = '#fff3cd'; + } + } + + async function loadComments(drucksache) { + const container = document.getElementById('comments-' + drucksache.replace('/', '-')); + if (!container) return; + try { + const comments = await fetch(`/api/comments?drucksache=${encodeURIComponent(drucksache)}`).then(r => r.json()); + if (comments.length === 0) { + container.innerHTML = 'Noch keine Kommentare.'; + return; + } + container.innerHTML = comments.map(c => ` +
+ ${c.user_name || 'Anonym'} + ${new Date(c.created_at).toLocaleString('de-DE')} + ${currentUser && currentUser.sub === c.user_id ? `` : ''} +
${c.text}
+
+ `).join(''); + } catch { container.innerHTML = ''; } + } + + async function addCommentUI(drucksache) { + const input = document.getElementById('comment-input-' + drucksache.replace('/', '-')); + if (!input || !input.value.trim()) return; + await fetch('/api/comment', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: `drucksache=${encodeURIComponent(drucksache)}&text=${encodeURIComponent(input.value)}` + }); + input.value = ''; + loadComments(drucksache); + } + + async function deleteCommentUI(commentId, drucksache) { + await fetch(`/api/comment/${commentId}`, {method: 'DELETE'}); + loadComments(drucksache); + } + async function reAnalyze(drucksache, bundesland, btn) { if (!currentUser) { alert('Bitte zuerst anmelden.'); @@ -1767,9 +1828,41 @@ ${item.source ? ` · Quelle: ${item.source}` : ''} ${item.model ? ` · Modell: ${item.model}` : ''} + + +
+
+ + Kommentare: +
+
+ Lade Kommentare... +
+ ${currentUser ? ` +
+ + +
+ ` : 'Anmelden um zu kommentieren'} +
`; + // Kommentare + Bookmark-Status laden + loadComments('${item.drucksache}'); + loadBookmarkState('${item.drucksache}'); + // Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird if (window.matchMedia('(max-width: 900px)').matches) { document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });