Consolidate MangaCircus app, scraper, and Compose baseline
This commit is contained in:
1 parent
cfb2d79103
commit
eef4ffa07c
103 files changed
+9316
-172
No files matched your search
+196
-8
@@ -20,6 +20,7 @@ h1 { font-size: 26px; line-height: 1.2; margin: 0 0 6px; }
|
||||
}
|
||||
.btn:hover { background: var(--card2); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn.danger { border-color: #8c3f48; color: #ff9aa7; }
|
||||
.btn.on { border-color: var(--accent); color: #ffb3bd; }
|
||||
.btn.small { min-height: 28px; padding: 2px 9px; font-size: 13px; }
|
||||
.btn.icon { padding: 6px 9px; }
|
||||
@@ -48,8 +49,39 @@ label { display: block; }
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
|
||||
/* library */
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.toolbar input { flex: 1; min-width: 0; }
|
||||
.reading-summary {
|
||||
display: grid; grid-template-columns: minmax(150px, .7fr) minmax(220px, 1fr) auto; align-items: center; gap: 12px 22px;
|
||||
padding: 14px 16px; margin-bottom: 16px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
border-radius: 12px; background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 12%, var(--card)), var(--card));
|
||||
}
|
||||
.collection-mosaic { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.collection-mosaic img { width: 100%; height: 100%; object-fit: cover; min-height: 0; }
|
||||
.collection-count { display: inline-block; background: var(--card2); padding: 2px 6px; border-radius: 5px; font-size: 12px; }
|
||||
.collection-heading { grid-column: 1 / -1; margin: 12px 0 0; }
|
||||
.collection-member { display: flex; gap: 16px; align-items: center; padding: 10px; border-bottom: 1px solid var(--line); text-decoration: none; }
|
||||
.collection-member img { width: 55px; height: 80px; object-fit: cover; }
|
||||
.collection-editor > div { padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.collection-editor input[type=number] { width: 75px; }
|
||||
.catalog-json { width: 100%; min-height: 350px; font: 13px/1.4 monospace; }
|
||||
.card > textarea { display: block; width: 100%; margin: 10px 0; }
|
||||
.reading-summary-title { display: flex; gap: 8px; align-items: center; }
|
||||
.reading-summary-stats { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 7px; }
|
||||
.reading-summary-stats strong { font-size: 18px; }
|
||||
.reading-summary-links { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px 14px; font-size: 13px; }
|
||||
.story-pill { padding: 1px 7px; border-radius: 10px; background: var(--card2); color: var(--muted); font-size: 12px; }
|
||||
.library-controls { margin-bottom: 18px; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.toolbar input { flex: 1; min-width: 180px; }
|
||||
.filter-group { border-top: 1px solid var(--line); padding-top: 10px; }
|
||||
.filter-heading { display: flex; align-items: baseline; gap: 10px; margin-bottom: 7px; }
|
||||
.tag-filter, .volume-filter { display: flex; gap: 7px; overflow-x: auto; padding: 1px 1px 7px; scrollbar-width: thin; }
|
||||
.filter-chip {
|
||||
flex: none; border: 1px solid var(--line); border-radius: 14px; padding: 3px 10px; background: var(--card); color: var(--fg);
|
||||
font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.filter-chip:hover { background: var(--card2); }
|
||||
.filter-chip.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 22%, var(--card)); color: #ffd8de; }
|
||||
.result-count { margin-top: 6px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 16px; }
|
||||
.tile { position: relative; display: block; text-decoration: none; }
|
||||
.tile .cv { aspect-ratio: 2 / 3; border-radius: 8px; overflow: hidden; background: var(--card); box-shadow: 0 3px 14px rgba(0, 0, 0, .45); }
|
||||
@@ -63,8 +95,13 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.empty { text-align: center; padding: 60px 10px; }
|
||||
@media (max-width: 560px) {
|
||||
main { padding: 12px; }
|
||||
.toolbar { flex-wrap: wrap; }
|
||||
.toolbar input { flex-basis: 100%; }
|
||||
.toolbar select { flex: 1; }
|
||||
.grid { grid-template-columns: repeat(2, 1fr); gap: 16px 12px; }
|
||||
.nav { padding-left: 12px; padding-right: 12px; }
|
||||
.reading-summary { grid-template-columns: 1fr; gap: 5px; }
|
||||
.reading-summary-links { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
/* manga page */
|
||||
@@ -73,26 +110,76 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.hero .info { min-width: 0; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||
.chip { background: var(--card2); border-radius: 12px; padding: 1px 9px; font-size: 13px; }
|
||||
.chip-link { text-decoration: none; border: 1px solid transparent; }
|
||||
.chip-link:hover { border-color: var(--accent); color: #ffd8de; }
|
||||
.desc { white-space: pre-line; margin: 10px 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.sources a { margin-right: 10px; }
|
||||
.metadata.card { max-width: none; }
|
||||
.reading-story.card { max-width: none; padding: 0; overflow: hidden; }
|
||||
.reading-story-summary {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 14px 16px; cursor: pointer;
|
||||
background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 12%, var(--card)), var(--card));
|
||||
}
|
||||
.reading-story[open] > .reading-story-summary { border-bottom: 1px solid var(--line); }
|
||||
.story-stats { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 10px; padding: 14px 16px 8px; }
|
||||
.story-stat { min-width: 0; }
|
||||
.story-stat strong, .story-stat span { display: block; }
|
||||
.story-stat strong { font-size: 19px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.story-last { margin: 4px 16px 16px; }
|
||||
.story-rhythm-head { display: flex; align-items: baseline; gap: 9px; margin: 0 16px 7px; }
|
||||
.story-rhythm {
|
||||
display: flex; align-items: flex-end; gap: 3px; min-height: 68px; margin: 0 16px; padding: 6px 3px 3px;
|
||||
overflow-x: auto; border-bottom: 1px solid var(--line); scrollbar-width: thin;
|
||||
}
|
||||
.rhythm-bar { flex: 0 0 7px; min-height: 3px; border-radius: 3px 3px 1px 1px; background: var(--accent); opacity: .86; }
|
||||
.rhythm-bar:hover, .rhythm-bar:focus { opacity: 1; outline: 2px solid #fff; outline-offset: 1px; }
|
||||
.rhythm-bar.empty { height: 3px; background: var(--line); opacity: 1; }
|
||||
.story-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px 14px; }
|
||||
.metadata-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; list-style-position: outside; }
|
||||
.metadata.card .metadata-summary h2 { display: inline; margin: 0; }
|
||||
.metadata-summary::after { content: 'Show'; color: var(--muted); font-size: 13px; font-weight: 400; }
|
||||
.metadata[open] > .metadata-summary { margin-bottom: 12px; }
|
||||
.metadata[open] > .metadata-summary::after { content: 'Hide'; }
|
||||
.meta-grid { display: grid; grid-template-columns: minmax(130px, max-content) minmax(0, 1fr); gap: 8px 18px; margin: 0; }
|
||||
.meta-grid dt { color: var(--muted); }
|
||||
.meta-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
.meta-tags, .meta-links { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
|
||||
.raw-meta { margin-top: 16px; }
|
||||
.raw-meta summary { cursor: pointer; color: var(--muted); }
|
||||
.raw-meta pre { max-height: 440px; overflow: auto; padding: 12px; border-radius: 8px; background: var(--bg); color: var(--muted); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@media (max-width: 640px) {
|
||||
.hero { flex-direction: column; align-items: center; text-align: center; }
|
||||
.hero > img { width: 180px; }
|
||||
.chips, .actions { justify-content: center; }
|
||||
.meta-grid { grid-template-columns: 1fr; gap: 2px; }
|
||||
.meta-grid dd { margin-bottom: 9px; }
|
||||
.story-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.story-actions { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
.chapters { border-top: 1px solid var(--line); }
|
||||
.chapter-section { margin-top: 24px; }
|
||||
.chapter-heading { display: flex; align-items: baseline; gap: 10px; }
|
||||
.chapter-heading h2 { margin: 0; }
|
||||
.volume-filter { margin: 9px 0 6px; max-width: 100%; }
|
||||
.chapter-list-wrap { max-width: 100%; overflow-x: auto; }
|
||||
.chapters { display: inline-block; width: max-content; min-width: min(440px, 100%); max-width: 100%; border-top: 1px solid var(--line); }
|
||||
.book-sep { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; padding: 16px 4px 6px; }
|
||||
.ch { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); }
|
||||
.ch-main { flex: 1; min-width: 0; display: flex; gap: 10px; align-items: baseline; padding: 12px 4px; text-decoration: none; }
|
||||
.chapter-entry { width: 100%; }
|
||||
.ch { display: flex; width: 100%; max-width: 100%; align-items: center; gap: 6px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
.ch-main { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: 76px 52px minmax(70px, 1fr) max-content; gap: 8px; align-items: baseline; padding: 6px 4px; text-decoration: none; }
|
||||
.ch-main.has-time { grid-template-columns: 76px 52px 72px minmax(70px, 1fr) max-content; }
|
||||
.ch-main:hover .ch-title { text-decoration: underline; }
|
||||
.ch-label { font-weight: 650; flex: none; }
|
||||
.ch-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||
.ch-pages { min-width: 0; overflow: hidden; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; text-align: left; white-space: nowrap; }
|
||||
.ch-time { min-width: 0; overflow: hidden; color: #c9a7ad; font-size: 13px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ch-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ch.read .ch-main { color: var(--muted); }
|
||||
.tag { font-size: 11px; border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; color: var(--muted); flex: none; }
|
||||
.state { color: var(--muted); font-size: 13px; background: none; border: 0; cursor: pointer; padding: 6px; font: inherit; font-size: 13px; }
|
||||
.state.done { color: #7fd3a0; }
|
||||
.versions { background: var(--card); border-radius: 8px; margin: 4px 0 10px; padding: 4px 10px; }
|
||||
.ch > .btn.small { min-height: 24px; padding: 1px 7px; }
|
||||
.ch > .state { padding: 3px 4px; }
|
||||
.versions { width: 100%; background: var(--card); border-radius: 8px; margin: 4px 0 10px; padding: 4px 10px; }
|
||||
.ver { display: flex; align-items: center; gap: 10px; padding: 7px 0; flex-wrap: wrap; }
|
||||
.ver + .ver { border-top: 1px solid var(--line); }
|
||||
.ver .src { flex: 1; min-width: 140px; }
|
||||
@@ -100,8 +187,71 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.vote button { background: var(--card2); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; width: 30px; height: 28px; cursor: pointer; }
|
||||
.vote button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.vote b { min-width: 22px; text-align: center; }
|
||||
.chapter-empty { margin: 16px 0; }
|
||||
@media (max-width: 640px) {
|
||||
.chapters { display: block; width: 100%; min-width: 0; }
|
||||
.ch { flex-wrap: nowrap; gap: 4px; overflow: hidden; }
|
||||
.ch-main { grid-template-columns: 64px 44px minmax(0, 1fr) max-content; gap: 6px; }
|
||||
.ch-main.has-time { grid-template-columns: 64px 44px 62px minmax(0, 1fr) max-content; }
|
||||
.ch > .btn.small { min-width: 0; padding-left: 5px; padding-right: 5px; }
|
||||
.ch > .state { flex: none; }
|
||||
}
|
||||
|
||||
/* settings & login */
|
||||
.settings-layout { display: grid; grid-template-columns: 225px minmax(0, 1fr); gap: 28px; align-items: start; }
|
||||
.settings-menu { position: sticky; top: 76px; border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: var(--card); }
|
||||
.settings-menu-group + .settings-menu-group { margin-top: 18px; border-top: 1px solid var(--line); padding-top: 14px; }
|
||||
.settings-menu-group h2 { margin: 0 8px 7px; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.settings-menu a { display: block; padding: 8px; border-radius: 7px; text-decoration: none; }
|
||||
.settings-menu a:hover { background: var(--card2); }
|
||||
.settings-menu a[aria-current=page] { background: var(--card2); color: #ffb3bd; box-shadow: inset 3px 0 var(--accent); }
|
||||
.settings-workspace, .settings-content { min-width: 0; }
|
||||
.settings-breadcrumb { margin: 0 0 5px; }
|
||||
.settings-title-row { display: flex; align-items: center; gap: 12px; }
|
||||
.settings-hint { margin: 5px 0 20px; }
|
||||
.settings-content .card { max-width: none; }
|
||||
.catalog-workspace { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.catalog-workspace > .row { margin-bottom: 16px; }
|
||||
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
|
||||
.catalog-workspace .card > input, .catalog-workspace .card > textarea { display: block; width: 100%; margin-bottom: 12px; }
|
||||
.catalog-workspace .collection-editor .row { padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.catalog-workspace .collection-editor input[type=number] { width: 80px; }
|
||||
.catalog-workspace .catalog-json { width: 100%; min-height: 280px; font-family: monospace; }
|
||||
@media (max-width: 760px) {
|
||||
.catalog-workspace [data-member] > span { flex-basis: 100%; overflow-wrap: anywhere; }
|
||||
}
|
||||
.settings-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
|
||||
.settings-tabs a[aria-current=page] { border-color: var(--accent); color: #ffb3bd; }
|
||||
.scraper-filters { margin: 0 0 18px; }
|
||||
.scraper-filters button[aria-pressed=true] { border-color: var(--accent); color: #ffb3bd; background: color-mix(in srgb, var(--accent) 12%, var(--card)); }
|
||||
.scraper-overview { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 18px; }
|
||||
.scraper-overview span { border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px; font-size: 13px; }
|
||||
.scraper-add, .scraper-completed, .scraper-history { border: 1px solid var(--line); border-radius: 9px; padding: 11px 13px; margin: 14px 0; }
|
||||
.scraper-add > summary, .scraper-completed > summary, .scraper-history > summary { cursor: pointer; font-weight: 600; }
|
||||
.scraper-add[open] > summary, .scraper-completed[open] > summary, .scraper-history[open] > summary { margin-bottom: 12px; }
|
||||
.scraper-update-list { margin: 12px 0; border-top: 1px solid var(--line); }
|
||||
.scraper-update-row { display: grid; grid-template-columns: minmax(180px, 1.5fr) minmax(100px, .7fr) repeat(2, minmax(130px, 1fr)); gap: 5px 12px; align-items: center; padding: 11px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
.scraper-update-row label { min-width: 0; overflow-wrap: anywhere; }
|
||||
.scraper-update-row .saved { grid-column: 3 / -1; }
|
||||
.scraper-source-card { border: 1px solid var(--line); border-radius: 10px; padding: 12px; margin: 0 0 14px; }
|
||||
.scraper-source-card h3 { margin: 0 0 5px; }
|
||||
.scraper-source-card .source-rate { border-top: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.scraper-update-row { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.scraper-update-row label { grid-column: 1 / -1; }
|
||||
.scraper-update-row .saved { grid-column: 1 / -1; }
|
||||
}
|
||||
.settings-content .scraper-item .row { flex-shrink: 0; }
|
||||
.settings-menu a:focus-visible, .settings-tabs a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
@media (max-width: 760px) {
|
||||
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: 18px; }
|
||||
.settings-menu { position: static; padding: 10px; }
|
||||
.settings-menu-group { display: flex; flex-wrap: wrap; gap: 3px; align-items: center; }
|
||||
.settings-menu-group h2 { flex: 0 0 100%; margin: 0 5px 4px; }
|
||||
.settings-menu-group + .settings-menu-group { margin-top: 9px; padding-top: 9px; }
|
||||
.settings-menu a { font-size: 13px; padding: 7px 9px; }
|
||||
.settings-content .scraper-item { flex-wrap: wrap; }
|
||||
}
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; max-width: 720px; }
|
||||
.card h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
@@ -113,6 +263,24 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.login { max-width: 360px; margin: 12vh auto 0; }
|
||||
.login input { width: 100%; }
|
||||
.saved { color: #7fd3a0; font-size: 13px; }
|
||||
.reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.reading-data > summary { cursor: pointer; font-weight: 600; }
|
||||
.reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.reading-danger p { margin: 3px 0 8px; }
|
||||
.scraper-card { max-width: 980px; }
|
||||
.scraper-progress { height: 7px; max-width: 620px; margin: 8px 0 4px; overflow: hidden; border-radius: 5px; background: var(--line); }
|
||||
.scraper-progress i { display: block; height: 100%; background: var(--accent); transition: width .25s; }
|
||||
.scraper-queue { border-top: 1px solid var(--line); }
|
||||
.scraper-item { display: flex; align-items: center; gap: 7px; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.scraper-item.active { border-left: 3px solid var(--accent); padding-left: 8px; }
|
||||
@media (max-width: 600px) {
|
||||
.scraper-item { flex-wrap: wrap; }
|
||||
.scraper-item > .grow { flex-basis: 100%; width: 100%; }
|
||||
}
|
||||
.source-rate { padding: 9px 0; border-top: 1px solid var(--line); }
|
||||
.source-rate > b { display: block; margin-bottom: 6px; }
|
||||
.rate-row label { display: grid; gap: 3px; color: var(--muted); }
|
||||
.rate-row input { width: 115px; }
|
||||
|
||||
/* ---------------- reader ---------------- */
|
||||
body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden; background: #0b0b0c; overscroll-behavior: none; }
|
||||
@@ -122,6 +290,7 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
|
||||
-webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #3a3a40 transparent;
|
||||
}
|
||||
#stage.lock-x { overflow-x: hidden; touch-action: pan-y; overscroll-behavior-x: none; }
|
||||
#stage.pan { cursor: grab; }
|
||||
#stage.drag { cursor: grabbing; }
|
||||
#canvas { position: relative; }
|
||||
@@ -166,10 +335,29 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
|
||||
.seg button + button { border-left: 1px solid var(--line); }
|
||||
.seg button.on { background: var(--accent); color: #fff; }
|
||||
.seg button.meh:not(.on) { color: var(--muted); }
|
||||
.zoom-controls button { min-width: 34px; padding-left: 9px; padding-right: 9px; }
|
||||
.zoom-controls #zoom { min-width: 58px; font-variant-numeric: tabular-nums; }
|
||||
.clock-chip { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.clock-chip.clock-running { border-color: #3d8b62; background: #183426; }
|
||||
.clock-chip.clock-warning { border-color: #c58a27; background: #3b2c14; }
|
||||
.clock-panel {
|
||||
position: fixed; right: max(8px, env(safe-area-inset-right)); bottom: calc(112px + env(safe-area-inset-bottom)); z-index: 25;
|
||||
width: min(360px, calc(100vw - 16px)); padding: 14px; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: rgba(20, 20, 23, .98); color: var(--fg); box-shadow: 0 12px 40px rgba(0, 0, 0, .55);
|
||||
}
|
||||
.clock-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.clock-state { color: var(--muted); font-size: 13px; text-align: right; }
|
||||
.clock-details { display: grid; grid-template-columns: auto 1fr; gap: 5px 16px; margin: 12px 0; }
|
||||
.clock-details dt { color: var(--muted); }
|
||||
.clock-details dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
#ver { max-width: 170px; }
|
||||
#toast {
|
||||
position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none;
|
||||
background: rgba(0, 0, 0, .75); color: #fff; padding: 9px 16px; border-radius: 10px; opacity: 0; transition: opacity .25s; text-align: center;
|
||||
}
|
||||
#toast.show { opacity: 1; }
|
||||
@media (max-width: 560px) { .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } }
|
||||
@media (max-width: 700px) { .clock-label { display: none; } }
|
||||
@media (max-width: 560px) {
|
||||
.seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; }
|
||||
.clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Collection · MangaCircus</title><link rel="stylesheet" href="app.css"><script src="common.js" defer></script><script src="collection.js" defer></script></head><body><main><p>Loading…</p></main></body></html>
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
(async () => {
|
||||
try {
|
||||
const [me, c] = await Promise.all([api('api/me'), api('api/collection?' + qsa({ id: new URLSearchParams(location.search).get('id') }))]);
|
||||
topNav(me); document.title = c.title + ' · MangaCircus';
|
||||
const main = $('main'); main.textContent = '';
|
||||
main.append(h('section', { class: 'hero' }, c.cover ? h('img', { src: c.cover, alt: '' }) : null,
|
||||
h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`),
|
||||
c.description ? h('p', {}, c.description) : null,
|
||||
c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null,
|
||||
h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v) }, c.continue.text),
|
||||
me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null))));
|
||||
main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m =>
|
||||
h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null,
|
||||
h('div', {}, h('strong', {}, `${m.readingOrder}. ${m.title}`), h('div', { class: 'muted' }, `${m.role.replaceAll('-', ' ')} · ${m.status} · ${m.read}/${m.chapters} read`))))));
|
||||
if (c.relations.length) main.append(h('details', { class: 'card' }, h('summary', {}, 'Relationships & evidence'), c.relations.map(r =>
|
||||
h('p', {}, `${c.members.find(m => m.id === r.from)?.title || r.from} → ${r.type} → ${c.members.find(m => m.id === r.to)?.title || r.to}`,
|
||||
h('span', { class: 'muted' }, ' · ' + r.explanation)))));
|
||||
} catch (e) { showError(e); }
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Catalog Curator · MangaCircus</title><link rel="stylesheet" href="app.css"><script src="common.js" defer></script><script src="settings-nav.js" defer></script><script src="collections.js" defer></script></head><body><main><p>Loading…</p></main></body></html>
|
||||
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
// randomUUID is secure-context-only; tailnet HTTP and LAN IPs need this fallback.
|
||||
function collectionId() {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
(async () => {
|
||||
let me, data;
|
||||
try { [me, data] = await Promise.all([api('api/me'), api('api/admin/catalog')]); } catch (e) { return showError(e); }
|
||||
topNav(me);
|
||||
let selected = new URLSearchParams(location.search).get('id');
|
||||
const main = settingsShell(me, 'admin/collections').content;
|
||||
const message = h('p', { role: 'status', 'aria-live': 'polite' });
|
||||
let dirty = false, busy = false, workspace, jsonDraft = null;
|
||||
const notice = (text, error = false) => {
|
||||
message.textContent = text; message.className = error ? 'error' : '';
|
||||
if (error) message.scrollIntoView({ block: 'nearest' });
|
||||
};
|
||||
const changed = () => { dirty = true; notice('Unsaved changes — save the collection to apply them.'); };
|
||||
addEventListener('beforeunload', event => { if (dirty || jsonDraft !== null) { event.preventDefault(); event.returnValue = ''; } });
|
||||
async function request(endpoint, body, success = 'Saved', replaceJson = false) {
|
||||
if (busy) return false;
|
||||
if (jsonDraft !== null && !replaceJson) { notice('Validate & save JSON first, or Reload catalog to discard the JSON draft.', true); return false; }
|
||||
busy = true; workspace.disabled = true; main.setAttribute('aria-busy', 'true'); notice('Saving…');
|
||||
try {
|
||||
data = { ...data, ...await api(endpoint, body) }; dirty = false; jsonDraft = null; busy = false; draw(); notice(success); return true;
|
||||
} catch (e) { notice(e.message + (dirty ? ' Your unsaved changes are still here.' : ''), true); return false; }
|
||||
finally { busy = false; workspace.disabled = false; main.removeAttribute('aria-busy'); }
|
||||
}
|
||||
const persist = () => request('api/admin/catalog', data);
|
||||
const cachedRequest = endpoint => dirty || jsonDraft !== null
|
||||
? notice('Save your changes before importing or researching metadata.', true)
|
||||
: request(endpoint, { m: researchManga, revision: data.revision });
|
||||
let researchManga;
|
||||
const select = (items, value, change, label = 'Catalog selection') => {
|
||||
const input = h('select', { 'aria-label': label, onchange: e => change(e.target.value) }, items.map(item => h('option', { value: typeof item === 'object' ? item.id : item }, typeof item === 'object' ? item.title : item)));
|
||||
input.value = value; return input;
|
||||
};
|
||||
function draw() {
|
||||
main.textContent = '';
|
||||
workspace = h('fieldset', { class: 'catalog-workspace', disabled: busy });
|
||||
main.append(h('p', { class: 'muted' }, 'Collections organize the library. Relationships describe works. Reading order is your choice.'), message, workspace);
|
||||
workspace.append(h('div', { class: 'row' }, select([{ id: '', title: 'Choose collection' }, ...data.collections], selected || '', value => { selected = value; draw(); }, 'Choose collection'),
|
||||
h('button', { class: 'btn', onclick: () => {
|
||||
const unused = data.mangas.find(m => !data.collections.some(c => c.members.some(member => member.mangaId === m.id)));
|
||||
if (!unused) { notice(data.mangas.length ? 'All manga are already grouped. Remove a manga from its current collection before creating another.' : 'Add manga to the library before creating a collection.', true); return; }
|
||||
try {
|
||||
const c = { id: collectionId(), title: 'New collection', description: '', members: [{ mangaId: unused.id, role: 'main', readingOrder: 1 }], coverMode: 'selected', coverMangaId: unused.id, grouped: true };
|
||||
data.collections.push(c); selected = c.id; changed(); draw(); $('input[aria-label="Collection title"]', main).focus();
|
||||
} catch (e) { notice(e.message, true); }
|
||||
} }, 'New collection'),
|
||||
h('button', { class: 'btn', onclick: async () => {
|
||||
if ((dirty || jsonDraft !== null) && !confirm('Discard unsaved catalog changes and reload?')) return;
|
||||
await request('api/admin/catalog', undefined, 'Catalog reloaded', true);
|
||||
} }, 'Reload catalog'), h('button', { class: 'btn', onclick: persist }, 'Save all changes')));
|
||||
const c = data.collections.find(c => c.id === selected);
|
||||
if (c) {
|
||||
const title = h('input', { value: c.title, 'aria-label': 'Collection title', oninput: e => { c.title = e.target.value; changed(); } });
|
||||
const description = h('textarea', { 'aria-label': 'Collection description', oninput: e => { c.description = e.target.value; changed(); } }, c.description);
|
||||
const file = h('input', { type: 'file', accept: 'image/jpeg,image/png,image/webp,image/gif,image/avif,image/bmp', 'aria-label': 'Custom cover', onchange: async e => {
|
||||
const chosen = e.target.files[0]; if (!chosen) return;
|
||||
if (chosen.size > 600000) { notice('Choose an image under 600 KB.', true); return; }
|
||||
// Read first, then serialize save + upload so no other edit can race its revision.
|
||||
let base64;
|
||||
try { base64 = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader(); reader.onerror = () => reject(new Error('Could not read this image.'));
|
||||
reader.onload = () => resolve(reader.result.split(',')[1]); reader.readAsDataURL(chosen);
|
||||
}); } catch (err) { notice(err.message, true); return; }
|
||||
if (!await persist()) return;
|
||||
await request('api/admin/collection-cover', { id: c.id, revision: data.revision, base64 }, 'Cover saved');
|
||||
} });
|
||||
const members = h('div', { class: 'collection-editor' });
|
||||
for (const member of c.members) members.append(h('div', { class: 'row', 'data-member': member.mangaId },
|
||||
h('span', { class: 'grow' }, data.mangas.find(m => m.id === member.mangaId)?.title || member.mangaId),
|
||||
select(data.roles, member.role, value => { member.role = value; changed(); }, 'Member role'),
|
||||
h('input', { type: 'number', min: 0, value: member.readingOrder, 'aria-label': 'Reading order', oninput: e => { member.readingOrder = +e.target.value; changed(); } }),
|
||||
h('button', { class: 'btn small', disabled: c.members.length === 1, title: c.members.length === 1 ? 'A collection needs a member. Delete the collection to ungroup its last manga.' : 'Remove from this collection', onclick: () => { c.members = c.members.filter(m => m !== member); if (c.coverMangaId === member.mangaId) c.coverMangaId = c.members[0]?.mangaId; changed(); draw(); } }, 'Remove')));
|
||||
const available = data.mangas.filter(m => !data.collections.some(group => group.members.some(member => member.mangaId === m.id)));
|
||||
const add = select([{ id: '', title: available.length ? 'Add a manga…' : 'No ungrouped manga available' }, ...available], '', value => { if (value) { c.members.push({ mangaId: value, role: 'side-story', readingOrder: Math.max(0, ...c.members.map(m => m.readingOrder)) + 1 }); changed(); draw(); } }, 'Add manga to collection');
|
||||
add.disabled = !available.length;
|
||||
workspace.append(h('section', { class: 'card' }, h('h2', {}, 'Collection'), title, description,
|
||||
h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: c.grouped, onchange: e => { c.grouped = e.target.checked; changed(); } }), 'Group on the dashboard'),
|
||||
h('div', { class: 'row' }, h('span', {}, 'Cover'), select(['selected', 'mosaic', ...(c.customCover ? ['custom'] : [])], c.coverMode, value => { c.coverMode = value; changed(); }, 'Cover mode'),
|
||||
select(c.members.map(member => ({ id: member.mangaId, title: data.mangas.find(m => m.id === member.mangaId)?.title || member.mangaId })), c.coverMangaId, value => { c.coverMangaId = value; changed(); }, 'Cover manga'), file),
|
||||
h('p', { class: 'muted small' }, 'Uploading a custom cover saves your changes first. A collection must contain at least one manga.'),
|
||||
h('h3', {}, 'Members & reading order'), members, add,
|
||||
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: persist }, 'Save collection'),
|
||||
h('button', { class: 'btn danger', onclick: async () => { if (!confirm('Remove this collection? Its manga and reading progress remain.')) return; data.collections = data.collections.filter(group => group !== c); selected = ''; changed(); if (!await persist()) draw(); } }, 'Delete collection'))));
|
||||
}
|
||||
const from = select(data.mangas, data.mangas[0]?.id, () => {}, 'Relationship source'), to = select(data.mangas, data.mangas[1]?.id, () => {}, 'Relationship target'), type = select(data.types, 'sequel', () => {}, 'Relationship type');
|
||||
workspace.append(h('section', { class: 'card' }, h('h2', {}, 'Relationships'),
|
||||
h('p', { class: 'muted' }, 'Direction: the target is a sequel/prequel/etc. of the source. Suggestions need review; accepting a relationship does not create a collection.'),
|
||||
h('div', { class: 'row' }, from, h('span', {}, '→'), type, h('span', {}, '→'), to,
|
||||
h('button', { class: 'btn', onclick: () => {
|
||||
if (dirty || jsonDraft !== null) return notice('Save your collection changes before editing relationships.', true);
|
||||
if (from.value === to.value) return notice('Choose two different manga for a relationship.', true);
|
||||
if (data.relations.some(r => r.from === from.value && r.to === to.value && r.type === type.value)) return notice('That relationship already exists.', true);
|
||||
data.relations.push({ from: from.value, to: to.value, type: type.value, state: 'confirmed', method: 'manual', evidence: [], explanation: '' }); changed(); persist();
|
||||
} }, 'Add relationship')),
|
||||
h('button', { class: 'btn', onclick: () => cachedRequest('api/admin/catalog/suggest') }, 'Import cached metadata suggestions'),
|
||||
h('div', { class: 'collection-editor' }, data.relations.map(r => h('div', { 'data-relation': JSON.stringify([r.from, r.to, r.type]) },
|
||||
h('p', {}, `${data.mangas.find(m => m.id === r.from)?.title || r.from} → ${r.type} → ${data.mangas.find(m => m.id === r.to)?.title || r.to}`),
|
||||
h('p', { class: 'muted small' }, r.explanation),
|
||||
r.evidence.map(e => h('a', { href: safeHref(e.url), target: '_blank', rel: 'noopener', class: 'btn small' }, e.provider || 'Evidence')),
|
||||
select(['suggested', 'confirmed', 'rejected', 'conflicted'], r.state, value => {
|
||||
if (dirty || jsonDraft !== null) { notice('Save your collection changes before editing relationships.', true); draw(); return; }
|
||||
r.state = value; changed(); persist();
|
||||
}, 'Relationship state'),
|
||||
h('button', { class: 'btn small', onclick: () => {
|
||||
if (dirty || jsonDraft !== null) return notice('Save your collection changes before editing relationships.', true);
|
||||
data.relations = data.relations.filter(item => item !== r); changed(); persist();
|
||||
} }, 'Remove'))))));
|
||||
researchManga = researchManga || data.mangas[0]?.id;
|
||||
const research = select(data.mangas, researchManga, value => researchManga = value, 'Research manga');
|
||||
workspace.append(h('section', { class: 'card' }, h('h2', {}, 'Public metadata research'),
|
||||
h('p', { class: 'muted' }, 'Uses the verified AniList ID and a 30-day cache. Results remain suggestions. An agent can use the JSON export to research conflicts and propose edits.'),
|
||||
h('div', { class: 'row' }, research, h('button', { class: 'btn', onclick: () => cachedRequest('api/admin/catalog/research') }, 'Research relationships'))));
|
||||
const json = h('textarea', { class: 'catalog-json', 'aria-label': 'Catalog JSON', oninput: e => { jsonDraft = e.target.value; notice('Unsaved JSON — use Validate & save JSON to apply it.'); } }, jsonDraft ?? JSON.stringify({ version: 1, revision: data.revision, collections: data.collections, relations: data.relations }, null, 2));
|
||||
workspace.append(h('details', { class: 'card' }, h('summary', {}, 'Advanced · JSON import/export & agent research packet'), json,
|
||||
h('button', { class: 'btn', onclick: async () => { try { const parsed = JSON.parse(json.value); await request('api/admin/catalog', parsed, 'Saved', true); } catch (e) { notice(e.message, true); } } }, 'Validate & save JSON')));
|
||||
}
|
||||
draw();
|
||||
})();
|
||||
@@ -49,9 +49,25 @@ function ago(t) {
|
||||
return new Date(t).toLocaleDateString();
|
||||
}
|
||||
|
||||
function readingDuration(ms, compact = false) {
|
||||
const seconds = Math.max(0, Math.round((+ms || 0) / 1000));
|
||||
if (!seconds) return 'Not timed';
|
||||
if (seconds < 60) return compact ? '<1 min' : 'Under a minute';
|
||||
const minutes = Math.max(1, Math.round(seconds / 60));
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60), rest = minutes % 60;
|
||||
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||
}
|
||||
|
||||
const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); };
|
||||
const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v });
|
||||
const mangaUrl = m => 'manga.html?' + qsa({ m });
|
||||
const libraryUrlForTags = tags => {
|
||||
const p = new URLSearchParams();
|
||||
for (const tag of tags || []) if (tag) p.append('tag', tag);
|
||||
const query = p.toString();
|
||||
return './' + (query ? '?' + query : '');
|
||||
};
|
||||
const safeHref = u => (/^https?:\/\//i.test(u || '') ? u : null);
|
||||
|
||||
// Top navigation with the notification bell.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Service health · MangaCircus</title><link rel="stylesheet" href="app.css"><script src="common.js" defer></script><script src="settings-nav.js" defer></script><script src="health.js" defer></script></head><body><main><p>Loading…</p></main></body></html>
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
(async () => {
|
||||
let me;
|
||||
try { me = await api('api/me'); } catch (e) { return showError(e); }
|
||||
topNav(me);
|
||||
if (!me.admin) return showError(new Error('Administrator access required.'));
|
||||
const main = settingsShell(me, 'admin/health').content;
|
||||
async function load() {
|
||||
try {
|
||||
const [health, scraper] = await Promise.all([api('api/admin/health'), api('api/admin/scraper')]);
|
||||
main.textContent = '';
|
||||
main.append(h('section', { class: 'card' },
|
||||
h('p', {}, `Reader: ${health.reader.ok ? 'healthy' : 'scan error'} · Sentinel: ${health.sentinel.status} · Scraper: ${scraper.status || 'unavailable'}`),
|
||||
h('p', { class: 'muted small' }, health.sentinel.checkedAt ? `Last independent check: ${new Date(health.sentinel.checkedAt).toLocaleString()}` : 'Sentinel has not reported yet.'),
|
||||
h('a', { class: 'btn', href: 'settings.html#admin/scraper' }, 'Queue & hoster settings')));
|
||||
for (const entry of scraper.active || []) main.append(h('section', { class: 'card' }, h('h2', {}, entry.source),
|
||||
h('p', {}, `${entry.current.title} · ${entry.job?.completed || 0}/${entry.job?.total || '?'} chapters`),
|
||||
h('p', { class: 'muted' }, `Checkpoint: ${entry.job?.updated || 'catalog discovery'}`)));
|
||||
const failures = (scraper.queue || []).filter(item => ['error', 'quarantined', 'unavailable', 'retry_wait'].includes(item.status));
|
||||
main.append(h('section', { class: 'card' }, h('h2', {}, 'Jobs needing attention'), failures.length ? failures.map(item => h('div', { class: 'collection-editor' },
|
||||
h('p', {}, `${item.title} · ${item.status} · ${item.failure?.kind || 'unknown'}`), h('p', { class: 'muted' }, item.error || ''),
|
||||
item.nextAttemptAt ? h('p', {}, `Retry scheduled: ${new Date(item.nextAttemptAt).toLocaleString()}`) : null,
|
||||
h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => {
|
||||
if (item.failure?.kind === 'access' && !confirm('Has the source access problem been resolved? This manually authorizes one retry.')) return;
|
||||
await api('api/admin/scraper', { command: 'control', action: item.status === 'unavailable' ? 'inspect' : 'retry', id: item.id }); load();
|
||||
} }, item.status === 'unavailable' ? 'Inspect source' : 'Retry'), h('button', { class: 'btn', onclick: async () => { await api('api/admin/scraper', { command: 'control', action: 'skip', id: item.id }); load(); } }, 'Skip job')))) : h('p', { class: 'muted' }, 'No failed jobs.')));
|
||||
const incidents = [...(health.sentinel.incidents || []), ...(scraper.incidents || [])];
|
||||
main.append(h('details', { class: 'card', open: incidents.some(i => i.state === 'open') }, h('summary', {}, 'Incident & recovery history'), incidents.slice(-50).map(i =>
|
||||
h('p', {}, `${i.at} · ${i.state} · ${i.message || i.failure?.message || ''}${i.resolution ? ' · ' + i.resolution : ''}`))));
|
||||
} catch (e) { main.textContent = ''; main.append(h('p', { class: 'error', role: 'alert' }, e.message)); }
|
||||
setTimeout(load, document.hidden ? 30000 : 10000);
|
||||
}
|
||||
load();
|
||||
})();
|
||||
+129
-14
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* Library overview: a grid of covers. */
|
||||
/* Library overview: searchable covers with combinable metadata filters. */
|
||||
(async function () {
|
||||
let me, data;
|
||||
try { [me, data] = await Promise.all([api('api/me'), api('api/library')]); } catch (e) { return showError(e); }
|
||||
@@ -14,32 +14,147 @@
|
||||
if (data.scanning) setTimeout(() => location.reload(), 2000);
|
||||
return;
|
||||
}
|
||||
const storyAvailable = !!(data.reading && data.reading.activeMs > 0);
|
||||
|
||||
const filter = h('input', { type: 'search', placeholder: 'Filter…', 'aria-label': 'Filter', value: store.get('filter') || '' });
|
||||
const sort = h('select', { 'aria-label': 'Sort' },
|
||||
h('option', { value: 'title' }, 'A–Z'), h('option', { value: 'updated' }, 'Recently updated'), h('option', { value: 'read' }, 'Recently read'));
|
||||
sort.value = store.get('sort') || 'title';
|
||||
const allTags = [...new Set(data.mangas.flatMap(m => m.tags || []).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
const allStatuses = [...new Set(data.mangas.map(m => m.status).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
const requestedTags = new URLSearchParams(location.search).getAll('tag').filter(tag => allTags.includes(tag));
|
||||
let rememberedTags = [];
|
||||
try { rememberedTags = JSON.parse(store.get('tags') || '[]'); } catch (_) { /* ignore old invalid state */ }
|
||||
const selectedTags = new Set((requestedTags.length ? requestedTags : rememberedTags).filter(tag => allTags.includes(tag)));
|
||||
|
||||
const filter = h('input', { type: 'search', placeholder: 'Search titles or authors…', 'aria-label': 'Search library', value: requestedTags.length ? '' : (store.get('filter') || '') });
|
||||
const status = h('select', { 'aria-label': 'Filter by publication status' }, h('option', { value: '' }, 'All statuses'),
|
||||
allStatuses.map(value => h('option', { value }, value)));
|
||||
status.value = !requestedTags.length && allStatuses.includes(store.get('status')) ? store.get('status') : '';
|
||||
const sort = h('select', { 'aria-label': 'Sort library' },
|
||||
h('option', { value: 'title' }, 'Title A–Z'),
|
||||
h('option', { value: 'title-desc' }, 'Title Z–A'),
|
||||
h('option', { value: 'release-desc' }, 'First release · newest'),
|
||||
h('option', { value: 'release-asc' }, 'First release · oldest'),
|
||||
h('option', { value: 'updated' }, 'Recently updated'),
|
||||
h('option', { value: 'read' }, 'Recently read'),
|
||||
storyAvailable ? h('option', { value: 'timed' }, 'Recently timed') : null,
|
||||
storyAvailable ? h('option', { value: 'time' }, 'Time together') : null);
|
||||
const allowedSorts = ['title', 'title-desc', 'release-desc', 'release-asc', 'updated', 'read', ...(storyAvailable ? ['timed', 'time'] : [])];
|
||||
sort.value = allowedSorts.includes(store.get('sort')) ? store.get('sort') : 'title';
|
||||
const resultCount = h('div', { class: 'muted small result-count', 'aria-live': 'polite' });
|
||||
const tagButtons = new Map();
|
||||
const tagBar = h('div', { class: 'tag-filter', 'aria-label': 'Filter by tags' });
|
||||
const clearTags = h('button', { class: 'filter-chip', type: 'button' }, 'All tags');
|
||||
clearTags.onclick = () => { selectedTags.clear(); syncTags(); render(); };
|
||||
tagBar.append(clearTags);
|
||||
for (const tag of allTags) {
|
||||
const button = h('button', { class: 'filter-chip', type: 'button' }, tag);
|
||||
button.onclick = () => { selectedTags.has(tag) ? selectedTags.delete(tag) : selectedTags.add(tag); syncTags(); render(); };
|
||||
tagButtons.set(tag, button);
|
||||
tagBar.append(button);
|
||||
}
|
||||
const grid = h('div', { class: 'grid' });
|
||||
main.append(h('div', { class: 'toolbar' }, filter, sort), grid);
|
||||
const grouping = h('select', { 'aria-label': 'Collection display' },
|
||||
h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga'));
|
||||
grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped';
|
||||
grouping.onchange = render;
|
||||
if (storyAvailable) {
|
||||
const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId);
|
||||
const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId);
|
||||
main.append(h('section', { class: 'reading-summary' },
|
||||
h('div', { class: 'reading-summary-title' }, h('span', { 'aria-hidden': 'true' }, '⏱'), h('b', {}, 'Your Reading Story')),
|
||||
h('div', { class: 'reading-summary-stats' },
|
||||
h('strong', {}, readingDuration(data.reading.activeMs)),
|
||||
h('span', { class: 'muted' }, `together · ${data.reading.timedChapters} chapter${data.reading.timedChapters === 1 ? '' : 's'} timed`),
|
||||
data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null),
|
||||
h('div', { class: 'reading-summary-links' },
|
||||
most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null,
|
||||
latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId) }, `Continue ${latest.title} →`) : null)));
|
||||
}
|
||||
main.append(
|
||||
h('div', { class: 'library-controls' },
|
||||
h('div', { class: 'toolbar' }, filter, status, sort, grouping,
|
||||
me.admin ? h('a', { class: 'btn', href: 'collections.html' }, 'Manage collections') : null),
|
||||
allTags.length ? h('div', { class: 'filter-group' },
|
||||
h('div', { class: 'filter-heading' }, h('b', {}, 'Tags'), h('span', { class: 'muted small' }, 'Manga must match every selected tag')),
|
||||
tagBar) : null,
|
||||
resultCount),
|
||||
grid);
|
||||
|
||||
function syncTags() {
|
||||
store.set('tags', JSON.stringify([...selectedTags]));
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete('tag');
|
||||
for (const tag of selectedTags) params.append('tag', tag);
|
||||
const query = params.toString();
|
||||
history.replaceState(null, '', location.pathname + (query ? '?' + query : ''));
|
||||
}
|
||||
|
||||
function releaseSort(a, b, direction) {
|
||||
const at = +a.firstReleaseAt || 0, bt = +b.firstReleaseAt || 0;
|
||||
if (!at && !bt) return a.title.localeCompare(b.title);
|
||||
if (!at) return 1;
|
||||
if (!bt) return -1;
|
||||
return direction * (at - bt) || a.title.localeCompare(b.title);
|
||||
}
|
||||
|
||||
function render() {
|
||||
store.set('filter', filter.value); store.set('sort', sort.value);
|
||||
store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value);
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
const list = data.mangas.filter(m => !q || m.title.toLowerCase().includes(q));
|
||||
const groups = (data.collections || []).filter(c => c.grouped);
|
||||
const membership = new Set(groups.flatMap(c => c.members.map(m => m.id)));
|
||||
const candidates = grouping.value === 'grouped'
|
||||
? [...data.mangas.filter(m => !membership.has(m.id)), ...groups.map(c => ({ ...c, isCollection: true }))] : [...data.mangas];
|
||||
const matches = m => {
|
||||
const haystack = [m.title, ...(m.altTitles || []), ...(m.authors || [])].join(' ').toLowerCase();
|
||||
return (!q || haystack.includes(q)) && (!status.value || m.status === status.value) &&
|
||||
[...selectedTags].every(tag => (m.tags || []).includes(tag));
|
||||
};
|
||||
// Match complete tag combinations on a single member, not unrelated tags spread across a group.
|
||||
const list = candidates.filter(m => m.isCollection ? m.members.some(matches) || (q && m.title.toLowerCase().includes(q) && !status.value && !selectedTags.size) : matches(m));
|
||||
if (sort.value === 'title') list.sort((a, b) => a.title.localeCompare(b.title));
|
||||
if (sort.value === 'title-desc') list.sort((a, b) => b.title.localeCompare(a.title));
|
||||
if (sort.value === 'release-asc') list.sort((a, b) => releaseSort(a, b, 1));
|
||||
if (sort.value === 'release-desc') list.sort((a, b) => releaseSort(a, b, -1));
|
||||
if (sort.value === 'updated') list.sort((a, b) => b.updated - a.updated);
|
||||
if (sort.value === 'read') list.sort((a, b) => b.lastRead - a.lastRead);
|
||||
if (sort.value === 'timed') list.sort((a, b) => (b.reading?.updatedAt || 0) - (a.reading?.updatedAt || 0));
|
||||
if (sort.value === 'time') list.sort((a, b) => (b.reading?.activeMs || 0) - (a.reading?.activeMs || 0));
|
||||
clearTags.classList.toggle('on', !selectedTags.size);
|
||||
clearTags.setAttribute('aria-pressed', String(!selectedTags.size));
|
||||
for (const [tag, button] of tagButtons) {
|
||||
button.classList.toggle('on', selectedTags.has(tag));
|
||||
button.setAttribute('aria-pressed', String(selectedTags.has(tag)));
|
||||
}
|
||||
resultCount.textContent = `${list.length} results · ${data.mangas.length} manga in the library`;
|
||||
grid.textContent = '';
|
||||
for (const m of list) {
|
||||
grid.append(h('a', { class: 'tile', href: mangaUrl(m.id) },
|
||||
h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null),
|
||||
const tile = m => {
|
||||
const cover = m.isCollection && m.coverMode === 'mosaic' && m.covers.length
|
||||
? h('div', { class: 'cv collection-mosaic' }, m.covers.map(src => h('img', { loading: 'lazy', src, alt: '' })))
|
||||
: h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null);
|
||||
return h('a', { class: 'tile', href: m.isCollection ? 'collection.html?' + qsa({ id: m.id }) : mangaUrl(m.id) },
|
||||
cover, m.isCollection ? h('span', { class: 'collection-count' }, `${m.members.length} manga`) : null,
|
||||
m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null,
|
||||
h('div', { class: 't' }, m.title),
|
||||
h('div', { class: 's' }, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapter${m.chapters === 1 ? '' : 's'}`),
|
||||
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / m.chapters)}%` })) : null));
|
||||
h('div', { class: 's' }, [m.firstRelease, m.status, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapters`].filter(Boolean).join(' · ')),
|
||||
m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / Math.max(1, m.chapters))}%` })) : null);
|
||||
};
|
||||
if (grouping.value === 'expanded') {
|
||||
const visible = new Set(list.map(m => m.id));
|
||||
for (const c of groups) {
|
||||
const members = c.members.filter(m => visible.has(m.id));
|
||||
if (!members.length) continue;
|
||||
grid.append(h('h2', { class: 'collection-heading' }, h('a', { href: 'collection.html?' + qsa({ id: c.id }) }, c.title)));
|
||||
members.forEach(m => { grid.append(tile(m)); visible.delete(m.id); });
|
||||
}
|
||||
list.filter(m => visible.has(m.id)).forEach(m => grid.append(tile(m)));
|
||||
return;
|
||||
}
|
||||
if (!list.length) grid.append(h('p', { class: 'muted' }, 'Nothing matches.'));
|
||||
for (const m of list) {
|
||||
grid.append(tile(m));
|
||||
}
|
||||
if (!list.length) grid.append(h('p', { class: 'muted' }, 'Nothing matches these filters.'));
|
||||
}
|
||||
filter.addEventListener('input', render);
|
||||
status.addEventListener('change', render);
|
||||
sort.addEventListener('change', render);
|
||||
syncTags();
|
||||
render();
|
||||
})();
|
||||
+211
-31
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* Manga page: info, follow toggle and the chapter list (books are just separators). */
|
||||
/* Manga overview: complete metadata, volume filters and newest-first chapters. */
|
||||
(async function () {
|
||||
const id = new URLSearchParams(location.search).get('m') || '';
|
||||
let me, M;
|
||||
@@ -11,63 +11,225 @@
|
||||
|
||||
const TYPE = { rtl: 'Manga · right to left', ltr: 'Comic · left to right', ttb: 'Webtoon · vertical strip' };
|
||||
const arr = x => (Array.isArray(x) ? x : x ? [x] : []);
|
||||
const text = value => {
|
||||
if (value == null || value === '') return '';
|
||||
if (Array.isArray(value)) return value.map(text).filter(Boolean).join(', ');
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
};
|
||||
const releaseDate = () => {
|
||||
const raw = meta.firstReleaseDate || meta.releaseDate || meta.startDate;
|
||||
if (!raw) return text(meta.year);
|
||||
if (typeof raw !== 'object') return text(raw);
|
||||
return [raw.year, raw.month && String(raw.month).padStart(2, '0'), raw.day && String(raw.day).padStart(2, '0')].filter(Boolean).join('-');
|
||||
};
|
||||
const follow = h('button', { class: 'btn' }, '');
|
||||
const drawFollow = () => { follow.textContent = M.following ? '🔔 Notifying you' : 'Notify me about new chapters'; follow.classList.toggle('on', M.following); };
|
||||
follow.onclick = async () => { M.following = (await api('api/follow', { m: M.id, on: !M.following })).following; drawFollow(); };
|
||||
drawFollow();
|
||||
|
||||
const sources = arr(meta.sources).filter(s => s && s.name);
|
||||
const sources = arr(meta.sources).filter(source => source && (source.name || source.url));
|
||||
const tags = arr(meta.tags).filter(Boolean);
|
||||
const books = {};
|
||||
for (const book of M.books) books[book.key] = book;
|
||||
const selectedBooks = new Set();
|
||||
main.append(h('section', { class: 'hero' },
|
||||
M.cover ? h('img', { src: M.cover, alt: '' }) : null,
|
||||
h('div', { class: 'info' },
|
||||
h('h1', {}, M.title),
|
||||
arr(meta.altTitles).length ? h('div', { class: 'muted' }, arr(meta.altTitles).join(' · ')) : null,
|
||||
h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, meta.year, TYPE[M.direction]].filter(Boolean).join(' · ')),
|
||||
h('div', { class: 'chips' }, arr(meta.tags).map(t => h('span', { class: 'chip' }, t))),
|
||||
h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, releaseDate(), TYPE[M.direction]].filter(Boolean).join(' · ')),
|
||||
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
|
||||
meta.description ? h('p', { class: 'desc' }, meta.description) : null,
|
||||
sources.length ? h('div', { class: 'sources small' }, h('span', { class: 'muted' }, 'Sources: '),
|
||||
sources.map(s => (safeHref(s.url) ? h('a', { href: safeHref(s.url), target: '_blank', rel: 'noopener noreferrer' }, s.name) : h('span', {}, s.name + ' ')))) : null,
|
||||
h('div', { class: 'actions' },
|
||||
M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v) }, M.continue.text) : null,
|
||||
follow))));
|
||||
|
||||
const books = {};
|
||||
for (const b of M.books) books[b.key] = b;
|
||||
let storyRhythm = null;
|
||||
if (M.collections?.length || M.relations?.length) main.append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Collections & related manga'),
|
||||
h('div', { class: 'chips' }, (M.collections || []).map(c => h('a', { class: 'btn', href: 'collection.html?' + qsa({ id: c.id }) }, c.title))),
|
||||
(M.relations || []).map(r => h('p', {}, h('a', { href: mangaUrl(r.otherId) }, r.otherTitle),
|
||||
` · ${r.from === M.id ? 'target is ' : 'this manga is '}${r.type.replaceAll('-', ' ')}`,
|
||||
h('details', {}, h('summary', {}, 'Evidence'), h('p', { class: 'muted' }, r.explanation),
|
||||
r.evidence.map(e => h('a', { class: 'btn small', href: safeHref(e.url), target: '_blank', rel: 'noopener' }, e.provider || 'Source')))))));
|
||||
if (M.reading && M.reading.activeMs > 0) {
|
||||
const lastChapter = M.chapters.find(chapter => chapter.id === M.reading.lastChapterId);
|
||||
storyRhythm = h('div', { class: 'story-rhythm', role: 'list', 'aria-label': 'Completed chapter reading times' });
|
||||
const openKey = `reading-story-open.${me.name}`;
|
||||
const story = h('details', { class: 'reading-story card', open: store.get(openKey) !== '0' },
|
||||
h('summary', { class: 'reading-story-summary' },
|
||||
h('span', {}, h('span', { 'aria-hidden': 'true' }, '⏱ '), h('b', {}, 'Your Reading Story')),
|
||||
h('span', { class: 'muted small' }, readingDuration(M.reading.activeMs) + ' together')),
|
||||
h('div', { class: 'story-stats' },
|
||||
storyStat(readingDuration(M.reading.activeMs), 'time together'),
|
||||
storyStat(M.reading.timedChapters, `chapter${M.reading.timedChapters === 1 ? '' : 's'} timed`),
|
||||
storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'),
|
||||
storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)),
|
||||
lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ',
|
||||
h('a', { href: readUrl(M.id, lastChapter.id) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`),
|
||||
` · ${ago(M.reading.updatedAt)}`) : null,
|
||||
h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')),
|
||||
storyRhythm,
|
||||
h('div', { class: 'story-actions' },
|
||||
h('span', { class: 'muted small' }, 'Only you can see these measurements.'),
|
||||
h('button', { class: 'btn small', onclick: async () => {
|
||||
if (!confirm(`Forget all reading time recorded for ${M.title}? Reading progress is kept.`)) return;
|
||||
await api('api/reading/delete', { m: M.id });
|
||||
location.reload();
|
||||
} }, 'Forget this manga’s time')));
|
||||
story.addEventListener('toggle', () => store.set(openKey, story.open ? '1' : '0'));
|
||||
main.append(story);
|
||||
drawStoryRhythm();
|
||||
}
|
||||
|
||||
function storyStat(value, label) {
|
||||
return h('div', { class: 'story-stat' }, h('strong', {}, value), h('span', { class: 'muted small' }, label));
|
||||
}
|
||||
|
||||
function drawStoryRhythm() {
|
||||
if (!storyRhythm) return;
|
||||
const chapters = M.chapters.filter(chapter => !selectedBooks.size || selectedBooks.has(chapter.book));
|
||||
const completed = chapters.map(chapter => chapter.reading?.lastCompleteMs || 0).filter(Boolean);
|
||||
const scale = Math.max(1, ...completed.map(value => Math.log1p(value)));
|
||||
storyRhythm.textContent = '';
|
||||
for (const chapter of chapters) {
|
||||
const value = chapter.reading?.lastCompleteMs || 0;
|
||||
const label = `${chapter.label}${chapter.title ? ' · ' + chapter.title : ''}`;
|
||||
if (!value) {
|
||||
storyRhythm.append(h('span', { class: 'rhythm-bar empty', role: 'listitem', title: `${label} · Not timed`, 'aria-label': `${label}, not timed` }));
|
||||
continue;
|
||||
}
|
||||
const height = Math.max(12, Math.round(12 + 48 * Math.log1p(value) / scale));
|
||||
const passes = chapter.reading.completedPasses;
|
||||
const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`;
|
||||
storyRhythm.append(h('a', {
|
||||
class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id), title,
|
||||
'aria-label': title, style: `height:${height}px`,
|
||||
}));
|
||||
}
|
||||
if (!chapters.length) storyRhythm.append(h('span', { class: 'muted small' }, 'No chapters in the selected volumes.'));
|
||||
}
|
||||
|
||||
const metadata = h('dl', { class: 'meta-grid' });
|
||||
function addMeta(label, value) {
|
||||
if (value == null || value === '' || (Array.isArray(value) && !value.length)) return;
|
||||
metadata.append(h('dt', {}, label), h('dd', {}, value instanceof Node ? value : text(value)));
|
||||
}
|
||||
addMeta('Title', meta.title || M.title);
|
||||
addMeta('Alternative titles', arr(meta.altTitles));
|
||||
addMeta('Authors', arr(meta.authors));
|
||||
addMeta('Status', meta.status);
|
||||
addMeta('First release', releaseDate());
|
||||
addMeta('Format', meta['x-anilist'] && meta['x-anilist'].format);
|
||||
addMeta('Reading style', TYPE[M.direction] || M.direction);
|
||||
addMeta('Volumes', M.books.length);
|
||||
addMeta('Chapters', M.chapters.length);
|
||||
addMeta('Library ID', M.id);
|
||||
addMeta('Tags', h('span', { class: 'meta-tags' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]) }, tag))));
|
||||
if (sources.length) addMeta('Content sources', h('span', { class: 'meta-links' }, sources.map(source => {
|
||||
const label = source.name || source.url;
|
||||
return safeHref(source.url) ? h('a', { href: safeHref(source.url), target: '_blank', rel: 'noopener noreferrer' }, label) : h('span', {}, label);
|
||||
})));
|
||||
|
||||
const anilist = meta['x-anilist'] || {};
|
||||
if (anilist.url || anilist.id) addMeta('AniList', safeHref(anilist.url)
|
||||
? h('a', { href: safeHref(anilist.url), target: '_blank', rel: 'noopener noreferrer' }, anilist.id ? `Title ${anilist.id}` : 'Open title')
|
||||
: anilist.id);
|
||||
if (anilist.idMal) addMeta('MyAnimeList', h('a', { href: `https://myanimelist.net/manga/${encodeURIComponent(anilist.idMal)}`, target: '_blank', rel: 'noopener noreferrer' }, `Title ${anilist.idMal}`));
|
||||
if (anilist.coverUrl) addMeta('Metadata cover', safeHref(anilist.coverUrl)
|
||||
? h('a', { href: safeHref(anilist.coverUrl), target: '_blank', rel: 'noopener noreferrer' }, 'Open source image') : anilist.coverUrl);
|
||||
|
||||
const cover = meta['x-cover'] || {};
|
||||
addMeta('Cover provider', [cover.source, cover.label].filter(Boolean).join(' · '));
|
||||
if (cover.url) addMeta('Cover source', safeHref(cover.url)
|
||||
? h('a', { href: safeHref(cover.url), target: '_blank', rel: 'noopener noreferrer' }, cover.url) : cover.url);
|
||||
const scraper = meta['x-scraper'] || {};
|
||||
addMeta('Auto update', typeof scraper.autoUpdate === 'boolean' ? (scraper.autoUpdate ? 'Enabled' : 'Disabled') : '');
|
||||
addMeta('Scraper strategy', scraper.strategy);
|
||||
addMeta('Source identifiers', scraper.ids);
|
||||
|
||||
const handled = new Set(['title', 'altTitles', 'authors', 'status', 'year', 'firstReleaseDate', 'releaseDate', 'startDate', 'tags', 'description', 'direction', 'sources', 'x-anilist', 'x-cover', 'x-scraper']);
|
||||
for (const [key, value] of Object.entries(meta)) if (!handled.has(key)) addMeta(key, value);
|
||||
const rawMeta = h('details', { class: 'raw-meta' },
|
||||
h('summary', {}, 'Raw metadata'),
|
||||
h('pre', {}, JSON.stringify(meta, null, 2)));
|
||||
const metadataOpen = !window.matchMedia('(max-width: 640px)').matches;
|
||||
main.append(h('details', { class: 'metadata card', open: metadataOpen },
|
||||
h('summary', { class: 'metadata-summary' }, h('h2', {}, 'Metadata')),
|
||||
metadata, rawMeta));
|
||||
|
||||
const volumeButtons = new Map();
|
||||
const volumeBar = h('div', { class: 'volume-filter', 'aria-label': 'Filter chapters by volume' });
|
||||
const allVolumes = h('button', { class: 'filter-chip', type: 'button' }, 'All volumes');
|
||||
allVolumes.onclick = () => { selectedBooks.clear(); drawVolumeFilter(); drawChapters(); drawStoryRhythm(); };
|
||||
volumeBar.append(allVolumes);
|
||||
for (const book of M.books) {
|
||||
const label = book.label + (book.title ? ' · ' + book.title : '');
|
||||
const button = h('button', { class: 'filter-chip', type: 'button', title: label }, label);
|
||||
button.onclick = () => { selectedBooks.has(book.key) ? selectedBooks.delete(book.key) : selectedBooks.add(book.key); drawVolumeFilter(); drawChapters(); drawStoryRhythm(); };
|
||||
volumeButtons.set(book.key, button);
|
||||
volumeBar.append(button);
|
||||
}
|
||||
const list = h('section', { class: 'chapters' });
|
||||
main.append(list);
|
||||
let lastBook = null;
|
||||
for (const ch of M.chapters) {
|
||||
if (ch.book !== lastBook && M.books.length > 1) {
|
||||
const b = books[ch.book];
|
||||
list.append(h('div', { class: 'book-sep' }, b.label + (b.title ? ' · ' + b.title : '')));
|
||||
const empty = h('p', { class: 'muted chapter-empty', hidden: true }, 'No chapters match these volumes.');
|
||||
main.append(h('section', { class: 'chapter-section' },
|
||||
h('div', { class: 'chapter-heading' }, h('h2', {}, 'Chapters'), h('span', { class: 'muted small' }, 'Newest first')),
|
||||
volumeBar,
|
||||
h('div', { class: 'chapter-list-wrap' }, list), empty));
|
||||
|
||||
function drawVolumeFilter() {
|
||||
allVolumes.classList.toggle('on', !selectedBooks.size);
|
||||
allVolumes.setAttribute('aria-pressed', String(!selectedBooks.size));
|
||||
for (const [key, button] of volumeButtons) {
|
||||
button.classList.toggle('on', selectedBooks.has(key));
|
||||
button.setAttribute('aria-pressed', String(selectedBooks.has(key)));
|
||||
}
|
||||
}
|
||||
|
||||
function drawChapters() {
|
||||
list.textContent = '';
|
||||
const chapters = [...M.chapters].reverse().filter(chapter => !selectedBooks.size || selectedBooks.has(chapter.book));
|
||||
empty.hidden = !!chapters.length;
|
||||
let lastBook = null;
|
||||
for (const chapter of chapters) {
|
||||
if (chapter.book !== lastBook && M.books.length > 1) {
|
||||
const book = books[chapter.book];
|
||||
list.append(h('div', { class: 'book-sep' }, book.label + (book.title ? ' · ' + book.title : '')));
|
||||
}
|
||||
lastBook = chapter.book;
|
||||
list.append(chapterRow(chapter));
|
||||
}
|
||||
lastBook = ch.book;
|
||||
list.append(chapterRow(ch));
|
||||
}
|
||||
|
||||
function chapterRow(ch) {
|
||||
const wrap = h('div', {});
|
||||
const wrap = h('div', { class: 'chapter-entry' });
|
||||
const panel = h('div', { class: 'versions', hidden: true });
|
||||
function draw() {
|
||||
const p = ch.progress, done = p && p.done;
|
||||
const state = h('button', { class: 'state' + (done ? ' done' : ''), title: done ? 'Mark as unread' : 'Mark as read' },
|
||||
done ? '✓ read' : p ? `page ${p.p + 1} / ${p.n}` : `${ch.versions[0].pages} p.`);
|
||||
done ? '✓ read' : p ? `page ${p.p + 1} / ${p.n}` : 'unread');
|
||||
state.onclick = async () => {
|
||||
if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; }
|
||||
else {
|
||||
const v = ch.versions[0];
|
||||
await api('api/progress', { m: M.id, c: ch.id, v: v.id, p: v.pages - 1, n: v.pages, done: true });
|
||||
ch.progress = { v: v.id, p: v.pages - 1, n: v.pages, done: true };
|
||||
const version = ch.versions[0];
|
||||
await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true });
|
||||
ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true };
|
||||
}
|
||||
draw();
|
||||
};
|
||||
const verBtn = ch.versions.length > 1
|
||||
? h('button', { class: 'btn small', onclick: () => { panel.hidden = !panel.hidden; } }, ch.versions.length + ' versions')
|
||||
: null;
|
||||
const pages = ch.versions[0] ? ch.versions[0].pages : 0;
|
||||
const timing = chapterTiming(ch);
|
||||
wrap.textContent = '';
|
||||
wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') },
|
||||
h('a', { class: 'ch-main', href: readUrl(M.id, ch.id) },
|
||||
h('span', { class: 'ch-label' }, ch.label), h('span', { class: 'ch-title' }, ch.title || ''),
|
||||
h('a', { class: 'ch-main' + (timing ? ' has-time' : ''), href: readUrl(M.id, ch.id) },
|
||||
h('span', { class: 'ch-label' }, ch.label),
|
||||
h('span', { class: 'ch-pages' }, `${pages} p.`),
|
||||
timing ? h('span', { class: 'ch-time', title: timing.title, 'aria-label': timing.title }, timing.text) : null,
|
||||
h('span', { class: 'ch-title', title: ch.title || '' }, ch.title || 'Untitled'),
|
||||
ch.extra ? h('span', { class: 'tag' }, 'extra') : null),
|
||||
verBtn, state), panel);
|
||||
drawVersions();
|
||||
@@ -75,21 +237,39 @@
|
||||
function drawVersions() {
|
||||
panel.textContent = '';
|
||||
panel.append(h('div', { class: 'muted small', style: 'padding-top:6px' }, 'The best rated version opens by default. Vote to change that for everyone.'));
|
||||
ch.versions.forEach((v, i) => {
|
||||
const vote = async value => { ch.versions = (await api('api/vote', { m: M.id, c: ch.id, v: v.id, value: v.my === value ? 0 : value })).versions; drawVersions(); };
|
||||
ch.versions.forEach((version, i) => {
|
||||
const vote = async value => { ch.versions = (await api('api/vote', { m: M.id, c: ch.id, v: version.id, value: version.my === value ? 0 : value })).versions; drawVersions(); };
|
||||
panel.append(h('div', { class: 'ver' },
|
||||
h('div', { class: 'src' },
|
||||
safeHref(v.sourceUrl) ? h('a', { href: safeHref(v.sourceUrl), target: '_blank', rel: 'noopener noreferrer' }, v.source || v.id) : h('span', {}, v.source || v.id),
|
||||
safeHref(version.sourceUrl) ? h('a', { href: safeHref(version.sourceUrl), target: '_blank', rel: 'noopener noreferrer' }, version.source || version.id) : h('span', {}, version.source || version.id),
|
||||
i === 0 ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'default') : null,
|
||||
h('div', { class: 'muted small' }, `${v.pages} pages · added ${new Date(v.added).toLocaleDateString()}`)),
|
||||
h('div', { class: 'muted small' }, `${version.pages} pages · added ${new Date(version.added).toLocaleDateString()}`)),
|
||||
h('span', { class: 'vote' },
|
||||
h('button', { class: v.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
|
||||
h('b', {}, v.score),
|
||||
h('button', { class: v.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
|
||||
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, v.id) }, 'Read')));
|
||||
h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
|
||||
h('b', {}, version.score),
|
||||
h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
|
||||
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id) }, 'Read')));
|
||||
});
|
||||
}
|
||||
draw();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function chapterTiming(chapter) {
|
||||
if (!M.reading) return null;
|
||||
const value = chapter.reading;
|
||||
if (!value) return { text: 'Not timed', title: 'No reading time recorded' };
|
||||
if (value.completedPasses) {
|
||||
const text = readingDuration(value.lastCompleteMs, true) + (value.completedPasses > 1 ? ` ↻${value.completedPasses}` : '');
|
||||
return {
|
||||
text,
|
||||
title: `Last timed read ${readingDuration(value.lastCompleteMs)}; ${value.completedPasses} timed read${value.completedPasses === 1 ? '' : 's'}; ${readingDuration(value.totalActiveMs)} total`,
|
||||
};
|
||||
}
|
||||
const active = value.currentPassMs || value.totalActiveMs;
|
||||
return { text: `◷ ${readingDuration(active, true)}`, title: `${readingDuration(active)} active; current pass in progress` };
|
||||
}
|
||||
|
||||
drawVolumeFilter();
|
||||
drawChapters();
|
||||
})();
|
||||
+9
-1
@@ -12,6 +12,8 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#0b0b0c">
|
||||
<script src="common.js" defer></script>
|
||||
<script src="reading-clock-core.js" defer></script>
|
||||
<script src="reading-clock.js" defer></script>
|
||||
<script src="reader.js" defer></script>
|
||||
</head>
|
||||
<body class="reader">
|
||||
@@ -37,11 +39,17 @@
|
||||
<button data-mode="book" title="Two pages side by side, like an open book">Book</button>
|
||||
</div>
|
||||
<button id="shift" class="btn" title="Page pairs look wrong? Shift them by one page">⇄<span class="wide-only"> Shift</span></button>
|
||||
<button id="zoom" class="btn" title="Page zoom (Ctrl + scroll or pinch). Click to fit the screen again.">100%</button>
|
||||
<div class="seg zoom-controls" role="group" aria-label="Page zoom">
|
||||
<button id="zoomout" title="Zoom out 5%" aria-label="Zoom out 5%">−</button>
|
||||
<button id="zoom" title="Current page zoom. Click to fit the screen again.">100%</button>
|
||||
<button id="zoomin" title="Zoom in 5%" aria-label="Zoom in 5%">+</button>
|
||||
</div>
|
||||
<button id="readclock" class="btn clock-chip" hidden aria-label="Active Reading Clock"><span class="clock-icon">⏸</span> <span class="clock-time">00:00</span><span class="clock-label"></span></button>
|
||||
<select id="ver" title="Version of this chapter" aria-label="Version"></select>
|
||||
<a id="nextch" class="btn" title="Next chapter"><span class="wide-only">Next </span>›</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="clockpanel" class="clock-panel" hidden></div>
|
||||
<div id="toast"></div>
|
||||
</body>
|
||||
</html>
|
||||
+81
-13
@@ -16,7 +16,7 @@
|
||||
catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; }
|
||||
|
||||
const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb';
|
||||
const GAP = ttb ? 0 : 8, END_H = 280;
|
||||
const GAP = ttb || S.flowPageGap === false ? 0 : 8, END_H = 280;
|
||||
document.title = `${D.chapter.label} · ${D.manga.title}`;
|
||||
|
||||
// ---------- pages ----------
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
let cur = 0, atEnd = false, done = !!(D.progress && D.progress.done), bars = true, barsAt = 0;
|
||||
let spreads = [], spreadIdx = 0, shown = [], els = [], boxes = [], endEl = null, lastW = 0, lastH = 0;
|
||||
let lockFlowX = false, readingClock = null;
|
||||
const shift = new Set(D.progress && D.progress.v === D.version ? D.progress.shift : []);
|
||||
const zkey = () => (mode === 'flow' && ttb ? 'strip' : mode);
|
||||
const zoom = () => S.zoom[zkey()] || 1;
|
||||
@@ -112,8 +113,9 @@
|
||||
Object.assign(p, { unknown: false, w, a: w / hh, wide: p.wide || w > hh });
|
||||
relayout();
|
||||
}
|
||||
if (readingClock) readingClock.viewChanged();
|
||||
};
|
||||
img.onerror = state;
|
||||
img.onerror = () => { state(); if (readingClock) readingClock.viewChanged(); };
|
||||
state();
|
||||
}
|
||||
function setSrc(el, url) { el.firstChild.src = url; watch(el); }
|
||||
@@ -162,6 +164,7 @@
|
||||
}))).then(() => { if (token === buildToken) preloadAround(); });
|
||||
}
|
||||
layout();
|
||||
if (readingClock) readingClock.viewChanged();
|
||||
}
|
||||
let buildToken = 0;
|
||||
|
||||
@@ -198,7 +201,11 @@
|
||||
canvas.style.width = cw + 'px';
|
||||
canvas.style.height = ch + 'px';
|
||||
els.forEach((el, j) => place(el, boxes[j]));
|
||||
stage.classList.toggle('pan', cw > vw + 1 || (mode === 'flow' ? z > 1.01 : ch > vh + 1));
|
||||
const overflowsX = cw > vw + 1;
|
||||
lockFlowX = mode === 'flow' && !overflowsX;
|
||||
stage.classList.toggle('pan', overflowsX || (mode !== 'flow' && ch > vh + 1));
|
||||
stage.classList.toggle('lock-x', lockFlowX);
|
||||
if (lockFlowX) stage.scrollLeft = 0;
|
||||
lastW = vw; lastH = vh;
|
||||
}
|
||||
|
||||
@@ -230,17 +237,18 @@
|
||||
function restore(a, cx, cy) {
|
||||
if (!a || !boxes.length) return;
|
||||
const b = a.k < 0 ? union() : boxes[a.k];
|
||||
stage.scrollLeft = b.x + a.fx * b.w - cx;
|
||||
stage.scrollLeft = lockFlowX ? 0 : b.x + a.fx * b.w - cx;
|
||||
stage.scrollTop = b.y + a.fy * b.h - cy;
|
||||
barsAt = stage.scrollTop;
|
||||
}
|
||||
function relayout() { const a = anchor(lastW / 2, lastH / 2); layout(); restore(a, lastW / 2, lastH / 2); }
|
||||
|
||||
// ---------- zoom ----------
|
||||
const MIN_ZOOM = 0.2, MAX_ZOOM = 6, ZOOM_STEP = 0.05;
|
||||
let zoomTimer = 0;
|
||||
function setZoom(z, cx = stage.clientWidth / 2, cy = stage.clientHeight / 2) {
|
||||
z = Math.min(6, Math.max(0.2, z));
|
||||
if (Math.abs(z - 1) < 0.03) z = 1; // easy to get back to "fit"
|
||||
z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
||||
if (Math.abs(z - 1) < 0.01) z = 1; // easy to get back to "fit" without swallowing fine adjustments
|
||||
if (Math.abs(z - zoom()) < 0.001) return;
|
||||
const a = anchor(cx, cy);
|
||||
S.zoom[zkey()] = z;
|
||||
@@ -252,11 +260,39 @@
|
||||
zoomTimer = setTimeout(() => api('api/settings', { zoom: S.zoom }).catch(() => {}), 800);
|
||||
}
|
||||
|
||||
// Pages exposed enough to count as something the reader could actually inspect.
|
||||
// The clock handles decoding, duration and plausibility; this keeps layout knowledge here.
|
||||
function readingView() {
|
||||
const viewport = stage.getBoundingClientRect(), viewportArea = viewport.width * viewport.height, pages = [];
|
||||
for (let j = 0; j < els.length; j++) {
|
||||
const el = els[j], rect = el.getBoundingClientRect();
|
||||
const w = Math.max(0, Math.min(rect.right, viewport.right) - Math.max(rect.left, viewport.left));
|
||||
const hh = Math.max(0, Math.min(rect.bottom, viewport.bottom) - Math.max(rect.top, viewport.top));
|
||||
const visibleArea = w * hh, pageArea = Math.max(1, rect.width * rect.height);
|
||||
if (visibleArea < 0.10 * Math.min(pageArea, viewportArea)) continue;
|
||||
const img = el.firstChild, page = mode === 'flow' ? list[j] : list[shown[j]];
|
||||
pages.push({
|
||||
index: page.i,
|
||||
loaded: !!(img.complete && img.naturalWidth),
|
||||
failed: !!(img.complete && img.getAttribute('src') && !img.naturalWidth),
|
||||
});
|
||||
}
|
||||
const last = boxes[boxes.length - 1];
|
||||
const flowEnd = mode === 'flow' && last && !pages.length && stage.scrollTop + stage.clientHeight >= last.y + last.h + 32;
|
||||
return { pages, atEnd: mode === 'flow' ? !!flowEnd : atEnd };
|
||||
}
|
||||
|
||||
function stepZoom(direction) {
|
||||
const units = zoom() / ZOOM_STEP;
|
||||
const stepped = (direction > 0 ? Math.floor(units + 1e-6) + 1 : Math.ceil(units - 1e-6) - 1) * ZOOM_STEP;
|
||||
setZoom(Math.round(stepped * 100) / 100);
|
||||
}
|
||||
|
||||
// Ctrl + wheel (and touchpad pinch, which browsers report the same way) zooms the pages, not the page controls.
|
||||
stage.addEventListener('wheel', e => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
setZoom(zoom() * Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0025)), e.clientX, e.clientY);
|
||||
setZoom(zoom() * Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.015 : 0.0005)), e.clientX, e.clientY);
|
||||
}, { passive: false });
|
||||
|
||||
// Touch: two fingers zoom, a quick horizontal swipe turns the page (page/book mode, when not zoomed in).
|
||||
@@ -266,10 +302,18 @@
|
||||
stage.addEventListener('touchstart', e => {
|
||||
const t = e.touches;
|
||||
if (t.length === 2) { pinch = { d: dist(t), z: zoom() }; swipe = null; }
|
||||
else if (t.length === 1) swipe = { x: t[0].clientX, y: t[0].clientY, t: Date.now() };
|
||||
else if (t.length === 1) swipe = { x: t[0].clientX, y: t[0].clientY, top: stage.scrollTop, t: Date.now() };
|
||||
}, { passive: true });
|
||||
stage.addEventListener('touchmove', e => {
|
||||
if (!pinch || e.touches.length !== 2) return;
|
||||
if (!pinch || e.touches.length !== 2) {
|
||||
const t = e.touches[0], s = swipe;
|
||||
if (t && s && lockFlowX && Math.abs(t.clientX - s.x) > Math.abs(t.clientY - s.y)) {
|
||||
e.preventDefault();
|
||||
stage.scrollLeft = 0;
|
||||
stage.scrollTop = s.top - (t.clientY - s.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const t = e.touches;
|
||||
pinchNext = [pinch.z * dist(t) / pinch.d, (t[0].clientX + t[1].clientX) / 2, (t[0].clientY + t[1].clientY) / 2];
|
||||
@@ -293,7 +337,14 @@
|
||||
// Mouse: drag to move a zoomed page around; a click without dragging uses the tap zones.
|
||||
let drag = null, dragged = false;
|
||||
stage.addEventListener('pointerdown', e => {
|
||||
if (e.pointerType === 'mouse' && e.button === 0) drag = { x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, moved: false };
|
||||
if (e.pointerType === 'mouse' && e.button === 0) drag = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
l: stage.scrollLeft,
|
||||
t: stage.scrollTop,
|
||||
panX: !lockFlowX && stage.scrollWidth > stage.clientWidth + 1,
|
||||
moved: false
|
||||
};
|
||||
});
|
||||
addEventListener('pointermove', e => {
|
||||
if (!drag) return;
|
||||
@@ -301,7 +352,7 @@
|
||||
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 6) return;
|
||||
drag.moved = true;
|
||||
stage.classList.add('drag');
|
||||
stage.scrollLeft = drag.l - dx;
|
||||
if (drag.panX) stage.scrollLeft = drag.l - dx;
|
||||
stage.scrollTop = drag.t - dy;
|
||||
});
|
||||
addEventListener('pointerup', () => {
|
||||
@@ -338,8 +389,8 @@
|
||||
if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); }
|
||||
else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); }
|
||||
else if (k === 'PageUp' && mode !== 'flow') { e.preventDefault(); prev(); }
|
||||
else if (k === '+' || k === '=') setZoom(zoom() * 1.15);
|
||||
else if (k === '-') setZoom(zoom() / 1.15);
|
||||
else if (k === '+' || k === '=') stepZoom(1);
|
||||
else if (k === '-') stepZoom(-1);
|
||||
else if (k === '0') setZoom(1);
|
||||
else if (k === 'm' || k === 'Escape') toggleBars();
|
||||
else if (k === 'f') toggleFullscreen();
|
||||
@@ -347,6 +398,7 @@
|
||||
|
||||
let scrollRaf = 0;
|
||||
stage.addEventListener('scroll', () => {
|
||||
if (lockFlowX && stage.scrollLeft !== 0) stage.scrollLeft = 0;
|
||||
if (mode !== 'flow' || scrollRaf) return;
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
scrollRaf = 0;
|
||||
@@ -485,6 +537,7 @@
|
||||
if (b && stage.scrollTop + stage.clientHeight >= b.y + b.h - 4) { done = true; cur = list.length - 1; }
|
||||
} else if (atEnd || (mode === 'page' ? cur === list.length - 1 : spreadIdx === spreads.length - 1)) done = true;
|
||||
updateUI();
|
||||
if (readingClock) readingClock.viewChanged();
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveProgress, 700);
|
||||
}
|
||||
@@ -519,7 +572,9 @@
|
||||
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); };
|
||||
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
|
||||
$('#shift').onclick = toggleShift;
|
||||
$('#zoomout').onclick = () => stepZoom(-1);
|
||||
$('#zoom').onclick = () => setZoom(1);
|
||||
$('#zoomin').onclick = () => stepZoom(1);
|
||||
slider.dir = rtl ? 'rtl' : 'ltr';
|
||||
slider.addEventListener('input', () => goTo(+slider.value - 1));
|
||||
function toggleFullscreen() {
|
||||
@@ -533,6 +588,19 @@
|
||||
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
|
||||
|
||||
setMode(mode, false);
|
||||
if (S.readingClock) readingClock = createReadingClock({
|
||||
button: $('#readclock'),
|
||||
panel: $('#clockpanel'),
|
||||
identity: { m: D.manga.id, c: D.chapter.id, v: D.version },
|
||||
initial: D.reading,
|
||||
eligibleCount: list.length,
|
||||
eligiblePages: list.map(page => page.i),
|
||||
surface: stage,
|
||||
view: readingView,
|
||||
onComplete: result => toast(result.completedPasses > 1
|
||||
? 'Reread time is now counted · 60% of pages viewed'
|
||||
: 'Reading time is now counted · 60% of pages viewed', 3500),
|
||||
});
|
||||
stage.focus({ preventScroll: true });
|
||||
setBars(true);
|
||||
})();
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
// Shared pure policy. No speed limit; credit only loaded pages actually observed,
|
||||
// not pages skipped between two scroll positions. Coordinates never leave reader.
|
||||
(function (root) {
|
||||
const IDLE_MS = 30000, EXPOSURE_MS = 100;
|
||||
function exposureTracker() {
|
||||
const seen = new Set(), exposures = new Map();
|
||||
function update(pages, now, active) {
|
||||
const visible = new Set(active ? pages.filter(page => page.loaded).map(page => page.index) : []);
|
||||
for (const [page, entry] of exposures) {
|
||||
if (entry.visible) entry.ms += Math.max(0, now - entry.at);
|
||||
entry.at = now; entry.visible = visible.has(page);
|
||||
if (entry.ms >= EXPOSURE_MS) { seen.add(page); exposures.delete(page); }
|
||||
}
|
||||
for (const page of visible) if (!seen.has(page) && !exposures.has(page)) exposures.set(page, { ms: 0, at: now, visible: true });
|
||||
return seen;
|
||||
}
|
||||
return { seen, update };
|
||||
}
|
||||
function stateFor({ visible, focused, lastInputAt, now, owned, view }) {
|
||||
if (!visible) return 'hidden';
|
||||
if (!focused) return 'unfocused';
|
||||
if (now - lastInputAt >= IDLE_MS) return 'idle';
|
||||
if (!owned) return 'other-tab';
|
||||
if (view.atEnd) return 'finished';
|
||||
if (view.pages.some(page => page.loaded)) return 'reading';
|
||||
return view.pages.some(page => !page.failed) || !view.pages.length ? 'loading' : 'page-error';
|
||||
}
|
||||
const policy = { IDLE_MS, EXPOSURE_MS, exposureTracker, stateFor };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = policy;
|
||||
else root.ReadingClockPolicy = policy;
|
||||
})(typeof window === 'undefined' ? globalThis : window);
|
||||
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
/* Opt-in, personal reading estimates. The server counts visits with 60% coverage. */
|
||||
(function () {
|
||||
const { IDLE_MS, exposureTracker, stateFor } = window.ReadingClockPolicy;
|
||||
const INPUTS = ['mouse', 'touch', 'keyboard'], PULSE_MS = 10000;
|
||||
const labels = {
|
||||
reading: ['⏱', 'Reading'], loading: ['⌛', 'Loading page'], idle: ['⏸', 'Idle'],
|
||||
hidden: ['⏸', 'Tab hidden'], unfocused: ['⏸', 'Window inactive'],
|
||||
'other-tab': ['⏸', 'Another reader is active'], 'page-error': ['⏸', 'Page unavailable'],
|
||||
finished: ['⏸', 'Chapter finished'],
|
||||
};
|
||||
const blank = () => ({ activeMs: 0, loadingMs: 0, inputMs: { mouse: 0, touch: 0, keyboard: 0 } });
|
||||
const fmt = ms => {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000)), hours = Math.floor(seconds / 3600);
|
||||
return (hours ? hours + ':' : '') + String(Math.floor(seconds / 60) % 60).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
|
||||
};
|
||||
window.createReadingClock = function (options) {
|
||||
const button = options.button, panel = options.panel;
|
||||
const session = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36)).replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const started = performance.now(), eligible = new Set(options.eligiblePages);
|
||||
const required = Math.ceil(eligible.size * 0.6);
|
||||
let visit = blank(), acknowledged = blank(), tracker = exposureTracker(), acknowledgedSeen = [];
|
||||
let reading = options.initial || {}, state = 'idle', lastSample = started, lastInputAt = started, lastType = 'mouse';
|
||||
let focused = document.hasFocus(), needsInput = false, owned = true, claimAt = 0, channel;
|
||||
let sequence = 0, lastSent = started, sending = false, urgent = false, releasePending = false;
|
||||
let retryTimer = 0, saveError = false, ended = false, destroyed = false, notified = false;
|
||||
const listeners = [];
|
||||
function listen(target, name, fn, opts) {
|
||||
target.addEventListener(name, fn, opts); listeners.push(() => target.removeEventListener(name, fn, opts));
|
||||
}
|
||||
function accrue(now) {
|
||||
const delta = Math.max(0, Math.min(now, lastInputAt + IDLE_MS) - lastSample);
|
||||
if (state === 'reading') { visit.activeMs += delta; visit.inputMs[lastType] += delta; }
|
||||
else if (state === 'loading') visit.loadingMs += delta;
|
||||
lastSample = now;
|
||||
}
|
||||
function covered() { return [...tracker.seen].filter(page => eligible.has(page)); }
|
||||
function sample() {
|
||||
if (destroyed || ended) return;
|
||||
const now = performance.now(); accrue(now);
|
||||
const view = options.view();
|
||||
const previous = state;
|
||||
state = stateFor({ visible: !document.hidden, focused, now, lastInputAt: needsInput ? -Infinity : lastInputAt, owned, view });
|
||||
tracker.update(view.pages, now, state === 'reading');
|
||||
if (previous !== state && ['idle', 'finished'].includes(state) && ['reading', 'loading'].includes(previous)) requestSave(true);
|
||||
if (now - lastSent >= PULSE_MS && owned && ['reading', 'loading'].includes(state)) requestSave();
|
||||
render();
|
||||
}
|
||||
function snapshot(final = false, release = false) {
|
||||
lastSent = performance.now();
|
||||
return { ...options.identity, session, seq: ++sequence, elapsedMs: Math.round(lastSent - started),
|
||||
activeMs: Math.round(visit.activeMs), loadingMs: Math.round(visit.loadingMs),
|
||||
inputMs: Object.fromEntries(INPUTS.map(key => [key, Math.round(visit.inputMs[key])])),
|
||||
seen: covered(), final, release: final || release };
|
||||
}
|
||||
function requestSave(release = false) {
|
||||
urgent = true; releasePending ||= release;
|
||||
pump();
|
||||
}
|
||||
async function pump() {
|
||||
if (sending || destroyed || ended || !urgent) return;
|
||||
sending = true; urgent = false;
|
||||
const body = snapshot(false, releasePending); releasePending = false;
|
||||
clearTimeout(retryTimer);
|
||||
try {
|
||||
const response = await fetch('api/reading/visit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || response.statusText);
|
||||
saveError = false;
|
||||
if (data.accepted) {
|
||||
acknowledged = body; acknowledgedSeen = body.seen; reading = data.reading || reading;
|
||||
if (reading.currentPass?.complete && !notified) {
|
||||
notified = true;
|
||||
options.onComplete?.({ activeMs: visit.activeMs, totalActiveMs: reading.totalActiveMs, completedPasses: reading.completedPasses });
|
||||
}
|
||||
} else {
|
||||
accrue(performance.now()); owned = false; state = 'other-tab';
|
||||
visit = { ...acknowledged, inputMs: { ...acknowledged.inputMs } };
|
||||
tracker = exposureTracker(); acknowledgedSeen.forEach(page => tracker.seen.add(page));
|
||||
retryTimer = setTimeout(() => { if (!needsInput && !ended) { owned = true; requestSave(); sample(); } }, Math.max(500, data.retryAfterMs || 2500));
|
||||
}
|
||||
} catch {
|
||||
// Network delays aren't fake reading. Retain cumulative time and retry.
|
||||
saveError = true;
|
||||
retryTimer = setTimeout(() => requestSave(), 2500);
|
||||
} finally {
|
||||
sending = false; sample();
|
||||
// New milliseconds accrued during a response must NOT trigger another
|
||||
// request. Only an explicitly requested save can run the pump again.
|
||||
if (urgent) pump();
|
||||
}
|
||||
}
|
||||
function flush() {
|
||||
if (destroyed || ended) return;
|
||||
sample(); ended = true;
|
||||
const body = JSON.stringify(snapshot(true, true));
|
||||
// Send even with a pulse in flight. Cumulative sequence handling makes
|
||||
// either arrival order safe, including the final beacon arriving first.
|
||||
let queued = false;
|
||||
try { queued = navigator.sendBeacon?.('api/reading/visit', new Blob([body], { type: 'application/json' })); } catch {}
|
||||
if (!queued) fetch('api/reading/visit', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {});
|
||||
}
|
||||
function input(type) {
|
||||
if (ended || destroyed || document.hidden) return;
|
||||
const now = performance.now(); accrue(now);
|
||||
lastType = type; lastInputAt = now; needsInput = false; focused = document.hasFocus();
|
||||
if (!owned) { owned = true; requestSave(); }
|
||||
const at = Date.now();
|
||||
if (at - claimAt >= 1000) { claimAt = at; channel?.postMessage({ session, at }); }
|
||||
sample();
|
||||
}
|
||||
function render() {
|
||||
const [icon, label] = labels[state] || labels.idle, coverage = covered().length;
|
||||
const qualified = coverage >= required && visit.activeMs > 0;
|
||||
const total = (+reading.totalActiveMs || 0) + (qualified ? Math.max(0, visit.activeMs - (reading.currentPass?.complete ? acknowledged.activeMs : 0)) : 0);
|
||||
button.querySelector('.clock-icon').textContent = icon;
|
||||
button.querySelector('.clock-time').textContent = fmt(visit.activeMs);
|
||||
button.querySelector('.clock-label').textContent = ` · ${label}${saveError ? ' · Save pending' : ''}`;
|
||||
button.classList.toggle('clock-running', state === 'reading');
|
||||
button.classList.remove('clock-warning');
|
||||
button.title = `Active Reading Clock · ${label} · ${coverage}/${eligible.size} pages`;
|
||||
if (!panel.hidden) {
|
||||
panel.textContent = '';
|
||||
panel.append(h('div', { class: 'clock-head' }, h('b', {}, 'Active Reading Clock'), h('span', { class: 'clock-state' }, `${icon} ${label}`)),
|
||||
h('dl', { class: 'clock-details' },
|
||||
h('dt', {}, 'This visit'), h('dd', {}, fmt(visit.activeMs)),
|
||||
h('dt', {}, 'Pages viewed'), h('dd', {}, `${coverage} / ${eligible.size}`),
|
||||
h('dt', {}, 'Counted as a read'), h('dd', {}, qualified ? 'Yes · time continues counting' : `Not yet · ${required} pages needed (60%)`),
|
||||
h('dt', {}, 'Saved chapter total'), h('dd', {}, fmt(total)),
|
||||
h('dt', {}, 'Loading wait'), h('dd', {}, fmt(visit.loadingMs))),
|
||||
h('p', { class: 'muted small' }, saveError ? 'Save pending. Your time is retained in this tab and will be retried.' :
|
||||
'No scrolling-speed limit. Loaded pages count after 100 ms of visible exposure. At 60% coverage, all active time from this visit counts and is saved when you leave. Short picture lookups stay out of your totals. Pauses in the background and after 30 seconds without reader activity.'));
|
||||
}
|
||||
}
|
||||
button.hidden = false;
|
||||
listen(button, 'click', e => { e.stopPropagation(); panel.hidden = !panel.hidden; render(); });
|
||||
listen(panel, 'click', e => e.stopPropagation());
|
||||
listen(document, 'click', () => { panel.hidden = true; });
|
||||
try {
|
||||
channel = new BroadcastChannel('mangacircus-reading-clock');
|
||||
channel.onmessage = event => {
|
||||
const msg = event.data || {};
|
||||
if (msg.session === session || !msg.at || msg.at < claimAt || (msg.at === claimAt && msg.session < session)) return;
|
||||
accrue(performance.now()); owned = false; state = 'other-tab'; requestSave(true); render();
|
||||
};
|
||||
} catch { channel = null; }
|
||||
let pointer = null;
|
||||
listen(document, 'pointerdown', e => { if (e.isTrusted) input(e.pointerType === 'touch' ? 'touch' : 'mouse'); }, { passive: true });
|
||||
listen(document, 'pointermove', e => {
|
||||
if (!e.isTrusted) return;
|
||||
const now = performance.now();
|
||||
if (!pointer || (now - pointer.at >= 150 && Math.hypot(e.clientX - pointer.x, e.clientY - pointer.y) >= 3)) {
|
||||
pointer = { x: e.clientX, y: e.clientY, at: now }; input(e.pointerType === 'touch' ? 'touch' : 'mouse');
|
||||
}
|
||||
}, { passive: true });
|
||||
listen(document, 'wheel', e => { if (e.isTrusted) input('mouse'); }, { passive: true });
|
||||
listen(document, 'keydown', e => {
|
||||
if (e.isTrusted && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'PageDown', 'PageUp', 'Home', 'End', '+', '=', '-', '0', 'm', 'Escape', 'f'].includes(e.key)) input('keyboard');
|
||||
});
|
||||
// Momentum scrolling remains activity after the finger lifts.
|
||||
if (options.surface) listen(options.surface, 'scroll', e => { if (e.isTrusted && !needsInput) input(lastType); }, { passive: true });
|
||||
listen(window, 'blur', () => { accrue(performance.now()); focused = false; needsInput = true; sample(); requestSave(true); });
|
||||
listen(window, 'focus', () => { focused = true; sample(); });
|
||||
listen(document, 'visibilitychange', () => {
|
||||
if (document.hidden) { accrue(performance.now()); needsInput = true; sample(); requestSave(true); } else sample();
|
||||
});
|
||||
listen(window, 'pagehide', flush);
|
||||
listen(document, 'freeze', () => { accrue(performance.now()); needsInput = true; sample(); requestSave(true); });
|
||||
listen(window, 'pageshow', e => { if (e.persisted) { ended = false; lastSample = performance.now(); input(lastType); requestSave(); } });
|
||||
const timer = setInterval(sample, 50);
|
||||
sample(); requestSave();
|
||||
return { viewChanged: sample, flush, destroy() {
|
||||
flush(); destroyed = true; clearInterval(timer); clearTimeout(retryTimer);
|
||||
listeners.forEach(remove => remove()); channel?.close();
|
||||
} };
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
|
||||
// Two navigation levels; scraper tabs stay within their section, not another menu.
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ id: 'user/reading', group: 'User', title: 'Reading', hint: 'Reader modes, page spacing and zoom.' },
|
||||
{ id: 'user/reading-time', group: 'User', title: 'Reading time & privacy', hint: 'Your clock, Reading Story and personal data.' },
|
||||
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
|
||||
{ id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' },
|
||||
{ id: 'admin/scraper', group: 'Admin', title: 'Scraper', hint: 'Live activity, downloads, update checks and sources.' },
|
||||
{ id: 'admin/library', group: 'Admin', title: 'Server & library', hint: 'Library location, scan status and maintenance.' },
|
||||
{ id: 'admin/users', group: 'Admin', title: 'Users & access', hint: 'Manage accounts, passwords and administrators.' },
|
||||
{ id: 'admin/collections', group: 'Admin', title: 'Collections & metadata', hint: 'Group manga and review their relationships.', href: 'collections.html' },
|
||||
{ id: 'admin/health', group: 'Admin', title: 'Service health', hint: 'Service status, incidents and recovery.', href: 'health.html' },
|
||||
];
|
||||
const SCRAPER_TABS = ['activity', 'queue', 'updates', 'sources'];
|
||||
|
||||
function settingsRoute(hash, admin) {
|
||||
const parts = String(hash || '').replace(/^#/, '').split('/');
|
||||
const candidate = parts.slice(0, 2).join('/'), tab = parts[2];
|
||||
const section = SETTINGS_SECTIONS.find(item => item.id === candidate && (admin || item.group === 'User'));
|
||||
return { id: section?.id || 'user/reading', tab: SCRAPER_TABS.includes(tab) ? tab : tab === 'hosters' ? 'sources' : 'activity' };
|
||||
}
|
||||
|
||||
function settingsShell(me, active = 'user/reading') {
|
||||
const main = $('main'); main.textContent = '';
|
||||
const nav = h('nav', { class: 'settings-menu', 'aria-label': 'Settings sections' });
|
||||
const links = new Map();
|
||||
for (const group of me.admin ? ['User', 'Admin'] : ['User']) {
|
||||
nav.append(h('div', { class: 'settings-menu-group' }, h('h2', {}, group),
|
||||
SETTINGS_SECTIONS.filter(section => section.group === group).map(section => {
|
||||
const link = h('a', { href: section.href || 'settings.html#' + section.id }, section.title);
|
||||
links.set(section.id, link);
|
||||
return link;
|
||||
})));
|
||||
}
|
||||
const crumb = h('p', { class: 'settings-breadcrumb muted small' });
|
||||
const title = h('h1', { tabindex: '-1' });
|
||||
const titleRow = h('div', { class: 'settings-title-row' }, title);
|
||||
const hint = h('p', { class: 'muted settings-hint' });
|
||||
const content = h('div', { class: 'settings-content' });
|
||||
main.append(h('div', { class: 'settings-layout' }, nav,
|
||||
h('div', { class: 'settings-workspace' }, crumb, titleRow, hint, content)));
|
||||
function select(id, focus = false) {
|
||||
const section = SETTINGS_SECTIONS.find(item => item.id === id);
|
||||
if (!section || (section.group === 'Admin' && !me.admin)) return;
|
||||
for (const [key, link] of links) {
|
||||
if (key === id) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
crumb.textContent = 'Settings / ' + section.group;
|
||||
title.textContent = section.title;
|
||||
hint.textContent = section.hint;
|
||||
document.title = section.title + ' · Settings · MangaCircus';
|
||||
if (focus) title.focus({ preventScroll: true });
|
||||
}
|
||||
select(active);
|
||||
return { content, titleRow, select };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = { SETTINGS_SECTIONS, SCRAPER_TABS, settingsRoute };
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#121214">
|
||||
<script src="common.js" defer></script>
|
||||
<script src="settings-nav.js" defer></script>
|
||||
<script src="settings.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+360
-13
@@ -1,13 +1,19 @@
|
||||
'use strict';
|
||||
/* Settings: reading preferences, notifications, password and (for admins) library + users. */
|
||||
/* Settings workspace: User/Admin sections, with local scraper tabs. */
|
||||
(async function () {
|
||||
let me;
|
||||
try { me = await api('api/me'); } catch (e) { return showError(e); }
|
||||
topNav(me);
|
||||
const S = me.settings, main = $('main');
|
||||
main.textContent = '';
|
||||
const S = me.settings, shell = settingsShell(me);
|
||||
const sections = new Map(), controllers = new Map();
|
||||
const section = id => {
|
||||
if (!sections.has(id)) sections.set(id, h('div', { 'data-settings-section': id }));
|
||||
return sections.get(id);
|
||||
};
|
||||
let route = settingsRoute(location.hash, me.admin);
|
||||
|
||||
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
|
||||
shell.titleRow.append(saved);
|
||||
let savedTimer = 0;
|
||||
async function save(patch) {
|
||||
Object.assign(S, (await api('api/settings', patch)).settings);
|
||||
@@ -28,22 +34,59 @@
|
||||
const LABEL = { flow: 'Flow', page: 'Page', book: 'Book', strip: 'Webtoon flow' };
|
||||
const zooms = () => Object.keys(LABEL).map(k => `${LABEL[k]} ${Math.round((S.zoom[k] || 1) * 100)}%`).join(' · ');
|
||||
const zoomText = h('span', { class: 'muted' }, zooms());
|
||||
const readingPassword = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' });
|
||||
const readingDataMessage = h('span', { class: 'small' });
|
||||
const readingData = h('details', { class: 'reading-data' },
|
||||
h('summary', {}, 'Manage my reading-time data'),
|
||||
h('p', { class: 'muted small' }, 'Reading Story is private to your account. Turning the clock off stops collection but keeps existing history.'),
|
||||
h('div', { class: 'row' },
|
||||
h('button', { class: 'btn small', onclick: async () => {
|
||||
try {
|
||||
const data = await api('api/reading/export');
|
||||
const blob = new Blob([JSON.stringify(data, null, 2) + '\n'], { type: 'application/json' });
|
||||
const link = h('a', { href: URL.createObjectURL(blob), download: `mangacircus-reading-${me.name}.json` });
|
||||
document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(link.href), 0);
|
||||
readingDataMessage.textContent = 'Export created'; readingDataMessage.className = 'saved';
|
||||
} catch (error) { readingDataMessage.textContent = error.message; readingDataMessage.className = 'small error'; }
|
||||
} }, 'Export my summary')),
|
||||
h('div', { class: 'reading-danger' },
|
||||
h('b', {}, 'Delete all reading-time history'),
|
||||
h('p', { class: 'muted small' }, 'Reading progress and accounts are not affected. This cannot be undone.'),
|
||||
h('div', { class: 'row' }, readingPassword,
|
||||
h('button', { class: 'btn small danger', onclick: async () => {
|
||||
if (!readingPassword.value || !confirm('Permanently delete all of your reading-time history?')) return;
|
||||
try {
|
||||
await api('api/reading/delete', { all: true, password: readingPassword.value });
|
||||
readingPassword.value = '';
|
||||
readingDataMessage.textContent = 'Reading-time history deleted'; readingDataMessage.className = 'saved';
|
||||
} catch (error) { readingDataMessage.textContent = error.message; readingDataMessage.className = 'small error'; }
|
||||
} }, 'Delete history'), readingDataMessage)));
|
||||
|
||||
main.append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Reading ', saved),
|
||||
section('user/reading').append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Reader preferences'),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Open chapters in'), startMode,
|
||||
h('div', { class: 'muted small' }, `Last used: ${LABEL[S.lastMode]}. Only clicking a mode in the reader changes it.`)),
|
||||
check('adapt', 'Use the fitting mode when the content is not made for my choice',
|
||||
'Example: webtoons are one long strip, so they always open in Flow. This never changes the mode you picked.'),
|
||||
check('skipExtras', 'Skip extra pages and extra chapters', 'Pages or chapters marked as extra (omake, notes, specials) are left out while reading.'),
|
||||
check('skipCovers', 'Skip chapter cover pages'),
|
||||
check('flowPageGap', 'Show a gap between pages in Flow mode',
|
||||
'Turn this off for continuous webtoon artwork or text that crosses page edges.'),
|
||||
h('div', { class: 'field' }, h('span', {}, 'Page zoom'), zoomText, ' ',
|
||||
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
|
||||
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
|
||||
|
||||
section('user/reading-time').append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Clock & Reading Story'),
|
||||
check('readingClock', 'Track and show the Active Reading Clock',
|
||||
'Counts a visit after you view 60% of its pages, with no scrolling-speed limit. Pauses in the background or after 30 seconds without reader activity. Disabled by default.'),
|
||||
check('readingStory', 'Show Your Reading Story',
|
||||
'Shows your private chapter times and personal summaries on manga and library pages. Hiding it does not delete existing history.'),
|
||||
readingData));
|
||||
|
||||
const hook = h('input', { type: 'url', placeholder: 'https://ntfy.sh/my-manga-topic', value: S.webhook || '', style: 'width:100%' });
|
||||
const hookMsg = h('div', { class: 'small' });
|
||||
main.append(h('section', { class: 'card' },
|
||||
section('user/notifications').append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Notifications'),
|
||||
h('p', { class: 'muted' }, 'Press "Notify me" on a manga page. New chapters then show up under the bell at the top.'),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Also send them to a webhook (optional)'), hook,
|
||||
@@ -55,18 +98,290 @@
|
||||
const oldPw = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' });
|
||||
const newPw = h('input', { type: 'password', autocomplete: 'new-password', placeholder: 'New password' });
|
||||
const pwMsg = h('span', { class: 'small' });
|
||||
main.append(h('section', { class: 'card' },
|
||||
section('user/account').append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Account · ' + me.name),
|
||||
h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => {
|
||||
try { await api('api/password', { old: oldPw.value, new: newPw.value }); pwMsg.textContent = 'Password changed'; pwMsg.className = 'saved'; oldPw.value = newPw.value = ''; }
|
||||
catch (e) { pwMsg.textContent = e.message; pwMsg.className = 'small error'; }
|
||||
} }, 'Change password'), pwMsg)));
|
||||
|
||||
if (me.admin) adminCard();
|
||||
function scraperCard() {
|
||||
const card = h('section', { class: 'card scraper-card' });
|
||||
section('admin/scraper').append(card);
|
||||
let timer = 0, active = false, lastState = null, dirty = false, sourceFilter = '', onlyEnabled = false;
|
||||
const disclosureOpen = { add: false, completed: false, history: false };
|
||||
const commandError = h('p', { class: 'error small', role: 'alert', hidden: true });
|
||||
card.addEventListener('input', () => { dirty = true; });
|
||||
// A poll can resolve between the summary click and the browser's deferred
|
||||
// details toggle event. Record the intended state synchronously, rather
|
||||
// than sampling the old DOM immediately before replacing it.
|
||||
card.addEventListener('click', event => {
|
||||
const summary = event.target.closest('summary');
|
||||
const details = summary?.parentElement;
|
||||
const key = details?.dataset.scraperDisclosure;
|
||||
if (key && Object.hasOwn(disclosureOpen, key)) disclosureOpen[key] = !details.open;
|
||||
});
|
||||
card.addEventListener('toggle', event => {
|
||||
const details = event.target;
|
||||
const key = details.dataset?.scraperDisclosure;
|
||||
if (details.isConnected && key && Object.hasOwn(disclosureOpen, key)) disclosureOpen[key] = details.open;
|
||||
}, true);
|
||||
|
||||
async function adminCard() {
|
||||
const command = async payload => {
|
||||
try {
|
||||
await api('api/admin/scraper', payload);
|
||||
dirty = false; commandError.hidden = true;
|
||||
await load(true);
|
||||
} catch (e) { commandError.textContent = e.message; commandError.hidden = false; }
|
||||
};
|
||||
|
||||
const statusText = entry => {
|
||||
const job = entry.job;
|
||||
const bits = [entry.current.title];
|
||||
if (job?.current?.book?.title) bits.push(job.current.book.title);
|
||||
if (job?.current?.number) bits.push(job.current.label || 'chapter ' + job.current.number);
|
||||
if (job?.current?.expectedPages) bits.push(`page ${job.current.downloadedPages || 0}/${job.current.expectedPages}`);
|
||||
return bits.join(' · ');
|
||||
};
|
||||
|
||||
const draw = state => {
|
||||
lastState = state;
|
||||
card.textContent = '';
|
||||
const tabs = h('nav', { class: 'settings-tabs', 'aria-label': 'Scraper sections' },
|
||||
[['activity', 'Activity'], ['queue', 'Queue'], ['updates', 'Updates'], ['sources', 'Sources']].map(([id, label]) =>
|
||||
h('a', { class: 'btn', href: '#admin/scraper/' + id, 'aria-current': route.tab === id ? 'page' : null }, label)));
|
||||
card.append(tabs, commandError);
|
||||
if (!state.available) {
|
||||
card.append(h('h2', {}, 'Scraper'),
|
||||
h('p', { class: 'error' }, state.error || 'Scraper service unavailable'),
|
||||
h('p', { class: 'muted small' }, 'Check the managed services and their logs. Do not start a second scraper against the same library.'),
|
||||
h('a', { class: 'btn small', href: 'health.html' }, 'Service health & incidents'));
|
||||
return;
|
||||
}
|
||||
const activeEntries = state.active?.length ? state.active : state.current ? [{ source: state.current.source, current: state.current, job: state.job }] : [];
|
||||
const url = h('input', { type: 'url', placeholder: 'WeebCentral or MangaDex title URL', class: 'grow' });
|
||||
const title = h('input', { placeholder: 'Title override (optional)' });
|
||||
const scope = h('select', {},
|
||||
h('option', { value: 'missing' }, 'All missing chapters'),
|
||||
h('option', { value: 'latest' }, 'Newest chapter only'),
|
||||
h('option', { value: 'last' }, 'Last N chapters'),
|
||||
h('option', { value: 'first' }, 'First N chapters'),
|
||||
h('option', { value: 'chapter' }, 'One chapter'),
|
||||
h('option', { value: 'after' }, 'After chapter'),
|
||||
h('option', { value: 'volume' }, 'One volume'));
|
||||
const scopeValue = h('input', { type: 'number', min: '0', step: '0.1', placeholder: 'N / chapter / volume', disabled: true });
|
||||
scope.onchange = () => { scopeValue.disabled = ['missing', 'latest'].includes(scope.value); scopeValue.placeholder = ['last', 'first'].includes(scope.value) ? 'Count' : scope.value === 'volume' ? 'Volume' : 'Chapter'; };
|
||||
const addSource = h('input', { type: 'checkbox' });
|
||||
const libraryTarget = h('select', {}, h('option', { value: '' }, 'Auto-detect library manga'),
|
||||
...(state.library || []).map(manga => h('option', { value: manga.id }, manga.title)));
|
||||
const addMessage = h('span', { class: 'small' });
|
||||
const rate = state.settings || {};
|
||||
const sourceRateEditors = (state.adapters || []).map(adapter => {
|
||||
const sourceRate = { ...rate, ...((rate.sourceRates || {})[adapter.id] || {}) };
|
||||
const min = h('input', { type: 'number', min: '.5', step: '.5', value: (sourceRate.minMs ?? 1000) / 1000, title: 'Minimum seconds between image and API requests' });
|
||||
const max = h('input', { type: 'number', min: '.5', step: '.5', value: (sourceRate.maxMs ?? 4000) / 1000, title: 'Maximum seconds between image and API requests' });
|
||||
const chapterMin = h('input', { type: 'number', min: '.5', step: '1', value: (sourceRate.chapterMinMs ?? 5000) / 1000, title: 'Minimum seconds between chapters' });
|
||||
const chapterMax = h('input', { type: 'number', min: '.5', step: '1', value: (sourceRate.chapterMaxMs ?? 15000) / 1000, title: 'Maximum seconds between chapters' });
|
||||
return h('div', { class: 'source-rate' },
|
||||
h('b', {}, adapter.label),
|
||||
h('div', { class: 'row rate-row' },
|
||||
h('label', {}, h('small', {}, 'Requests min (s)'), min), h('label', {}, h('small', {}, 'Requests max (s)'), max),
|
||||
h('label', {}, h('small', {}, 'Chapters min (s)'), chapterMin), h('label', {}, h('small', {}, 'Chapters max (s)'), chapterMax),
|
||||
h('button', { class: 'btn', onclick: () => command({ command: 'settings', settings: { sourceRates: { [adapter.id]: {
|
||||
minMs: +min.value * 1000, maxMs: +max.value * 1000,
|
||||
chapterMinMs: +chapterMin.value * 1000, chapterMaxMs: +chapterMax.value * 1000,
|
||||
} } } }) }, `Save ${adapter.label}`)));
|
||||
});
|
||||
const autoEnabled = h('input', { type: 'checkbox', checked: rate.autoUpdateEnabled !== false });
|
||||
const autoTime = h('input', { type: 'time', value: rate.autoUpdateTime || '03:00' });
|
||||
|
||||
const visible = items => items.filter(item => !sourceFilter || item.source === sourceFilter);
|
||||
const pending = visible(state.queue.filter(item => ['queued', 'retry_wait'].includes(item.status)));
|
||||
const attention = visible(state.queue.filter(item => ['ready', 'error', 'quarantined', 'unavailable', 'skipped'].includes(item.status)));
|
||||
const completed = visible(state.queue.filter(item => item.status === 'complete' && item.kind === 'download'));
|
||||
const when = value => value && !Number.isNaN(Date.parse(value)) ? new Date(value).toLocaleString() : 'Never';
|
||||
const chips = h('div', { class: 'row scraper-filters', role: 'group', 'aria-label': 'Filter by source' },
|
||||
[{ id: '', label: 'All sources' }, ...(state.adapters || []).map(adapter => ({ id: adapter.id, label: adapter.label }))].map(option =>
|
||||
h('button', { class: 'btn small', type: 'button', 'aria-pressed': sourceFilter === option.id ? 'true' : 'false',
|
||||
onclick: () => { sourceFilter = option.id; draw(lastState); } }, option.label)));
|
||||
card.append(chips);
|
||||
const move = async (item, delta) => {
|
||||
const position = pending.findIndex(entry => entry.id === item.id), other = pending[position + delta];
|
||||
if (!other) return;
|
||||
const ids = state.queue.map(entry => entry.id), a = ids.indexOf(item.id), b = ids.indexOf(other.id);
|
||||
[ids[a], ids[b]] = [ids[b], ids[a]];
|
||||
await command({ command: 'reorder', ids });
|
||||
};
|
||||
const jobRow = (item, movable = false) => {
|
||||
const running = activeEntries.some(entry => entry.current.id === item.id);
|
||||
const availability = item.availability;
|
||||
const outcome = item.status === 'complete'
|
||||
? `${item.publishedCount ?? 0} chapter${item.publishedCount === 1 ? '' : 's'} published · finished ${when(item.completed)}`
|
||||
: item.status === 'ready' ? 'Awaiting edition confirmation' : item.status.replaceAll('_', ' ');
|
||||
return h('div', { class: 'scraper-item' + (running ? ' active' : ''), 'data-source': item.source },
|
||||
h('div', { class: 'grow' }, h('b', {}, item.title),
|
||||
h('div', { class: 'muted small' }, `${item.source} · ${item.kind === 'discovery' ? 'Checking for updates' : item.scopeLabel || 'all missing chapters'} · ${outcome}`
|
||||
+ (item.existingPolicy === 'add-source' ? ' · alternate source' : '')),
|
||||
item.error ? h('div', { class: 'small error' }, item.error) : null,
|
||||
item.sourceTitle ? h('div', { class: 'small' }, `Source edition: ${item.sourceTitle}`) : null,
|
||||
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''} · ${availability.coverage === 'partial' ? 'Partial source coverage' : 'Whole-series completeness not verified'}`) : null),
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending[0]?.id === item.id, title: 'Move up', onclick: () => move(item, -1) }, '↑') : null,
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending.at(-1)?.id === item.id, title: 'Move down', onclick: () => move(item, 1) }, '↓') : null,
|
||||
item.status === 'error' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'retry', id: item.id }) }, 'Retry') : null,
|
||||
['unavailable', 'quarantined', 'complete', 'ready'].includes(item.status) ? h('button', { class: 'btn small', onclick: () => command({ command: 'control', action: 'inspect', id: item.id }) }, 'Inspect again') : null,
|
||||
item.status === 'ready' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'confirm', id: item.id }) }, 'Confirm download') : null,
|
||||
h('button', { class: 'btn small', disabled: running, onclick: async () => {
|
||||
if (confirm(`Remove ${item.title} from the queue? Its library chapters will stay.`)) await command({ command: 'remove', id: item.id });
|
||||
} }, 'Remove'));
|
||||
};
|
||||
const activeDownloads = h('div', { class: 'scraper-queue' });
|
||||
const activeVisible = activeEntries.filter(entry => !sourceFilter || entry.source === sourceFilter || entry.current?.source === sourceFilter);
|
||||
if (!activeVisible.length) activeDownloads.append(h('p', { class: 'muted small' }, state.paused ? 'Queue paused' : 'No active download on this source'));
|
||||
activeVisible.forEach(entry => {
|
||||
const job = entry.job, percent = job?.percent || 0;
|
||||
const problem = job?.lastError || job?.lastProblem;
|
||||
activeDownloads.append(h('div', { class: 'scraper-item active' }, h('div', { class: 'grow' },
|
||||
h('b', {}, entry.source || entry.current.source),
|
||||
h('div', {}, statusText(entry)),
|
||||
job?.total ? h('div', { class: 'scraper-progress', title: `${job.completed}/${job.total}` }, h('i', { style: `width:${percent}%` })) : null,
|
||||
job?.total ? h('div', { class: 'muted small' }, `${job.completed}/${job.total} chapters · ${percent}%`) : null,
|
||||
problem ? h('div', { class: problem.level === 'warning' ? 'small' : 'small error' }, problem.message || String(problem)) : null)));
|
||||
});
|
||||
const activityPanel = h('div', { 'data-scraper-tab': 'activity', hidden: route.tab !== 'activity' },
|
||||
h('h2', {}, 'Activity ', h('span', { class: 'tag' }, state.status)),
|
||||
h('div', { class: 'scraper-overview' },
|
||||
h('span', {}, `${activeVisible.length} active`), h('span', {}, `${pending.length} pending`),
|
||||
h('span', {}, `${attention.length} need attention`)),
|
||||
h('div', { class: 'field' }, h('span', {}, 'Downloading now'), activeDownloads,
|
||||
h('div', { class: 'row', style: 'margin-top:8px' }, state.paused
|
||||
? h('button', { class: 'btn primary', onclick: () => command({ command: 'control', action: 'resume' }) }, 'Resume queue')
|
||||
: h('button', { class: 'btn', onclick: () => command({ command: 'control', action: 'pause' }) }, 'Pause queue'))),
|
||||
h('div', { class: 'field' }, h('span', {}, `Needs attention · ${attention.length}`),
|
||||
attention.length ? h('div', { class: 'scraper-queue' }, attention.map(item => jobRow(item)))
|
||||
: h('p', { class: 'muted small' }, 'Nothing needs action on this source.'),
|
||||
h('a', { class: 'btn small', href: 'health.html' }, 'Service health & incidents')),
|
||||
h('div', { class: 'field' }, h('span', {}, 'Up next'), pending.filter(item => item.status !== 'ready').length
|
||||
? h('div', { class: 'scraper-queue' }, pending.filter(item => item.status !== 'ready').slice(0, 5).map(item => jobRow(item)))
|
||||
: h('p', { class: 'muted small' }, 'No downloads waiting.')));
|
||||
const addPanel = h('details', { class: 'scraper-add', 'data-scraper-disclosure': 'add', open: disclosureOpen.add }, h('summary', {}, 'Add manga to queue'),
|
||||
h('div', { class: 'field' },
|
||||
h('div', { class: 'row' }, url, title),
|
||||
h('div', { class: 'row', style: 'margin-top:8px' }, scope, scopeValue, libraryTarget,
|
||||
h('label', { class: 'row small' }, addSource, 'Add as another source version'),
|
||||
h('button', { class: 'btn primary', onclick: async () => {
|
||||
try {
|
||||
const selectedScope = { mode: scope.value };
|
||||
if (['last', 'first'].includes(scope.value)) selectedScope.count = +scopeValue.value;
|
||||
if (['chapter', 'after'].includes(scope.value)) selectedScope.chapter = scopeValue.value;
|
||||
if (scope.value === 'volume') selectedScope.volume = scopeValue.value;
|
||||
await command({
|
||||
command: 'enqueue', url: url.value, title: title.value, scope: selectedScope,
|
||||
existingPolicy: addSource.checked ? 'add-source' : 'skip-any',
|
||||
libraryMangaId: libraryTarget.value || undefined,
|
||||
inspect: true,
|
||||
});
|
||||
}
|
||||
catch (e) { addMessage.textContent = e.message; addMessage.className = 'small error'; }
|
||||
} }, 'Inspect & queue'), addMessage),
|
||||
h('div', { class: 'muted small' }, 'Inspection uses the normal source lane and request delays. Confirm the source edition before images are downloaded.'),
|
||||
h('div', { class: 'muted small' }, 'Existing chapters are never overwritten. Enable alternate versions only when you want the same chapter from this additional host.')));
|
||||
const queuePanel = h('div', { 'data-scraper-tab': 'queue', hidden: route.tab !== 'queue' },
|
||||
h('h2', {}, 'Queue'), addPanel,
|
||||
h('div', { class: 'field' }, h('span', {}, `Pending · ${pending.length}`),
|
||||
pending.length ? h('div', { class: 'scraper-queue' }, pending.map(item => jobRow(item, true)))
|
||||
: h('p', { class: 'muted small' }, 'No pending downloads on this source.')),
|
||||
h('details', { class: 'scraper-completed', 'data-scraper-disclosure': 'completed', open: disclosureOpen.completed }, h('summary', {}, `Completed downloads · ${completed.length}`),
|
||||
h('p', { class: 'muted small' }, 'Clearing removes finished queue rows only. Library chapters and activity records stay.'),
|
||||
h('button', { class: 'btn small', disabled: !completed.length, onclick: async () => {
|
||||
if (confirm(`Clear ${completed.length} completed download job${completed.length === 1 ? '' : 's'}${sourceFilter ? ` from ${sourceFilter}` : ''} from the queue? Library chapters will stay.`)) {
|
||||
await command({ command: 'clear-completed', source: sourceFilter });
|
||||
}
|
||||
} }, `Clear completed downloads (${completed.length})`),
|
||||
h('div', { class: 'scraper-queue' }, completed.slice().reverse().map(item => jobRow(item)))));
|
||||
const checkFor = (manga, source) => (state.updateChecks || []).find(item => item.mangaId === manga.id && item.source === source);
|
||||
const updateRows = (state.library || []).filter(manga => !onlyEnabled || manga.autoUpdate)
|
||||
.filter(manga => !sourceFilter || manga.sources?.some(source => source.adapter === sourceFilter))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const updatesPanel = h('div', { 'data-scraper-tab': 'updates', hidden: route.tab !== 'updates' },
|
||||
h('h2', {}, 'Updates'),
|
||||
h('div', { class: 'field' },
|
||||
state.libraryStatus?.error ? h('div', { class: 'small error' }, `Library index refresh failed: ${state.libraryStatus.error}`) : null,
|
||||
state.libraryStatus?.refreshing ? h('div', { class: 'muted small', role: 'status' },
|
||||
state.libraryStatus.ready ? 'Refreshing library index; showing the previous snapshot.' : 'Loading library index… Queue and rate controls are ready.') : null,
|
||||
h('div', { class: 'row' },
|
||||
h('label', { class: 'row' }, autoEnabled, 'Nightly discovery'),
|
||||
h('label', {}, h('small', {}, 'Local time'), autoTime),
|
||||
h('button', { class: 'btn', onclick: () => command({ command: 'settings', settings: {
|
||||
autoUpdateEnabled: autoEnabled.checked, autoUpdateTime: autoTime.value,
|
||||
} }) }, 'Save schedule'),
|
||||
h('button', { class: 'btn', onclick: () => command({ command: 'discover' }) }, 'Check now')),
|
||||
h('div', { class: 'muted small' }, state.nextDiscoveryAt ? `Next check: ${new Date(state.nextDiscoveryAt).toLocaleString()}` : 'Nightly discovery is disabled.'),
|
||||
h('label', { class: 'row small' }, h('input', { type: 'checkbox', checked: onlyEnabled,
|
||||
onchange: e => { onlyEnabled = e.target.checked; draw(lastState); } }), 'Only auto-updated manga'),
|
||||
h('div', { class: 'scraper-update-list' }, updateRows.map(manga => {
|
||||
const checks = (manga.sources || []).filter(source => source.adapter && (!sourceFilter || source.adapter === sourceFilter))
|
||||
.map(source => checkFor(manga, source.adapter)).filter(Boolean);
|
||||
const newest = key => checks.map(check => check[key]).filter(Boolean).sort().at(-1);
|
||||
const found = newest('lastNewContentAt'), downloaded = newest('lastDownloadedAt');
|
||||
return h('div', { class: 'scraper-update-row' },
|
||||
h('label', { class: 'row' }, h('input', { type: 'checkbox', checked: !!manga.autoUpdate,
|
||||
onchange: e => command({ command: 'auto-update', id: manga.id, enabled: e.target.checked }) }), h('b', {}, manga.title)),
|
||||
h('span', { class: 'muted small' }, `${manga.status} · ${manga.chapters} chapters`),
|
||||
h('span', {}, `Last checked: ${when(newest('lastCheckedAt'))}`),
|
||||
h('span', {}, `Last downloaded: ${when(downloaded)}`),
|
||||
found && (!downloaded || found > downloaded) ? h('span', { class: 'small saved' }, `New content found ${when(found)}; download pending`) : null);
|
||||
})),
|
||||
!updateRows.length ? h('p', { class: 'muted small' }, 'No manga match this filter.') : null,
|
||||
h('div', { class: 'muted small' }, 'Ongoing manga default to enabled. Completed manga default to disabled. A successful check updates “Last checked”; a published chapter updates “Last downloaded”.')),
|
||||
h('details', { class: 'scraper-history', 'data-scraper-disclosure': 'history', open: disclosureOpen.history }, h('summary', {}, 'Recent check activity'),
|
||||
h('div', { class: 'scraper-queue' }, (state.history || []).filter(item => item.status === 'checked' && (!sourceFilter || item.source === sourceFilter)).slice(0, 20).map(item =>
|
||||
h('div', { class: 'scraper-item small' }, `${item.title} · ${item.source} · checked ${when(item.time)} · no new chapters`)))));
|
||||
const sourcesPanel = h('div', { 'data-scraper-tab': 'sources', hidden: route.tab !== 'sources' },
|
||||
h('h2', {}, 'Sources'),
|
||||
(state.adapters || []).filter(adapter => !sourceFilter || adapter.id === sourceFilter).map(adapter => {
|
||||
const running = activeEntries.find(entry => entry.source === adapter.id);
|
||||
const waiting = state.queue.filter(item => item.source === adapter.id && ['queued', 'retry_wait', 'ready'].includes(item.status)).length;
|
||||
const problems = state.queue.filter(item => item.source === adapter.id && ['error', 'quarantined', 'unavailable'].includes(item.status)).length;
|
||||
return h('section', { class: 'scraper-source-card' }, h('h3', {}, adapter.label),
|
||||
h('p', { class: 'muted small' }, `${running ? 'Active' : state.sourcePaused?.[adapter.id] ? 'Paused by source protection' : 'Idle'} · ${waiting} waiting · ${problems} need attention`),
|
||||
sourceRateEditors.find(editor => editor.querySelector('b')?.textContent === adapter.label));
|
||||
}), h('p', { class: 'muted small' }, 'Each source runs sequentially with its own limits. New limits apply when that source starts or resumes a manga.'));
|
||||
card.append(activityPanel, queuePanel, updatesPanel, sourcesPanel);
|
||||
};
|
||||
|
||||
async function load(force = false) {
|
||||
if (!active) return;
|
||||
try {
|
||||
const state = await api('api/admin/scraper');
|
||||
lastState = state;
|
||||
const focused = card.contains(document.activeElement) && /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement.tagName);
|
||||
// Replacing a live <details> during polling is disruptive on mobile:
|
||||
// the browser can deliver its toggle after the replacement. Leave the
|
||||
// disclosure's DOM alone until the user closes it or takes an action.
|
||||
const readingDisclosure = [...card.querySelectorAll('details[data-scraper-disclosure]')]
|
||||
.some(details => details.open);
|
||||
if (force || (!focused && !dirty && !readingDisclosure)) draw(state);
|
||||
}
|
||||
catch (error) { draw({ available: false, error: error.message }); }
|
||||
clearTimeout(timer);
|
||||
if (active) timer = setTimeout(load, document.hidden ? 15000 : 3000);
|
||||
}
|
||||
return {
|
||||
activate() { active = true; if (lastState && !dirty) draw(lastState); load(); },
|
||||
deactivate() { active = false; clearTimeout(timer); },
|
||||
tab() {
|
||||
card.querySelectorAll('[data-scraper-tab]').forEach(panel => { panel.hidden = panel.getAttribute('data-scraper-tab') !== route.tab; });
|
||||
card.querySelectorAll('.settings-tabs a').forEach(link => {
|
||||
if (link.getAttribute('href') === '#admin/scraper/' + route.tab) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function adminCard(id) {
|
||||
const card = h('section', { class: 'card' });
|
||||
main.append(card);
|
||||
section(id).append(card);
|
||||
const draw = data => {
|
||||
const s = data.scan;
|
||||
const scanText = s.running ? 'Scanning…' : s.error ? 'Last scan failed: ' + s.error
|
||||
@@ -80,11 +395,14 @@
|
||||
const isAdmin = h('input', { type: 'checkbox' });
|
||||
const msg = h('span', { class: 'small' });
|
||||
card.textContent = '';
|
||||
card.append(h('h2', {}, 'Admin'),
|
||||
if (id === 'admin/library') card.append(h('h2', {}, 'Library maintenance'),
|
||||
h('div', { class: 'field' }, h('span', {}, 'Library'), h('div', { class: 'muted small' }, s.library), h('div', {}, scanText),
|
||||
h('div', { class: 'row', style: 'margin-top:8px' },
|
||||
h('button', { class: 'btn', onclick: rescan(false) }, 'Rescan'),
|
||||
h('button', { class: 'btn', onclick: rescan(true), title: 'Also re-read all image sizes' }, 'Full rescan'))),
|
||||
h('p', { class: 'muted small' }, 'Deployment, mounts and service startup are managed by Docker Compose. Library scans do not change download settings.'),
|
||||
h('a', { class: 'btn', href: 'health.html' }, 'Service health & incidents'));
|
||||
if (id === 'admin/users') card.append(h('h2', {}, 'User administration'),
|
||||
h('div', { class: 'field' }, h('span', {}, 'Users'),
|
||||
h('div', { class: 'users' }, data.users.map(u => h('div', {},
|
||||
h('span', {}, u.name, u.admin ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'admin') : null),
|
||||
@@ -99,7 +417,36 @@
|
||||
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
|
||||
} }, 'Save user'), msg)));
|
||||
};
|
||||
async function load() { draw(await api('api/admin')); }
|
||||
load();
|
||||
async function load() {
|
||||
try { draw(await api('api/admin')); }
|
||||
catch (e) { card.textContent = ''; card.append(h('p', { class: 'error', role: 'alert' }, e.message), h('button', { class: 'btn', onclick: load }, 'Try again')); }
|
||||
}
|
||||
return { activate: load, deactivate() {} };
|
||||
}
|
||||
|
||||
function navigate(focus = false) {
|
||||
const next = settingsRoute(location.hash, me.admin);
|
||||
const entry = SETTINGS_SECTIONS.find(item => item.id === next.id);
|
||||
if (entry.href) { location.href = entry.href; return; }
|
||||
if (route.id !== next.id) controllers.get(route.id)?.deactivate();
|
||||
const changed = route.id !== next.id;
|
||||
route = next;
|
||||
shell.select(route.id, focus);
|
||||
if (!controllers.has(route.id) && route.id.startsWith('admin/')) {
|
||||
controllers.set(route.id, route.id === 'admin/scraper' ? scraperCard() : adminCard(route.id));
|
||||
}
|
||||
shell.content.replaceChildren(section(route.id));
|
||||
const controller = controllers.get(route.id);
|
||||
if (changed || !controller?.started) {
|
||||
if (controller) { controller.started = true; controller.activate(); }
|
||||
} else controller?.tab?.();
|
||||
// Normalize unknown/unauthorized routes without creating an extra history entry.
|
||||
if (location.hash !== '#' + route.id && !location.hash.startsWith('#' + route.id + '/')) {
|
||||
history.replaceState(null, '', '#' + route.id);
|
||||
}
|
||||
}
|
||||
window.addEventListener('hashchange', () => navigate(true));
|
||||
window.addEventListener('pagehide', () => controllers.get(route.id)?.deactivate());
|
||||
window.addEventListener('pageshow', e => { if (e.persisted) controllers.get(route.id)?.activate(); });
|
||||
navigate();
|
||||
})();
|
||||
Reference in new issue
Block a user