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(
|
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}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 }}&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 }}&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">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-top:10px; flex-wrap:wrap;">
|
||||||
{% if ai_enabled %}
|
{% if ai_enabled %}
|
||||||
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem; margin-top:10px;">
|
<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 %}
|
<input type="checkbox" name="semantic" value="1" {% if semantic %}checked{% endif %}
|
||||||
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
||||||
hx-trigger="change" hx-include="#searchform" style="width:auto;">
|
hx-trigger="change" hx-include="#searchform" style="width:auto;">
|
||||||
KI-Suche (findet auch sinnverwandte Treffer)
|
KI-Suche (findet auch sinnverwandte Treffer)
|
||||||
</label>
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<span></span>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user