Restyle link cards as a uniform tile grid

Cards previously had ragged heights because summaries vary in length.
Follow the community-scripts.org card pattern instead: fixed-height
title and summary blocks (clamped to 2 and 3 lines with an ellipsis),
a colored letter avatar derived from the domain, category and
manufacturer as badges in the header, tags in a single clipped row,
and domain plus added-date in a footer.

The avatar color is derived locally from the domain, so no external
favicon requests are made.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-07-20 15:34:27 +02:00
parent 4d96dfb9ac
commit fdfddf8ccf
3 changed files with 103 additions and 36 deletions

View File

@@ -1,6 +1,7 @@
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
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
@@ -36,7 +37,20 @@ def format_datum(value: datetime | None) -> str:
return value.astimezone().strftime("%d.%m.%Y, %H:%M") return value.astimezone().strftime("%d.%m.%Y, %H:%M")
def domain_of(url: str) -> str:
"""Hostname ohne 'www.' dient als Kürzel/Beschriftung der Karten."""
host = urlparse(url or "").hostname or ""
return host[4:] if host.startswith("www.") else host
def avatar_hue(url: str) -> int:
"""Stabile Farbe pro Domain, damit Karten wiedererkennbar sind."""
return sum(ord(c) for c in domain_of(url)) % 360
templates.env.filters["datum"] = format_datum templates.env.filters["datum"] = format_datum
templates.env.filters["domain"] = domain_of
templates.env.filters["hue"] = avatar_hue
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,22 +1,25 @@
<div class="panel link-card" id="link-{{ link.id }}"> <div class="panel link-card" id="link-{{ link.id }}">
<div style="display:flex; justify-content:space-between; gap:12px;"> <div class="card-head">
<div style="min-width:0;"> <span class="card-avatar" style="--h: {{ link.url | hue }};">
<a href="{{ link.url }}" target="_blank" rel="noopener" {{ (link.url | domain)[:1] | upper or '?' }}
style="font-weight:600; font-size:1.05rem;">{{ link.title or link.url }}</a> </span>
<div class="muted" style="font-size:.8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ link.url }} <div class="card-badges">
{% if link.category %}
<span class="badge badge-cat">{{ link.category }}</span>
{% endif %}
{% if link.manufacturer %}
<span class="badge badge-man">{{ link.manufacturer }}</span>
{% endif %}
</div> </div>
<div class="muted" style="font-size:.75rem; margin-top:3px;">
🕓 hinzugefügt am {{ link.created_at | datum }} <div class="card-actions">
</div> <button class="ghost" style="padding:3px 7px; font-size:.8rem;"
</div>
<div style="display:flex; gap:4px; flex-shrink:0; align-items:flex-start;">
<button class="ghost" style="padding:4px 9px; font-size:.85rem;"
title="Bearbeiten" title="Bearbeiten"
hx-get="/links/{{ link.id }}/edit" hx-get="/links/{{ link.id }}/edit"
hx-target="#link-{{ link.id }}" hx-target="#link-{{ link.id }}"
hx-swap="outerHTML"></button> hx-swap="outerHTML"></button>
<button class="danger" <button class="danger" style="padding:3px 6px;"
title="Löschen" title="Löschen"
hx-delete="/links/{{ link.id }}" hx-delete="/links/{{ link.id }}"
hx-target="#link-{{ link.id }}" hx-target="#link-{{ link.id }}"
@@ -25,29 +28,30 @@
</div> </div>
</div> </div>
{% if link.summary %} <a href="{{ link.url }}" target="_blank" rel="noopener" class="card-title">
<p style="margin:10px 0 8px;">{{ link.summary }}</p> {{ link.title or link.url }}
{% elif link.status == 'no_content' %} </a>
<p class="muted" style="margin:10px 0 8px; font-style:italic;">
Inhalt konnte nicht abgerufen werden Kategorisierung anhand der URL.
</p>
{% endif %}
<div style="display:flex; flex-wrap:wrap; gap:6px; align-items:center; font-size:.8rem;"> <p class="card-summary">
{% if link.category %} {%- if link.summary -%}
<span style="background:var(--accent); color:white; padding:2px 9px; border-radius:99px;"> {{ link.summary }}
{{ link.category }} {%- elif link.status == 'no_content' -%}
</span> Inhalt konnte nicht abgerufen werden Kategorisierung anhand der URL.
{% endif %} {%- endif -%}
{% if link.manufacturer %} </p>
<span style="background:#0e7490; color:#cffafe; padding:2px 9px; border-radius:99px;">
🏢 {{ link.manufacturer }} <div class="card-spacer"></div>
</span>
{% endif %} {% if link.tag_list %}
<div class="card-tags">
{% for tag in link.tag_list %} {% for tag in link.tag_list %}
<span style="background:var(--panel2); color:var(--muted); padding:2px 9px; border-radius:99px;"> <span class="badge badge-tag">#{{ tag }}</span>
#{{ tag }}
</span>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
<div class="card-foot">
<span class="card-domain" title="{{ link.url }}">{{ link.url | domain }}</span>
<span title="hinzugefügt am {{ link.created_at | datum }}">{{ link.created_at | datum }}</span>
</div>
</div> </div>

