Compare commits
3 Commits
57f0d6500f
...
7ee6c5e276
| Author | SHA1 | Date |
|---|---|---|
|
|
7ee6c5e276 | 2 weeks ago |
|
|
6d15746738 | 2 weeks ago |
|
|
16012543ea | 2 weeks ago |
@ -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,262 @@
|
||||
/* =======================================================================
|
||||
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 */
|
||||
}
|
||||
/* Basestijl voor alle logregels */
|
||||
.log-line {
|
||||
white-space: pre-wrap;
|
||||
padding: 2px 0;
|
||||
font-family: "SF Mono", "Consolas", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Subklassen per logtype */
|
||||
.log-line.default {
|
||||
color: #00ff66; /* groen */
|
||||
}
|
||||
|
||||
.log-line.dl {
|
||||
color: #00ccff; /* cyan */
|
||||
}
|
||||
|
||||
.log-line.parse {
|
||||
color: #ffaa00; /* oranje */
|
||||
}
|
||||
|
||||
.log-line.save {
|
||||
color: #ffdd33; /* geel */
|
||||
}
|
||||
|
||||
.log-line.audio {
|
||||
color: #ff66ff; /* paars */
|
||||
}
|
||||
|
||||
.log-line.ctrl {
|
||||
color: #66aaff; /* lichtblauw */
|
||||
}
|
||||
|
||||
.log-line.error {
|
||||
color: #ff3333; /* rood */
|
||||
}
|
||||
|
||||
/* ------------------------------
|
||||
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;
|
||||
}
|
||||
.book-abort-area {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.abort-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #cc0000;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.abort-btn:hover {
|
||||
background: #ff2222;
|
||||
border-color: #aa0000;
|
||||
}
|
||||
@ -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,200 @@
|
||||
/* =======================================================================
|
||||
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);
|
||||
}
|
||||
setInterval(refreshActiveBooks, 2000);
|
||||
async function refreshActiveBooks() {
|
||||
const books = await apiGet("/api/books");
|
||||
if (!books) return;
|
||||
|
||||
const container = $("#book-list");
|
||||
if (!container) return;
|
||||
|
||||
// Herbouw de lijst
|
||||
container.innerHTML = "";
|
||||
books.forEach((b) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "book-list-item";
|
||||
div.dataset.bookId = b.book_id;
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="book-title">${b.title}</div>
|
||||
<div class="book-status">${b.status}</div>
|
||||
<div class="book-progress">
|
||||
${b.download_done}/${b.download_total} downloaded,
|
||||
${b.audio_done}/${b.audio_total} audio
|
||||
</div>
|
||||
|
||||
<button class="abort-btn" onclick="abortBook('${b.book_id}')">Abort</button>
|
||||
`;
|
||||
|
||||
// Event listener opnieuw koppelen
|
||||
div.addEventListener("click", () => selectBook(b.book_id));
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// Als ACTIVE_BOOK nog niet bekend → auto-selecteer eerste boek
|
||||
if (!ACTIVE_BOOK && books.length > 0) {
|
||||
selectBook(books[0].book_id);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
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);
|
||||
}
|
||||
|
||||
function abortBook(book_id) {
|
||||
if (!confirm(`Abort tasks for book ${book_id}?`)) return;
|
||||
|
||||
fetch(`/abort/${book_id}`, { method: "POST" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
console.log("Abort:", data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Abort failed:", err);
|
||||
});
|
||||
}
|
||||
@ -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,132 @@
|
||||
/* =======================================================================
|
||||
File: static/js/log_view.js
|
||||
Purpose:
|
||||
Log viewer functionality:
|
||||
- filtering
|
||||
- clearing
|
||||
- auto-scroll
|
||||
- delta polling (efficient)
|
||||
- rolling limit (prevent GUI freeze)
|
||||
======================================================================= */
|
||||
|
||||
console.log(">>> log_view.js LOADING…");
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Log filtering
|
||||
--------------------------------------------------------- */
|
||||
let LOG_FILTER = "ALL";
|
||||
let LAST_LOG_INDEX = -1; // For delta polling
|
||||
const MAX_LOG_LINES = 2000; // Rolling cap to prevent freezing
|
||||
|
||||
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 = "";
|
||||
LAST_LOG_INDEX = -1; // reset delta polling
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Append + Rolling buffer
|
||||
--------------------------------------------------------- */
|
||||
function logAppend(lineText) {
|
||||
const output = $("#log-output");
|
||||
if (!output) return;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("log-line");
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Assign subtype classes
|
||||
// -----------------------------------------------------
|
||||
if (lineText.includes("[DL]") || lineText.includes("[DOWNLOAD]")) {
|
||||
div.classList.add("dl");
|
||||
} else if (lineText.includes("[PARSE]")) {
|
||||
div.classList.add("parse");
|
||||
} else if (lineText.includes("[SAVE]")) {
|
||||
div.classList.add("save");
|
||||
} else if (lineText.includes("[AUDIO]")) {
|
||||
div.classList.add("audio");
|
||||
} else if (lineText.includes("[CTRL]")) {
|
||||
div.classList.add("ctrl");
|
||||
} else if (lineText.includes("[ERROR]")) {
|
||||
div.classList.add("error");
|
||||
} else {
|
||||
div.classList.add("default");
|
||||
}
|
||||
|
||||
div.innerText = lineText;
|
||||
output.appendChild(div);
|
||||
|
||||
// Rolling buffer
|
||||
while (output.children.length > MAX_LOG_LINES) {
|
||||
output.removeChild(output.firstChild);
|
||||
}
|
||||
|
||||
applyLogFilter();
|
||||
autoScroll(output);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Delta-based log polling
|
||||
--------------------------------------------------------- */
|
||||
function pollLogs() {
|
||||
fetch(`/logs?last_index=${LAST_LOG_INDEX}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const lines = data.lines || [];
|
||||
if (lines.length > 0) {
|
||||
lines.forEach((line) => logAppend(line));
|
||||
LAST_LOG_INDEX = data.total - 1;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(">>> log_view.js pollLogs() error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// Poll every 800 ms
|
||||
setInterval(pollLogs, 800);
|
||||
|
||||
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,64 @@
|
||||
<!-- =======================================================================
|
||||
File: templates/components/book_list_item.html
|
||||
Purpose:
|
||||
Dashboard weergave van één boek in de lijst.
|
||||
Variabelen komen binnen via:
|
||||
|
||||
→ 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 class="book-abort-area">
|
||||
<button class="abort-btn" onclick="abortBook('{{ book.book_id }}')">
|
||||
Abort
|
||||
</button>
|
||||
</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="/api/books" 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 id="book-list" class="book-list">
|
||||
{% for book in books %} {% include "components/book_list_item.html" %} {%
|
||||
endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="book-list" 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 %}
|
||||
Loading…
Reference in new issue