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:
54
app/main.py
54
app/main.py
@@ -6,7 +6,7 @@ from urllib.parse import urlparse
|
||||
from fastapi import Depends, FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
@@ -17,6 +17,16 @@ from .security import hash_password, verify_password
|
||||
|
||||
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.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)
|
||||
.order_by(func.count().desc())
|
||||
).all()
|
||||
review_count = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Link)
|
||||
.where(Link.user_id == user.id, Link.needs_review == True) # noqa: E712
|
||||
)
|
||||
return {
|
||||
"categories": [(name, count) for name, count in cat_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 = "",
|
||||
semantic: bool = False,
|
||||
sort: str = DEFAULT_SORT,
|
||||
review_only: bool = False,
|
||||
) -> list[Link]:
|
||||
stmt = select(Link).where(Link.user_id == user.id)
|
||||
if category:
|
||||
stmt = stmt.where(Link.category == category)
|
||||
if 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())
|
||||
|
||||
q = (q or "").strip()
|
||||
@@ -231,6 +250,7 @@ def index(
|
||||
manufacturer: str = "",
|
||||
semantic: str = "",
|
||||
sort: str = DEFAULT_SORT,
|
||||
review: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
@@ -240,7 +260,10 @@ def index(
|
||||
if sort not in SORT_OPTIONS:
|
||||
sort = DEFAULT_SORT
|
||||
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(
|
||||
"index.html",
|
||||
{
|
||||
@@ -255,6 +278,7 @@ def index(
|
||||
"ai_enabled": config.AI_ENABLED,
|
||||
"sort": sort,
|
||||
"sort_options": SORT_OPTIONS,
|
||||
"review_only": review_only,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -267,6 +291,7 @@ def search_links(
|
||||
manufacturer: str = "",
|
||||
semantic: str = "",
|
||||
sort: str = DEFAULT_SORT,
|
||||
review: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
@@ -276,7 +301,10 @@ def search_links(
|
||||
if sort not in SORT_OPTIONS:
|
||||
sort = DEFAULT_SORT
|
||||
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(
|
||||
"_links_list.html", {"request": request, "links": links}
|
||||
)
|
||||
@@ -347,6 +375,26 @@ def delete_link(
|
||||
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:
|
||||
link = db.get(Link, link_id)
|
||||
if link and link.user_id == user.id:
|
||||
|
||||
@@ -37,6 +37,7 @@ class Link(Base):
|
||||
tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste
|
||||
embedding: Mapped[str] = mapped_column(Text, default="") # JSON-Liste (Vektor)
|
||||
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)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="links")
|
||||
|
||||
@@ -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">
|
||||
<span class="card-avatar" style="--h: {{ link.url | hue }};">
|
||||
{{ (link.url | domain)[:1] | upper or '?' }}
|
||||
@@ -14,6 +14,12 @@
|
||||
</div>
|
||||
|
||||
<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;"
|
||||
title="Bearbeiten"
|
||||
hx-get="/links/{{ link.id }}/edit"
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
#links > #empty-state { grid-column: 1 / -1; }
|
||||
|
||||
.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-avatar {
|
||||
flex-shrink: 0; width: 34px; height: 34px; border-radius: 9px;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<h3>Kategorien</h3>
|
||||
{% for name, count in facets.categories %}
|
||||
<a href="/?category={{ name | urlencode }}&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>
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -39,13 +39,20 @@
|
||||
<h3>Hersteller / Quellen</h3>
|
||||
{% for name, count in facets.manufacturers %}
|
||||
<a href="/?manufacturer={{ name | urlencode }}&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>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="facet-group">
|
||||
<h3>Merkliste</h3>
|
||||
<a href="/?review=1&sort={{ sort }}" class="facet {% if review_only %}active{% endif %}">
|
||||
<span>🔖 Zur Durchsicht</span><span class="count">{{ facets.review_count }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -71,6 +78,7 @@
|
||||
<form id="searchform" onsubmit="return false;" style="margin:0;">
|
||||
<input type="hidden" name="category" value="{{ active_category }}">
|
||||
<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 }}"
|
||||
placeholder="🔍 Suche in Titel, Zusammenfassung, Tags..."
|
||||
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
||||
|
||||
Reference in New Issue
Block a user