View File

@@ -49,10 +49,59 @@
.main { flex: 1; min-width: 0; } .main { flex: 1; min-width: 0; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 16px; } .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
/* Linkliste: bis zu drei Karten pro Zeile, je nach Fensterbreite. */ /* Linkliste: bis zu drei Karten pro Zeile, je nach Fensterbreite.
#links { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; align-items: start; } Karten einer Zeile sind gleich hoch; die Zusammenfassung wird
dafür auf drei Zeilen gekürzt. */
#links { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
#links > .panel { margin-bottom: 0; } #links > .panel { margin-bottom: 0; }
#links > #empty-state { grid-column: 1 / -1; } #links > #empty-state { grid-column: 1 / -1; }
.link-card { display: flex; flex-direction: column; }
.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;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 1rem;
background: hsl(var(--h) 45% 22%); color: hsl(var(--h) 80% 78%);
border: 1px solid hsl(var(--h) 40% 32%);
}
.card-badges {
display: flex; gap: 4px; flex: 1; min-width: 0;
overflow: hidden; max-height: 22px; align-items: flex-start;
}
.card-actions { display: flex; gap: 4px; flex-shrink: 0; }
.badge {
font-size: .7rem; padding: 2px 8px; border-radius: 99px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;
}
.badge-cat { background: var(--accent); color: #fff; }
.badge-man { background: #0e7490; color: #cffafe; }
.badge-tag { background: var(--panel2); color: var(--muted); }
/* Feste Höhen für Titel und Zusammenfassung: so beginnt jeder Block auf
gleicher Höhe und das Kürzen mit "…" greift zuverlässig. */
.card-title {
font-weight: 600; font-size: 1rem; line-height: 1.35; margin-bottom: 6px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; height: calc(2 * 1.35 * 1rem); flex: none;
}
.card-summary {
margin: 0 0 12px; font-size: .875rem; color: var(--muted); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden; height: calc(3 * 1.5 * .875rem); flex: none;
}
.card-spacer { flex: 1 1 auto; }
/* Tags: nur eine Zeile, Rest wird abgeschnitten hält die Karten gleich hoch. */
.card-tags {
display: flex; gap: 4px;
overflow: hidden; max-height: 22px; flex-wrap: wrap;
}
.card-foot {
padding-top: 10px; border-top: 1px solid var(--border);
display: flex; justify-content: space-between; gap: 8px;
font-size: .75rem; color: var(--muted);
}
.card-domain { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-foot > span:last-child { flex-shrink: 0; }
@media (min-width: 1000px) { #links { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (min-width: 1000px) { #links { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 1500px) { #links { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (min-width: 1500px) { #links { grid-template-columns: repeat(3, minmax(0, 1fr)); } }