templates. done,

feat/dashboard-upgrade
peter.fong 2 weeks ago
parent 57f0d6500f
commit 16012543ea

@ -17,26 +17,28 @@ from scraper.abort import set_abort
from scraper.progress import get_progress
# UI LOGS (GLOBAL — no book_id)
from scraper.ui_log import get_ui_logs, reset_ui_logs # <-- ADDED
from scraper.ui_log import get_ui_logs, reset_ui_logs
from celery.result import AsyncResult
from scraper.state import state as r
# ⬇⬇⬇ TOEGEVOEGD voor cover-serving
# Cover serving
from flask import send_from_directory
import os
app = Flask(__name__)
import redis
# Flask
app = Flask(__name__)
# =====================================================
# STATIC FILE SERVING FOR OUTPUT ← TOEGEVOEGD
# STATIC FILE SERVING FOR OUTPUT
# =====================================================
OUTPUT_ROOT = os.getenv("BOOKSCRAPER_OUTPUT_DIR", "output")
@app.route("/output/<path:filename>")
def serve_output(filename):
"""Serve output files such as cover.jpg and volumes."""
return send_from_directory(OUTPUT_ROOT, filename, as_attachment=False)
@ -56,13 +58,15 @@ def start_scraping():
url = request.form.get("url", "").strip()
if not url:
return render_template("result.html", error="Geen URL opgegeven.")
# ★ FIX: dashboard moet altijd books + logs meekrijgen
return render_template(
"dashboard/dashboard.html",
error="Geen URL opgegeven.",
books=list_active_books(),
logs=get_ui_logs(),
)
# ---------------------------------------------------------
# NEW: Clear UI log buffer when starting a new scrape
# ---------------------------------------------------------
reset_ui_logs()
log_debug(f"[WEB] Scraping via Celery: {url}")
async_result = celery_app.send_task(
@ -71,16 +75,17 @@ def start_scraping():
queue="scraping",
)
# ★ FIX: direct dashboard tonen met actuele data
return render_template(
"result.html",
message="Scraping gestart.",
"dashboard/dashboard.html",
scraping_task_id=async_result.id,
book_title=None,
books=list_active_books(),
logs=get_ui_logs(),
)
# =====================================================
# CLEAR UI LOGS MANUALLY (NEW)
# CLEAR UI LOGS
# =====================================================
@app.route("/clear-logs", methods=["POST"])
def clear_logs():
@ -115,7 +120,7 @@ def logs():
# =====================================================
# CELERY RESULT → return book_id when scraping finishes
# CELERY RESULT → return book_id
# =====================================================
@app.route("/celery-result/<task_id>", methods=["GET"])
def celery_result(task_id):
@ -123,13 +128,133 @@ def celery_result(task_id):
if result.successful():
return jsonify({"ready": True, "result": result.get()})
if result.failed():
return jsonify({"ready": True, "error": "failed"})
return jsonify({"ready": False})
# =====================================================
# REDIS BACKEND — BOOK STATE MODEL
# =====================================================
REDIS_URL = os.getenv("REDIS_BROKER", "redis://redis:6379/0")
r = redis.Redis.from_url(REDIS_URL, decode_responses=True)
def list_active_books():
"""Return list of active books from Redis Book State Model."""
keys = r.keys("book:*:status")
books = []
for key in keys:
book_id = key.split(":")[1]
status = r.get(f"book:{book_id}:status") or "unknown"
title = r.get(f"book:{book_id}:title") or book_id
dl_done = int(r.get(f"book:{book_id}:download:done") or 0)
dl_total = int(r.get(f"book:{book_id}:download:total") or 0)
au_done = int(r.get(f"book:{book_id}:audio:done") or 0)
au_total = dl_total
books.append(
{
"book_id": book_id,
"title": title,
"status": status,
"download_done": dl_done,
"download_total": dl_total,
"audio_done": au_done,
"audio_total": au_total,
}
)
return books
# =====================================================
# API: list all active books
# =====================================================
@app.route("/api/books")
def api_books():
return jsonify(list_active_books())
# =====================================================
# API: book status
# =====================================================
@app.route("/api/book/<book_id>/status")
def api_book_status(book_id):
status = r.get(f"book:{book_id}:status") or "unknown"
dl_done = int(r.get(f"book:{book_id}:download:done") or 0)
dl_total = int(r.get(f"book:{book_id}:download:total") or 0)
au_done = int(r.get(f"book:{book_id}:audio:done") or 0)
au_total = dl_total
return jsonify(
{
"book_id": book_id,
"status": status,
"download_done": dl_done,
"download_total": dl_total,
"audio_done": au_done,
"audio_total": au_total,
}
)
# =====================================================
# API: book logs
# =====================================================
@app.route("/api/book/<book_id>/logs")
def api_book_logs(book_id):
logs = r.lrange(f"logs:{book_id}", 0, -1) or []
return jsonify(logs)
# =====================================================
# VIEW: DASHBOARD
# =====================================================
@app.route("/dashboard")
def dashboard():
# ★ FIX: dashboard moet altijd books + logs krijgen
return render_template(
"dashboard/dashboard.html",
books=list_active_books(),
logs=get_ui_logs(),
)
# =====================================================
# VIEW: BOOK DETAIL PAGE
# =====================================================
@app.route("/book/<book_id>")
def book_detail(book_id):
title = r.get(f"book:{book_id}:title") or book_id
return render_template(
"dashboard/book_detail.html",
book_id=book_id,
title=title,
logs=get_ui_logs(),
)
@app.route("/debug/redis-keys")
def debug_redis_keys():
cursor = 0
results = {}
while True:
cursor, keys = r.scan(cursor, match="*", count=200)
for k in keys:
try:
results[k] = r.get(k)
except:
results[k] = "<non-string value>"
if cursor == 0:
break
return jsonify(results)
# =====================================================
# RUN FLASK
# =====================================================

