Files
linkvault/app/templates/base.html
Erik Thiele 78c95b522d 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>
2026-07-30 14:45:30 +02:00

345 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
/* Theme so früh wie möglich setzen, damit die Seite nicht kurz hell aufblitzt. */
(function () {
try {
var stored = localStorage.getItem('linkvault-theme');
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
document.documentElement.setAttribute('data-theme', 'light');
}
} catch (e) {}
})();
</script>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
:root {
color-scheme: dark;
--bg: #0f172a; --panel: #1e293b; --panel2: #273449;
--text: #e2e8f0; --muted: #94a3b8; --accent: #6366f1;
--accent-hover: #818cf8; --border: #334155; --danger: #ef4444;
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #f8fafc; --panel: #ffffff; --panel2: #f1f5f9;
--text: #0f172a; --muted: #64748b; --accent: #6366f1;
--accent-hover: #4f46e5; --border: #e2e8f0; --danger: #dc2626;
}
body { transition: background-color .15s, color .15s; }
* { 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); }
.topbar-right { display: flex; align-items: center; gap: 16px; }
.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] {
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%;
}
.file-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.file-label {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
white-space: nowrap;
}
.file-label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
input:focus, select:focus { outline: none; border-color: var(--accent); }
select {
background: var(--panel2); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 6px 10px; font-size: .85rem; font-family: inherit;
cursor: pointer;
}
.layout { display: flex; gap: 24px; 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; }
/* Linkliste: bis zu drei Karten pro Zeile, je nach Fensterbreite.
Karten einer Zeile sind gleich hoch; die Zusammenfassung wird
dafür auf drei Zeilen gekürzt. */
#links, #prompts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
#links > .panel, #prompts > .panel { margin-bottom: 0; }
#links > #empty-state, #prompts > #empty-state { grid-column: 1 / -1; }
.link-card { display: flex; flex-direction: column; }
.link-card.needs-review { border-color: #ca8a04; }
.review-toggle.active { color: #fbbf24; border-color: #ca8a04; }
.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;
}
/* 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; }
/* 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, #prompts { grid-template-columns: repeat(2, 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. */
@media (max-width: 800px) {
.layout { flex-direction: column; gap: 16px; padding: 0 16px; }
.sidebar { width: auto; }
}
.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; }
.icon { display: inline-block; vertical-align: -3px; flex-shrink: 0; }
.icon-spin { animation: icon-spin 0.8s linear infinite; }
@keyframes icon-spin { to { transform: rotate(360deg); } }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; padding: 0;
}
.theme-icon-dark, .theme-icon-light { display: inline-flex; }
.icon-warn { color: #eab308; }
.icon-bookmark-active { color: #eab308; }
.icon-copied { color: #22c55e; border-color: #16a34a; }
.input-icon { position: relative; }
.input-icon > .icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--muted); pointer-events: none;
}
.input-icon > input { padding-left: 36px; }
footer.app-footer {
text-align: center; padding: 16px 24px; margin-top: 24px;
color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border);
}
.user-menu { position: relative; display: flex; align-items: center; gap: 4px; }
.user-dropdown {
position: absolute; top: calc(100% + 8px); right: 0; min-width: 230px;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,.35); padding: 6px; z-index: 50;
}
:root[data-theme="light"] .user-dropdown { box-shadow: 0 12px 32px rgba(15,23,42,.14); }
.dropdown-email {
padding: 8px 10px; font-size: .8rem; color: var(--muted);
word-break: break-all;
}
.dropdown-sep { height: 1px; background: var(--border); margin: 4px 0; }
.dropdown-item {
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
background: transparent; color: var(--text); border: none; border-radius: 6px;
padding: 8px 10px; font-size: .875rem; font-weight: 400; cursor: pointer;
}
.dropdown-item:hover:not(:disabled) { background: var(--panel2); }
.dropdown-item:disabled { color: var(--muted); cursor: default; opacity: .65; }
.dropdown-item .soon {
margin-left: auto; font-size: .65rem; text-transform: uppercase; letter-spacing: .04em;
background: var(--panel2); color: var(--muted); padding: 2px 6px; border-radius: 99px;
}
</style>
</head>
<body>
{% block body %}{% endblock %}
<footer class="app-footer">
&copy; {{ app_author }} &middot; Version {{ app_version }} &middot; {{ app_host }}
</footer>
<script>
(function () {
var themeBtn = document.getElementById('theme-toggle');
if (themeBtn) {
var dark = themeBtn.querySelector('.theme-icon-dark');
var light = themeBtn.querySelector('.theme-icon-light');
var applyIcon = function (theme) {
dark.style.display = theme === 'light' ? 'none' : 'inline-flex';
light.style.display = theme === 'light' ? 'inline-flex' : 'none';
};
applyIcon(document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark');
themeBtn.addEventListener('click', function () {
var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('linkvault-theme', next); } catch (e) {}
applyIcon(next);
});
}
var menuBtn = document.getElementById('menu-toggle');
var dropdown = document.getElementById('user-dropdown');
if (menuBtn && dropdown) {
var close = function () {
dropdown.hidden = true;
menuBtn.setAttribute('aria-expanded', 'false');
};
var open = function () {
dropdown.hidden = false;
menuBtn.setAttribute('aria-expanded', 'true');
};
menuBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (dropdown.hidden) open(); else close();
});
document.addEventListener('click', function (e) {
if (!dropdown.hidden && !dropdown.contains(e.target) && e.target !== menuBtn) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
}
})();
</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>
</html>