Add link editing, AI re-description, and footer

Links can now be edited manually (title, summary, category,
manufacturer, tags) via an inline edit form, with a "KI neu
beschreiben lassen" action that re-fetches the URL and lets the AI
regenerate all fields. Editing recomputes the search embedding.

Also adds an app footer showing author, version, and hostname.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-07-19 21:54:01 +02:00
parent cc10776734
commit a0db3e5147
5 changed files with 209 additions and 5 deletions

View File

@@ -1,9 +1,14 @@
import os import os
import socket
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
APP_AUTHOR = "Erik Thiele"
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
APP_HOST = socket.gethostname()
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-secret-change-me") SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-secret-change-me")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")

View File

@@ -19,6 +19,11 @@ app = FastAPI(title="LinkVault")
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14) app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14)
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
templates.env.globals.update(
app_author=config.APP_AUTHOR,
app_version=config.APP_VERSION,
app_host=config.APP_HOST,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -281,3 +286,130 @@ def delete_link(
db.delete(link) db.delete(link)
db.commit() db.commit()
return Response(status_code=200) return Response(status_code=200)
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:
return link
return None
def _parse_tags(raw: str) -> list[str]:
return [t.strip() for t in raw.split(",") if t.strip()][:8]
@app.get("/links/{link_id}/edit", response_class=HTMLResponse)
def edit_link_form(
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)
return templates.TemplateResponse(
"_link_card_edit.html",
{"request": request, "link": link, "ai_enabled": config.AI_ENABLED},
)
@app.get("/links/{link_id}/view", response_class=HTMLResponse)
def view_link_card(
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)
return templates.TemplateResponse(
"_link_card.html", {"request": request, "link": link}
)
@app.put("/links/{link_id}", response_class=HTMLResponse)
def update_link(
link_id: int,
request: Request,
title: str = Form(""),
summary: str = Form(""),
category: str = Form(""),
manufacturer: str = Form(""),
tags: str = Form(""),
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.title = title.strip()[:300] or link.url
link.summary = summary.strip()[:1000]
link.category = category.strip()[:120]
link.manufacturer = manufacturer.strip()[:120]
link.tags = json.dumps(_parse_tags(tags), ensure_ascii=False)
embed_source = " ".join([link.title, link.summary, tags])
embedding = ai.embed(embed_source)
if embedding:
link.embedding = json.dumps(embedding)
db.commit()
db.refresh(link)
return templates.TemplateResponse(
"_link_card.html", {"request": request, "link": link}
)
@app.post("/links/{link_id}/reanalyze", response_class=HTMLResponse)
def reanalyze_link(
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)
title, text = scraper.fetch(link.url)
existing = facets(db, user)
meta = ai.categorize(
link.url,
title,
text,
[name for name, _ in existing["categories"] if name != link.category],
[name for name, _ in existing["manufacturers"] if name != link.manufacturer],
)
embed_source = " ".join(
[meta["title"], meta["summary"], " ".join(meta["tags"]), text[:1000]]
)
embedding = ai.embed(embed_source)
link.title = meta["title"]
link.summary = meta["summary"]
link.category = meta["category"]
link.manufacturer = meta["manufacturer"]
link.tags = json.dumps(meta["tags"], ensure_ascii=False)
if embedding:
link.embedding = json.dumps(embedding)
link.status = "ok" if text else "no_content"
db.commit()
db.refresh(link)
return templates.TemplateResponse(
"_link_card_edit.html",
{"request": request, "link": link, "ai_enabled": config.AI_ENABLED},
)

View File

@@ -7,11 +7,17 @@
{{ link.url }} {{ link.url }}
</div> </div>
</div> </div>
<button class="danger" <div style="display:flex; gap:4px; flex-shrink:0;">
hx-delete="/links/{{ link.id }}" <button class="ghost" style="padding:4px 10px; font-size:.8rem;"
hx-target="#link-{{ link.id }}" hx-get="/links/{{ link.id }}/edit"
hx-swap="outerHTML" hx-target="#link-{{ link.id }}"
hx-confirm="Diesen Link wirklich löschen?"></button> hx-swap="outerHTML">✎ Bearbeiten</button>
<button class="danger"
hx-delete="/links/{{ link.id }}"
hx-target="#link-{{ link.id }}"
hx-swap="outerHTML"
hx-confirm="Diesen Link wirklich löschen?"></button>
</div>
</div> </div>
{% if link.summary %} {% if link.summary %}

View File

@@ -0,0 +1,54 @@
<div class="panel link-card" id="link-{{ link.id }}">
<form hx-put="/links/{{ link.id }}" hx-target="#link-{{ link.id }}" hx-swap="outerHTML"
hx-disabled-elt="button">
<div class="muted" style="font-size:.8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-bottom:8px;">
{{ link.url }}
</div>
<div class="field">
<label>Titel</label>
<input type="text" name="title" value="{{ link.title }}">
</div>
<div class="field">
<label>Zusammenfassung</label>
<textarea name="summary" rows="3" style="width:100%; background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:8px; padding:10px 12px; font-size:.95rem; font-family:inherit;">{{ link.summary }}</textarea>
</div>
<div style="display:flex; gap:12px;">
<div class="field" style="flex:1;">
<label>Kategorie</label>
<input type="text" name="category" value="{{ link.category }}">
</div>
<div class="field" style="flex:1;">
<label>Hersteller / Quelle</label>
<input type="text" name="manufacturer" value="{{ link.manufacturer }}">
</div>
</div>
<div class="field">
<label>Tags (mit Komma getrennt)</label>
<input type="text" name="tags" value="{{ link.tag_list | join(', ') }}">
</div>
<div style="display:flex; justify-content:space-between; gap:8px; margin-top:4px;">
<div style="display:flex; gap:8px;">
<button type="submit">Speichern</button>
<button type="button" class="ghost"
hx-get="/links/{{ link.id }}/view"
hx-target="#link-{{ link.id }}"
hx-swap="outerHTML">Abbrechen</button>
</div>
{% if ai_enabled %}
<button type="button" class="ghost"
hx-post="/links/{{ link.id }}/reanalyze"
hx-target="#link-{{ link.id }}"
hx-swap="outerHTML"
hx-disabled-elt="this"
hx-confirm="Link erneut laden und von der KI neu beschreiben lassen? Bestehende Angaben werden dabei ersetzt.">
<span class="htmx-indicator"></span> 🤖 KI neu beschreiben lassen
</button>
{% endif %}
</div>
</form>
</div>

View File

@@ -59,9 +59,16 @@
.htmx-indicator { opacity: 0; transition: opacity .2s; } .htmx-indicator { opacity: 0; transition: opacity .2s; }
.htmx-request .htmx-indicator { opacity: 1; } .htmx-request .htmx-indicator { opacity: 1; }
.htmx-request.htmx-indicator { opacity: 1; } .htmx-request.htmx-indicator { opacity: 1; }
footer.app-footer {
text-align: center; padding: 16px 24px; margin-top: 24px;
color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border);
}
</style> </style>
</head> </head>
<body> <body>
{% block body %}{% endblock %} {% block body %}{% endblock %}
<footer class="app-footer">
&copy; {{ app_author }} &middot; Version {{ app_version }} &middot; {{ app_host }}
</footer>
</body> </body>
</html> </html>