Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78cdf9ecdb | ||
|
|
65c29ec4c0 |
@@ -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
|
||||
@@ -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
@@ -14,11 +14,14 @@ from src.database import db_fetch_one, db_fetch_all, init_db
|
||||
from src.epub_creator import _slugify
|
||||
from .job_manager import (
|
||||
jobs,
|
||||
job_queues,
|
||||
create_job,
|
||||
cancel_job,
|
||||
cancel_all_jobs,
|
||||
run_job,
|
||||
get_active_job,
|
||||
subscribe_job_events,
|
||||
unsubscribe_job_events,
|
||||
WorkerBusyError,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
async def start_job(payload: dict):
|
||||
url = (payload.get("url") or "").strip()
|
||||
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||
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
|
||||
row = db_fetch_one("SELECT json_text FROM main_page WHERE url = ?", (url,))
|
||||
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")
|
||||
|
||||
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))
|
||||
return {"job_id": job_id, "status": "queued"}
|
||||
|
||||
@@ -109,6 +133,13 @@ async def fix_library_epub(filename: str):
|
||||
if "-fixed.epub" in safe.lower():
|
||||
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)
|
||||
if not novel_url:
|
||||
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.",
|
||||
)
|
||||
|
||||
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))
|
||||
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")
|
||||
|
||||
async def event_gen():
|
||||
q = job_queues.get(job_id)
|
||||
if q is None:
|
||||
return
|
||||
last_idx = 0
|
||||
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)
|
||||
q = subscribe_job_events(job_id)
|
||||
try:
|
||||
events = list(job.get("events", []))
|
||||
for evt in events:
|
||||
yield f"data: {json.dumps(evt)}\n\n"
|
||||
if evt.get("message") == "__DONE__":
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
if job["status"] in ("completed", "failed", "cancelled"):
|
||||
break
|
||||
return
|
||||
|
||||
if job.get("status") in ("completed", "failed", "cancelled"):
|
||||
yield f"data: {json.dumps({'stage': 'done', 'message': '__DONE__'})}\n\n"
|
||||
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={
|
||||
"Cache-Control": "no-cache",
|
||||
|
||||
+73
-4
@@ -12,8 +12,61 @@ from src.chapter import clean_chapters, fix_chapters, scrape_chapters
|
||||
from src.epub_creator import create_epub
|
||||
|
||||
jobs: dict[str, dict] = {}
|
||||
job_queues: dict[str, asyncio.Queue] = {}
|
||||
subscribers: dict[str, set[asyncio.Queue]] = {}
|
||||
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):
|
||||
@@ -30,8 +83,9 @@ def _push(job_id: str, event: dict):
|
||||
job["total"] = event["total"]
|
||||
if "message" in event:
|
||||
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:
|
||||
q.put_nowait(event)
|
||||
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:
|
||||
global active_job_id
|
||||
active = get_active_job()
|
||||
if active is not None:
|
||||
raise WorkerBusyError(active)
|
||||
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
active_job_id = job_id
|
||||
jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"url": url,
|
||||
@@ -58,12 +118,13 @@ def create_job(url: str, refresh_mode: str = "none", fix: bool = False, output_d
|
||||
"created_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()
|
||||
return job_id
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
global active_job_id
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
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.")
|
||||
_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__"})
|
||||
if active_job_id == job_id:
|
||||
active_job_id = None
|
||||
return True
|
||||
|
||||
|
||||
def cancel_all_jobs():
|
||||
global active_job_id
|
||||
for job_id, evt in cancel_events.items():
|
||||
job = jobs.get(job_id)
|
||||
if job and job.get("status") in ("running", "queued"):
|
||||
evt.set()
|
||||
job["status"] = "cancelled"
|
||||
job["stage"] = "cancelled"
|
||||
active_job_id = None
|
||||
|
||||
|
||||
async def run_job(job_id: str):
|
||||
global active_job_id
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
@@ -181,3 +247,6 @@ async def run_job(job_id: str):
|
||||
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": "done", "current": 0, "total": 0, "message": "__DONE__"})
|
||||
finally:
|
||||
if active_job_id == job_id:
|
||||
active_job_id = None
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
.library-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.search-wrap {
|
||||
width: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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: #BAE6FD;
|
||||
background-color: var(--btn-blue-hover);
|
||||
border: 1px solid var(--btn-blue);
|
||||
}
|
||||
|
||||
.btn-book-fix:hover {
|
||||
background-color: var(--btn-blue);
|
||||
color: #FFFFFF;
|
||||
border-color: var(--btn-blue-border);
|
||||
}
|
||||
|
||||
.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
@@ -3,111 +3,54 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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/png" href="/static/icon.png">
|
||||
<link rel="apple-touch-icon" href="/static/icon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<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>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</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">
|
||||
<header class="border-b border-theme-border bg-theme-surface sticky top-0 z-30">
|
||||
<div class="max-w-4xl mx-auto px-4 py-3.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/static/icon.png" alt="Silksoup" class="w-8 h-8 rounded-md object-contain shadow-sm">
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand-wrap">
|
||||
<img src="/static/icon.png" alt="Silksoup" class="brand-icon">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base leading-none text-theme-text">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>
|
||||
<div class="brand-title-line">
|
||||
<span class="brand-title">Silksoup</span>
|
||||
<span id="headerBadge" class="header-badge hidden">Active</span>
|
||||
</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 class="flex items-center gap-2.5">
|
||||
<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">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<div class="header-actions">
|
||||
<button id="notifyBtn" onclick="toggleNotifications()" type="button" aria-label="Desktop notifications" class="btn-header">
|
||||
<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="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
|
||||
</svg>
|
||||
<span id="notifyText" class="hidden sm:inline">Notify</span>
|
||||
<span id="notifyText">Notify</span>
|
||||
</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">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<a href="/api/epubs" target="_blank" rel="noopener" class="btn-header">
|
||||
<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="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">API</span>
|
||||
<span>API</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-4xl mx-auto w-full px-4 py-6 flex flex-col gap-6 flex-1">
|
||||
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-base font-semibold text-theme-text">Scrape Novel</h2>
|
||||
</div>
|
||||
<form id="jobForm" class="space-y-4" onsubmit="return false">
|
||||
<div>
|
||||
<label for="urlInput" class="block text-sm font-medium text-theme-text mb-1.5">Novel URL</label>
|
||||
<div class="flex gap-2">
|
||||
<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">
|
||||
<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">
|
||||
<main class="app-main">
|
||||
<section class="card" aria-labelledby="headingScrape">
|
||||
<h2 id="headingScrape" class="card-title">Scrape Novel</h2>
|
||||
<form id="jobForm">
|
||||
<div class="form-group">
|
||||
<label for="urlInput" class="form-label">Novel URL</label>
|
||||
<div class="input-row">
|
||||
<input id="urlInput" type="url" required placeholder="https://kakuyomu.jp/works/... or novel index URL" class="input-text">
|
||||
<button type="button" onclick="pasteClipboard()" class="btn-paste">
|
||||
<svg width="16" height="16" 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"/>
|
||||
<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>
|
||||
@@ -115,49 +58,49 @@ tailwind.config = {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-theme-text mb-1.5">Cache Mode</span>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
<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">
|
||||
<input type="radio" name="refreshMode" value="none" checked class="mt-0.5 text-theme-primary border-theme-border focus:ring-0">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-theme-text">Full Cache</span>
|
||||
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML & AI results</span>
|
||||
<div class="form-group">
|
||||
<span class="form-label">Cache Mode</span>
|
||||
<div class="cache-grid" role="radiogroup" aria-label="Cache Mode">
|
||||
<label class="cache-card">
|
||||
<input type="radio" name="refreshMode" value="none" checked>
|
||||
<div>
|
||||
<div class="cache-card-title">Full Cache</div>
|
||||
<div class="cache-card-desc">Reuse HTML and AI results</div>
|
||||
</div>
|
||||
</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">
|
||||
<input type="radio" name="refreshMode" value="llm" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-theme-text">Re-run AI</span>
|
||||
<span class="text-xs text-theme-muted mt-0.5">Reuse HTML, re-sanitize</span>
|
||||
<label class="cache-card">
|
||||
<input type="radio" name="refreshMode" value="llm">
|
||||
<div>
|
||||
<div class="cache-card-title">Re-run AI</div>
|
||||
<div class="cache-card-desc">Reuse HTML, re-sanitize chapters</div>
|
||||
</div>
|
||||
</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">
|
||||
<input type="radio" name="refreshMode" value="all" class="mt-0.5 text-theme-primary border-theme-border focus:ring-0">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-theme-text">Full Refresh</span>
|
||||
<span class="text-xs text-theme-muted mt-0.5">Bypass cache & fetch fresh</span>
|
||||
<label class="cache-card">
|
||||
<input type="radio" name="refreshMode" value="all">
|
||||
<div>
|
||||
<div class="cache-card-title">Full Refresh</div>
|
||||
<div class="cache-card-desc">Bypass cache and fetch fresh</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<input id="fixCheck" type="checkbox" class="mt-0.5 rounded border-[#22396F] bg-[#090e1a] text-sky-400 focus:ring-0">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-[#FCF1D0]">Fix (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>
|
||||
<div class="form-group">
|
||||
<label class="toggle-card">
|
||||
<input id="fixCheck" type="checkbox">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="cache-card-title">Polished Edition (Automated Post-Editing)</span>
|
||||
<span class="badge-ape">APE</span>
|
||||
</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>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end pt-1">
|
||||
<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">
|
||||
<svg id="btnSpinner" class="hidden w-4 h-4 animate-spin text-current" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-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>
|
||||
<div class="form-actions">
|
||||
<button id="startBtn" type="submit" class="btn-primary">
|
||||
<svg id="btnSpinner" class="spinner hidden" viewBox="0 0 24 24" fill="none">
|
||||
<circle opacity="0.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path opacity="0.75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
||||
</svg>
|
||||
<span id="btnText">Start Scraping</span>
|
||||
</button>
|
||||
@@ -165,106 +108,109 @@ tailwind.config = {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="activeJob" class="hidden rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="statusPulse" class="w-2.5 h-2.5 rounded-full bg-emerald-500"></span>
|
||||
<h2 class="text-base font-semibold text-theme-text">Task Monitor</h2>
|
||||
<section id="activeJob" class="card monitor-section hidden" aria-labelledby="headingMonitor" role="region">
|
||||
<div class="monitor-header">
|
||||
<div class="monitor-title-group">
|
||||
<span id="statusPulse" class="status-dot dot-emerald"></span>
|
||||
<h2 id="headingMonitor" class="card-title" style="margin-bottom: 0;">Task Monitor</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<div class="monitor-actions">
|
||||
<button id="cancelJobBtn" type="button" onclick="requestCancelJob()" class="btn-stop">Stop Process</button>
|
||||
<span id="jobStatusBadge" class="badge-status">idle</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between text-sm gap-3">
|
||||
<div class="flex items-center gap-2 min-w-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="jobMessage" class="text-theme-text truncate font-medium">Starting...</span>
|
||||
<div>
|
||||
<div class="stage-row">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; min-width: 0;">
|
||||
<span id="jobStage" class="stage-pill">init</span>
|
||||
<span id="jobMessage" class="stage-message">Starting...</span>
|
||||
</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 class="h-2 rounded bg-theme-subtle border border-theme-border overflow-hidden">
|
||||
<div id="jobProgress" class="h-full bg-theme-primary transition-all duration-200" style="width: 0%"></div>
|
||||
<div class="progress-track" role="progressbar" id="progressTrack" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="jobProgress" class="progress-fill"></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>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input id="autoScrollCheck" type="checkbox" checked class="rounded border-theme-border bg-theme-subtle text-theme-primary focus:ring-0">
|
||||
<div class="log-actions">
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.375rem; cursor: pointer; user-select: none;">
|
||||
<input id="autoScrollCheck" type="checkbox" checked style="accent-color: var(--primary);">
|
||||
<span>Auto-scroll</span>
|
||||
</label>
|
||||
<button type="button" onclick="clearLogs()" class="hover:text-theme-text transition">Clear</button>
|
||||
<button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="hover:text-theme-text transition">Expand</button>
|
||||
<button type="button" onclick="clearLogs()" class="log-action-btn">Clear</button>
|
||||
<button type="button" onclick="toggleLogHeight()" id="expandLogBtn" class="log-action-btn">Expand</button>
|
||||
</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">
|
||||
<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">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<div id="jobResult" class="result-bar hidden">
|
||||
<a id="downloadLink" href="#" class="btn-primary" style="text-decoration: none;">
|
||||
<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"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span>Download EPUB</span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-theme-border bg-theme-surface p-5 shadow-sm">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-base font-semibold text-theme-text">Library</h2>
|
||||
<span id="epubCount" class="text-sm text-theme-muted font-mono font-medium"></span>
|
||||
<section class="card" aria-labelledby="headingLibrary">
|
||||
<div class="library-header">
|
||||
<div class="library-title-group">
|
||||
<h2 id="headingLibrary" class="card-title" style="margin-bottom: 0;">Library</h2>
|
||||
<span id="epubCount" class="library-counter"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1 sm:w-64">
|
||||
<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">
|
||||
<div class="library-controls">
|
||||
<div class="search-wrap">
|
||||
<input id="searchEpub" type="text" oninput="filterEpubs()" placeholder="Filter books..." class="input-text" style="min-height: 2.25rem; font-size: 0.875rem;">
|
||||
</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 id="epubList" class="space-y-3"></div>
|
||||
<div id="epubEmpty" class="text-sm text-theme-muted py-8 text-center hidden">
|
||||
<div id="epubList" class="book-shelf"></div>
|
||||
<div id="epubEmpty" class="empty-state hidden">
|
||||
No EPUBs found. Scrape a novel to populate your library.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-theme-border bg-theme-surface p-5">
|
||||
<h3 class="text-sm font-semibold text-theme-text mb-3">Workflow Pipeline</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 text-xs text-theme-muted">
|
||||
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
|
||||
<span class="text-sm font-medium text-theme-text block mb-1">1. Ingest & Cache</span>
|
||||
Fetches novel HTML via anti-bot client into database.
|
||||
<details class="guide-details">
|
||||
<summary class="guide-summary">
|
||||
<span>Workflow Pipeline Guide</span>
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">Details</span>
|
||||
</summary>
|
||||
<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 class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
|
||||
<span class="text-sm font-medium text-theme-text block mb-1">2. Parse Index</span>
|
||||
<div class="guide-step">
|
||||
<div class="guide-step-title">2. Parse Index</div>
|
||||
LLM extracts chronological volumes and chapter URLs.
|
||||
</div>
|
||||
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
|
||||
<span class="text-sm font-medium text-theme-text block mb-1">3. Sanitize & APE</span>
|
||||
Fixes bad MTL, typos, entities, and formats notes into blockquotes.
|
||||
<div class="guide-step">
|
||||
<div class="guide-step-title">3. Sanitize & APE</div>
|
||||
Fixes machine translation flaws, entities, and formats notes into blockquotes.
|
||||
</div>
|
||||
<div class="p-3 rounded-md bg-theme-subtle/70 border border-theme-border/70">
|
||||
<span class="text-sm font-medium text-theme-text block mb-1">4. Build EPUB</span>
|
||||
<div class="guide-step">
|
||||
<div class="guide-step-title">4. Build EPUB</div>
|
||||
Downloads images, compresses assets, and compiles book.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
</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">
|
||||
Silksoup — Novel to EPUB Pipeline
|
||||
<footer class="app-footer">
|
||||
Silksoup: Novel to EPUB Pipeline
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
@@ -276,7 +222,7 @@ let originalTitle = document.title;
|
||||
|
||||
function updateNotifyButton() {
|
||||
if (Notification.permission === 'granted') {
|
||||
$('#notifyBtn').classList.add('text-emerald-400', 'border-emerald-800');
|
||||
$('#notifyBtn').classList.add('active');
|
||||
$('#notifyText').textContent = 'Notifications on';
|
||||
}
|
||||
}
|
||||
@@ -289,9 +235,9 @@ async function toggleNotifications() {
|
||||
updateNotifyButton();
|
||||
}
|
||||
} else if (Notification.permission === 'denied') {
|
||||
showToast('Notifications are blocked in browser settings', 'error');
|
||||
showToast('Notifications blocked in browser settings', 'error');
|
||||
} 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') {
|
||||
const container = $('#toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
const borderClass = type === 'error' ? 'border-theme-danger-border bg-theme-danger-bg text-theme-danger' :
|
||||
type === 'success' ? 'border-emerald-800 bg-emerald-950 text-emerald-200' :
|
||||
'border-theme-border bg-theme-surface text-theme-text';
|
||||
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}`;
|
||||
const typeClass = type === 'error' ? 'toast-error' :
|
||||
type === 'success' ? 'toast-success' : 'toast-info';
|
||||
toast.className = `toast ${typeClass}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.remove('opacity-0');
|
||||
toast.classList.add('show');
|
||||
});
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0');
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 150);
|
||||
}, 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();
|
||||
if (!url) {
|
||||
showToast('Please enter a novel URL', 'error');
|
||||
@@ -362,6 +315,12 @@ $('#startBtn').addEventListener('click', async () => {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
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) {
|
||||
const j = await res.json();
|
||||
throw new Error(j.detail || 'Failed to submit job');
|
||||
@@ -377,19 +336,60 @@ $('#startBtn').addEventListener('click', async () => {
|
||||
} finally {
|
||||
setBtnLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
$('#jobForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitJob();
|
||||
});
|
||||
|
||||
function showActive() {
|
||||
function showActive(jobData = null) {
|
||||
setFormLocked(true);
|
||||
$('#activeJob').classList.remove('hidden');
|
||||
$('#headerBadge').classList.remove('hidden');
|
||||
$('#cancelJobBtn').classList.remove('hidden');
|
||||
$('#jobLog').textContent = '';
|
||||
$('#jobProgress').style.width = '0%';
|
||||
$('#jobCounter').textContent = '0 / 0';
|
||||
$('#jobStage').textContent = 'queued';
|
||||
$('#jobStatusBadge').textContent = 'running';
|
||||
$('#jobResult').classList.add('hidden');
|
||||
$('#statusPulse').className = 'w-2.5 h-2.5 rounded-full bg-emerald-500';
|
||||
if (!jobData) {
|
||||
$('#jobLog').textContent = '';
|
||||
$('#jobProgress').style.width = '0%';
|
||||
$('#progressTrack').setAttribute('aria-valuenow', '0');
|
||||
$('#jobCounter').textContent = '0 / 0';
|
||||
$('#jobStage').textContent = 'queued';
|
||||
$('#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' });
|
||||
}
|
||||
|
||||
@@ -402,6 +402,7 @@ async function requestCancelJob() {
|
||||
const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
showToast('Process cancellation requested', 'info');
|
||||
setFormLocked(false);
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('Failed to cancel job', 'error');
|
||||
@@ -417,6 +418,7 @@ function dismissActiveJob() {
|
||||
if (evtSource) evtSource.close();
|
||||
$('#activeJob').classList.add('hidden');
|
||||
$('#headerBadge').classList.add('hidden');
|
||||
setFormLocked(false);
|
||||
document.title = originalTitle;
|
||||
}
|
||||
|
||||
@@ -431,6 +433,7 @@ function connectSSE(jobId) {
|
||||
evtSource.close();
|
||||
refreshJob();
|
||||
loadEpubs();
|
||||
setFormLocked(false);
|
||||
return;
|
||||
}
|
||||
const pct = evt.total ? Math.round(evt.current / evt.total * 100) : 0;
|
||||
@@ -438,6 +441,7 @@ function connectSSE(jobId) {
|
||||
$('#jobMessage').textContent = evt.message || '';
|
||||
$('#jobCounter').textContent = `${evt.current} / ${evt.total}`;
|
||||
$('#jobProgress').style.width = pct + '%';
|
||||
$('#progressTrack').setAttribute('aria-valuenow', String(pct));
|
||||
|
||||
if (evt.stage === 'scraping' || evt.stage === 'cleaning' || evt.stage === 'fixing' || evt.stage === 'epub') {
|
||||
document.title = `(${pct}%) Silksoup`;
|
||||
@@ -445,23 +449,29 @@ function connectSSE(jobId) {
|
||||
|
||||
if (evt.stage === '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');
|
||||
document.title = `Failed — Silksoup`;
|
||||
setFormLocked(false);
|
||||
loadEpubs();
|
||||
document.title = 'Failed: Silksoup';
|
||||
showToast(`Job failed: ${evt.message}`, 'error');
|
||||
}
|
||||
if (evt.stage === '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');
|
||||
document.title = `Silksoup`;
|
||||
setFormLocked(false);
|
||||
loadEpubs();
|
||||
document.title = 'Silksoup';
|
||||
showToast('Process stopped. Cached chapters remain safe.', 'info');
|
||||
}
|
||||
if (evt.stage === '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');
|
||||
document.title = `Completed — Silksoup`;
|
||||
setFormLocked(false);
|
||||
loadEpubs();
|
||||
document.title = 'Completed: Silksoup';
|
||||
notify('Silksoup EPUB Ready', evt.message || 'Book compiled successfully.');
|
||||
showToast('EPUB compiled successfully', 'success');
|
||||
}
|
||||
@@ -485,7 +495,7 @@ async function refreshJob() {
|
||||
const j = await res.json();
|
||||
$('#jobStatusBadge').textContent = j.status;
|
||||
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');
|
||||
if (j.result_path) {
|
||||
const name = j.result_path.split(/[\\/]/).pop();
|
||||
@@ -495,11 +505,11 @@ async function refreshJob() {
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
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');
|
||||
$('#jobLog').textContent += `\n[ERROR] ${j.error || j.message}\n`;
|
||||
}
|
||||
@@ -514,13 +524,11 @@ function toggleLogHeight() {
|
||||
const log = $('#jobLog');
|
||||
const btn = $('#expandLogBtn');
|
||||
if (isLogExpanded) {
|
||||
log.classList.remove('h-96');
|
||||
log.classList.add('h-40');
|
||||
log.classList.remove('expanded');
|
||||
btn.textContent = 'Expand';
|
||||
isLogExpanded = false;
|
||||
} else {
|
||||
log.classList.remove('h-40');
|
||||
log.classList.add('h-96');
|
||||
log.classList.add('expanded');
|
||||
btn.textContent = 'Collapse';
|
||||
isLogExpanded = true;
|
||||
}
|
||||
@@ -545,13 +553,19 @@ function filterEpubs() {
|
||||
return;
|
||||
}
|
||||
const filtered = allEpubs.filter(f => f.name.toLowerCase().includes(query));
|
||||
renderEpubs(filtered);
|
||||
renderEpubs(filtered, true);
|
||||
}
|
||||
|
||||
async function triggerFix(encodedName) {
|
||||
const name = decodeURIComponent(encodedName);
|
||||
try {
|
||||
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) {
|
||||
const j = await res.json();
|
||||
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');
|
||||
container.innerHTML = '';
|
||||
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');
|
||||
return;
|
||||
}
|
||||
$('#epubEmpty').classList.add('hidden');
|
||||
|
||||
// Group EPUBs by base novel slug
|
||||
const groups = {};
|
||||
for (const f of list) {
|
||||
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)) {
|
||||
const g = groups[key];
|
||||
const groupWrapper = document.createElement('div');
|
||||
groupWrapper.className = 'flex flex-col';
|
||||
|
||||
const hasBoth = g.regular && g.fixed;
|
||||
const groupCard = document.createElement('div');
|
||||
groupCard.className = 'book-card';
|
||||
|
||||
if (g.regular) {
|
||||
const f = g.regular;
|
||||
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
|
||||
const row = document.createElement('div');
|
||||
const roundedClass = hasBoth ? 'rounded-t-md border-b-0' : 'rounded-md';
|
||||
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.className = 'book-edition-row';
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<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">
|
||||
<div class="book-info">
|
||||
<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="M6 6h10"/>
|
||||
<path d="M6 10h10"/>
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-theme-text capitalize" title="${cleanTitle}">${cleanTitle}</div>
|
||||
<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>
|
||||
<div class="book-meta-block">
|
||||
<div class="book-title-line">
|
||||
<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 class="flex items-center gap-2 shrink-0">
|
||||
<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>
|
||||
<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>
|
||||
<div class="relative inline-block" 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>
|
||||
<div class="book-actions">
|
||||
<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="btn-book-action btn-book-fix">Fix (APE)</button>
|
||||
<div class="delete-wrap" id="delWrap_${CSS.escape(f.name)}">
|
||||
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
groupWrapper.appendChild(row);
|
||||
groupCard.appendChild(row);
|
||||
}
|
||||
|
||||
if (g.fixed) {
|
||||
const f = g.fixed;
|
||||
const cleanTitle = f.name.replace(/\.epub$/i, '').replace(/[-_]/g, ' ');
|
||||
const row = document.createElement('div');
|
||||
const roundedClass = hasBoth ? 'rounded-b-md' : 'rounded-md';
|
||||
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.className = 'book-edition-row fixed-edition';
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<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">
|
||||
<div class="book-info">
|
||||
<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="M6 6h10"/>
|
||||
<path d="M6 10h10"/>
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-medium text-[#FCF1D0] capitalize" 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>
|
||||
<div class="book-meta-block">
|
||||
<div class="book-title-line">
|
||||
<span class="book-title" title="${cleanTitle}">${cleanTitle}</span>
|
||||
<span class="badge-fixed">Polished Edition</span>
|
||||
</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 class="flex items-center gap-2 shrink-0">
|
||||
<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>
|
||||
<div class="relative inline-block" 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>
|
||||
<div class="book-actions">
|
||||
<a href="/api/epubs/${encodeURIComponent(f.name)}" download class="btn-book-action btn-book-download">Download</a>
|
||||
<div class="delete-wrap" id="delWrap_${CSS.escape(f.name)}">
|
||||
<button type="button" onclick="confirmDelete('${encodeURIComponent(f.name)}')" class="btn-book-action btn-book-delete">Delete</button>
|
||||
</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)}`);
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = `
|
||||
<div class="flex items-center gap-1.5 bg-theme-danger-bg border border-theme-danger-border rounded-md p-1">
|
||||
<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="loadEpubs()" class="text-xs px-2.5 py-1 text-theme-muted hover:text-theme-text">Cancel</button>
|
||||
<div class="delete-confirm-box">
|
||||
<button type="button" onclick="executeDelete('${encodeURIComponent(name)}')" class="btn-confirm-delete">Confirm</button>
|
||||
<button type="button" onclick="loadEpubs()" class="btn-cancel-delete">Cancel</button>
|
||||
</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) {
|
||||
try {
|
||||
const res = await fetch(`/api/epubs/${encodedName}`, { method: 'DELETE' });
|
||||
@@ -684,36 +707,49 @@ async function executeDelete(encodedName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStoredJob() {
|
||||
if (!currentJobId) return;
|
||||
async function syncActiveJob() {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${currentJobId}`);
|
||||
const res = await fetch('/api/jobs/active');
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
showActive();
|
||||
$('#jobStage').textContent = j.stage;
|
||||
$('#jobMessage').textContent = j.message || '';
|
||||
$('#jobCounter').textContent = `${j.current} / ${j.total}`;
|
||||
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') {
|
||||
const data = await res.json();
|
||||
if (data.active && data.job) {
|
||||
currentJobId = data.job.id;
|
||||
localStorage.setItem('silksoup_active_job', currentJobId);
|
||||
showActive(data.job);
|
||||
connectSSE(currentJobId);
|
||||
} else {
|
||||
refreshJob();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('silksoup_active_job');
|
||||
}
|
||||
} catch(e) {
|
||||
localStorage.removeItem('silksoup_active_job');
|
||||
} catch(e) {}
|
||||
|
||||
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();
|
||||
checkStoredJob();
|
||||
syncActiveJob();
|
||||
loadEpubs();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user