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:
42
app/main.py
42
app/main.py
@@ -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 A–Z",
|
||||
"name_desc": "Name Z–A",
|
||||
}
|
||||
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(
|
||||
db: Session,
|
||||
user: User,
|
||||
@@ -82,6 +104,7 @@ def query_links(
|
||||
category: str = "",
|
||||
manufacturer: str = "",
|
||||
semantic: bool = False,
|
||||
sort: str = DEFAULT_SORT,
|
||||
) -> list[Link]:
|
||||
stmt = select(Link).where(Link.user_id == user.id)
|
||||
if category:
|
||||
@@ -92,18 +115,18 @@ def query_links(
|
||||
|
||||
q = (q or "").strip()
|
||||
if not q:
|
||||
return links
|
||||
return sort_links(links, sort)
|
||||
|
||||
if semantic and config.AI_ENABLED:
|
||||
query_vec = ai.embed(q)
|
||||
ranked = search.cosine_rank(query_vec, links)
|
||||
result = [link for link, score in ranked if score > 0.20][:50]
|
||||
if result:
|
||||
return result
|
||||
return sort_links(result, sort)
|
||||
# Fallback auf Textsuche, falls keine Embeddings vorhanden sind.
|
||||
|
||||
ql = q.lower()
|
||||
return [
|
||||
matches = [
|
||||
link
|
||||
for link in links
|
||||
if ql
|
||||
@@ -111,6 +134,7 @@ def query_links(
|
||||
[link.title, link.summary, link.tags, link.category, link.manufacturer]
|
||||
).lower()
|
||||
]
|
||||
return sort_links(matches, sort)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,14 +216,17 @@ def index(
|
||||
category: str = "",
|
||||
manufacturer: str = "",
|
||||
semantic: str = "",
|
||||
sort: str = DEFAULT_SORT,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
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)
|
||||
links = query_links(db, user, q, category, manufacturer, is_semantic, sort)
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{
|
||||
@@ -212,6 +239,8 @@ def index(
|
||||
"active_manufacturer": manufacturer,
|
||||
"semantic": is_semantic,
|
||||
"ai_enabled": config.AI_ENABLED,
|
||||
"sort": sort,
|
||||
"sort_options": SORT_OPTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -223,14 +252,17 @@ def search_links(
|
||||
category: str = "",
|
||||
manufacturer: str = "",
|
||||
semantic: str = "",
|
||||
sort: str = DEFAULT_SORT,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
if not user:
|
||||
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")
|
||||
links = query_links(db, user, q, category, manufacturer, is_semantic)
|
||||
links = query_links(db, user, q, category, manufacturer, is_semantic, sort)
|
||||
return templates.TemplateResponse(
|
||||
"_links_list.html", {"request": request, "links": links}
|
||||
)
|
||||
|
||||
@@ -38,7 +38,12 @@
|
||||
background: var(--panel2); border: 1px solid var(--border); color: var(--text);
|
||||
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; }
|
||||
.sidebar { width: 240px; flex-shrink: 0; }
|
||||
.main { flex: 1; min-width: 0; }
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<div class="facet-group">
|
||||
<h3>Kategorien</h3>
|
||||
{% for name, count in facets.categories %}
|
||||
<a href="/?category={{ name | urlencode }}"
|
||||
<a href="/?category={{ name | urlencode }}&sort={{ sort }}"
|
||||
class="facet {% if active_category == name %}active{% endif %}">
|
||||
<span>{{ name }}</span><span class="count">{{ count }}</span>
|
||||
</a>
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="facet-group">
|
||||
<h3>Hersteller / Quellen</h3>
|
||||
{% for name, count in facets.manufacturers %}
|
||||
<a href="/?manufacturer={{ name | urlencode }}"
|
||||
<a href="/?manufacturer={{ name | urlencode }}&sort={{ sort }}"
|
||||
class="facet {% if active_manufacturer == name %}active{% endif %}">
|
||||
<span>{{ name }}</span><span class="count">{{ count }}</span>
|
||||
</a>
|
||||
@@ -75,14 +75,29 @@
|
||||
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
||||
hx-trigger="keyup changed delay:400ms, search"
|
||||
hx-include="#searchform">
|
||||
{% if ai_enabled %}
|
||||
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem; margin-top:10px;">
|
||||
<input type="checkbox" name="semantic" value="1" {% if semantic %}checked{% endif %}
|
||||
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
||||
hx-trigger="change" hx-include="#searchform" style="width:auto;">
|
||||
KI-Suche (findet auch sinnverwandte Treffer)
|
||||
</label>
|
||||
{% endif %}
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-top:10px; flex-wrap:wrap;">
|
||||
{% if ai_enabled %}
|
||||
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem;">
|
||||
<input type="checkbox" name="semantic" value="1" {% if semantic %}checked{% endif %}
|
||||
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
||||
hx-trigger="change" hx-include="#searchform" style="width:auto;">
|
||||
KI-Suche (findet auch sinnverwandte Treffer)
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user