Initial commit: LinkVault – KI-gestützte Link-Sammlung

FastAPI + HTMX Web-App zum Speichern von Links mit automatischer
KI-Kategorisierung, Zusammenfassung und semantischer Suche (OpenAI).
Mehrbenutzer mit Login, SQLite-Persistenz, Docker/Docker-Compose-Setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-07-13 22:07:05 +02:00
parent ff8c9f5141
commit 47685f0d34
22 changed files with 1035 additions and 0 deletions

0
app/__init__.py Normal file
View File

103
app/ai.py Normal file
View File

@@ -0,0 +1,103 @@
import json
from . import config
_client = None
def _client_or_none():
global _client
if not config.AI_ENABLED:
return None
if _client is None:
from openai import OpenAI
_client = OpenAI(api_key=config.OPENAI_API_KEY)
return _client
SYSTEM_PROMPT = (
"Du bist ein Assistent, der Web-Links organisiert. Analysiere den gegebenen "
"Inhalt und liefere strukturierte Metadaten als JSON. Verwende möglichst eine "
"der bereits existierenden Kategorien bzw. Hersteller/Quellen, wenn sie passt; "
"sonst erstelle eine kurze, allgemeingültige neue Bezeichnung. Antworte "
"ausschließlich auf Deutsch."
)
def categorize(
url: str,
title: str,
text: str,
existing_categories: list[str],
existing_manufacturers: list[str],
) -> dict:
"""Erzeuge Titel, Zusammenfassung, Kategorie, Hersteller und Tags."""
fallback = {
"title": title or url,
"summary": "",
"category": "Sonstiges",
"manufacturer": "",
"tags": [],
}
client = _client_or_none()
if client is None:
return fallback
user_prompt = f"""URL: {url}
Seitentitel: {title or "(unbekannt)"}
Bereits vorhandene Kategorien: {", ".join(existing_categories) or "(noch keine)"}
Bereits vorhandene Hersteller/Quellen: {", ".join(existing_manufacturers) or "(noch keine)"}
Seiteninhalt (gekürzt):
{text[:4000] or "(kein Inhalt abrufbar - nutze URL und Titel)"}
Gib ein JSON-Objekt mit genau diesen Feldern zurück:
- "title": kurzer, aussagekräftiger Titel
- "summary": 2-3 Sätze Zusammenfassung, worum es geht
- "category": eine passende Themen-Kategorie
- "manufacturer": Hersteller, Marke oder Quelle (leer lassen, wenn nicht erkennbar)
- "tags": Array aus 3-6 kurzen Schlagwörtern
"""
try:
resp = client.chat.completions.create(
model=config.OPENAI_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
response_format={"type": "json_object"},
temperature=0.2,
)
data = json.loads(resp.choices[0].message.content)
except Exception:
return fallback
tags = data.get("tags") or []
if not isinstance(tags, list):
tags = []
return {
"title": (str(data.get("title") or title or url)).strip()[:300],
"summary": (str(data.get("summary") or "")).strip()[:1000],
"category": (str(data.get("category") or "Sonstiges")).strip()[:120]
or "Sonstiges",
"manufacturer": (str(data.get("manufacturer") or "")).strip()[:120],
"tags": [str(t).strip()[:40] for t in tags if str(t).strip()][:8],
}
def embed(text: str) -> list[float]:
"""Erzeuge einen Embedding-Vektor für die semantische Suche."""
client = _client_or_none()
if client is None or not text.strip():
return []
try:
resp = client.embeddings.create(
model=config.EMBEDDING_MODEL, input=text[:8000]
)
return list(resp.data[0].embedding)
except Exception:
return []

15
app/config.py Normal file
View File

@@ -0,0 +1,15 @@
import os
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-secret-change-me")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./linkvault.db")
# KI-Funktionen (Kategorisierung, Zusammenfassung, semantische Suche)
# sind nur aktiv, wenn ein API-Key hinterlegt ist.
AI_ENABLED = bool(OPENAI_API_KEY)

25
app/database.py Normal file
View File

