Add sorting by date or name

Adds a sort dropdown offering newest/oldest first and name A-Z/Z-A.
Sorting applies to plain listings, text search, and semantic search
results, and the sidebar facet links preserve the current choice.
Invalid sort values fall back to the default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-07-19 22:05:22 +02:00
parent 6a35769943
commit 148074e24c
3 changed files with 69 additions and 17 deletions

View File

@@ -75,6 +75,28 @@ def facets(db: Session, user: User) -> dict:
} }
# Auswahl für die Sortierung: Wert -> Beschriftung in der Oberfläche.
SORT_OPTIONS = {
"date_desc": "Neueste zuerst",
"date_asc": "Älteste zuerst",
"name_asc": "Name AZ",
"name_desc": "Name ZA",
}
DEFAULT_SORT = "date_desc"
def sort_links(links: list[Link], sort: str) -> list[Link]:
if sort == "date_asc":
return sorted(links, key=lambda link: link.created_at)
if sort == "name_asc":
return sorted(links, key=lambda link: (link.title or link.url).lower())
if sort == "name_desc":
return sorted(
links, key=lambda link: (link.title or link.url).lower(), reverse=True
)
return sorted(links, key=lambda link: link.created_at, reverse=True)
def query_links( def query_links(
db: Session, db: Session,
user: User, user: User,
@@ -82,6 +104,7 @@ def query_links(
category: str = "", category: str = "",
manufacturer: str = "", manufacturer: str = "",
semantic: bool = False, semantic: bool = False,
sort: str = DEFAULT_SORT,
) -> 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:
@@ -92,18 +115,18 @@ def query_links(
q = (q or "").strip() q = (q or "").strip()
if not q: if not q:
return links return sort_links(links, sort)
if semantic and config.AI_ENABLED: if semantic and config.AI_ENABLED:
query_vec = ai.embed(q) query_vec = ai.embed(q)
ranked = search.cosine_rank(query_vec, links) ranked = search.cosine_rank(query_vec, links)
result = [link for link, score in ranked if score > 0.20][:50] result = [link for link, score in ranked if score > 0.20][:50]
if result: if result:
return result return sort_links(result, sort)
# Fallback auf Textsuche, falls keine Embeddings vorhanden sind. # Fallback auf Textsuche, falls keine Embeddings vorhanden sind.
ql = q.lower() ql = q.lower()
return [ matches = [
link link
for link in links for link in links
if ql if ql
@@ -111,6 +134,7 @@ def query_links(
[link.title, link.summary, link.tags, link.category, link.manufacturer] [link.title, link.summary, link.tags, link.category, link.manufacturer]
).lower() ).lower()
] ]
return sort_links(matches, sort)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -192,14 +216,17 @@ def index(
category: str = "", category: str = "",
manufacturer: str = "", manufacturer: str = "",
semantic: str = "", semantic: str = "",
sort: str = DEFAULT_SORT,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = current_user(request, db) user = current_user(request, db)
if not user: if not user:
return RedirectResponse("/login", status_code=303) return RedirectResponse("/login", status_code=303)
if sort not in SORT_OPTIONS:
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) links = query_links(db, user, q, category, manufacturer, is_semantic, sort)
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
@@ -212,6 +239,8 @@ def index(
"active_manufacturer": manufacturer, "active_manufacturer": manufacturer,
"semantic": is_semantic, "semantic": is_semantic,
"ai_enabled": config.AI_ENABLED, "ai_enabled": config.AI_ENABLED,
"sort": sort,
"sort_options": SORT_OPTIONS,
}, },
) )
@@ -223,14 +252,17 @@ def search_links(
category: str = "", category: str = "",
manufacturer: str = "", manufacturer: str = "",
semantic: str = "", semantic: str = "",
sort: str = DEFAULT_SORT,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = current_user(request, db) user = current_user(request, db)
if not user: if not user:
return Response(status_code=401, headers={"HX-Redirect": "/login"}) return Response(status_code=401, headers={"HX-Redirect": "/login"})
if sort not in SORT_OPTIONS:
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) links = query_links(db, user, q, category, manufacturer, is_semantic, sort)
return templates.TemplateResponse( return templates.TemplateResponse(
"_links_list.html", {"request": request, "links": links} "_links_list.html", {"request": request, "links": links}
) )

