chore(rebrand): rename WatchStack -> Quivio
CI / test (push) Has been cancelled

Quivio (Kofferwort aus Quire + Video) ersetzt den Arbeitstitel WatchStack.
Es ist origineller, frei auf PyPI/npm, und passt perfekt zur Doppelnatur
der App: Buecher (Quire) + Serien (Video).

Geaendert:
- Python-Paket app/ -> quivio/ (+ alle Imports angepasst)
- DB-File watchstack.db -> quivio.db
- Docker-Image: quivio:0.5.1-beta, Container-Name quivio
- Compose-Services: quivio, quivio-local
- ENV-Vars: QUIVIO_VERSION, QUIVIO_PORT, Volume quivio-data
- Frontend-Logo "W" -> "Q", Titel "Quivio", Footer
- Favicon "Q" (Georgia serif fuer klassischen Look)
- Logger-Name: watchstack -> quivio
- README, NOTICE, dist/README, data/README: Quivio
- CI-Workflow: testet quivio/ Pfade
- build-and-push.sh: lokaler Tag "quivio:VERSION"
- service-Feld in /api/health: quivio
- OpenAPI title: Quivio

Bugfix (gefunden beim Renaming):
- seed.py hatte versteckten "from app.database import DB_PATH" — gefixt

Verifiziert end-to-end:
- Python-Import OK
- Backend: service=quivio, version=0.5.1-beta, 8 unique items
- Docker-Build OK, Push in Gitea-Registry OK (neuer sha256-Digest)
- Container: Quivio 0.5.1-beta ready (logger), HTML ohne WatchStack-Rest
- OpenAPI title=Quivio
- 8 unique items nach cold start (Race-Fix haelt)
This commit is contained in:
ki
2026-07-22 00:42:09 +02:00
parent fe291ba8ec
commit 9a4909f083
25 changed files with 102 additions and 114 deletions
+588
View File
@@ -0,0 +1,588 @@
/* ========================================================================
Quivio — Theme
- Dark default
- Eine Akzentfarbe (cyan-violett), warme Status-Farben
======================================================================== */
:root {
--bg: #0f1216;
--bg-elev: #161b22;
--bg-elev-2: #1d242d;
--border: #2a313c;
--border-strong: #3a424f;
--text: #e8ecf2;
--text-dim: #9aa3b1;
--text-muted: #6e7684;
--accent: #7c5cff;
--accent-2: #22d3ee;
--accent-grad: linear-gradient(135deg, #7c5cff 0%, #22d3ee 100%);
--danger: #ef4444;
--warn: #f59e0b;
--success: #22c55e;
--plan: #94a3b8;
--reading: #38bdf8;
--done: #22c55e;
--hold: #f59e0b;
--dropped: #ef4444;
--radius: 12px;
--radius-sm: 8px;
--shadow-lg: 0 20px 60px rgba(0,0,0,.45);
--shadow-md: 0 8px 24px rgba(0,0,0,.35);
--transition: 180ms cubic-bezier(.2,.7,.2,1);
--container: 1400px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; cursor: pointer; }
input, select, textarea, button { font: inherit; }
a { color: var(--accent-2); text-decoration: none; }
.hidden { display: none !important; }
/* ===================== Hero ===================== */
.hero {
position: relative;
background: linear-gradient(180deg, #1a1f2b 0%, #0f1216 100%);
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.hero-bg {
position: absolute; inset: 0;
background:
radial-gradient(800px 400px at 20% 0%, rgba(124,92,255,.18), transparent 60%),
radial-gradient(700px 350px at 80% 0%, rgba(34,211,238,.15), transparent 60%);
pointer-events: none;
}
.hero-inner {
position: relative;
max-width: var(--container);
margin: 0 auto;
padding: 28px 24px 16px;
display: flex;
flex-direction: column;
gap: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 52px; height: 52px;
border-radius: 14px;
background: var(--accent-grad);
display: grid; place-items: center;
font-weight: 800; font-size: 26px;
color: white;
box-shadow: 0 8px 20px rgba(124,92,255,.35);
}
.brand-text h1 {
margin: 0;
font-size: 28px;
font-weight: 800;
letter-spacing: -.01em;
background: var(--accent-grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.brand-text p { margin: 2px 0 0; color: var(--text-dim); font-size: 14px; }
.version-badge {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
font-size: 10px;
font-weight: 800;
letter-spacing: .1em;
text-transform: uppercase;
padding: 3px 7px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(245,158,11,.25), rgba(239,68,68,.25));
color: #fbbf24;
border: 1px solid rgba(245,158,11,.4);
-webkit-text-fill-color: #fbbf24;
}
.hero-search {
display: flex; gap: 10px; flex-wrap: wrap;
align-items: center;
}
.hero-search input[type="search"] {
flex: 1 1 320px;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
padding: 11px 14px;
border-radius: var(--radius);
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.hero-search input[type="search"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(124,92,255,.18);
}
.hero-search select {
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: var(--radius);
}
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 6px;
padding: 10px 16px;
border-radius: var(--radius);
border: 1px solid var(--border-strong);
background: var(--bg-elev-2);
color: var(--text);
font-weight: 600;
transition: transform var(--transition), background var(--transition), border-color var(--transition);
}
.btn:hover { background: #252d39; transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-primary {
background: var(--accent-grad);
border-color: transparent;
color: white;
box-shadow: 0 6px 16px rgba(124,92,255,.35);
}
.btn-primary:hover { filter: brightness(1.08); background: var(--accent-grad); }
.btn-ghost { background: transparent; }
.btn.small { padding: 6px 10px; font-size: 13px; }
.btn-danger { background: rgba(239,68,68,.15); border-color: rgba(239,68,68,.4); color: #fda4a4; }
.btn-danger:hover { background: rgba(239,68,68,.25); }
/* Status-Tabs */
.status-tabs {
position: relative;
max-width: var(--container);
margin: 0 auto;
padding: 4px 18px 0;
display: flex; gap: 4px; flex-wrap: wrap;
}
.tab {
background: transparent;
border: none;
color: var(--text-dim);
padding: 12px 14px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border-bottom: 2px solid transparent;
font-weight: 600;
display: inline-flex; gap: 6px; align-items: center;
transition: color var(--transition), border-color var(--transition), background var(--transition);
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
background: rgba(124,92,255,.06);
}
.tab .count {
background: var(--bg-elev-2);
color: var(--text-dim);
font-size: 11px;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--border);
}
/* ===================== Container ===================== */
.container {
max-width: var(--container);
margin: 0 auto;
padding: 20px 24px 80px;
}
/* Chips (Genres/Tags) */
.chips {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
margin-bottom: 10px;
}
.chip-label {
color: var(--text-muted);
font-size: 12px;
margin-right: 4px;
text-transform: uppercase;
letter-spacing: .08em;
}
.chip {
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 12px;
padding: 5px 10px;
border-radius: 999px;
cursor: pointer;
transition: all var(--transition);
}
.chip:hover { color: var(--text); border-color: var(--border-strong); }
.chip.active {
background: var(--accent-grad);
color: white;
border-color: transparent;
}
/* ===================== Grid / Cards ===================== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 18px;
margin-top: 14px;
}
.grid-loading {
grid-column: 1 / -1;
display: flex; align-items: center; justify-content: center;
gap: 12px;
padding: 60px 20px;
color: var(--text-dim);
}
.spinner {
width: 22px; height: 22px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex; flex-direction: column;
cursor: pointer;
transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition);
}
.card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
border-color: var(--border-strong);
}
.cover {
aspect-ratio: 2/3;
background: linear-gradient(135deg, #1f2630, #2a323e);
position: relative;
overflow: hidden;
}
.cover img {
width: 100%; height: 100%; object-fit: cover;
display: block;
transition: transform var(--transition);
}
.card:hover .cover img { transform: scale(1.04); }
.cover-fallback {
position: absolute; inset: 0;
display: grid; place-items: center;
color: var(--text-muted);
font-size: 32px;
text-align: center;
padding: 12px;
font-weight: 600;
}
.card-body {
padding: 12px;
display: flex; flex-direction: column; gap: 6px;
flex: 1;
}
.card-title {
font-size: 14px;
font-weight: 700;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.5em;
}
.card-sub {
font-size: 12px; color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
margin-top: auto;
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
}
.status-badge {
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
}
.status-plan { background: rgba(148,163,184,.15); color: var(--plan); }
.status-reading { background: rgba(56,189,248,.15); color: var(--reading); }
.status-done { background: rgba(34,197,94,.15); color: var(--done); }
.status-hold { background: rgba(245,158,11,.15); color: var(--hold); }
.status-dropped { background: rgba(239,68,68,.15); color: var(--dropped); }
.rating { font-size: 12px; color: #facc15; font-weight: 700; }
.kind-badge {
position: absolute;
top: 8px; left: 8px;
background: rgba(0,0,0,.65);
backdrop-filter: blur(6px);
color: white;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
}
.progress-bar {
height: 4px;
background: var(--bg-elev-2);
overflow: hidden;
}
.progress-bar > div {
height: 100%;
background: var(--accent-grad);
transition: width var(--transition);
}
/* Empty state */
.empty {
text-align: center;
padding: 80px 20px;
color: var(--text-dim);
}
.empty h2 { color: var(--text); margin-bottom: 6px; }
/* ===================== Modal ===================== */
.modal { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; }
.modal-backdrop {
position: absolute; inset: 0;
background: rgba(0,0,0,.55);
backdrop-filter: blur(4px);
}
.modal-card {
position: relative;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
width: min(680px, 92vw);
max-height: 92vh;
overflow: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--bg-elev); z-index: 1;
}
.modal-header h2 { margin: 0; font-size: 18px; }
.icon-btn {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 18px;
padding: 6px 10px;
border-radius: 8px;
}
.icon-btn:hover { background: var(--bg-elev-2); color: var(--text); }
.form { padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
.field.grow { flex: 1 1 220px; }
.field span {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .06em;
font-weight: 600;
}
.field input, .field select, .field textarea {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(124,92,255,.18);
}
.field textarea { resize: vertical; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 10px;
border-top: 1px solid var(--border);
padding: 14px 20px;
position: sticky; bottom: 0;
background: var(--bg-elev);
}
/* Conditional field rows by media kind */
.row[data-when] { display: none; }
body.kind-book .row[data-when="book"] { display: flex; }
body.kind-series .row[data-when="series"] { display: flex; }
/* ===================== Drawer (Detail) ===================== */
.drawer { position: fixed; inset: 0; z-index: 60; }
.drawer-backdrop {
position: absolute; inset: 0;
background: rgba(0,0,0,.55);
backdrop-filter: blur(4px);
}
.drawer-card {
position: absolute; right: 0; top: 0; bottom: 0;
width: min(680px, 95vw);
background: var(--bg-elev);
border-left: 1px solid var(--border);
overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: slide-in .25s ease;
}
@keyframes slide-in {
from { transform: translateX(40px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.drawer-close {
position: absolute; top: 14px; right: 14px; z-index: 2;
background: rgba(0,0,0,.5);
border-radius: 999px;
}
.detail-hero {
position: relative;
padding: 28px 24px 20px;
background: linear-gradient(135deg, rgba(124,92,255,.2), rgba(34,211,238,.15));
border-bottom: 1px solid var(--border);
}
.detail-hero .row-top { display: flex; gap: 18px; align-items: flex-start; }
.detail-hero .cover-mini {
width: 110px; aspect-ratio: 2/3;
background: var(--bg-elev-2);
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
box-shadow: var(--shadow-md);
}
.detail-hero .cover-mini img { width: 100%; height: 100%; object-fit: cover; }
.detail-hero .meta-title h2 {
margin: 0 0 4px; font-size: 24px; line-height: 1.15;
}
.detail-hero .meta-title .sub { color: var(--text-dim); font-size: 14px; }
.detail-hero .badges { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.detail-section { padding: 18px 24px; border-bottom: 1px solid var(--border); }
.detail-section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px,1fr)); gap: 12px; }
.stat-grid .stat {
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
}
.stat .label { font-size: 11px; text-transform: uppercase; color: var(--text-muted); letter-spacing: .06em; }
.stat .value { font-size: 18px; font-weight: 700; margin-top: 2px; }
.progress-controls {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px;
}
.progress-controls input[type="number"] {
width: 80px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: var(--radius-sm);
}
.actions-row {
display: flex; gap: 8px; flex-wrap: wrap;
padding: 18px 24px;
}
/* Tags in Detail */
.tag-list { display: flex; gap: 6px; flex-wrap: wrap; }
.tag-list .chip { cursor: default; }
/* Rating slider */
.rating-display {
display: inline-flex; align-items: center; gap: 4px;
font-weight: 700;
color: #facc15;
}
.rating-display .star { font-size: 18px; }
/* ===================== Stats modal ===================== */
.stats-card { width: min(560px, 92vw); }
.stat-block { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.stat-block:last-child { border-bottom: none; }
.stat-block h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; color: var(--text-muted); letter-spacing: .08em; }
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.bar-row .label { width: 110px; color: var(--text-dim); font-size: 13px; }
.bar-row .bar {
flex: 1; height: 14px; background: var(--bg); border-radius: 8px; overflow: hidden;
border: 1px solid var(--border);
}
.bar-row .bar > div {
height: 100%;
background: var(--accent-grad);
transition: width var(--transition);
}
.bar-row .count { font-weight: 700; min-width: 30px; text-align: right; }
.big-number {
font-size: 32px; font-weight: 800;
background: var(--accent-grad);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
/* ===================== Toast ===================== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--bg-elev-2);
border: 1px solid var(--border-strong);
color: var(--text);
padding: 12px 18px;
border-radius: var(--radius);
box-shadow: var(--shadow-md);
z-index: 100;
animation: toast-in .25s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 10px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
/* ===================== Responsive ===================== */
@media (max-width: 640px) {
.hero-inner { padding: 18px 16px 10px; }
.container { padding: 14px 16px 60px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
.brand-text h1 { font-size: 22px; }
.status-tabs { padding: 4px 12px 0; }
.tab { padding: 10px 10px; font-size: 13px; }
}
/* ===================== Footer ===================== */
.footer {
max-width: var(--container);
margin: 0 auto;
padding: 24px 24px 40px;
display: flex; justify-content: space-between; align-items: center;
color: var(--text-muted);
font-size: 12px;
border-top: 1px solid var(--border);
margin-top: 40px;
}
.footer span:last-child { color: var(--text-dim); }
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#7c5cff"/>
<stop offset="100%" stop-color="#22d3ee"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<text x="32" y="44" font-family="Georgia, serif" font-size="38" font-weight="700" fill="white" text-anchor="middle">Q</text>
</svg>

After

Width:  |  Height:  |  Size: 444 B

+235
View File
@@ -0,0 +1,235 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quivio Watchlist für Bücher & Serien</title>
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<!-- ===================== Header / Hero ===================== -->
<header class="hero">
<div class="hero-bg"></div>
<div class="hero-inner">
<div class="brand">
<div class="logo">Q</div>
<div class="brand-text">
<h1>Quivio <span class="version-badge">beta</span></h1>
<p>Deine Watchlist für <strong>Bücher</strong>, <strong>Serien</strong> & mehr.</p>
</div>
</div>
<div class="hero-search">
<input id="search-input" type="search" placeholder="Titel, Autor oder Genre suchen…" autocomplete="off">
<select id="filter-kind">
<option value="">Alle Medien</option>
<option value="book">Nur Bücher</option>
<option value="series">Nur Serien</option>
</select>
<select id="filter-status">
<option value="">Alle Status</option>
<option value="plan">Geplant</option>
<option value="reading">Laufend</option>
<option value="done">Abgeschlossen</option>
<option value="hold">Pausiert</option>
<option value="dropped">Abgebrochen</option>
</select>
<select id="filter-sort">
<option value="updated_desc">Zuletzt geändert</option>
<option value="title_asc">Titel AZ</option>
<option value="title_desc">Titel ZA</option>
<option value="rating_desc">Beste Bewertung</option>
<option value="rating_asc">Schlechteste Bewertung</option>
<option value="year_desc">Neueste zuerst</option>
<option value="year_asc">Älteste zuerst</option>
</select>
<button id="open-stats" class="btn btn-ghost" title="Statistik anzeigen">📊</button>
<button id="open-add" class="btn btn-primary">+ Neu</button>
</div>
</div>
<!-- Status-Tabs (MAL-Style) -->
<nav class="status-tabs" id="status-tabs">
<button class="tab active" data-status="">Alle</button>
<button class="tab" data-status="plan">Geplant <span class="count" id="cnt-plan">0</span></button>
<button class="tab" data-status="reading">Laufend <span class="count" id="cnt-reading">0</span></button>
<button class="tab" data-status="done">Abgeschlossen <span class="count" id="cnt-done">0</span></button>
<button class="tab" data-status="hold">Pausiert <span class="count" id="cnt-hold">0</span></button>
<button class="tab" data-status="dropped">Abgebrochen <span class="count" id="cnt-dropped">0</span></button>
</nav>
</header>
<!-- ===================== Main ===================== -->
<main class="container">
<!-- Genre/Tag-Chips (Filter) -->
<div class="chips" id="genre-chips" aria-label="Genres">
<span class="chip-label">Genres:</span>
<!-- per JS befüllt -->
</div>
<div class="chips" id="tag-chips" aria-label="Tags">
<span class="chip-label">Tags:</span>
<!-- per JS befüllt -->
</div>
<!-- Listen-Grid -->
<section id="grid" class="grid">
<div class="grid-loading">
<div class="spinner"></div>
<span>Lade Watchlist…</span>
</div>
</section>
<div id="empty-state" class="empty hidden">
<h2>Noch nichts hier.</h2>
<p>Klick auf <strong>+ Neu</strong> und leg deinen ersten Eintrag an.</p>
</div>
</main>
<!-- ===================== Modal: Add / Edit ===================== -->
<div class="modal" id="modal-add" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card">
<header class="modal-header">
<h2 id="modal-title">Neuer Eintrag</h2>
<button class="icon-btn" data-close aria-label="Schließen"></button>
</header>
<form id="form-add" class="form">
<input type="hidden" name="id">
<div class="row">
<label class="field grow">
<span>Art</span>
<select name="kind" required>
<option value="book">📚 Buch</option>
<option value="series">📺 Serie</option>
</select>
</label>
<label class="field">
<span>Status</span>
<select name="status">
<option value="plan">Geplant</option>
<option value="reading">Laufend</option>
<option value="done">Abgeschlossen</option>
<option value="hold">Pausiert</option>
<option value="dropped">Abgebrochen</option>
</select>
</label>
</div>
<div class="row">
<label class="field grow">
<span>Titel*</span>
<input name="title" required maxlength="200">
<button type="button" id="btn-lookup" class="btn btn-ghost small" title="Online nachschlagen">🔍 Nachschlagen</button>
</label>
<label class="field">
<span>Jahr</span>
<input name="release_year" type="number" min="1800" max="2200">
</label>
</div>
<!-- Buch-Felder -->
<div class="row" data-when="book">
<label class="field grow">
<span>Autor</span>
<input name="author" maxlength="200">
</label>
<label class="field">
<span>ISBN</span>
<input name="isbn" maxlength="20" placeholder="z.B. 9783608938284">
</label>
</div>
<div class="row" data-when="book">
<label class="field"><span>Bände</span><input name="total_volumes" type="number" min="0"></label>
<label class="field"><span>Kapitel</span><input name="total_chapters" type="number" min="0"></label>
<label class="field"><span>Seiten</span><input name="total_pages" type="number" min="0"></label>
</div>
<!-- Serien-Felder -->
<div class="row" data-when="series">
<label class="field grow">
<span>Sender / Streaming</span>
<input name="network" maxlength="120" placeholder="z.B. Netflix, ARD, …">
</label>
<label class="field"><span>Staffeln</span><input name="total_seasons" type="number" min="0"></label>
<label class="field"><span>Episoden</span><input name="total_episodes" type="number" min="0"></label>
</div>
<div class="row" data-when="series">
<label class="field"><span>Start</span><input name="start_date" type="date"></label>
<label class="field"><span>Ende</span><input name="end_date" type="date"></label>
<label class="field"><span>Staffel aktuell</span><input name="season_watching" type="number" min="0"></label>
</div>
<div class="row">
<label class="field grow">
<span>Cover-URL</span>
<input name="cover_url" placeholder="https://… (oder per Nachschlagen automatisch)">
</label>
<label class="field">
<span>Bewertung (010)</span>
<input name="rating" type="number" step="0.1" min="0" max="10">
</label>
</div>
<label class="field">
<span>Beschreibung</span>
<textarea name="description" rows="3"></textarea>
</label>
<div class="row">
<label class="field grow">
<span>Genres (Komma-getrennt)</span>
<input name="genres" placeholder="Fantasy, Sci-Fi, Roman">
</label>
<label class="field grow">
<span>Tags (Komma-getrennt)</span>
<input name="tags" placeholder="long-runner, klassiker">
</label>
</div>
<label class="field">
<span>Notizen</span>
<textarea name="notes" rows="2"></textarea>
</label>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-close>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</footer>
</form>
</div>
</div>
<!-- ===================== Drawer: Detail ===================== -->
<aside class="drawer" id="drawer" hidden>
<div class="drawer-backdrop" data-close-drawer></div>
<div class="drawer-card">
<button class="icon-btn drawer-close" data-close-drawer aria-label="Schließen"></button>
<div id="drawer-content"><!-- per JS --></div>
</div>
</aside>
<!-- ===================== Modal: Stats ===================== -->
<div class="modal" id="modal-stats" hidden>
<div class="modal-backdrop" data-close-stats></div>
<div class="modal-card stats-card">
<header class="modal-header">
<h2>Deine Statistik</h2>
<button class="icon-btn" data-close-stats aria-label="Schließen"></button>
</header>
<div id="stats-body"><!-- per JS --></div>
</div>
</div>
<footer class="footer">
<span>Quivio <span id="version"></span> · Apache 2.0</span>
<span>Quire + Video — made for readers &amp; binge-watchers</span>
</footer>
<!-- ===================== Toast ===================== -->
<div id="toast" class="toast" hidden></div>
<script src="/static/js/app.js" defer></script>
</body>
</html>
+526
View File
@@ -0,0 +1,526 @@
/* ========================================================================
Quivio Client App
======================================================================== */
(() => {
'use strict';
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const state = {
items: [],
genres: [],
tags: [],
filter: {
kind: '',
status: '',
search: '',
genre: '',
tag: '',
sort: 'updated_desc',
},
editing: null, // media id when editing, null when creating
};
// ---------- Helpers --------------------------------------------------------
const api = {
async get(url) {
const r = await fetch(url);
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
return r.json();
},
async send(method, url, body) {
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok && r.status !== 204) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || r.statusText);
}
return r.status === 204 ? null : r.json();
},
};
function toast(msg, kind = 'info') {
const el = $('#toast');
el.textContent = msg;
el.hidden = false;
el.style.borderColor =
kind === 'error' ? 'rgba(239,68,68,.5)' :
kind === 'success' ? 'rgba(34,197,94,.5)' :
'var(--border-strong)';
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.hidden = true; }, 2400);
}
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function stars(rating) {
if (rating == null) return '';
return `<span class="rating-display" title="${rating}/10">★ ${Number(rating).toFixed(1)}</span>`;
}
function statusLabel(s) {
return ({
plan: 'Geplant', reading: 'Laufend', done: 'Abgeschlossen',
hold: 'Pausiert', dropped: 'Abgebrochen',
})[s] || s;
}
function kindLabel(k) {
return k === 'book' ? '📚 Buch' : '📺 Serie';
}
function kindIcon(k) {
return k === 'book' ? '📚' : '📺';
}
function progressText(m) {
if (m.kind === 'book') {
const total = m.total_chapters || m.total_pages || m.total_volumes || 0;
const done = m.chapters_read || m.pages_read || m.volumes_read || 0;
const unit = m.total_chapters ? 'Kap.' : m.total_pages ? 'S.' : 'Bd.';
return total ? `${done} / ${total} ${unit}` : '—';
}
const total = m.total_episodes || 0;
const done = m.episodes_watched || 0;
return total ? `${done} / ${total} Ep.` : '—';
}
// ---------- Initial Load ----------------------------------------------------
async function loadAll() {
try {
const params = new URLSearchParams();
if (state.filter.kind) params.set('kind', state.filter.kind);
if (state.filter.status) params.set('status', state.filter.status);
if (state.filter.search) params.set('search', state.filter.search);
if (state.filter.genre) params.set('genre', state.filter.genre);
if (state.filter.tag) params.set('tag', state.filter.tag);
params.set('sort', state.filter.sort);
const [items, genres, tags] = await Promise.all([
api.get(`/api/media?${params}`),
api.get('/api/genres'),
api.get('/api/tags'),
]);
state.items = items;
state.genres = genres;
state.tags = tags;
renderAll();
} catch (e) {
toast('Fehler beim Laden: ' + e.message, 'error');
}
}
// ---------- Render ----------------------------------------------------------
function renderAll() {
renderChips();
renderCounts();
renderGrid();
}
function renderChips() {
const g = $('#genre-chips');
const t = $('#tag-chips');
g.innerHTML = '<span class="chip-label">Genres:</span>';
t.innerHTML = '<span class="chip-label">Tags:</span>';
state.genres.forEach(x => {
const el = document.createElement('button');
el.className = 'chip' + (state.filter.genre === x.name ? ' active' : '');
el.textContent = x.name;
el.onclick = () => { state.filter.genre = state.filter.genre === x.name ? '' : x.name; loadAll(); };
g.appendChild(el);
});
state.tags.forEach(x => {
const el = document.createElement('button');
el.className = 'chip' + (state.filter.tag === x.name ? ' active' : '');
el.textContent = x.name;
el.onclick = () => { state.filter.tag = state.filter.tag === x.name ? '' : x.name; loadAll(); };
t.appendChild(el);
});
}
function renderCounts() {
const counts = { plan: 0, reading: 0, done: 0, hold: 0, dropped: 0 };
let filtered = state.items;
if (state.filter.kind) filtered = filtered.filter(m => m.kind === state.filter.kind);
if (state.filter.search) {
const s = state.filter.search.toLowerCase();
filtered = filtered.filter(m =>
(m.title || '').toLowerCase().includes(s) ||
(m.author || '').toLowerCase().includes(s) ||
(m.original_title || '').toLowerCase().includes(s)
);
}
filtered.forEach(m => { counts[m.status] = (counts[m.status] || 0) + 1; });
Object.entries(counts).forEach(([k, v]) => {
const el = $('#cnt-' + k);
if (el) el.textContent = v;
});
}
function renderGrid() {
const grid = $('#grid');
const empty = $('#empty-state');
if (state.items.length === 0) {
grid.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
grid.innerHTML = state.items.map(m => `
<article class="card" data-id="${m.id}">
<div class="cover">
<span class="kind-badge">${kindIcon(m.kind)} ${m.kind === 'book' ? 'Buch' : 'Serie'}</span>
${m.cover_url
? `<img src="${escapeHtml(m.cover_url)}" alt="${escapeHtml(m.title)}" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 28))}</div>`}
<div class="progress-bar"><div style="width:${m.progress_percent}%"></div></div>
</div>
<div class="card-body">
<div class="card-title">${escapeHtml(m.title)}</div>
<div class="card-sub">${escapeHtml(m.kind === 'book' ? (m.author || '—') : (m.network || '—'))}${m.release_year ? ' · ' + m.release_year : ''}</div>
<div class="card-meta">
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
${stars(m.rating)}
</div>
<div class="card-sub">${progressText(m)}</div>
</div>
</article>
`).join('');
$$('.card', grid).forEach(el => {
el.addEventListener('click', () => openDetail(parseInt(el.dataset.id, 10)));
});
}
// ---------- Filter / Suche --------------------------------------------------
function wireFilters() {
const search = $('#search-input');
let debounce;
search.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => { state.filter.search = search.value.trim(); loadAll(); }, 220);
});
$('#filter-kind').addEventListener('change', e => { state.filter.kind = e.target.value; loadAll(); });
$('#filter-status').addEventListener('change', e => { state.filter.status = e.target.value; loadAll(); });
$('#filter-sort').addEventListener('change', e => { state.filter.sort = e.target.value; loadAll(); });
$$('#status-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
$$('#status-tabs .tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
state.filter.status = tab.dataset.status;
$('#filter-status').value = state.filter.status;
loadAll();
});
});
}
// ---------- Modal: Add / Edit ---------------------------------------------
function openAdd() {
state.editing = null;
$('#modal-title').textContent = 'Neuer Eintrag';
const form = $('#form-add');
form.reset();
form.id.value = '';
form.kind.value = 'book';
document.body.classList.remove('kind-series');
document.body.classList.add('kind-book');
$('#modal-add').hidden = false;
}
function openEdit(m) {
state.editing = m.id;
$('#modal-title').textContent = 'Bearbeiten: ' + m.title;
const form = $('#form-add');
form.reset();
form.id.value = m.id;
form.kind.value = m.kind;
document.body.classList.toggle('kind-book', m.kind === 'book');
document.body.classList.toggle('kind-series', m.kind === 'series');
// Felder setzen
const setVal = (name, val) => { if (form[name]) form[name].value = val ?? ''; };
setVal('title', m.title);
setVal('status', m.status);
setVal('release_year', m.release_year);
setVal('author', m.author);
setVal('isbn', m.isbn);
setVal('total_volumes', m.total_volumes);
setVal('total_chapters', m.total_chapters);
setVal('total_pages', m.total_pages);
setVal('network', m.network);
setVal('total_seasons', m.total_seasons);
setVal('total_episodes', m.total_episodes);
setVal('season_watching', m.season_watching);
setVal('start_date', m.start_date);
setVal('end_date', m.end_date);
setVal('cover_url', m.cover_url);
setVal('rating', m.rating);
setVal('description', m.description);
setVal('notes', m.notes);
form.genres.value = (m.genres || []).map(g => g.name).join(', ');
form.tags.value = (m.tags || []).map(t => t.name).join(', ');
$('#modal-add').hidden = false;
}
function wireAddModal() {
const form = $('#form-add');
const modal = $('#modal-add');
$$('[data-close]', modal).forEach(b => b.addEventListener('click', () => { modal.hidden = true; }));
form.kind.addEventListener('change', () => {
document.body.classList.toggle('kind-book', form.kind.value === 'book');
document.body.classList.toggle('kind-series', form.kind.value === 'series');
});
// Online-Lookup (Open Library per ISBN, sonst Titel)
$('#btn-lookup').addEventListener('click', async () => {
const isbn = form.isbn.value.trim();
const title = form.title.value.trim();
if (!isbn && !title) { toast('ISBN oder Titel eingeben', 'error'); return; }
try {
const params = isbn ? `?isbn=${encodeURIComponent(isbn)}` : `?title=${encodeURIComponent(title)}`;
const data = await api.get('/api/lookup/book' + params);
if (data.title && !form.title.value) form.title.value = data.title;
if (data.author && !form.author.value) form.author.value = data.author;
if (data.release_year && !form.release_year.value) form.release_year.value = data.release_year;
if (data.total_pages && !form.total_pages.value) form.total_pages.value = data.total_pages;
if (data.cover_url && !form.cover_url.value) form.cover_url.value = data.cover_url;
toast('Daten geladen ✓', 'success');
} catch (e) {
toast('Lookup fehlgeschlagen: ' + e.message, 'error');
}
});
form.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(form);
const body = {};
for (const [k, v] of fd.entries()) {
if (k === 'id') continue;
body[k] = v;
}
// Numerische Felder
const intFields = ['release_year','total_volumes','total_chapters','total_pages',
'total_seasons','total_episodes','season_watching'];
intFields.forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseInt(body[f], 10); });
['rating'].forEach(f => { if (body[f] === '') body[f] = null; else if (body[f] != null) body[f] = parseFloat(body[f]); });
body.genres = (body.genres || '').split(',').map(s => s.trim()).filter(Boolean);
body.tags = (body.tags || '').split(',').map(s => s.trim()).filter(Boolean);
try {
if (state.editing) {
await api.send('PATCH', `/api/media/${state.editing}`, body);
toast('Aktualisiert ✓', 'success');
} else {
await api.send('POST', '/api/media', body);
toast('Hinzugefügt ✓', 'success');
}
modal.hidden = true;
await loadAll();
if (state.editing) openDetail(state.editing);
} catch (err) {
toast('Speichern fehlgeschlagen: ' + err.message, 'error');
}
});
}
// ---------- Drawer: Detail -------------------------------------------------
async function openDetail(id) {
const m = await api.get('/api/media/' + id).catch(() => null);
if (!m) { toast('Eintrag nicht gefunden', 'error'); return; }
const c = $('#drawer-content');
c.innerHTML = `
<div class="detail-hero">
<div class="row-top">
<div class="cover-mini">
${m.cover_url
? `<img src="${escapeHtml(m.cover_url)}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'cover-fallback',textContent:'${escapeHtml((m.title||'?').slice(0,20))}'}))">`
: `<div class="cover-fallback">${escapeHtml((m.title || '?').slice(0, 20))}</div>`}
</div>
<div class="meta-title">
<h2>${escapeHtml(m.title)}</h2>
<div class="sub">${kindLabel(m.kind)} · ${m.release_year || '—'}${m.author ? ' · ' + escapeHtml(m.author) : ''}${m.network ? ' · ' + escapeHtml(m.network) : ''}</div>
<div class="badges">
<span class="status-badge status-${m.status}">${statusLabel(m.status)}</span>
${stars(m.rating)}
</div>
</div>
</div>
</div>
${m.description ? `<section class="detail-section"><h3>Beschreibung</h3><p>${escapeHtml(m.description)}</p></section>` : ''}
<section class="detail-section">
<h3>Status & Fortschritt</h3>
<div class="progress-bar" style="height:8px;border-radius:8px;overflow:hidden;background:var(--bg-elev-2);">
<div style="width:${m.progress_percent}%;height:100%;background:var(--accent-grad);"></div>
</div>
<div style="margin-top:6px;color:var(--text-dim);font-size:13px;">${progressText(m)} (${m.progress_percent}%)</div>
<div class="progress-controls">
<button class="btn small" id="prog-minus">1</button>
<button class="btn small" id="prog-plus">+1</button>
<input type="number" id="prog-set" min="0" placeholder="absolut">
<button class="btn small btn-primary" id="prog-save">Setzen</button>
</div>
<div class="progress-controls" style="margin-top:14px;">
${['plan','reading','done','hold','dropped'].map(s =>
`<button class="btn small status-btn" data-st="${s}">${statusLabel(s)}</button>`
).join('')}
</div>
</section>
<section class="detail-section">
<h3>Eckdaten</h3>
<div class="stat-grid">
${m.kind === 'book' ? `
<div class="stat"><div class="label">Autor</div><div class="value">${escapeHtml(m.author || '—')}</div></div>
<div class="stat"><div class="label">ISBN</div><div class="value" style="font-size:14px">${escapeHtml(m.isbn || '—')}</div></div>
<div class="stat"><div class="label">Bände</div><div class="value">${m.total_volumes ?? '—'}</div></div>
<div class="stat"><div class="label">Kapitel</div><div class="value">${m.total_chapters ?? '—'}</div></div>
<div class="stat"><div class="label">Seiten</div><div class="value">${m.total_pages ?? '—'}</div></div>
` : `
<div class="stat"><div class="label">Sender</div><div class="value" style="font-size:14px">${escapeHtml(m.network || '—')}</div></div>
<div class="stat"><div class="label">Staffeln</div><div class="value">${m.total_seasons ?? '—'}</div></div>
<div class="stat"><div class="label">Episoden</div><div class="value">${m.total_episodes ?? '—'}</div></div>
<div class="stat"><div class="label">Staffel aktuell</div><div class="value">${m.season_watching ?? '—'}</div></div>
<div class="stat"><div class="label">Start</div><div class="value" style="font-size:14px">${m.start_date || '—'}</div></div>
<div class="stat"><div class="label">Ende</div><div class="value" style="font-size:14px">${m.end_date || '—'}</div></div>
`}
</div>
</section>
${(m.genres && m.genres.length) || (m.tags && m.tags.length) ? `
<section class="detail-section">
<h3>Genres & Tags</h3>
<div class="tag-list">
${(m.genres||[]).map(g => `<span class="chip">${escapeHtml(g.name)}</span>`).join('')}
${(m.tags||[]).map(t => `<span class="chip" style="border-style:dashed">#${escapeHtml(t.name)}</span>`).join('')}
</div>
</section>
` : ''}
${m.notes ? `<section class="detail-section"><h3>Notizen</h3><p style="white-space:pre-wrap">${escapeHtml(m.notes)}</p></section>` : ''}
<div class="actions-row">
<button class="btn btn-primary" id="detail-edit">✎ Bearbeiten</button>
<button class="btn btn-danger" id="detail-delete">🗑 Löschen</button>
</div>
`;
// wire drawer actions
$('#detail-edit').onclick = () => { closeDrawer(); openEdit(m); };
$('#detail-delete').onclick = async () => {
if (!confirm(`"${m.title}" wirklich löschen?`)) return;
try {
await api.send('DELETE', `/api/media/${m.id}`);
toast('Gelöscht', 'success');
closeDrawer();
loadAll();
} catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
};
$('#prog-plus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: 1 }); openDetail(m.id); loadAll(); };
$('#prog-minus').onclick = async () => { await api.send('POST', `/api/media/${m.id}/progress`, { delta: -1 }); openDetail(m.id); loadAll(); };
$('#prog-save').onclick = async () => {
const v = parseInt($('#prog-set').value, 10);
if (Number.isNaN(v)) return;
await api.send('POST', `/api/media/${m.id}/progress`, { set_to: v });
openDetail(m.id); loadAll();
};
$$('.status-btn', c).forEach(btn => btn.onclick = async () => {
await api.send('PATCH', `/api/media/${m.id}`, { status: btn.dataset.st });
openDetail(m.id); loadAll();
});
$('#drawer').hidden = false;
}
function closeDrawer() { $('#drawer').hidden = true; }
function wireDrawer() {
$$('[data-close-drawer]').forEach(b => b.addEventListener('click', closeDrawer));
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (!$('#modal-add').hidden) $('#modal-add').hidden = true;
else if (!$('#modal-stats').hidden) $('#modal-stats').hidden = true;
else if (!$('#drawer').hidden) closeDrawer();
}
});
}
// ---------- Stats -----------------------------------------------------------
async function openStats() {
try {
const s = await api.get('/api/stats');
const block = (kind, data, total) => {
const max = Math.max(1, ...data.map(b => b.count));
return `
<div class="stat-block">
<h3>${kind === 'book' ? '📚 Bücher' : '📺 Serien'} · ${total} Einträge · ⌀ ${data.avg_rating ?? '—'}</h3>
${data.map(b => `
<div class="bar-row">
<div class="label status-badge status-${b.status}">${statusLabel(b.status)}</div>
<div class="bar"><div style="width:${(b.count / max) * 100}%"></div></div>
<div class="count">${b.count}</div>
</div>
`).join('')}
</div>
`;
};
$('#stats-body').innerHTML = `
<div class="stat-block">
<div class="big-number">${s.total_entries}</div>
<div style="color:var(--text-dim)">Einträge insgesamt</div>
</div>
${block('book', s.books.by_status, s.books.total)}
${block('series', s.series.by_status, s.series.total)}
`;
$('#modal-stats').hidden = false;
} catch (e) { toast('Stats laden fehlgeschlagen', 'error'); }
}
// ---------- Bootstrap -------------------------------------------------------
function wire() {
wireFilters();
wireAddModal();
wireDrawer();
$('#open-add').addEventListener('click', openAdd);
$('#open-stats').addEventListener('click', openStats);
$$('[data-close-stats]').forEach(b => b.addEventListener('click', () => { $('#modal-stats').hidden = true; }));
}
// Version aus /api/health in den Footer schreiben
(async () => {
try {
const h = await api.get('/api/health');
const el = $('#version');
if (el && h.version) el.textContent = 'v' + h.version;
} catch (_) { /* silent */ }
})();
document.addEventListener('DOMContentLoaded', () => {
wire();
loadAll();
});
})();