Add a "needs review" flag with a sidebar filter

Links can be bookmarked for a later look via a toggle button on the
card (🏷️/🔖), shown with a yellow border while flagged. A new
sidebar section "Merkliste" links to /?review=1, filtering to just
the flagged links; the count updates live via the existing facets
query.

Since the project has no migration framework, add a minimal
startup check that ALTER TABLEs in the needs_review column for
existing SQLite databases (create_all only creates missing tables).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-07-20 22:41:40 +02:00
parent 8e9b49e57b
commit b6a236527e
5 changed files with 72 additions and 7 deletions

View File

@@ -6,7 +6,7 @@ from urllib.parse import urlparse
from fastapi import Depends, FastAPI, Form, Request from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import func, select from sqlalchemy import func, select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@@ -17,6 +17,16 @@ from .security import hash_password, verify_password
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Einfache Spalten-Migration für bestehende SQLite-Datenbanken (kein Alembic
# im Projekt): create_all legt nur neue Tabellen an, keine neuen Spalten.
if config.DATABASE_URL.startswith("sqlite"):
with engine.begin() as _conn:
_cols = {row[1] for row in _conn.execute(text("PRAGMA table_info(links)"))}
if "needs_review" not in _cols:
_conn.execute(
text("ALTER TABLE links ADD COLUMN needs_review BOOLEAN DEFAULT 0")
)
app = FastAPI(title="LinkVault") app = FastAPI(title="LinkVault")
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14) app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14)
@@ -83,9 +93,15 @@ def facets(db: Session, user: User) -> dict:
.group_by(Link.manufacturer) .group_by(Link.manufacturer)
.order_by(func.count().desc()) .order_by(func.count().desc())
).all() ).all()
review_count = db.scalar(
select(func.count())
.select_from(Link)
.where(Link.user_id == user.id, Link.needs_review == True) # noqa: E712
)
return { return {
"categories": [(name, count) for name, count in cat_rows], "categories": [(name, count) for name, count in cat_rows],
"manufacturers": [(name, count) for name, count in man_rows], "manufacturers": [(name, count) for name, count in man_rows],
"review_count": review_count or 0,
} }
@@ -119,12 +135,15 @@ def query_links(
manufacturer: str = "", manufacturer: str = "",
semantic: bool = False, semantic: bool = False,
sort: str = DEFAULT_SORT, sort: str = DEFAULT_SORT,
review_only: bool = False,
) -> list[Link]: ) -> list[Link]:
stmt = select(Link).where(Link.user_id == user.id) stmt = select(Link).where(Link.user_id == user.id)
if category: if category:
stmt = stmt.where(Link.category == category) stmt = stmt.where(Link.category == category)
if manufacturer: if manufacturer:
stmt = stmt.where(Link.manufacturer == manufacturer) stmt = stmt.where(Link.manufacturer == manufacturer)
if review_only:
stmt = stmt.where(Link.needs_review == True) # noqa: E712
links = list(db.scalars(stmt.order_by(Link.created_at.desc())).all()) links = list(db.scalars(stmt.order_by(Link.created_at.desc())).all())
q = (q or "").strip() q = (q or "").strip()
@@ -231,6 +250,7 @@ def index(
manufacturer: str = "", manufacturer: str = "",
semantic: str = "", semantic: str = "",
sort: str = DEFAULT_SORT, sort: str = DEFAULT_SORT,
review: str = "",
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = current_user(request, db) user = current_user(request, db)
@@ -240,7 +260,10 @@ def index(
if sort not in SORT_OPTIONS: if sort not in SORT_OPTIONS:
sort = DEFAULT_SORT sort = DEFAULT_SORT
is_semantic = semantic in ("1", "on", "true") is_semantic = semantic in ("1", "on", "true")
links = query_links(db, user, q, category, manufacturer, is_semantic, sort) review_only = review in ("1", "on", "true")
links = query_links(
db, user, q, category, manufacturer, is_semantic, sort, review_only
)
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
@@ -255,6 +278,7 @@ def index(
"ai_enabled": config.AI_ENABLED, "ai_enabled": config.AI_ENABLED,
"sort": sort, "sort": sort,
"sort_options": SORT_OPTIONS, "sort_options": SORT_OPTIONS,
"review_only": review_only,
}, },
) )
@@ -267,6 +291,7 @@ def search_links(
manufacturer: str = "", manufacturer: str = "",
semantic: str = "", semantic: str = "",
sort: str = DEFAULT_SORT, sort: str = DEFAULT_SORT,
review: str = "",
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = current_user(request, db) user = current_user(request, db)
@@ -276,7 +301,10 @@ def search_links(
if sort not in SORT_OPTIONS: if sort not in SORT_OPTIONS:
sort = DEFAULT_SORT sort = DEFAULT_SORT
is_semantic = semantic in ("1", "on", "true") is_semantic = semantic in ("1", "on", "true")
links = query_links(db, user, q, category, manufacturer, is_semantic, sort) review_only = review in ("1", "on", "true")
links = query_links(
db, user, q, category, manufacturer, is_semantic, sort, review_only
)
return templates.TemplateResponse( return templates.TemplateResponse(
"_links_list.html", {"request": request, "links": links} "_links_list.html", {"request": request, "links": links}
) )
@@ -347,6 +375,26 @@ def delete_link(
return Response(status_code=200) return Response(status_code=200)
@app.post("/links/{link_id}/toggle-review", response_class=HTMLResponse)
def toggle_review(
link_id: int,
request: Request,
db: Session = Depends(get_db),
):
user = current_user(request, db)
if not user:
return Response(status_code=401, headers={"HX-Redirect": "/login"})
link = _get_owned_link(db, user, link_id)
if not link:
return Response(status_code=404)
link.needs_review = not link.needs_review
db.commit()
db.refresh(link)
return templates.TemplateResponse(
"_link_card.html", {"request": request, "link": link}
)
def _get_owned_link(db: Session, user: User, link_id: int) -> Link | None: def _get_owned_link(db: Session, user: User, link_id: int) -> Link | None:
link = db.get(Link, link_id) link = db.get(Link, link_id)
if link and link.user_id == user.id: if link and link.user_id == user.id:

View File

@@ -37,6 +37,7 @@ class Link(Base):
tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste
embedding: Mapped[str] = mapped_column(Text, default="") # JSON-Liste (Vektor) embedding: Mapped[str] = mapped_column(Text, default="") # JSON-Liste (Vektor)
status: Mapped[str] = mapped_column(String(20), default="ok") status: Mapped[str] = mapped_column(String(20), default="ok")
needs_review: Mapped[bool] = mapped_column(default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
user: Mapped["User"] = relationship(back_populates="links") user: Mapped["User"] = relationship(back_populates="links")

View File

@@ -1,4 +1,4 @@
<div class="panel link-card" id="link-{{ link.id }}"> <div class="panel link-card {% if link.needs_review %}needs-review{% endif %}" id="link-{{ link.id }}">
<div class="card-head"> <div class="card-head">
<span class="card-avatar" style="--h: {{ link.url | hue }};"> <span class="card-avatar" style="--h: {{ link.url | hue }};">
{{ (link.url | domain)[:1] | upper or '?' }} {{ (link.url | domain)[:1] | upper or '?' }}
@@ -14,6 +14,12 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="ghost review-toggle {% if link.needs_review %}active{% endif %}"
style="padding:3px 7px; font-size:.8rem;"
title="{% if link.needs_review %}Nicht mehr zur Durchsicht{% else %}Zur Durchsicht markieren{% endif %}"
hx-post="/links/{{ link.id }}/toggle-review"
hx-target="#link-{{ link.id }}"
hx-swap="outerHTML">{% if link.needs_review %}🔖{% else %}🏷️{% endif %}</button>
<button class="ghost" style="padding:3px 7px; font-size:.8rem;" <button class="ghost" style="padding:3px 7px; font-size:.8rem;"
title="Bearbeiten" title="Bearbeiten"
hx-get="/links/{{ link.id }}/edit" hx-get="/links/{{ link.id }}/edit"

View File

@@ -57,6 +57,8 @@
#links > #empty-state { grid-column: 1 / -1; } #links > #empty-state { grid-column: 1 / -1; }
.link-card { display: flex; flex-direction: column; } .link-card { display: flex; flex-direction: column; }
.link-card.needs-review { border-color: #ca8a04; }
.review-toggle.active { color: #fbbf24; border-color: #ca8a04; }
.card-head { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 10px; } .card-head { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 10px; }
.card-avatar { .card-avatar {
flex-shrink: 0; width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0; width: 34px; height: 34px; border-radius: 9px;

View File

@@ -19,7 +19,7 @@
<div class="layout"> <div class="layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="panel"> <div class="panel">
<a href="/?sort={{ sort }}" class="facet {% if not active_category and not active_manufacturer %}active{% endif %}"> <a href="/?sort={{ sort }}" class="facet {% if not active_category and not active_manufacturer and not review_only %}active{% endif %}">
<span>📚 Alle Links</span> <span>📚 Alle Links</span>
</a> </a>
@@ -27,7 +27,7 @@
<h3>Kategorien</h3> <h3>Kategorien</h3>
{% for name, count in facets.categories %} {% for name, count in facets.categories %}
<a href="/?category={{ name | urlencode }}&amp;sort={{ sort }}" <a href="/?category={{ name | urlencode }}&amp;sort={{ sort }}"
class="facet {% if active_category == name %}active{% endif %}"> class="facet {% if active_category == name and not review_only %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span> <span>{{ name }}</span><span class="count">{{ count }}</span>
</a> </a>
{% else %} {% else %}
@@ -39,13 +39,20 @@
<h3>Hersteller / Quellen</h3> <h3>Hersteller / Quellen</h3>
{% for name, count in facets.manufacturers %} {% for name, count in facets.manufacturers %}
<a href="/?manufacturer={{ name | urlencode }}&amp;sort={{ sort }}" <a href="/?manufacturer={{ name | urlencode }}&amp;sort={{ sort }}"
class="facet {% if active_manufacturer == name %}active{% endif %}"> class="facet {% if active_manufacturer == name and not review_only %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span> <span>{{ name }}</span><span class="count">{{ count }}</span>
</a> </a>
{% else %} {% else %}
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div> <div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
{% endfor %} {% endfor %}
</div> </div>
<div class="facet-group">
<h3>Merkliste</h3>
<a href="/?review=1&amp;sort={{ sort }}" class="facet {% if review_only %}active{% endif %}">
<span>🔖 Zur Durchsicht</span><span class="count">{{ facets.review_count }}</span>
</a>
</div>
</div> </div>
</aside> </aside>
@@ -71,6 +78,7 @@
<form id="searchform" onsubmit="return false;" style="margin:0;"> <form id="searchform" onsubmit="return false;" style="margin:0;">
<input type="hidden" name="category" value="{{ active_category }}"> <input type="hidden" name="category" value="{{ active_category }}">
<input type="hidden" name="manufacturer" value="{{ active_manufacturer }}"> <input type="hidden" name="manufacturer" value="{{ active_manufacturer }}">
<input type="hidden" name="review" value="{{ '1' if review_only else '' }}">
<input type="text" name="q" value="{{ q }}" <input type="text" name="q" value="{{ q }}"
placeholder="🔍 Suche in Titel, Zusammenfassung, Tags..." placeholder="🔍 Suche in Titel, Zusammenfassung, Tags..."
hx-get="/search" hx-target="#links" hx-swap="innerHTML" hx-get="/search" hx-target="#links" hx-swap="innerHTML"