View File

@@ -38,7 +38,12 @@
background: var(--panel2); border: 1px solid var(--border); color: var(--text); background: var(--panel2); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 10px 12px; font-size: .95rem; width: 100%; border-radius: 8px; padding: 10px 12px; font-size: .95rem; width: 100%;
} }
input:focus { outline: none; border-color: var(--accent); } input:focus, select:focus { outline: none; border-color: var(--accent); }
select {
background: var(--panel2); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 6px 10px; font-size: .85rem; font-family: inherit;
cursor: pointer;
}
.layout { display: flex; gap: 24px; max-width: 1200px; margin: 24px auto; padding: 0 24px; } .layout { display: flex; gap: 24px; max-width: 1200px; margin: 24px auto; padding: 0 24px; }
.sidebar { width: 240px; flex-shrink: 0; } .sidebar { width: 240px; flex-shrink: 0; }
.main { flex: 1; min-width: 0; } .main { flex: 1; min-width: 0; }

View File

@@ -19,14 +19,14 @@
<div class="layout"> <div class="layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="panel"> <div class="panel">
<a href="/" 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 %}active{% endif %}">
<span>📚 Alle Links</span> <span>📚 Alle Links</span>
</a> </a>
<div class="facet-group"> <div class="facet-group">
<h3>Kategorien</h3> <h3>Kategorien</h3>
{% for name, count in facets.categories %} {% for name, count in facets.categories %}
<a href="/?category={{ name | urlencode }}" <a href="/?category={{ name | urlencode }}&amp;sort={{ sort }}"
class="facet {% if active_category == name %}active{% endif %}"> class="facet {% if active_category == name %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span> <span>{{ name }}</span><span class="count">{{ count }}</span>
</a> </a>
@@ -38,7 +38,7 @@
<div class="facet-group"> <div class="facet-group">
<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 }}" <a href="/?manufacturer={{ name | urlencode }}&amp;sort={{ sort }}"
class="facet {% if active_manufacturer == name %}active{% endif %}"> class="facet {% if active_manufacturer == name %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span> <span>{{ name }}</span><span class="count">{{ count }}</span>
</a> </a>
@@ -75,14 +75,29 @@
hx-get="/search" hx-target="#links" hx-swap="innerHTML" hx-get="/search" hx-target="#links" hx-swap="innerHTML"
hx-trigger="keyup changed delay:400ms, search" hx-trigger="keyup changed delay:400ms, search"
hx-include="#searchform"> hx-include="#searchform">
{% if ai_enabled %} <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-top:10px; flex-wrap:wrap;">
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem; margin-top:10px;"> {% if ai_enabled %}
<input type="checkbox" name="semantic" value="1" {% if semantic %}checked{% endif %} <label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem;">
hx-get="/search" hx-target="#links" hx-swap="innerHTML" <input type="checkbox" name="semantic" value="1" {% if semantic %}checked{% endif %}
hx-trigger="change" hx-include="#searchform" style="width:auto;"> hx-get="/search" hx-target="#links" hx-swap="innerHTML"
KI-Suche (findet auch sinnverwandte Treffer) hx-trigger="change" hx-include="#searchform" style="width:auto;">
</label> KI-Suche (findet auch sinnverwandte Treffer)
{% endif %} </label>
{% else %}
<span></span>
{% endif %}
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem;">
Sortierung:
<select name="sort"
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
hx-trigger="change" hx-include="#searchform">
{% for value, label in sort_options.items() %}
<option value="{{ value }}" {% if sort == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
</div>
</form> </form>
</div> </div>