@@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from . import config
connect_args = (
{"check_same_thread": False}
if config.DATABASE_URL.startswith("sqlite")
else {}
)
engine = create_engine(config.DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

283
app/main.py Normal file
View File

@@ -0,0 +1,283 @@
import json
from pathlib import Path
from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from . import ai, config, scraper, search
from .database import Base, engine, get_db
from .models import Link, User
from .security import hash_password, verify_password
Base.metadata.create_all(bind=engine)
app = FastAPI(title="LinkVault")
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14)
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def current_user(request: Request, db: Session) -> User | None:
user_id = request.session.get("user_id")
if not user_id:
return None
return db.get(User, user_id)
def normalize_url(url: str) -> str:
url = url.strip()
if url and not url.startswith(("http://", "https://")):
url = "https://" + url
return url
def facets(db: Session, user: User) -> dict:
cat_rows = db.execute(
select(Link.category, func.count())
.where(Link.user_id == user.id, Link.category != "")
.group_by(Link.category)
.order_by(func.count().desc())
).all()
man_rows = db.execute(
select(Link.manufacturer, func.count())
.where(Link.user_id == user.id, Link.manufacturer != "")
.group_by(Link.manufacturer)
.order_by(func.count().desc())
).all()
return {
"categories": [(name, count) for name, count in cat_rows],
"manufacturers": [(name, count) for name, count in man_rows],
}
def query_links(
db: Session,
user: User,
q: str = "",
category: str = "",
manufacturer: str = "",
semantic: bool = False,
) -> list[Link]:
stmt = select(Link).where(Link.user_id == user.id)
if category:
stmt = stmt.where(Link.category == category)
if manufacturer:
stmt = stmt.where(Link.manufacturer == manufacturer)
links = list(db.scalars(stmt.order_by(Link.created_at.desc())).all())
q = (q or "").strip()
if not q:
return links
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
# Fallback auf Textsuche, falls keine Embeddings vorhanden sind.
ql = q.lower()
return [
link
for link in links
if ql
in " ".join(
[link.title, link.summary, link.tags, link.category, link.manufacturer]
).lower()
]
# ---------------------------------------------------------------------------
# Authentifizierung
# ---------------------------------------------------------------------------
@app.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
return templates.TemplateResponse("login.html", {"request": request, "error": None})
@app.post("/login", response_class=HTMLResponse)
def login(
request: Request,
email: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.scalar(select(User).where(User.email == email.strip().lower()))
if not user or not verify_password(password, user.password_hash):
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "E-Mail oder Passwort ist falsch."},
status_code=400,
)
request.session["user_id"] = user.id
return RedirectResponse("/", status_code=303)
@app.get("/register", response_class=HTMLResponse)
def register_form(request: Request):
return templates.TemplateResponse(
"register.html", {"request": request, "error": None}
)
@app.post("/register", response_class=HTMLResponse)
def register(
request: Request,
email: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
email = email.strip().lower()
if not email or len(password) < 6:
return templates.TemplateResponse(
"register.html",
{
"request": request,
"error": "Bitte gültige E-Mail und Passwort (min. 6 Zeichen) angeben.",
},
status_code=400,
)
if db.scalar(select(User).where(User.email == email)):
return templates.TemplateResponse(
"register.html",
{"request": request, "error": "Diese E-Mail ist bereits registriert."},
status_code=400,
)
user = User(email=email, password_hash=hash_password(password))
db.add(user)
db.commit()
request.session["user_id"] = user.id
return RedirectResponse("/", status_code=303)
@app.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
# ---------------------------------------------------------------------------
# Dashboard & Links
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def index(
request: Request,
q: str = "",
category: str = "",
manufacturer: str = "",
semantic: str = "",
db: Session = Depends(get_db),
):
user = current_user(request, db)
if not user:
return RedirectResponse("/login", status_code=303)
is_semantic = semantic in ("1", "on", "true")
links = query_links(db, user, q, category, manufacturer, is_semantic)
return templates.TemplateResponse(
"index.html",
{
"request": request,
"user": user,
"links": links,
"facets": facets(db, user),
"q": q,
"active_category": category,
"active_manufacturer": manufacturer,
"semantic": is_semantic,
"ai_enabled": config.AI_ENABLED,
},
)
@app.get("/search", response_class=HTMLResponse)
def search_links(
request: Request,
q: str = "",
category: str = "",
manufacturer: str = "",
semantic: str = "",
db: Session = Depends(get_db),
):
user = current_user(request, db)
if not user:
return Response(status_code=401, headers={"HX-Redirect": "/login"})
is_semantic = semantic in ("1", "on", "true")
links = query_links(db, user, q, category, manufacturer, is_semantic)
return templates.TemplateResponse(
"_links_list.html", {"request": request, "links": links}
)
@app.post("/links", response_class=HTMLResponse)
def add_link(
request: Request,
url: str = Form(...),
db: Session = Depends(get_db),
):
user = current_user(request, db)
if not user:
return Response(status_code=401, headers={"HX-Redirect": "/login"})
url = normalize_url(url)
if not url:
return Response(status_code=400)
title, text = scraper.fetch(url)
existing = facets(db, user)
meta = ai.categorize(
url,
title,
text,
[name for name, _ in existing["categories"]],
[name for name, _ in existing["manufacturers"]],
)
embed_source = " ".join(
[meta["title"], meta["summary"], " ".join(meta["tags"]), text[:1000]]
)
embedding = ai.embed(embed_source)
link = Link(
user_id=user.id,
url=url,
title=meta["title"],
summary=meta["summary"],
category=meta["category"],
manufacturer=meta["manufacturer"],
tags=json.dumps(meta["tags"], ensure_ascii=False),
embedding=json.dumps(embedding) if embedding else "",
status="ok" if text else "no_content",
)
db.add(link)
db.commit()
db.refresh(link)
return templates.TemplateResponse(
"_link_card.html", {"request": request, "link": link}
)
@app.delete("/links/{link_id}", response_class=HTMLResponse)
def delete_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 = db.get(Link, link_id)
if link and link.user_id == user.id:
db.delete(link)
db.commit()
return Response(status_code=200)

49
app/models.py Normal file
View File

@@ -0,0 +1,49 @@
import json
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
links: Mapped[list["Link"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Link(Base):
__tablename__ = "links"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
url: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
summary: Mapped[str] = mapped_column(Text, default="")
category: Mapped[str] = mapped_column(String(120), default="", index=True)
manufacturer: Mapped[str] = mapped_column(String(120), default="", index=True)
tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste
embedding: Mapped[str] = mapped_column(Text, default="") # JSON-Liste (Vektor)
status: Mapped[str] = mapped_column(String(20), default="ok")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
user: Mapped["User"] = relationship(back_populates="links")
@property
def tag_list(self) -> list[str]:
try:
return json.loads(self.tags)
except (ValueError, TypeError):
return []

59
app/scraper.py Normal file
View File

@@ -0,0 +1,59 @@
import re
import httpx
import trafilatura
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (compatible; LinkVault/1.0; +https://example.local)"
)
}
def _download(url: str) -> str:
try:
with httpx.Client(
follow_redirects=True, timeout=15.0, headers=HEADERS
) as client:
resp = client.get(url)
resp.raise_for_status()
ctype = resp.headers.get("content-type", "")
if "html" not in ctype and "xml" not in ctype and ctype:
return ""
return resp.text
except Exception:
return ""
def fetch(url: str) -> tuple[str, str]:
"""Lade die Seite und liefere (Titel, Textinhalt). Wirft nie eine Exception."""
html = _download(url)
if not html:
return "", ""
title, text = "", ""
try:
data = trafilatura.bare_extraction(
html,
include_comments=False,
include_tables=False,
with_metadata=True,
)
if isinstance(data, dict):
title = data.get("title") or ""
text = data.get("text") or ""
except Exception:
pass
if not text:
try:
text = trafilatura.extract(html) or ""
except Exception:
text = ""
if not title:
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
if m:
title = re.sub(r"\s+", " ", m.group(1)).strip()
return title.strip(), (text or "")[:6000]

36
app/search.py Normal file
View File

@@ -0,0 +1,36 @@
import json
import numpy as np
def cosine_rank(query_vec: list[float], links: list) -> list[tuple]:
"""Sortiere Links nach Kosinus-Ähnlichkeit zum Query-Vektor.
Liefert eine Liste aus (Link, Score), absteigend sortiert.
Links ohne Embedding werden ignoriert.
"""
if not query_vec:
return []
q = np.asarray(query_vec, dtype=float)
q_norm = np.linalg.norm(q)
if q_norm == 0:
return []
ranked: list[tuple] = []
for link in links:
if not link.embedding:
continue
try:
v = np.asarray(json.loads(link.embedding), dtype=float)
except (ValueError, TypeError):
continue
if v.shape != q.shape:
continue
denom = q_norm * np.linalg.norm(v)
if denom == 0:
continue
ranked.append((link, float(np.dot(q, v) / denom)))
ranked.sort(key=lambda item: item[1], reverse=True)
return ranked

15
app/security.py Normal file
View File

@@ -0,0 +1,15 @@
import bcrypt
def hash_password(password: str) -> str:
# bcrypt akzeptiert max. 72 Bytes.
pw = password.encode("utf-8")[:72]
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
try:
pw = password.encode("utf-8")[:72]
return bcrypt.checkpw(pw, password_hash.encode("utf-8"))
except (ValueError, TypeError):
return False

View File

@@ -0,0 +1,42 @@
<div class="panel link-card" id="link-{{ link.id }}">
<div style="display:flex; justify-content:space-between; gap:12px;">
<div style="min-width:0;">
<a href="{{ link.url }}" target="_blank" rel="noopener"
style="font-weight:600; font-size:1.05rem;">{{ link.title or link.url }}</a>
<div class="muted" style="font-size:.8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ link.url }}
</div>
</div>
<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>
{% if link.summary %}
<p style="margin:10px 0 8px;">{{ link.summary }}</p>
{% elif link.status == 'no_content' %}
<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;">
{% if link.category %}
<span style="background:var(--accent); color:white; padding:2px 9px; border-radius:99px;">
{{ link.category }}
</span>
{% endif %}
{% if link.manufacturer %}
<span style="background:#0e7490; color:#cffafe; padding:2px 9px; border-radius:99px;">
🏢 {{ link.manufacturer }}
</span>
{% endif %}
{% for tag in link.tag_list %}
<span style="background:var(--panel2); color:var(--muted); padding:2px 9px; border-radius:99px;">
#{{ tag }}
</span>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,9 @@
{% if links %}
{% for link in links %}
{% include "_link_card.html" %}
{% endfor %}
{% else %}
<div id="empty-state" class="panel" style="text-align:center; color:var(--muted); padding:40px;">
Noch keine Links gefunden. Füge oben deinen ersten Link hinzu.
</div>
{% endif %}

67
app/templates/base.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}LinkVault{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
:root {
--bg: #0f172a; --panel: #1e293b; --panel2: #273449;
--text: #e2e8f0; --muted: #94a3b8; --accent: #6366f1;
--accent-hover: #818cf8; --border: #334155; --danger: #ef4444;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
}
a { color: var(--accent-hover); text-decoration: none; }
a:hover { text-decoration: underline; }
header.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
}
header.topbar .brand { font-weight: 700; font-size: 1.2rem; }
header.topbar .brand span { color: var(--accent-hover); }
.user-info { color: var(--muted); font-size: .9rem; display: flex; gap: 12px; align-items: center; }
button, input[type=submit] {
background: var(--accent); color: white; border: none; border-radius: 8px;
padding: 9px 16px; font-size: .95rem; cursor: pointer; font-weight: 500;
}
button:hover { background: var(--accent-hover); }
button.ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
button.ghost:hover { background: var(--panel2); color: var(--text); }
button.danger { background: transparent; color: var(--muted); padding: 4px 8px; font-size: .8rem; }
button.danger:hover { color: var(--danger); background: transparent; }
input[type=text], input[type=email], input[type=password], input[type=url] {
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); }
.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; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.facet-group h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 16px 0 8px; }
.facet { display: flex; justify-content: space-between; padding: 5px 8px; border-radius: 6px; font-size: .9rem; }
.facet:hover { background: var(--panel2); text-decoration: none; }
.facet.active { background: var(--accent); color: white; }
.facet .count { color: var(--muted); font-size: .8rem; }
.facet.active .count { color: #e0e7ff; }
.auth-wrap { max-width: 380px; margin: 8vh auto; }
.auth-wrap .panel { padding: 28px; }
.auth-wrap h1 { margin-top: 0; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: .85rem; color: var(--muted); margin-bottom: 5px; }
.error { background: #7f1d1d; color: #fecaca; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: .9rem; }
.muted { color: var(--muted); }
.htmx-indicator { opacity: 0; transition: opacity .2s; }
.htmx-request .htmx-indicator { opacity: 1; }
.htmx-request.htmx-indicator { opacity: 1; }
</style>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

94
app/templates/index.html Normal file
View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Meine Links · LinkVault{% endblock %}
{% block body %}
<header class="topbar">
<div class="brand">Link<span>Vault</span></div>
<div class="user-info">
{% if not ai_enabled %}
<span title="Kein OPENAI_API_KEY gesetzt Auto-Kategorisierung deaktiviert">
⚠️ KI aus
</span>
{% endif %}
<span>{{ user.email }}</span>
<form method="post" action="/logout" style="margin:0;">
<button class="ghost" type="submit">Abmelden</button>
</form>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<div class="panel">
<a href="/" 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 }}"
class="facet {% if active_category == name %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span>
</a>
{% else %}
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
{% endfor %}
</div>
<div class="facet-group">
<h3>Hersteller / Quellen</h3>
{% for name, count in facets.manufacturers %}
<a href="/?manufacturer={{ name | urlencode }}"
class="facet {% if active_manufacturer == name %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span>
</a>
{% else %}
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
{% endfor %}
</div>
</div>
</aside>
<main class="main">
<div class="panel">
<form hx-post="/links" hx-target="#links" hx-swap="afterbegin"
hx-disabled-elt="button"
hx-on::after-request="if(event.detail.successful){this.reset(); var e=document.getElementById('empty-state'); if(e) e.remove();}">
<div style="display:flex; gap:8px;">
<input type="url" name="url" placeholder="Link einfügen, z.B. https://hersteller.de/produkt" required>
<button type="submit" style="white-space:nowrap;">
<span class="htmx-indicator"></span> Hinzufügen
</button>
</div>
<div class="muted" style="font-size:.8rem; margin-top:6px;">
Der Link wird automatisch geladen, zusammengefasst und einsortiert.
</div>
</form>
</div>
<div class="panel">
<form id="searchform" onsubmit="return false;" style="margin:0;">
<input type="hidden" name="category" value="{{ active_category }}">
<input type="hidden" name="manufacturer" value="{{ active_manufacturer }}">
<input type="text" name="q" value="{{ q }}"
placeholder="🔍 Suche in Titel, Zusammenfassung, Tags..."
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 %}
</form>
</div>
<div id="links">
{% include "_links_list.html" %}
</div>
</main>
</div>
{% endblock %}

