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:
@@ -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); }
|
||||
@@ -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 |
@@ -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 A–Z</option>
|
||||
<option value="title_desc">Titel Z–A</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 (0–10)</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 & binge-watchers</span>
|
||||
</footer>
|
||||
|
||||
<!-- ===================== Toast ===================== -->
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user