@ -0,0 +1,7 @@
# scraper/state.py
import os
import redis
REDIS_STATE_URL = os.getenv("REDIS_STATE", "redis://redis:6379/2")
state = redis.Redis.from_url(REDIS_STATE_URL, decode_responses=True)

@ -0,0 +1,241 @@
/* =======================================================================
File: static/css/dashboard.css
Purpose:
Clean full-width vertical dashboard layout with large log viewer.
======================================================================= */
/* ------------------------------
GENERAL PAGE LAYOUT
------------------------------ */
/* Dashboard content should use full width */
.dashboard-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 1200px; /* voorkomt overflow rechts */
margin: 20px auto;
padding: 0 20px;
gap: 18px; /* kleiner dan 30px */
}
/* ------------------------------
SECTIONS (input, progress, logs)
------------------------------ */
.dashboard-section {
background: #ffffff;
padding: 16px; /* kleiner */
border-radius: 6px;
border: 1px solid #ddd;
margin: 0; /* weg extra witruimte */
}
.page-title {
font-size: 22px;
margin-bottom: 15px;
}
/* ------------------------------
BOOK LIST (optional)
------------------------------ */
.book-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.book-list-empty {
padding: 18px;
text-align: center;
color: #777;
}
/* List item */
.book-list-item {
padding: 12px 16px;
background: #f7f7f7;
border-radius: 6px;
border: 1px solid #ccc;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
transition: background 0.2s, border-color 0.2s;
}
.book-list-item:hover,
.book-list-item.active {
background: #eaf3ff;
border-color: #1e88e5;
}
/* Title + metadata */
.book-title {
font-size: 16px;
font-weight: 600;
}
.book-meta {
font-size: 12px;
color: #555;
}
.meta-label {
font-weight: 600;
}
/* ------------------------------
PROGRESS BOX
------------------------------ */
.progress-box {
background: #fafafa;
border: 1px solid #ddd;
padding: 18px;
border-radius: 6px;
width: 100%;
}
.progress-header h2 {
margin-bottom: 5px;
}
.progress-subtitle {
font-size: 14px;
color: #333;
font-weight: 600;
}
.progress-bookid {
font-size: 12px;
color: #777;
margin-bottom: 15px;
}
.progress-bar {
height: 14px;
background: #ddd;
border-radius: 6px;
overflow: hidden;
margin-bottom: 6px;
}
.progress-bar-fill {
height: 100%;
background: #1e88e5;
}
.progress-bar-fill.audio-fill {
background: #e65100;
}
.progress-stats {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #444;
margin-top: 4px;
}
/* ------------------------------
LOG VIEWER LARGE FULL-WIDTH
------------------------------ */
.log-viewer {
width: 100%;
max-width: 100%;
overflow: hidden; /* voorkom horizontaal uitsteken */
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-filters {
display: flex;
align-items: center;
gap: 8px;
}
.log-output {
flex: 1;
width: 100%;
max-width: 100%;
min-height: 60vh;
max-height: 75vh;
overflow-y: auto;
overflow-x: hidden; /* voorkom dat de log naar rechts uitsteekt */
background: #000000; /* Pure terminal black */
color: #00ff66; /* Matrix / retro green */
border: 1px solid #0f0; /* neon green frame */
border-radius: 6px;
padding: 12px;
font-family: "SF Mono", "Consolas", "Courier New", monospace;
font-size: 13px;
line-height: 1.35;
white-space: pre-wrap; /* wraps text */
word-break: break-word; /* lange links breken */
}
.log-line {
white-space: pre-wrap;
padding: 2px 0;
}
.log-empty {
color: #888;
font-style: italic;
}
.log-line {
color: #00ff66;
}
.log-line:has([DL]),
.log-line:has([DOWNLOAD]) {
color: #00ccff; /* cyan */
}
.log-line:has([PARSE]) {
color: #ffaa00;
} /* orange */
.log-line:has([SAVE]) {
color: #ffdd33;
}
.log-line:has([AUDIO]) {
color: #ff66ff;
} /* purple */
.log-line:has([CTRL]) {
color: #66aaff;
}
.log-line:has([ERROR]) {
color: #ff3333;
} /* red for errors */
/* ------------------------------
PLACEHOLDER
------------------------------ */
.dashboard-placeholder {
font-size: 15px;
padding: 20px;
text-align: center;
color: #777;
}
.footer {
text-align: center;
padding: 12px;
color: #666;
margin-top: 25px;
font-size: 12px;
border-top: 1px solid #ddd;
}

@ -0,0 +1,160 @@
/* =======================================================================
File: static/css/style.css
Purpose:
Global base styling for all pages.
Includes typography, buttons, forms, layout primitives.
======================================================================= */
/* ------------------------------
RESET / BASE
------------------------------ */
html,
body {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
background: #f5f6fa;
color: #222;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 20px;
}
h1,
h2,
h3 {
margin: 0 0 15px 0;
font-weight: 600;
}
a {
color: #1e88e5;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ------------------------------
BUTTONS
------------------------------ */
.btn-primary {
background: #1e88e5;
color: #fff;
padding: 10px 18px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 15px;
transition: background 0.2s ease;
}
.btn-primary:hover {
background: #1669b9;
}
.btn-small {
padding: 5px 10px;
background: #ccc;
border-radius: 4px;
border: none;
font-size: 13px;
}
.btn-small:hover {
background: #bbb;
}
/* ------------------------------
FORM ELEMENTS
------------------------------ */
.url-form {
display: flex;
gap: 10px;
flex-direction: column;
max-width: 550px;
}
.url-label {
font-weight: 600;
}
.url-input {
padding: 10px;
font-size: 15px;
border: 1px solid #bbb;
border-radius: 4px;
}
.url-submit {
align-self: flex-start;
}
/* ------------------------------
NAVBAR
------------------------------ */
.navbar {
background: #ffffff;
border-bottom: 1px solid #ddd;
padding: 12px 20px;
}
.nav-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand a {
font-size: 20px;
font-weight: bold;
color: #1e88e5;
}
.nav-links {
list-style: none;
display: flex;
gap: 25px;
margin: 0;
padding: 0;
}
.nav-item {
font-size: 15px;
color: #333;
}
.nav-item:hover {
color: #1e88e5;
}
/* ------------------------------
LANDING PAGE
------------------------------ */
.landing-container {
max-width: 600px;
margin: 40px auto;
background: #fff;
padding: 25px;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.landing-title {
margin-bottom: 20px;
}
.landing-links {
margin-top: 20px;
}

@ -0,0 +1,33 @@
/* =======================================================================
File: static/js/app.js
Purpose:
Global utility functions shared across all scripts.
No page-specific logic here.
======================================================================= */
// Shortcuts
const $ = (sel, parent = document) => parent.querySelector(sel);
const $$ = (sel, parent = document) => parent.querySelectorAll(sel);
// Safe log
function dbg(...args) {
console.log("[APP]", ...args);
}
// AJAX helper
async function apiGet(url) {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error("API GET Error:", url, err);
return null;
}
}
// Auto-scroll utility
function autoScroll(el) {
if (!el) return;
el.scrollTop = el.scrollHeight;
}

@ -0,0 +1,150 @@
/* =======================================================================
File: static/js/dashboard.js
Purpose:
Dashboard interactions:
- select book
- refresh logs
- refresh progress
NOTE:
$ / $$ / autoScroll komen uit helpers.js
======================================================================= */
/* ---------------------------------------------------------
Simple fetch wrapper
--------------------------------------------------------- */
async function apiGet(url) {
try {
const r = await fetch(url);
if (!r.ok) return null;
return await r.json();
} catch (e) {
console.error("API GET failed:", url, e);
return null;
}
}
/* ---------------------------------------------------------
Dashboard state
--------------------------------------------------------- */
let ACTIVE_BOOK = null;
let REFRESH_INTERVAL = null;
console.log(">>> dashboard.js LOADED");
/* ---------------------------------------------------------
DOM Ready setup
--------------------------------------------------------- */
document.addEventListener("DOMContentLoaded", () => {
console.log(">>> dashboard.js DOMContentLoaded");
// =====================================================
// GLOBAL FALLBACK POLLING — ALWAYS FETCH LOGS
// Runs when no books exist or no selection has been made
// =====================================================
console.log(">>> dashboard.js: enabling global fallback polling");
setInterval(() => {
// if no active book → fetch global logs
if (!ACTIVE_BOOK) {
refreshBook(null); // triggers /logs
}
}, 2000);
const items = $$(".book-list-item");
console.log(">>> dashboard.js found book-list items:", items.length);
// Geen boeken → geen polling starten
// if (!items || items.length === 0) {
// console.log(">>> dashboard.js: geen boeken aanwezig, polling uit.");
// return;
// }
// Book selection listener
items.forEach((item) => {
item.addEventListener("click", () => {
console.log(">>> dashboard.js: user clicked book:", item.dataset.bookId);
selectBook(item.dataset.bookId);
});
});
// Auto-select first book
if (!ACTIVE_BOOK && items[0]) {
console.log(
">>> dashboard.js: auto-select first book:",
items[0].dataset.bookId
);
selectBook(items[0].dataset.bookId);
}
});
/* ---------------------------------------------------------
Select a book (updates UI + starts polling)
--------------------------------------------------------- */
function selectBook(bookId) {
console.log(">>> selectBook(", bookId, ")");
ACTIVE_BOOK = bookId;
// Highlight
$$(".book-list-item").forEach((el) => {
el.classList.toggle("active", el.dataset.bookId === bookId);
});
// Reset previous polling
if (REFRESH_INTERVAL) {
console.log(">>> dashboard.js: clearing previous polling interval");
clearInterval(REFRESH_INTERVAL);
}
// Start new polling
console.log(">>> dashboard.js: starting polling for bookId =", bookId);
REFRESH_INTERVAL = setInterval(() => {
refreshBook(ACTIVE_BOOK);
}, 2000);
// Immediate refresh
refreshBook(ACTIVE_BOOK);
}
/* ---------------------------------------------------------
Fetch logs + progress from API
--------------------------------------------------------- */
async function refreshBook(bookId) {
console.log(">>> refreshBook(", bookId, ")");
// 1) Als er GEEN bookId is → haal alleen globale logs op
if (!bookId) {
console.log(">>> refreshBook: no active book → fetch /logs");
const data = await apiGet("/logs");
if (data && data.logs) updateLogs(data.logs);
return; // klaar
}
// 2) Als er WEL een boek is → haal book status + logs op
const state = await apiGet(`/api/book/${bookId}/status`);
const logs = await apiGet(`/api/book/${bookId}/logs`);
console.log(">>> refreshBook state =", state);
console.log(">>> refreshBook logs =", logs);
if (state) updateProgressBars(state);
if (logs) updateLogs(logs);
}
/* ---------------------------------------------------------
Update LOG VIEW panel
--------------------------------------------------------- */
function updateLogs(logList) {
const output = $("#log-output");
if (!output) {
console.warn(">>> updateLogs: no #log-output element found");
return;
}
output.innerHTML = "";
logList.forEach((line) => logAppend(line));
autoScroll(output);
}

@ -0,0 +1,13 @@
/* =======================================================================
File: static/js/helpers.js
Purpose:
Shared DOM helpers for all JS files.
======================================================================= */
window.$ = (sel) => document.querySelector(sel);
window.$$ = (sel) => document.querySelectorAll(sel);
window.autoScroll = function (el) {
if (!el) return;
el.scrollTop = el.scrollHeight;
};

@ -0,0 +1,87 @@
/* =======================================================================
File: static/js/log_view.js
Purpose:
Log viewer functionality:
- filtering
- clearing
- auto-scroll
- refresh support for dashboard polling
======================================================================= */
console.log(">>> log_view.js LOADING…");
/* ---------------------------------------------------------
Log filtering
--------------------------------------------------------- */
let LOG_FILTER = "ALL";
function applyLogFilter() {
console.log(">>> log_view.js applyLogFilter(), filter =", LOG_FILTER);
const lines = $$(".log-line");
console.log(">>> log_view.js number of log-line elements:", lines.length);
lines.forEach((line) => {
const text = line.innerText;
line.style.display =
LOG_FILTER === "ALL" || text.includes(LOG_FILTER) ? "block" : "none";
});
}
/* ---------------------------------------------------------
UI bindings
--------------------------------------------------------- */
document.addEventListener("DOMContentLoaded", () => {
console.log(">>> log_view.js DOMContentLoaded");
const filterSel = $("#log-filter");
const clearBtn = $("#log-clear");
const output = $("#log-output");
if (!filterSel) {
console.log(">>> log_view.js: No log viewer found on this page.");
return;
}
console.log(">>> log_view.js: log viewer detected.");
// Filter dropdown
filterSel.addEventListener("change", () => {
LOG_FILTER = filterSel.value;
console.log(">>> log_view.js filter changed to:", LOG_FILTER);
applyLogFilter();
});
// Clear log window
if (clearBtn) {
clearBtn.addEventListener("click", () => {
console.log(">>> log_view.js log-clear clicked → clearing output");
if (output) output.innerHTML = "";
});
}
});
/* ---------------------------------------------------------
Append a line to the log output
--------------------------------------------------------- */
function logAppend(lineText) {
const output = $("#log-output");
if (!output) {
console.log(">>> log_view.js logAppend() SKIPPED — no #log-output");
return;
}
console.log(">>> log_view.js logAppend():", lineText);
const div = document.createElement("div");
div.className = "log-line";
div.innerText = lineText;
output.appendChild(div);
applyLogFilter();
autoScroll(output);
}
console.log(">>> log_view.js LOADED");

@ -0,0 +1,72 @@
/* =======================================================================
File: static/js/progress.js
Purpose:
Update progress bars dynamically for the current book.
Expects data from API endpoints via dashboard.js or start.js.
======================================================================= */
console.log(">>> progress.js LOADED");
function updateProgressBars(data) {
console.log(">>> progress.js updateProgressBars() CALLED with:", data);
if (!data) {
console.warn(">>> progress.js: NO DATA RECEIVED");
return;
}
// Data format expected:
// {
// download_done,
// download_total,
// audio_done,
// audio_total
// }
const barDL = $(".progress-bar-fill");
const barAU = $(".progress-bar-fill.audio-fill");
console.log(">>> progress.js barDL =", barDL);
console.log(">>> progress.js barAU =", barAU);
const pctDL =
data.download_total > 0
? (100 * data.download_done) / data.download_total
: 0;
const pctAU =
data.audio_total > 0 ? (100 * data.audio_done) / data.audio_total : 0;
if (barDL) {
barDL.style.width = pctDL.toFixed(1) + "%";
console.log(">>> progress.js updated DL bar to", pctDL.toFixed(1) + "%");
} else {
console.warn(">>> progress.js: barDL NOT FOUND");
}
if (barAU) {
barAU.style.width = pctAU.toFixed(1) + "%";
console.log(">>> progress.js updated AU bar to", pctAU.toFixed(1) + "%");
} else {
console.warn(">>> progress.js: barAU NOT FOUND");
}
// Update textual stats
const stats = $$(".progress-stats span");
console.log(">>> progress.js stats elements found:", stats.length);
// Expected structure: [DL "x/y", DL "pct", AU "x/y", AU "pct"]
if (stats.length >= 4) {
stats[0].innerText = `${data.download_done} / ${data.download_total}`;
stats[1].innerText = pctDL.toFixed(1) + "%";
stats[2].innerText = `${data.audio_done} / ${data.audio_total}`;
stats[3].innerText = pctAU.toFixed(1) + "%";
console.log(">>> progress.js stats updated");
} else {
console.warn(
">>> progress.js: not enough stats spans, found",
stats.length
);
}
}

@ -0,0 +1,26 @@
<!-- File: templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>BookScraper</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- CSS -->
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/dashboard.css" />
</head>
<body>
<!-- Global Navigation -->
{% include "components/nav.html" %}
<!-- Main Content Area -->
<main class="container">{% block content %}{% endblock %}</main>
<!-- JS -->
<script src="/static/js/app.js"></script>
<script src="/static/js/log_view.js"></script>
<script src="/static/js/progress.js"></script>
<script src="/static/js/dashboard.js"></script>
</body>
</html>

@ -0,0 +1,61 @@
<!-- =======================================================================
File: templates/components/book_list_item.html
Purpose:
Dashboard weergave van één boek in de lijst.
Variabelen komen binnen via:
{% for book in books %}
{% include "components/book_list_item.html" %}
{% endfor %}
→ Dus alle velden moeten via "book.<veld>" aangesproken worden.
======================================================================= -->
<div class="book-list-item" data-book-id="{{ book.book_id }}">
<!-- Left area: title + metadata -->
<div class="book-info">
<div class="book-title">{{ book.title }}</div>
<div class="book-meta">
<span class="meta-label">ID:</span> {{ book.book_id }} {% if
book.last_update %}
<span class="meta-separator"></span>
<span class="meta-label">Updated:</span> {{ book.last_update }} {% endif
%}
</div>
</div>
<!-- Center area: Status -->
<div class="book-status">
<span class="status-badge status-{{ book.status|lower }}">
{{ book.status|capitalize }}
</span>
</div>
<!-- Right area: progress mini-bars -->
<div class="book-progress-mini">
<!-- Download progress -->
<div class="progress-mini-row">
<span class="mini-label">DL:</span>
{% set pct_dl = 0 %} {% if book.download_total > 0 %} {% set pct_dl = (100
* book.download_done / book.download_total) | round(0) %} {% endif %}
<div class="progress-mini-bar">
<div class="fill" style="width: {{ pct_dl }}%;"></div>
</div>
<span class="mini-value">{{ pct_dl }}%</span>
</div>
<!-- Audio progress -->
<div class="progress-mini-row">
<span class="mini-label">AU:</span>
{% set pct_au = 0 %} {% if book.audio_total > 0 %} {% set pct_au = (100 *
book.audio_done / book.audio_total) | round(0) %} {% endif %}
<div class="progress-mini-bar audio">
<div class="fill audio-fill" style="width: {{ pct_au }}%;"></div>
</div>
<span class="mini-value">{{ pct_au }}%</span>
</div>
</div>
</div>

@ -0,0 +1,44 @@
<!-- =======================================================================
File: templates/components/log_view.html
Purpose: Reusable log viewer component for any page (dashboard/start/book)
Notes:
- Requires JS: /static/js/log_view.js
- Supports filtering by tag (e.g. [DL], [PARSE], [AUDIO], [CTRL], ...)
- Template expects optional variable `logs` (list[str])
======================================================================= -->
<div id="log-viewer" class="log-viewer">
<!-- ========================== HEADER ========================== -->
<div class="log-header">
<h2>Live Log</h2>
<div class="log-filters">
<label for="log-filter">Filter:</label>
<select id="log-filter">
<option value="ALL">All</option>
<option value="[DL]">Download</option>
<option value="[PARSE]">Parse</option>
<option value="[SAVE]">Save</option>
<option value="[AUDIO]">Audio</option>
<option value="[CTRL]">Controller</option>
<option value="[SCRAPING]">Scraping</option>
<option value="[BOOK]">Book</option>
<option value="[ERROR]">Errors</option>
</select>
<button id="log-clear" class="btn-small">Clear</button>
</div>
</div>
<!-- ========================== OUTPUT ========================== -->
<div id="log-output" class="log-output">
{% if logs and logs|length > 0 %} {% for line in logs %}
<div class="log-line">{{ line }}</div>
{% endfor %} {% else %}
<div class="log-empty">No logs yet…</div>
{% endif %}
</div>
</div>
<script src="/static/js/log_view.js"></script>

@ -0,0 +1,28 @@
<!-- =======================================================================
File: templates/components/nav.html
Purpose: Global navigation bar for BookScraper UI
======================================================================= -->
<nav class="navbar">
<div class="nav-inner">
<!-- Left side: Branding -->
<div class="nav-brand">
<a href="/">BookScraper</a>
</div>
<!-- Right side: Navigation Links -->
<ul class="nav-links">
<li>
<a href="/dashboard" class="nav-item"> Dashboard </a>
</li>
<li>
<a href="/active" class="nav-item"> Active Books </a>
</li>
<li>
<a href="/logs" class="nav-item"> Logs </a>
</li>
</ul>
</div>
</nav>

@ -0,0 +1,61 @@
<!-- =======================================================================
File: templates/components/progress_box.html
Purpose: Reusable progress overview (download + audio) for any book.
Notes:
- Expects the following variables from Flask:
book_id: str
title: str
download_total: int
download_done: int
audio_total: int
audio_done: int
- Pure HTML; JS for live updates will be added later.
======================================================================= -->
<div class="progress-box">
<!-- Header -->
<div class="progress-header">
<h2>Progress</h2>
{% if title %}
<div class="progress-subtitle">{{ title }}</div>
{% endif %} {% if book_id %}
<div class="progress-bookid">Book ID: <span>{{ book_id }}</span></div>
{% endif %}
</div>
<!-- DOWNLOAD SECTION -->
<div class="progress-section">
<h3>Download Progress</h3>
<div class="progress-bar">
{% set pct = 0 %} {% if download_total > 0 %} {% set pct = (100 *
download_done / download_total) | round(1) %} {% endif %}
<div class="progress-bar-fill" style="width: {{ pct }}%;"></div>
</div>
<div class="progress-stats">
<span>{{ download_done }} / {{ download_total }}</span>
<span>{{ pct }}%</span>
</div>
</div>
<!-- AUDIO SECTION -->
<div class="progress-section">
<h3>Audio Progress</h3>
<div class="progress-bar audio">
{% set pct2 = 0 %} {% if audio_total > 0 %} {% set pct2 = (100 *
audio_done / audio_total) | round(1) %} {% endif %}
<div
class="progress-bar-fill audio-fill"
style="width: {{ pct2 }}%;"
></div>
</div>
<div class="progress-stats">
<span>{{ audio_done }} / {{ audio_total }}</span>
<span>{{ pct2 }}%</span>
</div>
</div>
<script src="/static/js/progress.js"></script>
</div>

@ -0,0 +1,21 @@
<!-- =======================================================================
File: templates/components/url_input.html
Purpose:
Reusable component for entering a book URL.
Used on landing pages or detail pages.
======================================================================= -->
<form method="POST" action="/start" class="url-form">
<label for="url" class="url-label">Book URL:</label>
<input
type="text"
id="url"
name="url"
class="url-input"
placeholder="https://www.piaotia.com/bookinfo/6/6072.html"
required
/>
<button type="submit" class="btn-primary url-submit">Start Scraping</button>
</form>

@ -0,0 +1,44 @@
<!-- =======================================================================
File: templates/dashboard/book_detail.html
Purpose:
Detailpagina voor één book_id.
Toont progress (download/audio) + filters + live logs.
======================================================================= -->
{% extends "layout.html" %} {% block content %}
<div class="dashboard-detail">
<h1 class="page-title">{{ title }}</h1>
<p class="breadcrumb">
<a href="/dashboard">← Terug naar dashboard</a>
</p>
<!-- Progress box -->
<section id="progressSection">
{% include "components/progress_box.html" %}
</section>
<!-- Log view -->
<section class="log-section">
<h2>Live Log</h2>
{% include "components/log_view.html" %}
</section>
</div>
<!-- PAGE-SPECIFIC JS -->
<script>
const BOOK_ID = "{{ book_id }}";
</script>
<script src="/static/js/helpers.js"></script>
<!-- Shared log viewer -->
<script src="/static/js/log_view.js"></script>
<!-- Dashboard behaviour (only does something if dashboard HTML is present) -->
<script src="/static/js/dashboard.js"></script>
<!-- Existing global app logic -->
<script src="/static/js/progress.js"></script>
<script src="/static/js/app.js"></script>
{% endblock %}

@ -0,0 +1,57 @@
{% extends "layout.html" %} {% block content %}
<div class="dashboard-container">
<!-- =======================================================================
File: templates/dashboard/dashboard.html
Purpose:
Functioneel dashboard:
• Start nieuwe scrape via URL input component
• Toont lijst van actieve boeken (actieve state model)
• Toont globale live logs
Vereist:
- books: lijst van actieve boeken
- logs: lijst van globale logs (optioneel)
======================================================================= -->
<!-- ===========================================================
URL INPUT — Start nieuwe scrape
=========================================================== -->
<section class="dashboard-section">
<h2>Start nieuwe scrape</h2>
{% include "components/url_input.html" %}
</section>
<hr />
<!-- ===========================================================
BOOK LIST
=========================================================== -->
<section class="dashboard-section">
<h2>Actieve boeken</h2>
{% if books and books|length > 0 %}
<div class="book-list">
{% for book in books %} {% include "components/book_list_item.html" %} {%
endfor %}
</div>
{% else %}
<div class="book-list-empty">Geen actieve boeken.</div>
{% endif %}
</section>
<hr />
<!-- ===========================================================
GLOBAL LIVE LOG VIEW
=========================================================== -->
<section class="dashboard-section">
<h2>Live log (globaal)</h2>
{# log_view verwacht altijd 'logs' — garandeer list #} {% set logs = logs or
[] %} {% include "components/log_view.html" %}
</section>
</div>
<!-- JS -->
<script src="/static/js/dashboard.js"></script>
{% endblock %}

@ -0,0 +1,23 @@
<!-- =======================================================================
File: templates/home.html
Purpose:
New landing page for starting a scrape.
Does NOT replace existing index.html.
Uses reusable components (url_input).
Redirects to /start?url=...
======================================================================= -->
{% extends "layout.html" %} {% block content %}
<div class="landing-container">
<h1 class="landing-title">Start a New Book Scrape</h1>
<!-- Reusable URL input component -->
{% include "components/url_input.html" %}
<div class="landing-links">
<a href="/dashboard">→ Go to Dashboard</a>
</div>
</div>
{% endblock %}

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<!-- =======================================================================
File: templates/layout.html
Purpose:
Globale layout voor alle paginas
======================================================================= -->
<meta charset="UTF-8" />
<title>BookScraper</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- CSS -->
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/dashboard.css" />
<!-- GLOBAL HELPERS (moet ALTIJD boven alles geladen worden) -->
<script src="/static/js/helpers.js"></script>
</head>
<body>
{% include "components/nav.html" %}
<main class="container">{% block content %}{% endblock %}</main>
<footer class="footer">
BookScraper © 2025 — Powered by Celery + Redis
</footer>
<!-- GLOBAL APP LOGIC (altijd als laatste) -->
<script src="/static/js/app.js"></script>
</body>
</html>
Loading…
Cancel
Save