25
app/templates/login.html Normal file
View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Anmelden · LinkVault{% endblock %}
{% block body %}
<div class="auth-wrap">
<div class="panel">
<h1>Link<span style="color:var(--accent-hover)">Vault</span></h1>
<p class="muted">Melde dich an, um deine Link-Sammlung zu verwalten.</p>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<div class="field">
<label>E-Mail</label>
<input type="email" name="email" required autofocus>
</div>
<div class="field">
<label>Passwort</label>
<input type="password" name="password" required>
</div>
<button type="submit" style="width:100%">Anmelden</button>
</form>
<p class="muted" style="margin-top:16px">
Noch kein Konto? <a href="/register">Jetzt registrieren</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Registrieren · LinkVault{% endblock %}
{% block body %}
<div class="auth-wrap">
<div class="panel">
<h1>Konto erstellen</h1>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post" action="/register">
<div class="field">
<label>E-Mail</label>
<input type="email" name="email" required autofocus>
</div>
<div class="field">
<label>Passwort (min. 6 Zeichen)</label>
<input type="password" name="password" required minlength="6">
</div>
<button type="submit" style="width:100%">Registrieren</button>
</form>
<p class="muted" style="margin-top:16px">
Bereits ein Konto? <a href="/login">Zur Anmeldung</a>
</p>
</div>
</div>
{% endblock %}