Add a Prompts library alongside Links, bump to v2.0.0
New /prompts section extends the existing database (new Prompt model,
same SQLite file) and mirrors the Links experience: add a prompt, get
it auto-categorized and tagged by AI, filter by category, full-text
search, sort, edit, or have the AI re-categorize it. A header nav
switch ("Links" / "Prompts") toggles between the two collections; the
shared topbar/menu markup was factored into _topbar.html and
_topbar_actions.html so both pages (and settings) stay in sync.
Each prompt card has a dedicated copy-to-clipboard icon next to
edit/delete, so a stored prompt can be reused immediately. Copying
uses the raw markdown source (not the rendered HTML) so structure
survives when pasted into another AI tool. The copy handler tries the
async Clipboard API first and falls back to a hidden-textarea +
execCommand('copy') for plain-http/non-secure contexts, with clear
success/failure icon feedback either way.
Prompt content supports Markdown and is rendered server-side
(app/mdrender.py) for the card preview. Since this is user-supplied
HTML-adjacent content, rendering goes through two defenses: the raw
text is escaped (only '<' and '&', not '>', so blockquotes keep
working) before conversion so no raw tag can survive, and the
resulting HTML is passed through bleach with a tag/attribute/protocol
allowlist so Markdown-generated links can't carry a javascript: URL.
Verified against raw <script>, <img onerror>, and javascript: link
payloads. The edit form always shows the raw Markdown source, never
the rendered HTML.
Also bumps the default APP_VERSION (shown in the footer) from 1.0.0
to 2.0.0 to mark this feature addition.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,5 +14,5 @@ EMBEDDING_MODEL=text-embedding-3-small
|
|||||||
# Datenbank (Standard: lokale SQLite-Datei)
|
# Datenbank (Standard: lokale SQLite-Datei)
|
||||||
DATABASE_URL=sqlite:///./linkvault.db
|
DATABASE_URL=sqlite:///./linkvault.db
|
||||||
|
|
||||||
# Versionsangabe für die Fußzeile (Standard: 1.0.0)
|
# Versionsangabe für die Fußzeile (Standard: 2.0.0)
|
||||||
APP_VERSION=1.0.0
|
APP_VERSION=2.0.0
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -1,24 +1,40 @@
|
|||||||
# LinkVault
|
# LinkVault
|
||||||
|
|
||||||
KI-gestützte Link-Sammlung: Links einfügen → automatisch laden, **zusammenfassen**,
|
KI-gestützte Sammlung für **Links** und **Prompts**: eingeben → automatisch
|
||||||
in **Kategorien / Hersteller** einsortieren und schnell **wiederfinden**.
|
**kategorisieren**, in **Kategorien / Hersteller / Tags** einsortieren und
|
||||||
|
schnell **wiederfinden**. Beide Sammlungen laufen in derselben App und
|
||||||
|
Datenbank, per Menü umschaltbar.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- 🔗 **Link hinzufügen** – Seite wird geladen und der Textinhalt extrahiert.
|
- 🔗 **Link hinzufügen** – Seite wird geladen und der Textinhalt extrahiert.
|
||||||
- 🤖 **Automatische KI-Analyse** (OpenAI) – Titel, Zusammenfassung, Kategorie,
|
- ✨ **Prompt hinzufügen** – eigener Bereich für KI-Prompts, per Kopf-Menü
|
||||||
Hersteller/Quelle und Tags werden erzeugt. Bestehende Kategorien werden
|
zwischen „Links" und „Prompts" umschaltbar. Prompts werden ebenfalls
|
||||||
wiederverwendet, damit die Sammlung konsistent bleibt.
|
automatisch kategorisiert und getaggt, lassen sich per Kategorie/Volltext
|
||||||
- ✎ **Bearbeiten** – Titel, Zusammenfassung, Kategorie, Hersteller und Tags
|
durchsuchen und mit einem Klick in die Zwischenablage kopieren. Der
|
||||||
lassen sich jederzeit manuell anpassen. Alternativ kann die KI den Link
|
Prompt-Text unterstützt **Markdown** (Überschriften, Listen, Fett/Kursiv,
|
||||||
**neu beschreiben**: die Seite wird erneut geladen und alle Felder werden
|
Codeblöcke, Zitate) – in der Karten-Vorschau wird das gerendert, beim
|
||||||
neu erzeugt.
|
Kopieren landet der rohe Markdown-Text (für Struktur in der Ziel-KI) in
|
||||||
- 🗂️ **Filtern** nach Kategorie und Hersteller über die Seitenleiste.
|
der Zwischenablage.
|
||||||
- 🔍 **Suche** – klassische Textsuche und optionale **semantische KI-Suche**
|
- 🤖 **Automatische KI-Analyse** (OpenAI) – Titel, Zusammenfassung/Kategorie
|
||||||
(findet auch sinnverwandte Treffer via Embeddings).
|
und Tags werden erzeugt. Bestehende Kategorien werden wiederverwendet,
|
||||||
|
damit die Sammlung konsistent bleibt.
|
||||||
|
- ✎ **Bearbeiten** – alle Felder lassen sich jederzeit manuell anpassen.
|
||||||
|
Alternativ kann die KI Links/Prompts **neu beschreiben bzw. neu
|
||||||
|
kategorisieren** lassen.
|
||||||
|
- 🗂️ **Filtern** nach Kategorie (Links zusätzlich nach Hersteller) über die
|
||||||
|
Seitenleiste.
|
||||||
|
- 🔍 **Suche** – klassische Textsuche, bei Links zusätzlich optionale
|
||||||
|
**semantische KI-Suche** (findet auch sinnverwandte Treffer via
|
||||||
|
Embeddings).
|
||||||
- ↕️ **Sortieren** nach Datum (neueste/älteste zuerst) oder Name (A–Z / Z–A).
|
- ↕️ **Sortieren** nach Datum (neueste/älteste zuerst) oder Name (A–Z / Z–A).
|
||||||
- 🕓 **Hinzufügedatum** wird pro Link angezeigt.
|
- 🔖 **Merkliste** – Links lassen sich zur späteren Durchsicht markieren.
|
||||||
- 👥 **Mehrbenutzer** – Registrierung/Login, jeder Nutzer sieht nur seine Links.
|
- 🕓 **Hinzufügedatum** wird pro Eintrag angezeigt.
|
||||||
|
- 📤 **Export/Import** (Einstellungen) – Links als CSV oder SQL-Dump
|
||||||
|
exportieren, SQL-Dump auch wieder importieren.
|
||||||
|
- 🌗 **Dark/Light-Modus** über den Umschalter im Kopfbereich.
|
||||||
|
- 👥 **Mehrbenutzer** – Registrierung/Login, jeder Nutzer sieht nur seine
|
||||||
|
eigenen Links und Prompts.
|
||||||
|
|
||||||
Die Fußzeile zeigt Autor, Version und den Hostnamen des Servers. Die Version
|
Die Fußzeile zeigt Autor, Version und den Hostnamen des Servers. Die Version
|
||||||
lässt sich über `APP_VERSION` in der `.env` setzen.
|
lässt sich über `APP_VERSION` in der `.env` setzen.
|
||||||
@@ -59,14 +75,16 @@ uvicorn app.main:app --reload --port 8000
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
main.py FastAPI-App, Routen, Auth-Sessions
|
main.py FastAPI-App, Routen (Links & Prompts), Auth-Sessions
|
||||||
config.py Konfiguration aus .env
|
config.py Konfiguration aus .env
|
||||||
database.py SQLAlchemy Engine/Session
|
database.py SQLAlchemy Engine/Session
|
||||||
models.py User- und Link-Modelle
|
models.py User-, Link- und Prompt-Modelle
|
||||||
security.py Passwort-Hashing (bcrypt)
|
security.py Passwort-Hashing (bcrypt)
|
||||||
scraper.py Seiteninhalt laden & extrahieren
|
scraper.py Seiteninhalt laden & extrahieren (nur Links)
|
||||||
ai.py OpenAI: Kategorisierung, Zusammenfassung, Embeddings
|
ai.py OpenAI: Kategorisierung (Links & Prompts), Embeddings
|
||||||
search.py Semantische Suche (Kosinus-Ähnlichkeit)
|
search.py Semantische Suche (Kosinus-Ähnlichkeit, nur Links)
|
||||||
|
backup.py CSV-/SQL-Export und sicherer SQL-Import (nur Links)
|
||||||
|
mdrender.py Sicheres Markdown-Rendering für Prompt-Inhalte
|
||||||
templates/ Jinja2 + HTMX Oberfläche
|
templates/ Jinja2 + HTMX Oberfläche
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
56
app/ai.py
56
app/ai.py
@@ -89,6 +89,62 @@ Gib ein JSON-Objekt mit genau diesen Feldern zurück:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_SYSTEM_PROMPT = (
|
||||||
|
"Du bist ein Assistent, der eine Sammlung von KI-Prompts organisiert. "
|
||||||
|
"Analysiere den gegebenen Prompt-Text und liefere strukturierte Metadaten "
|
||||||
|
"als JSON. Verwende möglichst eine der bereits existierenden Kategorien, "
|
||||||
|
"wenn sie passt; sonst erstelle eine kurze, allgemeingültige neue "
|
||||||
|
"Bezeichnung (z.B. Schreiben, Code, Bild, Analyse, Sonstiges). Antworte "
|
||||||
|
"ausschließlich auf Deutsch."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def categorize_prompt(content: str, existing_categories: list[str]) -> dict:
|
||||||
|
"""Erzeuge Titel, Kategorie und Tags für einen gespeicherten Prompt."""
|
||||||
|
fallback_title = (content.strip().splitlines() or [""])[0].strip()[:80] or "Prompt"
|
||||||
|
fallback = {"title": fallback_title, "category": "Sonstiges", "tags": []}
|
||||||
|
|
||||||
|
client = _client_or_none()
|
||||||
|
if client is None:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
user_prompt = f"""Prompt-Text:
|
||||||
|
{content[:4000]}
|
||||||
|
|
||||||
|
Bereits vorhandene Kategorien: {", ".join(existing_categories) or "(noch keine)"}
|
||||||
|
|
||||||
|
Gib ein JSON-Objekt mit genau diesen Feldern zurück:
|
||||||
|
- "title": kurzer, aussagekräftiger Titel (max. 80 Zeichen)
|
||||||
|
- "category": eine passende Kategorie
|
||||||
|
- "tags": Array aus 3-6 kurzen Schlagwörtern
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=config.OPENAI_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": PROMPT_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 fallback_title)).strip()[:120] or fallback_title,
|
||||||
|
"category": (str(data.get("category") or "Sonstiges")).strip()[:120]
|
||||||
|
or "Sonstiges",
|
||||||
|
"tags": [str(t).strip()[:40] for t in tags if str(t).strip()][:8],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def embed(text: str) -> list[float]:
|
def embed(text: str) -> list[float]:
|
||||||
"""Erzeuge einen Embedding-Vektor für die semantische Suche."""
|
"""Erzeuge einen Embedding-Vektor für die semantische Suche."""
|
||||||
client = _client_or_none()
|
client = _client_or_none()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
APP_AUTHOR = "Erik Thiele"
|
APP_AUTHOR = "Erik Thiele"
|
||||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
APP_VERSION = os.getenv("APP_VERSION", "2.0.0")
|
||||||
APP_HOST = socket.gethostname()
|
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")
|
||||||
|
|||||||
276
app/main.py
276
app/main.py
@@ -6,13 +6,14 @@ from urllib.parse import quote, urlparse
|
|||||||
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
|
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from markupsafe import Markup
|
||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from . import ai, backup, config, scraper, search
|
from . import ai, backup, config, mdrender, scraper, search
|
||||||
from .database import Base, engine, get_db
|
from .database import Base, engine, get_db
|
||||||
from .models import Link, User, utcnow
|
from .models import Link, Prompt, User, utcnow
|
||||||
from .security import hash_password, verify_password
|
from .security import hash_password, verify_password
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -53,14 +54,24 @@ def domain_of(url: str) -> str:
|
|||||||
return host[4:] if host.startswith("www.") else host
|
return host[4:] if host.startswith("www.") else host
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_hue(value: str) -> int:
|
||||||
|
return sum(ord(c) for c in (value or "")) % 360
|
||||||
|
|
||||||
|
|
||||||
def avatar_hue(url: str) -> int:
|
def avatar_hue(url: str) -> int:
|
||||||
"""Stabile Farbe pro Domain, damit Karten wiedererkennbar sind."""
|
"""Stabile Farbe pro Domain, damit Link-Karten wiedererkennbar sind."""
|
||||||
return sum(ord(c) for c in domain_of(url)) % 360
|
return _hash_hue(domain_of(url))
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(text: str) -> Markup:
|
||||||
|
return Markup(mdrender.render(text))
|
||||||
|
|
||||||
|
|
||||||
templates.env.filters["datum"] = format_datum
|
templates.env.filters["datum"] = format_datum
|
||||||
templates.env.filters["domain"] = domain_of
|
templates.env.filters["domain"] = domain_of
|
||||||
templates.env.filters["hue"] = avatar_hue
|
templates.env.filters["hue"] = avatar_hue
|
||||||
|
templates.env.filters["texthue"] = _hash_hue
|
||||||
|
templates.env.filters["markdown"] = render_markdown
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -170,6 +181,51 @@ def query_links(
|
|||||||
return sort_links(matches, sort)
|
return sort_links(matches, sort)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_facets(db: Session, user: User) -> dict:
|
||||||
|
cat_rows = db.execute(
|
||||||
|
select(Prompt.category, func.count())
|
||||||
|
.where(Prompt.user_id == user.id, Prompt.category != "")
|
||||||
|
.group_by(Prompt.category)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
).all()
|
||||||
|
return {"categories": [(name, count) for name, count in cat_rows]}
|
||||||
|
|
||||||
|
|
||||||
|
def sort_prompts(prompts: list[Prompt], sort: str) -> list[Prompt]:
|
||||||
|
if sort == "date_asc":
|
||||||
|
return sorted(prompts, key=lambda p: p.created_at)
|
||||||
|
if sort == "name_asc":
|
||||||
|
return sorted(prompts, key=lambda p: (p.title or "").lower())
|
||||||
|
if sort == "name_desc":
|
||||||
|
return sorted(prompts, key=lambda p: (p.title or "").lower(), reverse=True)
|
||||||
|
return sorted(prompts, key=lambda p: p.created_at, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def query_prompts(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
q: str = "",
|
||||||
|
category: str = "",
|
||||||
|
sort: str = DEFAULT_SORT,
|
||||||
|
) -> list[Prompt]:
|
||||||
|
stmt = select(Prompt).where(Prompt.user_id == user.id)
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(Prompt.category == category)
|
||||||
|
prompts = list(db.scalars(stmt.order_by(Prompt.created_at.desc())).all())
|
||||||
|
|
||||||
|
q = (q or "").strip()
|
||||||
|
if not q:
|
||||||
|
return sort_prompts(prompts, sort)
|
||||||
|
|
||||||
|
ql = q.lower()
|
||||||
|
matches = [
|
||||||
|
p
|
||||||
|
for p in prompts
|
||||||
|
if ql in " ".join([p.title, p.content, p.tags, p.category]).lower()
|
||||||
|
]
|
||||||
|
return sort_prompts(matches, sort)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Authentifizierung
|
# Authentifizierung
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -531,6 +587,217 @@ def reanalyze_link(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prompts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@app.get("/prompts", response_class=HTMLResponse)
|
||||||
|
def prompts_index(
|
||||||
|
request: Request,
|
||||||
|
q: str = "",
|
||||||
|
category: 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
|
||||||
|
prompts = query_prompts(db, user, q, category, sort)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"prompts.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"prompts": prompts,
|
||||||
|
"facets": prompt_facets(db, user),
|
||||||
|
"q": q,
|
||||||
|
"active_category": category,
|
||||||
|
"ai_enabled": config.AI_ENABLED,
|
||||||
|
"sort": sort,
|
||||||
|
"sort_options": SORT_OPTIONS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/prompts/search", response_class=HTMLResponse)
|
||||||
|
def search_prompts(
|
||||||
|
request: Request,
|
||||||
|
q: str = "",
|
||||||
|
category: 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
|
||||||
|
prompts = query_prompts(db, user, q, category, sort)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_prompts_list.html", {"request": request, "prompts": prompts}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/prompts", response_class=HTMLResponse)
|
||||||
|
def add_prompt(
|
||||||
|
request: Request,
|
||||||
|
content: str = Form(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = current_user(request, db)
|
||||||
|
if not user:
|
||||||
|
return Response(status_code=401, headers={"HX-Redirect": "/login"})
|
||||||
|
|
||||||
|
content = content.strip()
|
||||||
|
if not content:
|
||||||
|
return Response(status_code=400)
|
||||||
|
|
||||||
|
existing = prompt_facets(db, user)
|
||||||
|
meta = ai.categorize_prompt(content, [name for name, _ in existing["categories"]])
|
||||||
|
|
||||||
|
prompt = Prompt(
|
||||||
|
user_id=user.id,
|
||||||
|
title=meta["title"],
|
||||||
|
content=content,
|
||||||
|
category=meta["category"],
|
||||||
|
tags=json.dumps(meta["tags"], ensure_ascii=False),
|
||||||
|
)
|
||||||
|
db.add(prompt)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prompt)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_prompt_card.html", {"request": request, "prompt": prompt}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/prompts/{prompt_id}", response_class=HTMLResponse)
|
||||||
|
def delete_prompt(
|
||||||
|
prompt_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"})
|
||||||
|
prompt = db.get(Prompt, prompt_id)
|
||||||
|
if prompt and prompt.user_id == user.id:
|
||||||
|
db.delete(prompt)
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_owned_prompt(db: Session, user: User, prompt_id: int) -> Prompt | None:
|
||||||
|
prompt = db.get(Prompt, prompt_id)
|
||||||
|
if prompt and prompt.user_id == user.id:
|
||||||
|
return prompt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_edit_context(request: Request, db: Session, user: User, prompt: Prompt) -> dict:
|
||||||
|
existing = prompt_facets(db, user)
|
||||||
|
return {
|
||||||
|
"request": request,
|
||||||
|
"prompt": prompt,
|
||||||
|
"ai_enabled": config.AI_ENABLED,
|
||||||
|
"all_categories": [name for name, _ in existing["categories"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/prompts/{prompt_id}/edit", response_class=HTMLResponse)
|
||||||
|
def edit_prompt_form(
|
||||||
|
prompt_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"})
|
||||||
|
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||||
|
if not prompt:
|
||||||
|
return Response(status_code=404)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_prompt_card_edit.html", _prompt_edit_context(request, db, user, prompt)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/prompts/{prompt_id}/view", response_class=HTMLResponse)
|
||||||
|
def view_prompt_card(
|
||||||
|
prompt_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"})
|
||||||
|
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||||
|
if not prompt:
|
||||||
|
return Response(status_code=404)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_prompt_card.html", {"request": request, "prompt": prompt}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/prompts/{prompt_id}", response_class=HTMLResponse)
|
||||||
|
def update_prompt(
|
||||||
|
prompt_id: int,
|
||||||
|
request: Request,
|
||||||
|
title: str = Form(""),
|
||||||
|
content: str = Form(""),
|
||||||
|
category: 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"})
|
||||||
|
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||||
|
if not prompt:
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
prompt.title = title.strip()[:120] or "Prompt"
|
||||||
|
prompt.content = content.strip()
|
||||||
|
prompt.category = category.strip()[:120]
|
||||||
|
prompt.tags = json.dumps(_parse_tags(tags), ensure_ascii=False)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prompt)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_prompt_card.html", {"request": request, "prompt": prompt}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/prompts/{prompt_id}/reanalyze", response_class=HTMLResponse)
|
||||||
|
def reanalyze_prompt(
|
||||||
|
prompt_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"})
|
||||||
|
prompt = _get_owned_prompt(db, user, prompt_id)
|
||||||
|
if not prompt:
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
existing = prompt_facets(db, user)
|
||||||
|
meta = ai.categorize_prompt(
|
||||||
|
prompt.content, [name for name, _ in existing["categories"]]
|
||||||
|
)
|
||||||
|
prompt.title = meta["title"]
|
||||||
|
prompt.category = meta["category"]
|
||||||
|
prompt.tags = json.dumps(meta["tags"], ensure_ascii=False)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prompt)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"_prompt_card_edit.html", _prompt_edit_context(request, db, user, prompt)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Einstellungen: Export / Import
|
# Einstellungen: Export / Import
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -551,6 +818,7 @@ def settings_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
"link_count": link_count or 0,
|
"link_count": link_count or 0,
|
||||||
|
"ai_enabled": config.AI_ENABLED,
|
||||||
"imported": request.query_params.get("imported"),
|
"imported": request.query_params.get("imported"),
|
||||||
"import_error": request.query_params.get("import_error"),
|
"import_error": request.query_params.get("import_error"),
|
||||||
},
|
},
|
||||||
|
|||||||
53
app/mdrender.py
Normal file
53
app/mdrender.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Sicheres Markdown-Rendering für Prompt-Inhalte.
|
||||||
|
|
||||||
|
Nutzer geben rohes Markdown ein (**fett**, Listen, Codeblöcke ...), das in
|
||||||
|
der Karten-Vorschau als HTML dargestellt werden soll. Zwei Schutzschichten
|
||||||
|
gegen Stored-XSS:
|
||||||
|
|
||||||
|
1. Der Eingabetext wird vor der Konvertierung HTML-escaped. Echte
|
||||||
|
Markdown-Syntax verwendet nur ASCII-Satzzeichen (*, #, -, `, [] ...),
|
||||||
|
keine spitzen Klammern – jeder Versuch, rohes HTML/<script>
|
||||||
|
einzuschleusen, landet dadurch als reiner Text statt als Markup.
|
||||||
|
2. Das von Markdown selbst erzeugte HTML (insbesondere <a href="...">
|
||||||
|
aus [text](url)-Links) wird zusätzlich mit bleach bereinigt, da ein
|
||||||
|
Link mit z.B. "javascript:"-Schema sonst trotz Schritt 1 möglich wäre.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
import markdown as _markdown
|
||||||
|
|
||||||
|
_converter = _markdown.Markdown(
|
||||||
|
extensions=["extra", "sane_lists", "nl2br"], output_format="html"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_tags(text: str) -> str:
|
||||||
|
"""Nur '&' und '<' escapen (nicht '>' – das braucht Markdown für Zitate).
|
||||||
|
|
||||||
|
Eine HTML-Tag-Injection benötigt immer '<...>'; wird bereits '<' zu
|
||||||
|
'<', kann kein Tag mehr entstehen, unabhängig davon, ob ein
|
||||||
|
einzelnes '>' im Text steht.
|
||||||
|
"""
|
||||||
|
return text.replace("&", "&").replace("<", "<")
|
||||||
|
|
||||||
|
_ALLOWED_TAGS = [
|
||||||
|
"p", "br", "hr", "strong", "em", "del", "code", "pre",
|
||||||
|
"ul", "ol", "li", "blockquote",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"a", "table", "thead", "tbody", "tr", "th", "td",
|
||||||
|
]
|
||||||
|
_ALLOWED_ATTRS = {"a": ["href", "title"]}
|
||||||
|
_ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
||||||
|
|
||||||
|
|
||||||
|
def render(text: str) -> str:
|
||||||
|
_converter.reset()
|
||||||
|
escaped = _escape_tags(text or "")
|
||||||
|
raw_html = _converter.convert(escaped)
|
||||||
|
return bleach.clean(
|
||||||
|
raw_html,
|
||||||
|
tags=_ALLOWED_TAGS,
|
||||||
|
attributes=_ALLOWED_ATTRS,
|
||||||
|
protocols=_ALLOWED_PROTOCOLS,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
@@ -22,6 +22,9 @@ class User(Base):
|
|||||||
links: Mapped[list["Link"]] = relationship(
|
links: Mapped[list["Link"]] = relationship(
|
||||||
back_populates="user", cascade="all, delete-orphan"
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
prompts: Mapped[list["Prompt"]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Link(Base):
|
class Link(Base):
|
||||||
@@ -48,3 +51,24 @@ class Link(Base):
|
|||||||
return json.loads(self.tags)
|
return json.loads(self.tags)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class Prompt(Base):
|
||||||
|
__tablename__ = "prompts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
category: Mapped[str] = mapped_column(String(120), default="", index=True)
|
||||||
|
tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="prompts")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag_list(self) -> list[str]:
|
||||||
|
try:
|
||||||
|
return json.loads(self.tags)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
{{ base('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>', size) }}
|
{{ base('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>', size) }}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro copy(size=16) -%}
|
||||||
|
{{ base('<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', size) }}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro tag(size=16) -%}
|
{% macro tag(size=16) -%}
|
||||||
{{ base('<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r="1.5"/>', size) }}
|
{{ base('<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r="1.5"/>', size) }}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
51
app/templates/_prompt_card.html
Normal file
51
app/templates/_prompt_card.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% import "_icons.html" as icons %}
|
||||||
|
<div class="panel link-card" id="prompt-{{ prompt.id }}">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-avatar" style="--h: {{ prompt.category | texthue }};">
|
||||||
|
{{ (prompt.category[:1] | upper) or '?' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="card-badges">
|
||||||
|
{% if prompt.category %}
|
||||||
|
<span class="badge badge-cat">{{ prompt.category }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button type="button" class="ghost icon-btn"
|
||||||
|
title="In die Zwischenablage kopieren"
|
||||||
|
onclick="copyPromptContent(this)" data-content="{{ prompt.content }}">{{ icons.copy() }}</button>
|
||||||
|
<button class="ghost icon-btn"
|
||||||
|
title="Bearbeiten"
|
||||||
|
hx-get="/prompts/{{ prompt.id }}/edit"
|
||||||
|
hx-target="#prompt-{{ prompt.id }}"
|
||||||
|
hx-swap="outerHTML">{{ icons.edit() }}</button>
|
||||||
|
<button class="danger icon-btn"
|
||||||
|
title="Löschen"
|
||||||
|
hx-delete="/prompts/{{ prompt.id }}"
|
||||||
|
hx-target="#prompt-{{ prompt.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Diesen Prompt wirklich löschen?">{{ icons.trash() }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-title">{{ prompt.title or "Prompt" }}</div>
|
||||||
|
|
||||||
|
<div class="card-summary md-preview">{{ prompt.content | markdown }}</div>
|
||||||
|
|
||||||
|
<div class="card-spacer"></div>
|
||||||
|
|
||||||
|
{% if prompt.tag_list %}
|
||||||
|
<div class="card-tags">
|
||||||
|
{% for tag in prompt.tag_list %}
|
||||||
|
<span class="badge badge-tag">#{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;">
|
||||||
|
<span title="hinzugefügt am {{ prompt.created_at | datum }}">
|
||||||
|
{{ icons.clock(size=13) }} {{ prompt.created_at | datum }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
55
app/templates/_prompt_card_edit.html
Normal file
55
app/templates/_prompt_card_edit.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{% import "_icons.html" as icons %}
|
||||||
|
<div class="panel link-card" id="prompt-{{ prompt.id }}">
|
||||||
|
<form hx-put="/prompts/{{ prompt.id }}" hx-target="#prompt-{{ prompt.id }}" hx-swap="outerHTML"
|
||||||
|
hx-disabled-elt="button">
|
||||||
|
<div class="muted" style="font-size:.75rem; margin:0 0 10px;">
|
||||||
|
{{ icons.clock(size=13) }} hinzugefügt am {{ prompt.created_at | datum }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Titel</label>
|
||||||
|
<input type="text" name="title" value="{{ prompt.title }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Prompt-Text</label>
|
||||||
|
<textarea name="content" rows="6" 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;">{{ prompt.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Kategorie</label>
|
||||||
|
<input type="text" name="category" value="{{ prompt.category }}"
|
||||||
|
list="prompt-cats-{{ prompt.id }}" placeholder="wählen oder neu eingeben"
|
||||||
|
autocomplete="off">
|
||||||
|
<datalist id="prompt-cats-{{ prompt.id }}">
|
||||||
|
{% for name in all_categories %}<option value="{{ name }}"></option>{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Tags (mit Komma getrennt)</label>
|
||||||
|
<input type="text" name="tags" value="{{ prompt.tag_list | join(', ') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; gap:8px; margin-top:4px; flex-wrap:wrap;">
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" class="ghost"
|
||||||
|
hx-get="/prompts/{{ prompt.id }}/view"
|
||||||
|
hx-target="#prompt-{{ prompt.id }}"
|
||||||
|
hx-swap="outerHTML">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
{% if ai_enabled %}
|
||||||
|
<button type="button" class="ghost"
|
||||||
|
hx-post="/prompts/{{ prompt.id }}/reanalyze"
|
||||||
|
hx-target="#prompt-{{ prompt.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
hx-confirm="Prompt erneut von der KI kategorisieren lassen? Bestehende Angaben werden dabei ersetzt.">
|
||||||
|
<span class="htmx-indicator icon-spin">{{ icons.spinner(size=14) }}</span>
|
||||||
|
{{ icons.sparkles(size=14) }} KI neu kategorisieren lassen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
9
app/templates/_prompts_list.html
Normal file
9
app/templates/_prompts_list.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% if prompts %}
|
||||||
|
{% for prompt in prompts %}
|
||||||
|
{% include "_prompt_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div id="empty-state" class="panel" style="text-align:center; color:var(--muted); padding:40px;">
|
||||||
|
Noch keine Prompts gefunden. Füge oben deinen ersten Prompt hinzu.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
17
app/templates/_topbar.html
Normal file
17
app/templates/_topbar.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% import "_icons.html" as icons %}
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="/" class="brand" style="text-decoration:none;">Link<span>Vault</span></a>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<nav class="mode-switch">
|
||||||
|
<a href="/" class="mode-tab {% if request.url.path == '/' %}active{% endif %}">
|
||||||
|
{{ icons.grid(size=14) }} Links
|
||||||
|
</a>
|
||||||
|
<a href="/prompts" class="mode-tab {% if request.url.path.startswith('/prompts') %}active{% endif %}">
|
||||||
|
{{ icons.sparkles(size=14) }} Prompts
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="user-info">
|
||||||
|
{% include "_topbar_actions.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
32
app/templates/_topbar_actions.html
Normal file
32
app/templates/_topbar_actions.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% import "_icons.html" as icons %}
|
||||||
|
{% if not ai_enabled %}
|
||||||
|
<span title="Kein OPENAI_API_KEY gesetzt – Auto-Kategorisierung deaktiviert" class="icon-warn">
|
||||||
|
{{ icons.alert(size=14) }} KI aus
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button id="theme-toggle" type="button" class="ghost icon-btn" title="Farbschema wechseln">
|
||||||
|
<span class="theme-icon-dark">{{ icons.moon() }}</span>
|
||||||
|
<span class="theme-icon-light">{{ icons.sun() }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="user-menu">
|
||||||
|
<button type="button" class="ghost icon-btn" title="{{ user.email }}">{{ icons.user() }}</button>
|
||||||
|
<button id="menu-toggle" type="button" class="ghost icon-btn" title="Menü"
|
||||||
|
aria-haspopup="true" aria-expanded="false">{{ icons.menu() }}</button>
|
||||||
|
|
||||||
|
<div id="user-dropdown" class="user-dropdown" hidden>
|
||||||
|
<div class="dropdown-email">{{ user.email }}</div>
|
||||||
|
<div class="dropdown-sep"></div>
|
||||||
|
<button type="button" class="dropdown-item" disabled>
|
||||||
|
{{ icons.tag(size=15) }} Tags verwalten <span class="soon">bald</span>
|
||||||
|
</button>
|
||||||
|
<a href="/settings" class="dropdown-item" style="text-decoration:none;">
|
||||||
|
{{ icons.sliders(size=15) }} Einstellungen
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-sep"></div>
|
||||||
|
<form method="post" action="/logout" style="margin:0;">
|
||||||
|
<button type="submit" class="dropdown-item">{{ icons.log_out(size=15) }} Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -45,7 +45,15 @@
|
|||||||
}
|
}
|
||||||
header.topbar .brand { font-weight: 700; font-size: 1.2rem; }
|
header.topbar .brand { font-weight: 700; font-size: 1.2rem; }
|
||||||
header.topbar .brand span { color: var(--accent-hover); }
|
header.topbar .brand span { color: var(--accent-hover); }
|
||||||
|
.topbar-right { display: flex; align-items: center; gap: 16px; }
|
||||||
.user-info { color: var(--muted); font-size: .9rem; display: flex; gap: 12px; align-items: center; }
|
.user-info { color: var(--muted); font-size: .9rem; display: flex; gap: 12px; align-items: center; }
|
||||||
|
.mode-switch { display: flex; gap: 2px; background: var(--panel2); border-radius: 8px; padding: 2px; }
|
||||||
|
.mode-tab {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||||
|
border-radius: 6px; font-size: .85rem; color: var(--muted); text-decoration: none;
|
||||||
|
}
|
||||||
|
.mode-tab:hover { color: var(--text); text-decoration: none; }
|
||||||
|
.mode-tab.active { background: var(--accent); color: #fff; }
|
||||||
button, input[type=submit] {
|
button, input[type=submit] {
|
||||||
background: var(--accent); color: white; border: none; border-radius: 8px;
|
background: var(--accent); color: white; border: none; border-radius: 8px;
|
||||||
padding: 9px 16px; font-size: .95rem; cursor: pointer; font-weight: 500;
|
padding: 9px 16px; font-size: .95rem; cursor: pointer; font-weight: 500;
|
||||||
@@ -82,9 +90,9 @@
|
|||||||
/* Linkliste: bis zu drei Karten pro Zeile, je nach Fensterbreite.
|
/* Linkliste: bis zu drei Karten pro Zeile, je nach Fensterbreite.
|
||||||
Karten einer Zeile sind gleich hoch; die Zusammenfassung wird
|
Karten einer Zeile sind gleich hoch; die Zusammenfassung wird
|
||||||
dafür auf drei Zeilen gekürzt. */
|
dafür auf drei Zeilen gekürzt. */
|
||||||
#links { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
#links, #prompts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||||
#links > .panel { margin-bottom: 0; }
|
#links > .panel, #prompts > .panel { margin-bottom: 0; }
|
||||||
#links > #empty-state { grid-column: 1 / -1; }
|
#links > #empty-state, #prompts > #empty-state { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.link-card { display: flex; flex-direction: column; }
|
.link-card { display: flex; flex-direction: column; }
|
||||||
.link-card.needs-review { border-color: #ca8a04; }
|
.link-card.needs-review { border-color: #ca8a04; }
|
||||||
@@ -121,6 +129,38 @@
|
|||||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
||||||
overflow: hidden; height: calc(3 * 1.5 * .875rem); flex: none;
|
overflow: hidden; height: calc(3 * 1.5 * .875rem); flex: none;
|
||||||
}
|
}
|
||||||
|
/* Markdown-Vorschau (Prompts): line-clamp funktioniert nicht zuverlässig
|
||||||
|
über mehrere Block-Elemente (Überschriften, Listen, Codeblöcke)
|
||||||
|
hinweg, daher stattdessen feste Höhe + Fade-Ausblendung. */
|
||||||
|
.card-summary.md-preview {
|
||||||
|
display: block; -webkit-line-clamp: unset; position: relative;
|
||||||
|
height: calc(4 * 1.5 * .875rem);
|
||||||
|
}
|
||||||
|
.md-preview::after {
|
||||||
|
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1.6em;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--panel));
|
||||||
|
}
|
||||||
|
.md-preview > :first-child { margin-top: 0; }
|
||||||
|
.md-preview > :last-child { margin-bottom: 0; }
|
||||||
|
.md-preview h1, .md-preview h2, .md-preview h3,
|
||||||
|
.md-preview h4, .md-preview h5, .md-preview h6 {
|
||||||
|
font-size: .95rem; margin: 0 0 4px; color: var(--text);
|
||||||
|
}
|
||||||
|
.md-preview p { margin: 0 0 6px; }
|
||||||
|
.md-preview ul, .md-preview ol { margin: 0 0 6px; padding-left: 1.2em; }
|
||||||
|
.md-preview li { margin-bottom: 2px; }
|
||||||
|
.md-preview code {
|
||||||
|
background: var(--panel2); padding: 1px 5px; border-radius: 4px; font-size: .85em;
|
||||||
|
}
|
||||||
|
.md-preview pre {
|
||||||
|
background: var(--panel2); padding: 8px 10px; border-radius: 8px;
|
||||||
|
overflow-x: auto; margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.md-preview pre code { background: none; padding: 0; }
|
||||||
|
.md-preview blockquote {
|
||||||
|
border-left: 3px solid var(--border); margin: 0 0 6px; padding-left: 10px;
|
||||||
|
}
|
||||||
|
.md-preview a { text-decoration: underline; }
|
||||||
.card-spacer { flex: 1 1 auto; }
|
.card-spacer { flex: 1 1 auto; }
|
||||||
/* Tags: nur eine Zeile, Rest wird abgeschnitten – hält die Karten gleich hoch. */
|
/* Tags: nur eine Zeile, Rest wird abgeschnitten – hält die Karten gleich hoch. */
|
||||||
.card-tags {
|
.card-tags {
|
||||||
@@ -134,8 +174,8 @@
|
|||||||
}
|
}
|
||||||
.card-domain { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.card-domain { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.card-foot > span:last-child { flex-shrink: 0; }
|
.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, #prompts { 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, #prompts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
|
|
||||||
/* Auf schmalen Geräten die Seitenleiste über den Inhalt stellen. */
|
/* Auf schmalen Geräten die Seitenleiste über den Inhalt stellen. */
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
@@ -169,6 +209,7 @@
|
|||||||
.theme-icon-dark, .theme-icon-light { display: inline-flex; }
|
.theme-icon-dark, .theme-icon-light { display: inline-flex; }
|
||||||
.icon-warn { color: #eab308; }
|
.icon-warn { color: #eab308; }
|
||||||
.icon-bookmark-active { color: #eab308; }
|
.icon-bookmark-active { color: #eab308; }
|
||||||
|
.icon-copied { color: #22c55e; border-color: #16a34a; }
|
||||||
|
|
||||||
.input-icon { position: relative; }
|
.input-icon { position: relative; }
|
||||||
.input-icon > .icon {
|
.input-icon > .icon {
|
||||||
@@ -255,5 +296,49 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
var COPY_ICON_CHECK =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" ' +
|
||||||
|
'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
|
||||||
|
'stroke-linejoin="round" class="icon"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
|
|
||||||
|
function copyPromptContent(btn) {
|
||||||
|
var text = btn.getAttribute('data-content') || '';
|
||||||
|
if (!btn.dataset.originalIcon) btn.dataset.originalIcon = btn.innerHTML;
|
||||||
|
|
||||||
|
var showResult = function (ok) {
|
||||||
|
btn.innerHTML = ok ? COPY_ICON_CHECK : btn.dataset.originalIcon;
|
||||||
|
btn.title = ok ? 'In die Zwischenablage kopiert' : 'Kopieren fehlgeschlagen';
|
||||||
|
btn.classList.toggle('icon-copied', ok);
|
||||||
|
setTimeout(function () {
|
||||||
|
btn.innerHTML = btn.dataset.originalIcon;
|
||||||
|
btn.title = 'In die Zwischenablage kopieren';
|
||||||
|
btn.classList.remove('icon-copied');
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback für http (kein sicherer Kontext) oder ältere Browser, in
|
||||||
|
// denen die asynchrone Clipboard-API nicht verfügbar ist/abgelehnt wird.
|
||||||
|
var legacyCopy = function () {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
var ok = false;
|
||||||
|
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
showResult(ok);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () { showResult(true); }, legacyCopy);
|
||||||
|
} else {
|
||||||
|
legacyCopy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,42 +2,7 @@
|
|||||||
{% import "_icons.html" as icons %}
|
{% import "_icons.html" as icons %}
|
||||||
{% block title %}Meine Links · LinkVault{% endblock %}
|
{% block title %}Meine Links · LinkVault{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<header class="topbar">
|
{% include "_topbar.html" %}
|
||||||
<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" class="icon-warn">
|
|
||||||
{{ icons.alert(size=14) }} KI aus
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button id="theme-toggle" type="button" class="ghost icon-btn" title="Farbschema wechseln">
|
|
||||||
<span class="theme-icon-dark">{{ icons.moon() }}</span>
|
|
||||||
<span class="theme-icon-light">{{ icons.sun() }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="user-menu">
|
|
||||||
<button type="button" class="ghost icon-btn" title="{{ user.email }}">{{ icons.user() }}</button>
|
|
||||||
<button id="menu-toggle" type="button" class="ghost icon-btn" title="Menü"
|
|
||||||
aria-haspopup="true" aria-expanded="false">{{ icons.menu() }}</button>
|
|
||||||
|
|
||||||
<div id="user-dropdown" class="user-dropdown" hidden>
|
|
||||||
<div class="dropdown-email">{{ user.email }}</div>
|
|
||||||
<div class="dropdown-sep"></div>
|
|
||||||
<button type="button" class="dropdown-item" disabled>
|
|
||||||
{{ icons.tag(size=15) }} Tags verwalten <span class="soon">bald</span>
|
|
||||||
</button>
|
|
||||||
<a href="/settings" class="dropdown-item" style="text-decoration:none;">
|
|
||||||
{{ icons.sliders(size=15) }} Einstellungen
|
|
||||||
</a>
|
|
||||||
<div class="dropdown-sep"></div>
|
|
||||||
<form method="post" action="/logout" style="margin:0;">
|
|
||||||
<button type="submit" class="dropdown-item">{{ icons.log_out(size=15) }} Abmelden</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
|
|||||||
78
app/templates/prompts.html
Normal file
78
app/templates/prompts.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "_icons.html" as icons %}
|
||||||
|
{% block title %}Meine Prompts · LinkVault{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
{% include "_topbar.html" %}
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="panel">
|
||||||
|
<a href="/prompts?sort={{ sort }}" class="facet {% if not active_category %}active{% endif %}">
|
||||||
|
<span>{{ icons.grid(size=14) }} Alle Prompts</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="facet-group">
|
||||||
|
<h3>Kategorien</h3>
|
||||||
|
{% for name, count in facets.categories %}
|
||||||
|
<a href="/prompts?category={{ name | urlencode }}&sort={{ sort }}"
|
||||||
|
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>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="panel">
|
||||||
|
<form hx-post="/prompts" hx-target="#prompts" 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();}">
|
||||||
|
<textarea name="content" rows="3" required placeholder="Prompt eingeben..."
|
||||||
|
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;"></textarea>
|
||||||
|
<div style="display:flex; justify-content:flex-end; margin-top:8px;">
|
||||||
|
<button type="submit" style="white-space:nowrap;">
|
||||||
|
<span class="htmx-indicator icon-spin">{{ icons.spinner(size=14) }}</span>
|
||||||
|
{{ icons.plus(size=14) }} Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="font-size:.8rem; margin-top:6px;">
|
||||||
|
Der Prompt wird automatisch kategorisiert und mit Tags versehen.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<form id="promptsearchform" onsubmit="return false;" style="margin:0;">
|
||||||
|
<input type="hidden" name="category" value="{{ active_category }}">
|
||||||
|
<div class="input-icon">
|
||||||
|
{{ icons.search(size=15) }}
|
||||||
|
<input type="text" name="q" value="{{ q }}"
|
||||||
|
placeholder="Suche in Titel, Prompt-Text, Tags..."
|
||||||
|
hx-get="/prompts/search" hx-target="#prompts" hx-swap="innerHTML"
|
||||||
|
hx-trigger="keyup changed delay:400ms, search"
|
||||||
|
hx-include="#promptsearchform">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:flex-end; align-items:center; gap:12px; margin-top:10px;">
|
||||||
|
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem;">
|
||||||
|
Sortierung:
|
||||||
|
<select name="sort"
|
||||||
|
hx-get="/prompts/search" hx-target="#prompts" hx-swap="innerHTML"
|
||||||
|
hx-trigger="change" hx-include="#promptsearchform">
|
||||||
|
{% for value, label in sort_options.items() %}
|
||||||
|
<option value="{{ value }}" {% if sort == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="prompts">
|
||||||
|
{% include "_prompts_list.html" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,16 +2,7 @@
|
|||||||
{% import "_icons.html" as icons %}
|
{% import "_icons.html" as icons %}
|
||||||
{% block title %}Einstellungen · LinkVault{% endblock %}
|
{% block title %}Einstellungen · LinkVault{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<header class="topbar">
|
{% include "_topbar.html" %}
|
||||||
<a href="/" class="brand">Link<span>Vault</span></a>
|
|
||||||
<div class="user-info">
|
|
||||||
<button id="theme-toggle" type="button" class="ghost icon-btn" title="Farbschema wechseln">
|
|
||||||
<span class="theme-icon-dark">{{ icons.moon() }}</span>
|
|
||||||
<span class="theme-icon-light">{{ icons.sun() }}</span>
|
|
||||||
</button>
|
|
||||||
<a href="/" class="ghost icon-btn" title="Zurück zu den Links">{{ icons.grid() }}</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="layout" style="max-width:720px; margin-left:auto; margin-right:auto;">
|
<div class="layout" style="max-width:720px; margin-left:auto; margin-right:auto;">
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ trafilatura>=1.12,<2
|
|||||||
openai>=1.54,<2
|
openai>=1.54,<2
|
||||||
python-dotenv>=1.0,<2
|
python-dotenv>=1.0,<2
|
||||||
numpy>=2.0
|
numpy>=2.0
|
||||||
|
markdown>=3.6,<4
|
||||||
|
bleach>=6.1,<7
|
||||||
|
|||||||
Reference in New Issue
Block a user