Compare commits

..
3 Commits
Author SHA1 Message Date
Sylphian 69448499e8 feat: minor ui changes 2026-10-10 23:21:20 +07:00
Sylphian 78cdf9ecdb feat: singleton process 2026-10-09 15:19:32 +07:00
Sylphian 65c29ec4c0 feat: adjust user interface 2026-10-09 14:59:09 +07:00
6 changed files with 1663 additions and 288 deletions
+126
View File
@@ -0,0 +1,126 @@
import pytest
from fastapi.testclient import TestClient
from web.app import app
client = TestClient(app)
def test_index_page_returns_200():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "<!DOCTYPE html>" in html
assert "/static/app.css" in html
assert "Silksoup: Novel to EPUB" in html
def test_dark_mode_only():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert '<html lang="en" class="dark">' in html
assert "themeBtn" not in html
assert "toggleTheme" not in html
def test_no_external_cdn_dependencies():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "cdn.tailwindcss.com" not in html
assert "googleapis" not in html
assert "cloudflare" not in html
def test_no_em_dash_in_template():
response = client.get("/")
assert response.status_code == 200
html = response.text
# Rule R-02 ban
assert "—" not in html
def test_static_app_css_available_and_contains_azure_tokens():
response = client.get("/static/app.css")
assert response.status_code == 200
css = response.text
assert "--bg: #141517" in css
assert "--surface: #1C1E21" in css
assert "--primary: #0284C7" in css
assert "--primary-accent: #38BDF8" in css
assert ":focus-visible" in css
assert "ui-monospace" in css
def test_required_dom_contract_elements_exist():
response = client.get("/")
assert response.status_code == 200
html = response.text
required_ids = [
"jobForm",
"urlInput",
"startBtn",
"btnSpinner",
"btnText",
"fixCheck",
"activeJob",
"cancelJobBtn",
"statusPulse",
"jobStatusBadge",
"jobStage",
"jobMessage",
"jobCounter",
"jobProgress",
"jobLog",
"autoScrollCheck",
"expandLogBtn",
"jobResult",
"downloadLink",
"searchEpub",
"epubCount",
"epubList",
"epubEmpty",
"toastContainer",
"notifyBtn",
]
for element_id in required_ids:
assert f'id="{element_id}"' in html, f"Missing required element id: {element_id}"
def test_aria_accessibility_attributes_present():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert 'role="progressbar"' in html
assert 'aria-valuenow' in html
assert 'aria-live="polite"' in html
assert 'role="radiogroup"' in html
def test_form_submits_on_submit_event():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "submitJob()" in html
assert "addEventListener('submit'" in html
assert 'type="submit"' in html
def test_guide_details_present():
response = client.get("/")
assert response.status_code == 200
html = response.text
assert "<details" in html
assert "Workflow Pipeline Guide" in html
assert "1. Ingest & Cache" in html
assert "4. Build EPUB" in html
def test_api_epubs_endpoint_unmodified():
response = client.get("/api/epubs")
assert response.status_code == 200
data = response.json()
assert "epubs" in data
assert "output_dir" in data
def test_api_jobs_validation_unmodified():
response = client.post("/api/jobs", json={"url": "not-a-valid-url"})
assert response.status_code == 400
def test_api_cancel_missing_job_returns_404():
response = client.post("/api/jobs/nonexistent-id/cancel")
assert response.status_code == 404
def test_api_download_missing_epub_returns_404():
response = client.get("/api/epubs/nonexistent-book.epub")
assert response.status_code == 404
+130
View File
@@ -0,0 +1,130 @@
import asyncio
import pytest
from fastapi.testclient import TestClient
from web.app import app
import web.job_manager as jm
client = TestClient(app)
@pytest.fixture(autouse=True)
def reset_job_manager():
jm.cancel_all_jobs()
jm.jobs.clear()
jm.subscribers.clear()
jm.cancel_events.clear()
jm.active_job_id = None
yield
jm.cancel_all_jobs()
jm.jobs.clear()
jm.subscribers.clear()
jm.cancel_events.clear()
jm.active_job_id = None
def test_active_job_endpoint_returns_idle_when_empty():
res = client.get("/api/jobs/active")
assert res.status_code == 200
data = res.json()
assert data["active"] is False
assert data["job"] is None
def test_active_job_endpoint_returns_running_job():
job_id = jm.create_job("https://example.com/novel-1")
jm.jobs[job_id]["status"] = "running"
jm.jobs[job_id]["stage"] = "scraping"
jm.jobs[job_id]["current"] = 5
jm.jobs[job_id]["total"] = 20
jm.jobs[job_id]["message"] = "Scraping chapter 5"
res = client.get("/api/jobs/active")
assert res.status_code == 200
data = res.json()
assert data["active"] is True
assert data["job"]["id"] == job_id
assert data["job"]["stage"] == "scraping"
assert data["job"]["current"] == 5
assert data["job"]["total"] == 20
assert data["job"]["percentage"] == 25
assert data["job"]["message"] == "Scraping chapter 5"
def test_concurrent_job_submission_returns_409():
job_id = jm.create_job("https://example.com/active-novel")
jm.jobs[job_id]["status"] = "running"
res = client.post("/api/jobs", json={"url": "https://example.com/another-novel"})
assert res.status_code == 409
assert "Another novel conversion is currently in progress" in res.json()["detail"]
def test_fix_job_submission_returns_409_when_busy(tmp_path, monkeypatch):
test_epub = tmp_path / "test.epub"
test_epub.write_text("dummy epub")
monkeypatch.setattr("web.app.OUTPUT_DIR", tmp_path)
monkeypatch.setattr("web.app._find_novel_url_for_epub", lambda f: "https://example.com/test-novel")
job_id = jm.create_job("https://example.com/active-novel")
jm.jobs[job_id]["status"] = "running"
res = client.post("/api/epubs/test.epub/fix")
assert res.status_code == 409
assert "Another novel conversion is currently in progress" in res.json()["detail"]
def test_cancellation_frees_worker_lock():
job_id = jm.create_job("https://example.com/active-novel")
jm.jobs[job_id]["status"] = "running"
assert jm.is_worker_busy() is True
cancel_res = client.post(f"/api/jobs/{job_id}/cancel")
assert cancel_res.status_code == 200
assert cancel_res.json()["status"] == "cancelled"
assert jm.is_worker_busy() is False
active_res = client.get("/api/jobs/active")
assert active_res.status_code == 200
assert active_res.json()["active"] is False
# A subsequent job can now be submitted
new_job_id = jm.create_job("https://example.com/subsequent-novel")
assert new_job_id is not None
assert jm.active_job_id == new_job_id
def test_sse_pubsub_fanout_multiple_subscribers():
job_id = jm.create_job("https://example.com/sse-test")
q1 = jm.subscribe_job_events(job_id)
q2 = jm.subscribe_job_events(job_id)
assert len(jm.subscribers[job_id]) == 2
test_event = {"stage": "scraping", "current": 1, "total": 10, "message": "Chapter 1"}
jm._push(job_id, test_event)
assert not q1.empty()
assert not q2.empty()
assert q1.get_nowait() == test_event
assert q2.get_nowait() == test_event
jm.unsubscribe_job_events(job_id, q1)
assert len(jm.subscribers[job_id]) == 1
jm.unsubscribe_job_events(job_id, q2)
assert job_id not in jm.subscribers
def test_frontend_has_sync_active_job_and_reconnection():
res = client.get("/")
assert res.status_code == 200
html = res.text
assert "syncActiveJob()" in html
assert "/api/jobs/active" in html
assert "setFormLocked(true)" in html
assert "setFormLocked(false)" in html
assert "409" in html
+62 -23
View File
@@ -14,11 +14,14 @@ from src.database import db_fetch_one, db_fetch_all, init_db
from src.epub_creator import _slugify from src.epub_creator import _slugify
from .job_manager import ( from .job_manager import (
jobs, jobs,
job_queues,
create_job, create_job,
cancel_job, cancel_job,
cancel_all_jobs, cancel_all_jobs,
run_job, run_job,
get_active_job,
subscribe_job_events,
unsubscribe_job_events,
WorkerBusyError,
) )
OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "output")).resolve() OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "output")).resolve()
@@ -61,12 +64,27 @@ def _find_novel_url_for_epub(filename: str) -> str | None:
return None return None
@app.get("/api/jobs/active")
async def get_active_job_endpoint():
active = get_active_job()
if active is None:
return {"active": False, "job": None}
return {"active": True, "job": active}
@app.post("/api/jobs") @app.post("/api/jobs")
async def start_job(payload: dict): async def start_job(payload: dict):
url = (payload.get("url") or "").strip() url = (payload.get("url") or "").strip()
if not url or not (url.startswith("http://") or url.startswith("https://")): if not url or not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(status_code=400, detail="Valid URL required") raise HTTPException(status_code=400, detail="Valid URL required")
active = get_active_job()
if active is not None:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {active.get('url')}",
)
# Check if a fixed version of this novel already exists in the library # Check if a fixed version of this novel already exists in the library
row = db_fetch_one("SELECT json_text FROM main_page WHERE url = ?", (url,)) row = db_fetch_one("SELECT json_text FROM main_page WHERE url = ?", (url,))
if row is not None and row[0]: if row is not None and row[0]:
@@ -94,7 +112,13 @@ async def start_job(payload: dict):
fix = bool(payload.get("fix", False)) or (refresh_mode == "fix") fix = bool(payload.get("fix", False)) or (refresh_mode == "fix")
job_id = create_job(url, refresh_mode=refresh_mode, fix=fix, output_dir=str(OUTPUT_DIR)) try:
job_id = create_job(url, refresh_mode=refresh_mode, fix=fix, output_dir=str(OUTPUT_DIR))
except WorkerBusyError as e:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {e.active_job.get('url')}",
)
asyncio.create_task(run_job(job_id)) asyncio.create_task(run_job(job_id))
return {"job_id": job_id, "status": "queued"} return {"job_id": job_id, "status": "queued"}
@@ -109,6 +133,13 @@ async def fix_library_epub(filename: str):
if "-fixed.epub" in safe.lower(): if "-fixed.epub" in safe.lower():
raise HTTPException(status_code=400, detail="This EPUB is already a fixed version.") raise HTTPException(status_code=400, detail="This EPUB is already a fixed version.")
active = get_active_job()
if active is not None:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {active.get('url')}",
)
novel_url = _find_novel_url_for_epub(safe) novel_url = _find_novel_url_for_epub(safe)
if not novel_url: if not novel_url:
raise HTTPException( raise HTTPException(
@@ -116,7 +147,13 @@ async def fix_library_epub(filename: str):
detail="Source novel URL not found in cache. Re-scrape the novel to fix.", detail="Source novel URL not found in cache. Re-scrape the novel to fix.",
) )
job_id = create_job(novel_url, refresh_mode="none", fix=True, output_dir=str(OUTPUT_DIR)) try:
job_id = create_job(novel_url, refresh_mode="none", fix=True, output_dir=str(OUTPUT_DIR))
except WorkerBusyError as e:
raise HTTPException(
status_code=409,
detail=f"Another novel conversion is currently in progress: {e.active_job.get('url')}",
)
asyncio.create_task(run_job(job_id)) asyncio.create_task(run_job(job_id))
return {"job_id": job_id, "status": "queued", "novel_url": novel_url} return {"job_id": job_id, "status": "queued", "novel_url": novel_url}
@@ -179,28 +216,30 @@ async def job_events(job_id: str):
raise HTTPException(status_code=404, detail="Job not found") raise HTTPException(status_code=404, detail="Job not found")
async def event_gen(): async def event_gen():
q = job_queues.get(job_id) q = subscribe_job_events(job_id)
if q is None: try:
return events = list(job.get("events", []))
last_idx = 0 for evt in events:
events = job.get("events", [])
for evt in events[last_idx:]:
yield f"data: {json.dumps(evt)}\n\n"
last_idx += 1
if evt.get("message") == "__DONE__":
return
while True:
if job["status"] in ("completed", "failed", "cancelled") and q.empty():
break
try:
evt = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {json.dumps(evt)}\n\n" yield f"data: {json.dumps(evt)}\n\n"
if evt.get("message") == "__DONE__": if evt.get("message") == "__DONE__":
break return
except asyncio.TimeoutError:
yield ": keepalive\n\n" if job.get("status") in ("completed", "failed", "cancelled"):
if job["status"] in ("completed", "failed", "cancelled"): yield f"data: {json.dumps({'stage': 'done', 'message': '__DONE__'})}\n\n"
break return
while True:
try:
evt = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {json.dumps(evt)}\n\n"
if evt.get("message") == "__DONE__":
break
except asyncio.TimeoutError:
yield ": keepalive\n\n"
if job.get("status") in ("completed", "failed", "cancelled"):
break
finally:
unsubscribe_job_events(job_id, q)
return StreamingResponse(event_gen(), media_type="text/event-stream", headers={ return StreamingResponse(event_gen(), media_type="text/event-stream", headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
+73 -4
View File
@@ -12,8 +12,61 @@ from src.chapter import clean_chapters, fix_chapters, scrape_chapters
from src.epub_creator import create_epub from src.epub_creator import create_epub
jobs: dict[str, dict] = {} jobs: dict[str, dict] = {}
job_queues: dict[str, asyncio.Queue] = {} subscribers: dict[str, set[asyncio.Queue]] = {}
cancel_events: dict[str, threading.Event] = {} cancel_events: dict[str, threading.Event] = {}
active_job_id: str | None = None
class WorkerBusyError(Exception):
def __init__(self, active_job: dict):
self.active_job = active_job
super().__init__("Worker is currently busy with another job")
def get_active_job() -> dict | None:
if active_job_id is None:
return None
job = jobs.get(active_job_id)
if not job or job.get("status") not in ("queued", "running"):
return None
total = job.get("total") or 0
current = job.get("current") or 0
percentage = round(current / total * 100) if total else 0
return {
"id": job["id"],
"url": job["url"],
"refresh_mode": job.get("refresh_mode", "none"),
"fix": job.get("fix", False),
"status": job["status"],
"stage": job["stage"],
"current": current,
"total": total,
"percentage": percentage,
"message": job.get("message", ""),
"created_at": job.get("created_at"),
"result_path": job.get("result_path"),
"error": job.get("error"),
"logs": job.get("logs", [])[-200:],
}
def is_worker_busy() -> bool:
return get_active_job() is not None
def subscribe_job_events(job_id: str) -> asyncio.Queue:
q = asyncio.Queue(maxsize=1000)
if job_id not in subscribers:
subscribers[job_id] = set()
subscribers[job_id].add(q)
return q
def unsubscribe_job_events(job_id: str, q: asyncio.Queue):
if job_id in subscribers:
subscribers[job_id].discard(q)
if not subscribers[job_id]:
subscribers.pop(job_id, None)
def _push(job_id: str, event: dict): def _push(job_id: str, event: dict):
@@ -30,8 +83,9 @@ def _push(job_id: str, event: dict):
job["total"] = event["total"] job["total"] = event["total"]
if "message" in event: if "message" in event:
job["message"] = event["message"] job["message"] = event["message"]
q = job_queues.get(job_id)
if q is not None: queues = subscribers.get(job_id, set())
for q in list(queues):
try: try:
q.put_nowait(event) q.put_nowait(event)
except asyncio.QueueFull: except asyncio.QueueFull:
@@ -39,7 +93,13 @@ def _push(job_id: str, event: dict):
def create_job(url: str, refresh_mode: str = "none", fix: bool = False, output_dir: str = "output") -> str: def create_job(url: str, refresh_mode: str = "none", fix: bool = False, output_dir: str = "output") -> str:
global active_job_id
active = get_active_job()
if active is not None:
raise WorkerBusyError(active)
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
active_job_id = job_id
jobs[job_id] = { jobs[job_id] = {
"id": job_id, "id": job_id,
"url": url, "url": url,
@@ -58,12 +118,13 @@ def create_job(url: str, refresh_mode: str = "none", fix: bool = False, output_d
"created_at": time.time(), "created_at": time.time(),
"updated_at": time.time(), "updated_at": time.time(),
} }
job_queues[job_id] = asyncio.Queue(maxsize=1000) subscribers[job_id] = set()
cancel_events[job_id] = threading.Event() cancel_events[job_id] = threading.Event()
return job_id return job_id
def cancel_job(job_id: str) -> bool: def cancel_job(job_id: str) -> bool:
global active_job_id
job = jobs.get(job_id) job = jobs.get(job_id)
if not job: if not job:
return False return False
@@ -76,19 +137,24 @@ def cancel_job(job_id: str) -> bool:
job["logs"].append("[CANCEL] Process stopped by user. Cached chapters remain safe.") job["logs"].append("[CANCEL] Process stopped by user. Cached chapters remain safe.")
_push(job_id, {"stage": "cancelled", "current": job.get("current", 0), "total": job.get("total", 0), "message": "Process stopped by user"}) _push(job_id, {"stage": "cancelled", "current": job.get("current", 0), "total": job.get("total", 0), "message": "Process stopped by user"})
_push(job_id, {"stage": "done", "current": job.get("current", 0), "total": job.get("total", 0), "message": "__DONE__"}) _push(job_id, {"stage": "done", "current": job.get("current", 0), "total": job.get("total", 0), "message": "__DONE__"})
if active_job_id == job_id:
active_job_id = None
return True return True
def cancel_all_jobs(): def cancel_all_jobs():
global active_job_id
for job_id, evt in cancel_events.items(): for job_id, evt in cancel_events.items():
job = jobs.get(job_id) job = jobs.get(job_id)
if job and job.get("status") in ("running", "queued"): if job and job.get("status") in ("running", "queued"):
evt.set() evt.set()
job["status"] = "cancelled" job["status"] = "cancelled"
job["stage"] = "cancelled" job["stage"] = "cancelled"
active_job_id = None
async def run_job(job_id: str): async def run_job(job_id: str):
global active_job_id
job = jobs.get(job_id) job = jobs.get(job_id)
if job is None: if job is None:
return return
@@ -181,3 +247,6 @@ async def run_job(job_id: str):
job["logs"].append(f"[ERROR] {e}") job["logs"].append(f"[ERROR] {e}")
_push(job_id, {"stage": "failed", "current": job.get("current", 0), "total": job.get("total", 0), "message": str(e)}) _push(job_id, {"stage": "failed", "current": job.get("current", 0), "total": job.get("total", 0), "message": str(e)})
_push(job_id, {"stage": "done", "current": 0, "total": 0, "message": "__DONE__"}) _push(job_id, {"stage": "done", "current": 0, "total": 0, "message": "__DONE__"})
finally:
if active_job_id == job_id:
active_job_id = None
+975
View File
@@ -0,0 +1,975 @@
:root {
--bg: #141517;
--surface: #1C1E21;
--surface-subtle: #25282D;
--border: #2B2E33;
--border-strong: #3E434A;
--text: #EAEBED;
--text-muted: #909399;
--primary: #0284C7;
--primary-hover: #0369A1;
--primary-fg: #FFFFFF;
--primary-subtle: rgba(2, 132, 199, 0.15);
--primary-accent: #38BDF8;
--btn-blue: #075985;
--btn-blue-hover: #0C4A6E;
--btn-blue-border: #0369A1;
--success: #34D399;
--success-bg: rgba(52, 211, 153, 0.1);
--success-border: #065F46;
--warning: #FBBF24;
--warning-bg: rgba(251, 191, 36, 0.1);
--warning-border: #78350F;
--danger: #FB7185;
--danger-bg: rgba(251, 113, 133, 0.1);
--danger-border: #881337;
--danger-hover: #F43F5E;
--log-bg: #0F1012;
--log-border: #2B2E33;
--log-text: #EAEBED;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-pill: 9999px;
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--transition-fast: 150ms ease;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background-color: var(--primary);
color: var(--primary-fg);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
:focus-visible {
outline: 2px solid var(--primary-accent);
outline-offset: 2px;
}
.app-header {
position: sticky;
top: 0;
z-index: 30;
background-color: var(--surface);
border-bottom: 1px solid var(--border);
}
.header-inner {
max-width: 56rem;
margin: 0 auto;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand-wrap {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: inherit;
}
.brand-icon {
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
object-fit: contain;
}
.brand-title-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.brand-title {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.brand-subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
}
.header-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-pill);
background-color: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
}
.header-badge.hidden {
display: none;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-header {
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.375rem 0.625rem;
min-height: 2rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
text-decoration: none;
transition: all var(--transition-fast);
}
.btn-header:hover {
color: var(--text);
background-color: var(--surface-subtle);
border-color: var(--border-strong);
}
.btn-header.active {
color: var(--success);
border-color: var(--success);
}
.app-main {
max-width: 56rem;
margin: 0 auto;
width: 100%;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
flex: 1;
}
.card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.card-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.75rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
margin-bottom: 0.375rem;
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-text {
flex: 1;
font-family: inherit;
font-size: 0.9375rem;
background-color: var(--surface-subtle);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.625rem 0.875rem;
min-height: 2.75rem;
transition: border-color var(--transition-fast);
}
.input-text::placeholder {
color: var(--text-muted);
opacity: 0.8;
}
.input-text:hover {
border-color: var(--border-strong);
}
.input-text:focus {
border-color: var(--primary);
outline: none;
}
.btn-paste {
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.625rem 0.875rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn-paste:hover {
color: var(--text);
border-color: var(--border-strong);
}
.cache-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0.625rem;
}
@media (min-width: 640px) {
.cache-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.cache-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
background-color: var(--surface-subtle);
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
}
.cache-card:hover {
border-color: var(--border-strong);
}
.cache-card:has(input:checked) {
border-color: var(--primary-accent);
background-color: var(--primary-subtle);
}
.cache-card input[type="radio"] {
margin-top: 0.1875rem;
accent-color: var(--primary);
}
.cache-card-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.cache-card-desc {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.125rem;
}
.toggle-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
background-color: var(--surface-subtle);
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
}
.toggle-card:hover {
border-color: var(--border-strong);
}
.toggle-card:has(input:checked) {
border-color: var(--primary-accent);
background-color: var(--primary-subtle);
}
.toggle-card input[type="checkbox"] {
margin-top: 0.1875rem;
accent-color: var(--primary);
}
.badge-ape {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
background-color: var(--primary-subtle);
color: var(--primary-accent);
border: 1px solid var(--primary);
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
}
.btn-primary {
font-family: inherit;
font-size: 0.9375rem;
font-weight: 500;
color: var(--primary-fg);
background-color: var(--btn-blue);
border: 1px solid var(--btn-blue-border);
border-radius: var(--radius-md);
padding: 0.625rem 1.25rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
width: 100%;
}
@media (min-width: 640px) {
.btn-primary {
width: auto;
}
}
.btn-primary:hover:not(:disabled) {
background-color: var(--btn-blue-hover);
border-color: var(--btn-blue);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.monitor-section.hidden {
display: none;
}
.monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.monitor-title-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--success);
flex-shrink: 0;
}
.status-dot.dot-emerald { background-color: var(--success); }
.status-dot.dot-amber { background-color: var(--warning); }
.status-dot.dot-rose { background-color: var(--danger); }
.monitor-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-stop {
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--danger);
background-color: var(--danger-bg);
border: 1px solid var(--danger-border);
border-radius: var(--radius-md);
padding: 0.25rem 0.625rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-stop:hover:not(:disabled) {
background-color: var(--danger);
color: #FFFFFF;
}
.btn-stop.hidden {
display: none;
}
.badge-status {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background-color: var(--surface-subtle);
color: var(--text);
}
.stage-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.stage-pill {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
color: var(--text);
flex-shrink: 0;
}
.stage-message {
color: var(--text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stage-counter {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-muted);
flex-shrink: 0;
}
.progress-track {
height: 0.5rem;
border-radius: var(--radius-pill);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-fill {
height: 100%;
background-color: var(--primary);
width: 0%;
transition: width 200ms ease;
}
.log-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 0.375rem;
}
.log-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.log-action-btn {
background: none;
border: none;
font-family: inherit;
font-size: 0.8125rem;
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast);
}
.log-action-btn:hover {
color: var(--text);
}
.log-box {
height: 10rem;
overflow: auto;
border-radius: var(--radius-md);
background-color: var(--log-bg);
border: 1px solid var(--log-border);
padding: 0.75rem;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--log-text);
white-space: pre-wrap;
line-height: 1.6;
}
.log-box.expanded {
height: 24rem;
}
.result-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.result-bar.hidden {
display: none;
}
.btn-secondary {
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.625rem 1rem;
min-height: 2.75rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-secondary:hover {
color: var(--text);
background-color: var(--surface-subtle);
border-color: var(--border-strong);
}
.library-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (min-width: 640px) {
.library-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.library-title-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.library-counter {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-muted);
}
.library-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.search-wrap {
position: relative;
flex: 1;
display: flex;
}
.search-wrap .input-text {
width: 100%;
}
.book-shelf {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.book-card {
border: 1px solid var(--border);
background-color: var(--surface-subtle);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--transition-fast);
}
.book-card:hover {
border-color: var(--border-strong);
}
.book-edition-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.book-edition-row + .book-edition-row {
border-top: 1px solid var(--border);
}
.book-edition-row.fixed-edition {
background-color: var(--primary-subtle);
}
.book-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.book-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--text-muted);
flex-shrink: 0;
}
.fixed-edition .book-icon {
color: var(--primary-accent);
}
.book-meta-block {
min-width: 0;
flex: 1;
}
.book-title-line {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.book-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
}
.badge-fixed {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.0625rem 0.375rem;
border-radius: var(--radius-sm);
background-color: var(--primary);
color: var(--primary-fg);
}
.book-specs {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.125rem;
}
.book-actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.btn-book-action {
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
padding: 0.375rem 0.625rem;
min-height: 2rem;
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.btn-book-download {
color: var(--primary-fg);
background-color: var(--btn-blue);
border: 1px solid var(--btn-blue-border);
}
.btn-book-download:hover {
background-color: var(--btn-blue-hover);
}
.btn-book-fix {
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
}
.btn-book-fix:hover {
color: var(--primary-accent);
border-color: var(--primary-accent);
}
.btn-book-delete {
color: var(--text-muted);
background-color: transparent;
border: 1px solid var(--border);
}
.btn-book-delete:hover {
color: var(--danger);
border-color: var(--danger-border);
}
.delete-confirm-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background-color: var(--danger-bg);
border: 1px solid var(--danger-border);
border-radius: var(--radius-sm);
padding: 0.125rem;
}
.btn-confirm-delete {
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
background-color: var(--danger);
color: #FFFFFF;
border: none;
cursor: pointer;
}
.btn-confirm-delete:hover {
background-color: var(--danger-hover);
}
.btn-cancel-delete {
font-family: inherit;
font-size: 0.75rem;
padding: 0.25rem 0.375rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
}
.btn-cancel-delete:hover {
color: var(--text);
}
.empty-state {
text-align: center;
padding: 2.5rem 1rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.empty-state.hidden {
display: none;
}
.guide-details {
border: 1px solid var(--border);
background-color: var(--surface);
border-radius: var(--radius-lg);
padding: 0.75rem 1rem;
}
.guide-summary {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
}
.guide-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0.625rem;
margin-top: 0.75rem;
}
@media (min-width: 640px) {
.guide-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.guide-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.guide-step {
padding: 0.75rem;
border-radius: var(--radius-md);
background-color: var(--surface-subtle);
border: 1px solid var(--border);
font-size: 0.75rem;
color: var(--text-muted);
}
.guide-step-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.25rem;
}
.app-footer {
border-top: 1px solid var(--border);
padding: 1rem;
text-align: center;
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: auto;
}
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
pointer-events: auto;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background-color: var(--surface);
color: var(--text);
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
gap: 0.625rem;
transition: opacity 150ms ease, transform 150ms ease;
opacity: 0;
transform: translateY(4px);
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.toast-success {
border-color: var(--success);
background-color: var(--surface);
color: var(--success);
}
.toast.toast-error {
border-color: var(--danger);
background-color: var(--surface);
color: var(--danger);
}
.toast.toast-info {
border-color: var(--border-strong);
}
.spinner {
width: 1rem;
height: 1rem;
animation: spin 1s linear infinite;
}
.spinner.hidden {
display: none;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+297 -261
View File
@@ -3,111 +3,54 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Silksoup — Novel to EPUB</title> <title>Silksoup: Novel to EPUB</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"> <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="icon" type="image/png" href="/static/icon.png"> <link rel="icon" type="image/png" href="/static/icon.png">
<link rel="apple-touch-icon" href="/static/icon.png"> <link rel="apple-touch-icon" href="/static/icon.png">
<script src="https://cdn.tailwindcss.com"></script> <link rel="stylesheet" href="/static/app.css">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
theme: {
bg: 'var(--bg)',
surface: 'var(--surface)',
subtle: 'var(--surface-subtle)',
border: 'var(--border)',
'border-strong': 'var(--border-strong)',
primary: 'var(--primary)',
'primary-hover': 'var(--primary-hover)',
'primary-fg': 'var(--primary-fg)',
secondary: 'var(--secondary)',
text: 'var(--text)',
muted: 'var(--text-muted)',
danger: 'var(--danger)',
'danger-bg': 'var(--danger-bg)',
'danger-border': 'var(--danger-border)',
}
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif']
}
}
}
}
</script>
<style>
:root {
--bg: #09090b;
--surface: #121215;
--surface-subtle: #1c1c21;
--border: #27272a;
--border-strong: #3f3f46;
--primary: #fafafa;
--primary-hover: #f4f4f5;
--primary-fg: #09090b;
--secondary: #a1a1aa;
--text: #fafafa;
--text-muted: #a1a1aa;
--danger: #f87171;
--danger-bg: #2b1114;
--danger-border: #4c1d24;
--log-bg: #000000;
--log-border: #27272a;
--log-text: #f4f4f5;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
</style>
</head> </head>
<body class="bg-theme-bg text-theme-text min-h-screen flex flex-col font-sans antialiased selection:bg-zinc-800 selection:text-white"> <body>
<header class="border-b border-theme-border bg-theme-surface sticky top-0 z-30"> <header class="app-header">
<div class="max-w-4xl mx-auto px-4 py-3.5 flex items-center justify-between"> <div class="header-inner">
<div class="flex items-center gap-3"> <div class="brand-wrap">
<img src="/static/icon.png" alt="Silksoup" class="w-8 h-8 rounded-md object-contain shadow-sm"> <img src="/static/icon.png" alt="Silksoup" class="brand-icon">
<div> <div>
<div class="flex items-center gap-2"> <div class="brand-title-line">
<span class="font-semibold text-base leading-none text-theme-text">Silksoup</span> <span class="brand-title">Silksoup</span>
<span id="headerBadge" class="hidden text-xs font-medium px-2 py-0.5 rounded bg-emerald-950/60 text-emerald-400 border border-emerald-800/80">Active</span> <span id="headerBadge" class="header-badge hidden">Active</span>
</div> </div>
<p class="text-sm text-theme-muted mt-0.5">Novel to EPUB pipeline</p> <p class="brand-subtitle">Novel to EPUB pipeline</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2.5"> <div class="header-actions">
<button id="notifyBtn" onclick="toggleNotifications()" type="button" aria-label="Desktop notifications" class="text-sm text-theme-muted hover:text-theme-text px-3 py-1.5 rounded-md border border-theme-border hover:bg-theme-subtle hover:border-theme-border-strong transition flex items-center gap-1.5"> <button id="notifyBtn" onclick="toggleNotifications()" type="button" aria-label="Desktop notifications" class="btn-header">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/> <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
</svg> </svg>
<span id="notifyText" class="hidden sm:inline">Notify</span> <span id="notifyText">Notify</span>
</button> </button>
<a href="/api/epubs" target="_blank" class="text-sm text-theme-muted hover:text-theme-text px-3 py-1.5 rounded-md border border-theme-border hover:bg-theme-subtle hover:border-theme-border-strong transition flex items-center gap-1.5"> <a href="/api/epubs" target="_blank" rel="noopener" class="btn-header">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"/> <polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/> <polyline points="8 6 2 12 8 18"/>
</svg> </svg>
<span class="hidden sm:inline">API</span> <span>API</span>
</a> </a>
</div> </div>
</div> </div>
</header> </header>
<main class="max-w-4xl mx-auto w-full px-4 py-6 flex flex-col gap-6 flex-1"> <main class="app-main">
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm"> <section class="card" aria-labelledby="headingScrape">
<div class="flex items-center justify-between mb-3"> <h2 id="headingScrape" class="card-title">Scrape Novel</h2>
<h2 class="text-base font-semibold text-theme-text">Scrape Novel</h2> <form id="jobForm">
</div> <div class="form-group">
<form id="jobForm" class="space-y-4" onsubmit="return false"> <label for="urlInput" class="form-label">Novel URL</label>
<div> <div class="input-row">
<label for="urlInput" class="block text-sm font-medium text-theme-text mb-1.5">Novel URL</label> <input id="urlInput" type="url" required placeholder="https://kakuyomu.jp/works/... or novel index URL" class="input-text">
<div class="flex gap-2"> <button type="button" onclick="pasteClipboard()" class="btn-paste">
<input id="urlInput" type="url" required placeholder="https://kakuyomu.jp/works/... or novel index URL" class="flex-1 rounded-md bg-theme-subtle border border-theme-border hover:border-theme-border-strong focus:border-theme-primary px-3.5 py-2.5 text-base text-theme-text placeholder:text-theme-muted/60 focus:outline-none focus:ring-1 focus:ring-theme-primary transition"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<button type="button" onclick="pasteClipboard()" class="px-3.5 py-2.5 text-sm font-medium rounded-md border border-theme-border text-theme-muted hover:text-theme-text hover:bg-theme-subtle hover:border-theme-border-strong transition shrink-0 flex items-center gap-1.5">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/> <rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/> <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
</svg> </svg>
@@ -115,49 +58,49 @@ tailwind.config = {
</button> </button>
</div> </div>
</div> </div>
<div> <div class="form-group">
<span class="block text-sm font-medium text-theme-text mb-1.5">Cache Mode</span> <span class="form-label">Cache Mode</span>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5"> <div class="cache-grid" role="radiogroup" aria-label="Cache Mode">
<label class="flex items-start gap-3 p-3.5 rounded-md border border-theme-border bg-theme-subtle/50 hover:border-theme-border-strong cursor-pointer select-none transition"> <label class="cache-card">
<input type="radio" name="refreshMode" value="none" checked class="mt-0.5 text-theme-primary border-theme-border focus:ring-0"> <input type="radio" name="refreshMode" value="none" checked>
<div class="flex flex-col"> <div>
<span class="text-sm font-medium text-theme-text">Full Cache</span> <div class="cache-card-title">Full Cache</div>
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML & AI results</span> <div class="cache-card-desc">Reuse HTML and AI results</div>
</div> </div>
</label> </label>
<label class="flex items-start gap-3 p-3.5 rounded-md border border-theme-border bg-theme-subtle/50 hover:border-theme-border-strong cursor-pointer select-none transition"> <label class="cache-card">
<input type="radio" name="refreshMode" value="llm" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0"> <input type="radio" name="refreshMode" value="llm">
<div class="flex flex-col"> <div>
<span class="text-sm font-medium text-theme-text">Re-run AI</span> <div class="cache-card-title">Re-run AI</div>
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML, re-sanitize</span> <div class="cache-card-desc">Reuse HTML, re-sanitize chapters</div>
</div> </div>
</label> </label>
<label class="flex items-start gap-3 p-3.5 rounded-md border border-theme-border bg-theme-subtle/50 hover:border-theme-border-strong cursor-pointer select-none transition"> <label class="cache-card">
<input type="radio" name="refreshMode" value="all" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0"> <input type="radio" name="refreshMode" value="all">
<div class="flex flex-col"> <div>
<span class="text-sm font-medium text-theme-text">Full Refresh</span> <div class="cache-card-title">Full Refresh</div>
<span class="text-xs text-theme-muted mt-0.5">Bypass cache & fetch fresh</span> <div class="cache-card-desc">Bypass cache and fetch fresh</div>
</div> </div>
</label> </label>
</div> </div>
</div> </div>
<div> <div class="form-group">
<label class="flex items-start gap-3 p-3.5 rounded-md border border-[#22396F] bg-[#0D1C42]/60 hover:bg-[#0D1C42] cursor-pointer select-none transition"> <label class="toggle-card">
<input id="fixCheck" type="checkbox" class="mt-0.5 rounded border-[#22396F] bg-[#090e1a] text-sky-400 focus:ring-0"> <input id="fixCheck" type="checkbox">
<div class="flex flex-col"> <div>
<div class="flex items-center gap-2"> <div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="text-sm font-medium text-[#FCF1D0]">Fix (Automated Post-Editing)</span> <span class="cache-card-title">Polished Edition (Automated Post-Editing)</span>
<span class="text-xs font-mono uppercase px-1.5 py-0.2 rounded bg-sky-950 text-sky-300 border border-sky-700/80">APE</span> <span class="badge-ape">APE</span>
</div> </div>
<span class="text-xs text-sky-300/70 mt-0.5">Repair bad MTL, fix pronouns, enforce consistent character names, and format notes</span> <div class="cache-card-desc">Repair machine translation phrasing, pronoun confusion, and format translator notes</div>
</div> </div>
</label> </label>
</div> </div>
<div class="flex justify-end pt-1"> <div class="form-actions">
<button id="startBtn" type="button" class="w-full sm:w-auto rounded-md bg-theme-primary hover:bg-theme-primary-hover text-theme-primary-fg text-sm font-medium px-5 py-2.5 transition flex items-center justify-center gap-2 shadow-sm"> <button id="startBtn" type="submit" class="btn-primary">
<svg id="btnSpinner" class="hidden w-4 h-4 animate-spin text-current" viewBox="0 0 24 24" fill="none"> <svg id="btnSpinner" class="spinner hidden" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle opacity="0.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path> <path opacity="0.75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg> </svg>
<span id="btnText">Start Scraping</span> <span id="btnText">Start Scraping</span>
</button> </button>
@@ -165,106 +108,109 @@ tailwind.config = {
</form> </form>
</section> </section>
<section id="activeJob" class="hidden rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm space-y-4"> <section id="activeJob" class="card monitor-section hidden" aria-labelledby="headingMonitor" role="region">
<div class="flex items-center justify-between"> <div class="monitor-header">
<div class="flex items-center gap-2"> <div class="monitor-title-group">
<span id="statusPulse" class="w-2.5 h-2.5 rounded-full bg-emerald-500"></span> <span id="statusPulse" class="status-dot dot-emerald"></span>
<h2 class="text-base font-semibold text-theme-text">Task Monitor</h2> <h2 id="headingMonitor" class="card-title" style="margin-bottom: 0;">Task Monitor</h2>
</div> </div>
<div class="flex items-center gap-2"> <div class="monitor-actions">
<button id="cancelJobBtn" type="button" onclick="requestCancelJob()" class="text-sm px-3 py-1 rounded-md border border-theme-danger-border bg-theme-danger-bg text-theme-danger hover:opacity-90 transition font-medium">Stop Process</button> <button id="cancelJobBtn" type="button" onclick="requestCancelJob()" class="btn-stop">Stop Process</button>
<span id="jobStatusBadge" class="text-xs font-mono uppercase px-2.5 py-0.5 rounded border border-theme-border bg-theme-subtle text-theme-text font-medium">idle</span> <span id="jobStatusBadge" class="badge-status">idle</span>
</div> </div>
</div> </div>
<div class="space-y-3"> <div>
<div class="flex items-center justify-between text-sm gap-3"> <div class="stage-row">
<div class="flex items-center gap-2 min-w-0"> <div style="display: flex; align-items: center; gap: 0.5rem; min-width: 0;">
<span id="jobStage" class="px-2 py-0.5 rounded bg-theme-subtle border border-theme-border text-theme-text font-mono text-xs uppercase font-medium">init</span> <span id="jobStage" class="stage-pill">init</span>
<span id="jobMessage" class="text-theme-text truncate font-medium">Starting...</span> <span id="jobMessage" class="stage-message">Starting...</span>
</div> </div>
<span id="jobCounter" class="text-theme-muted font-mono tabular-nums shrink-0 font-medium">0 / 0</span> <span id="jobCounter" class="stage-counter">0 / 0</span>
</div> </div>
<div class="h-2 rounded bg-theme-subtle border border-theme-border overflow-hidden"> <div class="progress-track" role="progressbar" id="progressTrack" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="jobProgress" class="h-full bg-theme-primary transition-all duration-200" style="width: 0%"></div> <div id="jobProgress" class="progress-fill"></div>
</div> </div>
<div class="flex items-center justify-between text-sm text-theme-muted pt-1"> <div class="log-meta-row">
<span>Execution Log</span> <span>Execution Log</span>
<div class="flex items-center gap-3"> <div class="log-actions">
<label class="flex items-center gap-1.5 cursor-pointer select-none"> <label style="display: inline-flex; align-items: center; gap: 0.375rem; cursor: pointer; user-select: none;">
<input id="autoScrollCheck" type="checkbox" checked class="rounded border-theme-border bg-theme-subtle text-theme-primary focus:ring-0"> <input id="autoScrollCheck" type="checkbox" checked style="accent-color: var(--primary);">
<span>Auto-scroll</span> <span>Auto-scroll</span>
</label> </label>
<button type="button" onclick="clearLogs()" class="hover:text-theme-text transition">Clear</button> <button type="button" onclick="clearLogs()" class="log-action-btn">Clear</button>
<button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="hover:text-theme-text transition">Expand</button> <button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="log-action-btn">Expand</button>
</div> </div>
</div> </div>
<div id="jobLog" class="h-44 overflow-auto rounded-md bg-[var(--log-bg)] border border-[var(--log-border)] p-3.5 font-mono text-sm text-[var(--log-text)] whitespace-pre-wrap leading-relaxed"></div> <div id="jobLog" class="log-box" role="log" aria-live="polite"></div>
<div id="jobResult" class="hidden pt-2 flex items-center gap-2"> <div id="jobResult" class="result-bar hidden">
<a id="downloadLink" href="#" class="inline-flex items-center gap-2 rounded-md bg-theme-primary hover:bg-theme-primary-hover text-theme-primary-fg text-sm font-medium px-4 py-2.5 transition shadow-sm"> <a id="downloadLink" href="#" class="btn-primary" style="text-decoration: none;">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/> <polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/> <line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
<span>Download EPUB</span> <span>Download EPUB</span>
</a> </a>
<button type="button" onclick="dismissActiveJob()" class="text-sm text-theme-muted hover:text-theme-text px-3.5 py-2.5 rounded-md border border-theme-border hover:bg-theme-subtle transition">Dismiss</button> <button type="button" onclick="dismissActiveJob()" class="btn-secondary">Dismiss</button>
</div> </div>
</div> </div>
</section> </section>
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm"> <section class="card" aria-labelledby="headingLibrary">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4"> <div class="library-header">
<div class="flex items-center gap-2"> <div class="library-title-group">
<h2 class="text-base font-semibold text-theme-text">Library</h2> <h2 id="headingLibrary" class="card-title" style="margin-bottom: 0;">Library</h2>
<span id="epubCount" class="text-sm text-theme-muted font-mono font-medium"></span> <span id="epubCount" class="library-counter"></span>
</div> </div>
<div class="flex items-center gap-2"> <div class="library-controls">
<div class="relative flex-1 sm:w-64"> <div class="search-wrap">
<input id="searchEpub" type="text" oninput="filterEpubs()" placeholder="Filter books..." class="w-full rounded-md bg-theme-subtle border border-theme-border hover:border-theme-border-strong focus:border-theme-primary px-3.5 py-2 text-sm text-theme-text placeholder:text-theme-muted/60 focus:outline-none focus:ring-1 focus:ring-theme-primary transition"> <input id="searchEpub" type="text" oninput="filterEpubs()" placeholder="Filter books..." class="input-text" style="min-height: 2.25rem; font-size: 0.875rem;">
</div> </div>
<button type="button" onclick="loadEpubs()" class="text-sm px-3.5 py-2 rounded-md border border-theme-border text-theme-muted hover:text-theme-text hover:bg-theme-subtle transition shrink-0">Refresh</button> <button type="button" onclick="loadEpubs()" class="btn-header" style="min-height: 2.25rem;">Refresh</button>
</div> </div>
</div> </div>
<div id="epubList" class="space-y-3"></div> <div id="epubList" class="book-shelf"></div>
<div id="epubEmpty" class="text-sm text-theme-muted py-8 text-center hidden"> <div id="epubEmpty" class="empty-state hidden">
No EPUBs found. Scrape a novel to populate your library. No EPUBs found. Scrape a novel to populate your library.
</div> </div>
</section> </section>
<section class="rounded-lg border border-theme-border bg-theme-surface p-5"> <details class="guide-details">
<h3 class="text-sm font-semibold text-theme-text mb-3">Workflow Pipeline</h3> <summary class="guide-summary">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 text-xs text-theme-muted"> <span>Workflow Pipeline Guide</span>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <span style="font-size: 0.75rem; color: var(--text-muted);">Details</span>
<span class="text-sm font-medium text-theme-text block mb-1">1. Ingest & Cache</span> </summary>
Fetches novel HTML via anti-bot client into database. <div class="guide-grid">
<div class="guide-step">
<div class="guide-step-title">1. Ingest & Cache</div>
Fetches novel HTML via anti-bot client into local database.
</div> </div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <div class="guide-step">
<span class="text-sm font-medium text-theme-text block mb-1">2. Parse Index</span> <div class="guide-step-title">2. Parse Index</div>
LLM extracts chronological volumes and chapter URLs. LLM extracts chronological volumes and chapter URLs.
</div> </div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <div class="guide-step">
<span class="text-sm font-medium text-theme-text block mb-1">3. Sanitize & APE</span> <div class="guide-step-title">3. Sanitize & APE</div>
Fixes bad MTL, typos, entities, and formats notes into blockquotes. Fixes machine translation flaws, entities, and formats notes into blockquotes.
</div> </div>
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70"> <div class="guide-step">
<span class="text-sm font-medium text-theme-text block mb-1">4. Build EPUB</span> <div class="guide-step-title">4. Build EPUB</div>
Downloads images, compresses assets, and compiles book. Downloads images, compresses assets, and compiles book.
</div> </div>
</div> </div>
</section> </details>
</main> </main>
<div id="toastContainer" class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"></div> <div id="toastContainer" class="toast-container" role="region" aria-live="polite"></div>
<footer class="border-t border-theme-border py-4 text-center text-sm text-theme-muted"> <footer class="app-footer">
Silksoup — Novel to EPUB Pipeline Silksoup: Novel to EPUB Pipeline
</footer> </footer>
<script> <script>
@@ -276,7 +222,7 @@ let originalTitle = document.title;
function updateNotifyButton() { function updateNotifyButton() {
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
$('#notifyBtn').classList.add('text-emerald-400', 'border-emerald-800'); $('#notifyBtn').classList.add('active');
$('#notifyText').textContent = 'Notifications on'; $('#notifyText').textContent = 'Notifications on';
} }
} }
@@ -289,9 +235,9 @@ async function toggleNotifications() {
updateNotifyButton(); updateNotifyButton();
} }
} else if (Notification.permission === 'denied') { } else if (Notification.permission === 'denied') {
showToast('Notifications are blocked in browser settings', 'error'); showToast('Notifications blocked in browser settings', 'error');
} else { } else {
showToast('Notifications are already enabled', 'info'); showToast('Notifications already enabled', 'info');
} }
} }
@@ -304,17 +250,16 @@ function notify(title, body) {
function showToast(message, type = 'info') { function showToast(message, type = 'info') {
const container = $('#toastContainer'); const container = $('#toastContainer');
const toast = document.createElement('div'); const toast = document.createElement('div');
const borderClass = type === 'error' ? 'border-theme-danger-border bg-theme-danger-bg text-theme-danger' : const typeClass = type === 'error' ? 'toast-error' :
type === 'success' ? 'border-emerald-800 bg-emerald-950 text-emerald-200' : type === 'success' ? 'toast-success' : 'toast-info';
'border-theme-border bg-theme-surface text-theme-text'; toast.className = `toast ${typeClass}`;
toast.className = `pointer-events-auto rounded-md border px-3.5 py-2.5 text-sm shadow-md flex items-center gap-2.5 transition-opacity duration-150 opacity-0 ${borderClass}`;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
requestAnimationFrame(() => { requestAnimationFrame(() => {
toast.classList.remove('opacity-0'); toast.classList.add('show');
}); });
setTimeout(() => { setTimeout(() => {
toast.classList.add('opacity-0'); toast.classList.remove('show');
setTimeout(() => toast.remove(), 150); setTimeout(() => toast.remove(), 150);
}, 4000); }, 4000);
} }
@@ -346,7 +291,15 @@ function setBtnLoading(loading) {
} }
} }
$('#startBtn').addEventListener('click', async () => { function setFormLocked(locked) {
$('#urlInput').disabled = locked;
document.querySelectorAll('input[name="refreshMode"]').forEach(r => r.disabled = locked);
const fixCheck = $('#fixCheck');
if (fixCheck) fixCheck.disabled = locked;
$('#startBtn').disabled = locked;
}
async function submitJob() {
const url = $('#urlInput').value.trim(); const url = $('#urlInput').value.trim();
if (!url) { if (!url) {
showToast('Please enter a novel URL', 'error'); showToast('Please enter a novel URL', 'error');
@@ -362,6 +315,12 @@ $('#startBtn').addEventListener('click', async () => {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ url, refresh_mode, fix }) body: JSON.stringify({ url, refresh_mode, fix })
}); });
if (res.status === 409) {
const j = await res.json();
showToast(j.detail || 'Another novel conversion is currently in progress', 'error');
await syncActiveJob();
return;
}
if (!res.ok) { if (!res.ok) {
const j = await res.json(); const j = await res.json();
throw new Error(j.detail || 'Failed to submit job'); throw new Error(j.detail || 'Failed to submit job');
@@ -377,19 +336,60 @@ $('#startBtn').addEventListener('click', async () => {
} finally { } finally {
setBtnLoading(false); setBtnLoading(false);
} }
}
$('#jobForm').addEventListener('submit', (e) => {
e.preventDefault();
submitJob();
}); });
function showActive() { function showActive(jobData = null) {
setFormLocked(true);
$('#activeJob').classList.remove('hidden'); $('#activeJob').classList.remove('hidden');
$('#headerBadge').classList.remove('hidden'); $('#headerBadge').classList.remove('hidden');
$('#cancelJobBtn').classList.remove('hidden'); $('#cancelJobBtn').classList.remove('hidden');
$('#jobLog').textContent = ''; if (!jobData) {
$('#jobProgress').style.width = '0%'; $('#jobLog').textContent = '';
$('#jobCounter').textContent = '0 / 0'; $('#jobProgress').style.width = '0%';
$('#jobStage').textContent = 'queued'; $('#progressTrack').setAttribute('aria-valuenow', '0');
$('#jobStatusBadge').textContent = 'running'; $('#jobCounter').textContent = '0 / 0';
$('#jobResult').classList.add('hidden'); $('#jobStage').textContent = 'queued';
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-emerald-500'; $('#jobStatusBadge').textContent = 'running';
$('#jobResult').classList.add('hidden');
$('#statusPulse').className = 'status-dot dot-emerald';
} else {
$('#jobStage').textContent = jobData.stage || 'running';
$('#jobMessage').textContent = jobData.message || '';
$('#jobCounter').textContent = `${jobData.current || 0} / ${jobData.total || 0}`;
const pct = jobData.percentage != null ? jobData.percentage :
(jobData.total ? Math.round((jobData.current / jobData.total) * 100) : 0);
$('#jobProgress').style.width = pct + '%';
$('#progressTrack').setAttribute('aria-valuenow', String(pct));
$('#jobStatusBadge').textContent = jobData.status || 'running';
$('#statusPulse').className = 'status-dot dot-emerald';
if (jobData.logs && jobData.logs.length) {
$('#jobLog').textContent = jobData.logs.join('\n') + '\n';
$('#jobLog').scrollTop = $('#jobLog').scrollHeight;
}
if (jobData.status === 'completed') {
setFormLocked(false);
$('#cancelJobBtn').classList.add('hidden');
if (jobData.result_path) {
const name = jobData.result_path.split(/[\\/]/).pop();
$('#downloadLink').href = `/api/epubs/${encodeURIComponent(name)}`;
$('#downloadLink').querySelector('span').textContent = `Download ${name}`;
$('#jobResult').classList.remove('hidden');
}
} else if (jobData.status === 'cancelled' || jobData.status === 'failed') {
setFormLocked(false);
$('#cancelJobBtn').classList.add('hidden');
if (jobData.status === 'cancelled') {
$('#statusPulse').className = 'status-dot dot-amber';
} else {
$('#statusPulse').className = 'status-dot dot-rose';
}
}
}
$('#activeJob').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); $('#activeJob').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
@@ -402,6 +402,7 @@ async function requestCancelJob() {
const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: 'POST' }); const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: 'POST' });
if (res.ok) { if (res.ok) {
showToast('Process cancellation requested', 'info'); showToast('Process cancellation requested', 'info');
setFormLocked(false);
} }
} catch(e) { } catch(e) {
showToast('Failed to cancel job', 'error'); showToast('Failed to cancel job', 'error');
@@ -417,6 +418,7 @@ function dismissActiveJob() {
if (evtSource) evtSource.close(); if (evtSource) evtSource.close();
$('#activeJob').classList.add('hidden'); $('#activeJob').classList.add('hidden');
$('#headerBadge').classList.add('hidden'); $('#headerBadge').classList.add('hidden');
setFormLocked(false);
document.title = originalTitle; document.title = originalTitle;
} }
@@ -431,6 +433,7 @@ function connectSSE(jobId) {
evtSource.close(); evtSource.close();
refreshJob(); refreshJob();
loadEpubs(); loadEpubs();
setFormLocked(false);
return; return;
} }
const pct = evt.total ? Math.round(evt.current / evt.total * 100) : 0; const pct = evt.total ? Math.round(evt.current / evt.total * 100) : 0;
@@ -438,6 +441,7 @@ function connectSSE(jobId) {
$('#jobMessage').textContent = evt.message || ''; $('#jobMessage').textContent = evt.message || '';
$('#jobCounter').textContent = `${evt.current} / ${evt.total}`; $('#jobCounter').textContent = `${evt.current} / ${evt.total}`;
$('#jobProgress').style.width = pct + '%'; $('#jobProgress').style.width = pct + '%';
$('#progressTrack').setAttribute('aria-valuenow', String(pct));
if (evt.stage === 'scraping' || evt.stage === 'cleaning' || evt.stage === 'fixing' || evt.stage === 'epub') { if (evt.stage === 'scraping' || evt.stage === 'cleaning' || evt.stage === 'fixing' || evt.stage === 'epub') {
document.title = `(${pct}%) Silksoup`; document.title = `(${pct}%) Silksoup`;
@@ -445,23 +449,29 @@ function connectSSE(jobId) {
if (evt.stage === 'failed') { if (evt.stage === 'failed') {
$('#jobStatusBadge').textContent = 'failed'; $('#jobStatusBadge').textContent = 'failed';
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-rose-500'; $('#statusPulse').className = 'status-dot dot-rose';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
document.title = `Failed — Silksoup`; setFormLocked(false);
loadEpubs();
document.title = 'Failed: Silksoup';
showToast(`Job failed: ${evt.message}`, 'error'); showToast(`Job failed: ${evt.message}`, 'error');
} }
if (evt.stage === 'cancelled') { if (evt.stage === 'cancelled') {
$('#jobStatusBadge').textContent = 'cancelled'; $('#jobStatusBadge').textContent = 'cancelled';
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-amber-500'; $('#statusPulse').className = 'status-dot dot-amber';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
document.title = `Silksoup`; setFormLocked(false);
loadEpubs();
document.title = 'Silksoup';
showToast('Process stopped. Cached chapters remain safe.', 'info'); showToast('Process stopped. Cached chapters remain safe.', 'info');
} }
if (evt.stage === 'completed') { if (evt.stage === 'completed') {
$('#jobStatusBadge').textContent = 'completed'; $('#jobStatusBadge').textContent = 'completed';
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-emerald-500'; $('#statusPulse').className = 'status-dot dot-emerald';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
document.title = `Completed — Silksoup`; setFormLocked(false);
loadEpubs();
document.title = 'Completed: Silksoup';
notify('Silksoup EPUB Ready', evt.message || 'Book compiled successfully.'); notify('Silksoup EPUB Ready', evt.message || 'Book compiled successfully.');
showToast('EPUB compiled successfully', 'success'); showToast('EPUB compiled successfully', 'success');
} }
@@ -485,7 +495,7 @@ async function refreshJob() {
const j = await res.json(); const j = await res.json();
$('#jobStatusBadge').textContent = j.status; $('#jobStatusBadge').textContent = j.status;
if (j.status === 'completed') { if (j.status === 'completed') {
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-emerald-500'; $('#statusPulse').className = 'status-dot dot-emerald';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
if (j.result_path) { if (j.result_path) {
const name = j.result_path.split(/[\\/]/).pop(); const name = j.result_path.split(/[\\/]/).pop();
@@ -495,11 +505,11 @@ async function refreshJob() {
} }
} }
if (j.status === 'cancelled') { if (j.status === 'cancelled') {
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-amber-500'; $('#statusPulse').className = 'status-dot dot-amber';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
} }
if (j.status === 'failed') { if (j.status === 'failed') {
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-rose-500'; $('#statusPulse').className = 'status-dot dot-rose';
$('#cancelJobBtn').classList.add('hidden'); $('#cancelJobBtn').classList.add('hidden');
$('#jobLog').textContent += `\n[ERROR] ${j.error || j.message}\n`; $('#jobLog').textContent += `\n[ERROR] ${j.error || j.message}\n`;
} }
@@ -514,13 +524,11 @@ function toggleLogHeight() {
const log = $('#jobLog'); const log = $('#jobLog');
const btn = $('#expandLogBtn'); const btn = $('#expandLogBtn');
if (isLogExpanded) { if (isLogExpanded) {
log.classList.remove('h-96'); log.classList.remove('expanded');
log.classList.add('h-40');
btn.textContent = 'Expand'; btn.textContent = 'Expand';
isLogExpanded = false; isLogExpanded = false;
} else { } else {
log.classList.remove('h-40'); log.classList.add('expanded');
log.classList.add('h-96');
btn.textContent = 'Collapse'; btn.textContent = 'Collapse';
isLogExpanded = true; isLogExpanded = true;
} }
@@ -545,13 +553,19 @@ function filterEpubs() {
return; return;
} }
const filtered = allEpubs.filter(f => f.name.toLowerCase().includes(query)); const filtered = allEpubs.filter(f => f.name.toLowerCase().includes(query));
renderEpubs(filtered); renderEpubs(filtered, true);
} }
async function triggerFix(encodedName) { async function triggerFix(encodedName) {
const name = decodeURIComponent(encodedName); const name = decodeURIComponent(encodedName);
try { try {
const res = await fetch(`/api/epubs/${encodeURIComponent(name)}/fix`, { method: 'POST' }); const res = await fetch(`/api/epubs/${encodeURIComponent(name)}/fix`, { method: 'POST' });
if (res.status === 409) {
const j = await res.json();
showToast(j.detail || 'Another novel conversion is currently in progress', 'error');
await syncActiveJob();
return;
}
if (!res.ok) { if (!res.ok) {
const j = await res.json(); const j = await res.json();
throw new Error(j.detail || 'Failed to start fix job'); throw new Error(j.detail || 'Failed to start fix job');
@@ -567,16 +581,18 @@ async function triggerFix(encodedName) {
} }
} }
function renderEpubs(list) { function renderEpubs(list, isFiltered = false) {
const container = $('#epubList'); const container = $('#epubList');
container.innerHTML = ''; container.innerHTML = '';
if (!list.length) { if (!list.length) {
$('#epubEmpty').textContent = isFiltered ?
'No books match your search filter.' :
'No EPUBs found. Scrape a novel to populate your library.';
$('#epubEmpty').classList.remove('hidden'); $('#epubEmpty').classList.remove('hidden');
return; return;
} }
$('#epubEmpty').classList.add('hidden'); $('#epubEmpty').classList.add('hidden');
// Group EPUBs by base novel slug
const groups = {}; const groups = {};
for (const f of list) { for (const f of list) {
const key = f.base_key || f.name.replace(/(-fixed)?\.epub$/i, '').trim().toLowerCase(); const key = f.base_key || f.name.replace(/(-fixed)?\.epub$/i, '').trim().toLowerCase();
@@ -590,74 +606,72 @@ function renderEpubs(list) {
for (const key of Object.keys(groups)) { for (const key of Object.keys(groups)) {
const g = groups[key]; const g = groups[key];
const groupWrapper = document.createElement('div'); const groupCard = document.createElement('div');
groupWrapper.className = 'flex flex-col'; groupCard.className = 'book-card';
const hasBoth = g.regular && g.fixed;
if (g.regular) { if (g.regular) {
const f = g.regular; const f = g.regular;
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' '); const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
const row = document.createElement('div'); const row = document.createElement('div');
const roundedClass = hasBoth ? 'rounded-t-md border-b-0' : 'rounded-md'; row.className = 'book-edition-row';
row.className = `flex items-center justify-between gap-3.5 border border-theme-border bg-theme-subtle/50 px-4 py-3 hover:border-theme-border-strong transition ${roundedClass}`;
row.innerHTML = ` row.innerHTML = `
<div class="flex items-center gap-3 min-w-0 flex-1"> <div class="book-info">
<svg class="w-4 h-4 text-theme-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="book-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/> <path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/>
<path d="M6 6h10"/> <path d="M6 6h10"/>
<path d="M6 10h10"/> <path d="M6 10h10"/>
</svg> </svg>
<div class="min-w-0 flex-1"> <div class="book-meta-block">
<div class="truncate text-sm font-medium text-theme-text capitalize" title="${cleanTitle}">${cleanTitle}</div> <div class="book-title-line">
<div class="text-xs text-theme-muted font-mono mt-0.5">${f.size_human} • ${new Date(f.mtime * 1000).toLocaleDateString()} ${new Date(f.mtime * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</div> <span class="book-title" title="${cleanTitle}">${cleanTitle}</span>
</div>
<div class="book-specs">${f.size_human} • ${new Date(f.mtime * 1000).toLocaleDateString()} ${new Date(f.mtime * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</div>
</div> </div>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="book-actions">
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="text-sm px-3 py-1.5 rounded-md bg-theme-primary hover:bg-theme-primary-hover text-theme-primary-fg font-medium transition shadow-sm">Download</a> <a href="/api/epubs/${encodeURIComponent(f.name)}" download class="btn-book-action btn-book-download">Download</a>
<button type="button" onclick="triggerFix('${encodeURIComponent(f.name)}')" class="text-sm px-3 py-1.5 rounded-md border border-sky-800 bg-sky-950/40 hover:bg-sky-900/60 text-sky-300 font-medium transition">Fix</button> <button type="button" onclick="triggerFix('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-fix">Fix</button>
<div class="relative inline-block" id="delWrap_${CSS.escape(f.name)}"> <div class="delete-wrap" id="delWrap_${CSS.escape(f.name)}">
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="text-sm px-2.5 py-1.5 rounded-md border border-theme-border hover:border-theme-danger-border text-theme-muted hover:text-theme-danger transition">Delete</button> <button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-delete">Delete</button>
</div> </div>
</div> </div>
`; `;
groupWrapper.appendChild(row); groupCard.appendChild(row);
} }
if (g.fixed) { if (g.fixed) {
const f = g.fixed; const f = g.fixed;
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' '); const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
const row = document.createElement('div'); const row = document.createElement('div');
const roundedClass = hasBoth ? 'rounded-b-md' : 'rounded-md'; row.className = 'book-edition-row fixed-edition';
row.className = `flex items-center justify-between gap-3.5 border border-[#22396F] bg-[#0D1C42] px-4 py-3 text-[#FCF1D0] transition shadow-sm ${roundedClass}`;
row.innerHTML = ` row.innerHTML = `
<div class="flex items-center gap-3 min-w-0 flex-1"> <div class="book-info">
<svg class="w-4 h-4 text-sky-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="book-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/> <path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/>
<path d="M6 6h10"/> <path d="M6 6h10"/>
<path d="M6 10h10"/> <path d="M6 10h10"/>
</svg> </svg>
<div class="min-w-0 flex-1"> <div class="book-meta-block">
<div class="flex items-center gap-2"> <div class="book-title-line">
<span class="truncate text-sm font-medium text-[#FCF1D0] capitalize" title="${cleanTitle}">${cleanTitle}</span> <span class="book-title" title="${cleanTitle}">${cleanTitle}</span>
<span class="text-xs font-mono uppercase px-1.5 py-0.2 rounded bg-sky-950 text-sky-300 border border-sky-700/80">Fixed</span> <span class="badge-fixed">Polished Edition</span>
</div> </div>
<div class="text-xs text-sky-300/70 font-mono mt-0.5">${f.size_human} • ${new Date(f.mtime * 1000).toLocaleDateString()} ${new Date(f.mtime * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</div> <div class="book-specs">${f.size_human} • ${new Date(f.mtime * 1000).toLocaleDateString()} ${new Date(f.mtime * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</div>
</div> </div>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="book-actions">
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="text-sm px-3.5 py-1.5 rounded-md bg-[#FCF1D0] hover:bg-white text-[#0D1C42] font-semibold transition shadow-sm">Download</a> <a href="/api/epubs/${encodeURIComponent(f.name)}" download class="btn-book-action btn-book-download">Download</a>
<div class="relative inline-block" id="delWrap_${CSS.escape(f.name)}"> <div class="delete-wrap" id="delWrap_${CSS.escape(f.name)}">
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="text-sm px-2.5 py-1.5 rounded-md border border-[#22396F] hover:border-rose-900/80 text-sky-300/70 hover:text-rose-400 transition">Delete</button> <button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-delete">Delete</button>
</div> </div>
</div> </div>
`; `;
groupWrapper.appendChild(row); groupCard.appendChild(row);
} }
container.appendChild(groupWrapper); container.appendChild(groupCard);
} }
} }
@@ -666,13 +680,22 @@ function confirmDelete(encodedName) {
const wrap = document.getElementById(`delWrap_${CSS.escape(name)}`); const wrap = document.getElementById(`delWrap_${CSS.escape(name)}`);
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = ` wrap.innerHTML = `
<div class="flex items-center gap-1.5 bg-theme-danger-bg border border-theme-danger-border rounded-md p-1"> <div class="delete-confirm-box">
<button type="button" onclick="executeDelete('${encodeURIComponent(name)}')" class="text-xs px-2.5 py-1 bg-rose-600 text-white rounded font-medium hover:bg-rose-500">Confirm</button> <button type="button" onclick="executeDelete('${encodeURIComponent(name)}')" class="btn-confirm-delete">Confirm</button>
<button type="button" onclick="loadEpubs()" class="text-xs px-2.5 py-1 text-theme-muted hover:text-theme-text">Cancel</button> <button type="button" onclick="loadEpubs()" class="btn-cancel-delete">Cancel</button>
</div> </div>
`; `;
} }
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const confirmBoxes = document.querySelectorAll('.delete-confirm-box');
if (confirmBoxes.length > 0) {
loadEpubs();
}
}
});
async function executeDelete(encodedName) { async function executeDelete(encodedName) {
try { try {
const res = await fetch(`/api/epubs/${encodedName}`, { method: 'DELETE' }); const res = await fetch(`/api/epubs/${encodedName}`, { method: 'DELETE' });
@@ -684,36 +707,49 @@ async function executeDelete(encodedName) {
} }
} }
async function checkStoredJob() { async function syncActiveJob() {
if (!currentJobId) return;
try { try {
const res = await fetch(`/api/jobs/${currentJobId}`); const res = await fetch('/api/jobs/active');
if (res.ok) { if (res.ok) {
const j = await res.json(); const data = await res.json();
showActive(); if (data.active && data.job) {
$('#jobStage').textContent = j.stage; currentJobId = data.job.id;
$('#jobMessage').textContent = j.message || ''; localStorage.setItem('silksoup_active_job', currentJobId);
$('#jobCounter').textContent = `${j.current} / ${j.total}`; showActive(data.job);
const pct = j.total ? Math.round(j.current / j.total * 100) : 0;
$('#jobProgress').style.width = pct + '%';
if (j.logs) {
$('#jobLog').textContent = j.logs.join('\n') + '\n';
}
if (j.status === 'running' || j.status === 'queued') {
connectSSE(currentJobId); connectSSE(currentJobId);
} else { return;
refreshJob();
} }
} else {
localStorage.removeItem('silksoup_active_job');
} }
} catch(e) { } catch(e) {}
localStorage.removeItem('silksoup_active_job');
if (currentJobId) {
try {
const res = await fetch(`/api/jobs/${currentJobId}`);
if (res.ok) {
const j = await res.json();
if (j.status === 'running' || j.status === 'queued') {
showActive(j);
connectSSE(currentJobId);
} else {
showActive(j);
refreshJob();
setFormLocked(false);
}
} else {
localStorage.removeItem('silksoup_active_job');
setFormLocked(false);
}
} catch(e) {
localStorage.removeItem('silksoup_active_job');
setFormLocked(false);
}
} else {
setFormLocked(false);
} }
} }
updateNotifyButton(); updateNotifyButton();
checkStoredJob(); syncActiveJob();
loadEpubs(); loadEpubs();
</script> </script>
</body> </body>