Add comix.to and kagane.to support (#13)
Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #13.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
{{define "app"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>mangaBookmark</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
{{/* Body text before meta lines: DM Sans is the biggest face and the one
|
||||
most of the page is set in; the mono is small and arrives from CSS. */}}
|
||||
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "icons" .}}
|
||||
<div class="sheet">
|
||||
<header class="topbar">
|
||||
<h1 class="brand">{{template "mark" .}}<span>manga<em>Bookmark</em></span></h1>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{{/* Search sits above the tabs on a phone and folds into the tab row on a
|
||||
wider screen — one flex container, order swapped in CSS. */}}
|
||||
<div class="chrome">
|
||||
<div class="searchbar">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-search"/></svg>
|
||||
<input id="search" class="search" type="search" placeholder="Find a title"
|
||||
autocomplete="off" aria-label="Search titles">
|
||||
</div>
|
||||
|
||||
{{/* These are real links with real hrefs that change the URL, so they are
|
||||
navigation, not an ARIA tablist — aria-current carries "which bucket am
|
||||
I in" without owing a tabpanel contract we do not implement. */}}
|
||||
<nav class="tabs" aria-label="Bookmark buckets">
|
||||
<a href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
|
||||
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
|
||||
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated
|
||||
{{template "newcount" .}}</a>
|
||||
<a href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
<a href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=archived" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=archived" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
<a href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
||||
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{template "keyrow" .}}
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
<main id="list" class="list">
|
||||
{{template "list" .}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,145 @@
|
||||
{{define "card"}}
|
||||
{{/* One sheet per series. is-new turns the title crimson over an ember rule;
|
||||
is-dim sinks archived and finished rows into italic grey. */}}
|
||||
<article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
|
||||
id="card-{{.Key}}" data-title="{{.Title}}">
|
||||
<div class="row">
|
||||
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
|
||||
tabindex="-1" aria-hidden="true">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{/* aria-hidden on the cover link is not enough — Chromium still exposes
|
||||
the letter because the link is programmatically focusable — so the
|
||||
monogram carries its own, same as the recent strip's. */}}
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
{{if and (eq .Status "reading") .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</a>
|
||||
<div class="body">
|
||||
<div class="title-line">
|
||||
<h3 class="title">{{.Title}}</h3>
|
||||
{{if .Favorite}}
|
||||
<svg class="fav-mark" viewBox="0 0 24 24" aria-label="Favourite" role="img"><use href="#i-star-on"/></svg>
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="meta">
|
||||
<span class="site-{{.Site}}">{{.Site}}</span>
|
||||
<span class="sep">/</span>
|
||||
<span class="chapter">{{.DisplayChapter}}</span>
|
||||
{{if and (eq .Status "reading") .HasNewChapter}}
|
||||
<span class="sep">/</span>
|
||||
<span class="new-chapter">{{.DisplayLatest}} out</span>
|
||||
{{end}}
|
||||
{{if eq .Status "archived"}}
|
||||
<span class="sep">/</span>
|
||||
<span class="state">archived</span>
|
||||
{{else if eq .Status "finished"}}
|
||||
<span class="sep">/</span>
|
||||
<span class="state"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>finished</span>
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="play" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
|
||||
title="Continue reading" aria-label="Continue reading">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg>
|
||||
</a>
|
||||
<button class="fav{{if .Favorite}} on{{end}}"
|
||||
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
|
||||
aria-label="Toggle favourite"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star{{if .Favorite}}-on{{end}}"/></svg>
|
||||
</button>
|
||||
<button class="pencil" title="Set chapter" aria-label="Set chapter"
|
||||
onclick="toggleChapterForm('{{.Key}}')">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
||||
</button>
|
||||
{{/* Restore is a reversal, so it fires straight away; every move *out* of
|
||||
the list (archive, finish, remove) goes through a confirm row. */}}
|
||||
{{if eq .Status "reading"}}
|
||||
<button class="lifecycle box" title="Archive" aria-label="Archive"
|
||||
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
|
||||
onclick="toggleConfirmRow('{{.Key}}', 'archive')">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg>
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="lifecycle restore" title="Restore to reading" aria-label="Restore to reading"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
{{if ne .Status "finished"}}
|
||||
<button class="lifecycle finish" title="Mark finished" aria-label="Mark finished"
|
||||
aria-expanded="false" aria-controls="confirm-finish-{{.Key}}"
|
||||
onclick="toggleConfirmRow('{{.Key}}', 'finish')">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="lifecycle remove" title="Remove" aria-label="Remove"
|
||||
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
|
||||
onclick="toggleConfirmRow('{{.Key}}', 'remove')">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden
|
||||
hx-post="/ui/bookmarks/{{.Key}}/chapter"
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
|
||||
{{/* The field holds your progress; "Latest known" is the published chapter.
|
||||
Those are different numbers whenever this form is worth opening, so the
|
||||
label names the field and the latest sits after it as context. */}}
|
||||
<label class="hint" for="chapter-{{.Key}}">Chapter you're on</label>
|
||||
<div class="field">
|
||||
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}
|
||||
<input id="chapter-{{.Key}}" name="chapter" type="number" step="0.1" min="0" max="9999"
|
||||
value="{{.LastChapterNum}}" required>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
{{if .LatestChapter}}<p class="hint">Latest known: {{.DisplayLatest}}</p>{{end}}
|
||||
</form>
|
||||
{{/* One confirm row per way a series leaves the list. aria-live announces the
|
||||
step to a screen reader, which otherwise gets no word that the tap
|
||||
opened a second question. */}}
|
||||
{{if eq .Status "reading"}}
|
||||
<div class="confirm-row calm" id="confirm-archive-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||
<span>Archive this?</span>
|
||||
<div>
|
||||
<button class="go"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Archive</button>
|
||||
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'archive')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if ne .Status "finished"}}
|
||||
<div class="confirm-row calm" id="confirm-finish-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||
<span>Mark finished?</span>
|
||||
<div>
|
||||
<button class="go"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Finish</button>
|
||||
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'finish')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||
<span>Remove “{{.Title}}”? Chapter progress is lost.</span>
|
||||
<div>
|
||||
<button class="danger-solid"
|
||||
hx-delete="/ui/bookmarks/{{.Key}}"
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button>
|
||||
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'remove')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/* role=status announces a failed write; without it the tap just looks
|
||||
ignored to a screen reader. */}}
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -0,0 +1,78 @@
|
||||
{{/* The regions that live outside the swapped #list: the "Continue reading"
|
||||
strip, the Updated badge and the action key. All are rendered inline by
|
||||
app.html and again, out of band, on every /ui/ response — a mutation must
|
||||
not leave them describing the library as it was before the tap.
|
||||
|
||||
All always render, hidden when they have nothing to say, so an out-of-band
|
||||
swap always has an element with the right id to replace. */}}
|
||||
|
||||
{{define "recent"}}
|
||||
<section class="recent" id="recent"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .Recent}} hidden{{end}}>
|
||||
<h2>Continue reading</h2>
|
||||
<div class="recent-strip">
|
||||
{{range .Recent}}
|
||||
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<span class="recent-cover">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</span>
|
||||
<span class="recent-title">{{.Title}}</span>
|
||||
<span class="recent-chapter">{{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
|
||||
line under the tabs names every glyph. It follows the tab rather than the
|
||||
row: the archived and finished buckets swap Archive for Restore, and a
|
||||
finished series has no Done to offer. */}}
|
||||
{{define "keyrow"}}
|
||||
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
|
||||
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||
{{if or (eq .Tab "archived") (eq .Tab "finished")}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
||||
{{else}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
|
||||
{{end}}
|
||||
{{if ne .Tab "finished"}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg><span>Done</span></span>
|
||||
{{end}}
|
||||
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* The brand mark, inline so it takes the page's ink and ember rather than the
|
||||
fixed palette of /static/logo.svg (which the favicon needs). */}}
|
||||
{{define "mark"}}
|
||||
<svg class="mark" viewBox="0 0 200 172" aria-hidden="true">
|
||||
<g fill="var(--ink)" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path fill="none" d="M28 36H4v114h192V36h-24"></path>
|
||||
<path fill="none" d="M28 23H17v127h166V23h-11"></path>
|
||||
<g id="mb-half">
|
||||
<path d="M28 7 88 55v97L28 138z"></path>
|
||||
<g fill="currentColor" stroke="none">
|
||||
<path d="M37 25 55 39v41L37 66z"></path>
|
||||
<path d="M60 42 79 57v42L60 84z"></path>
|
||||
<path d="M37 75 79 108v13L37 88z"></path>
|
||||
<path d="M37 98 79 129v11L37 131z"></path>
|
||||
</g>
|
||||
</g>
|
||||
<use href="#mb-half" transform="matrix(-1 0 0 1 200 0)"></use>
|
||||
<g stroke="var(--ember)">
|
||||
<path d="M100 4l9 5v11l-9 5-9-5V9z"></path>
|
||||
<path d="M94 24h12v24H94z"></path>
|
||||
<path d="M70 47h60v14H70z"></path>
|
||||
<path d="M91 61h18v87l-9 20-9-20z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
{{end}}
|
||||
|
||||
{{define "newcount"}}<span class="count" id="new-count"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .NewCount}} hidden{{end}}>{{.NewCount}}</span>{{end}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "icons"}}
|
||||
<!-- One sprite per page. Card fragments swapped in by htmx reference these
|
||||
symbols by id, so the markup for a row stays a handful of <use> tags. -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5l11 7-11 7z" fill="currentColor"/></symbol>
|
||||
<symbol id="i-star" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-star-on" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="currentColor" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="M3 7h18v4H3zM5 11v9h14v-9M10 15h4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M4 12.5l5.2 5.5L20 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V4h5v3M6.5 7l1 13h9l1-13M10.5 11v6M13.5 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M4 9h9.5a5 5 0 010 10H8M4 9l4.2-4.2M4 9l4.2 4.2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M15.3 15.3L20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
</svg>
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "list"}}
|
||||
{{if .Items}}
|
||||
{{range .Items}}{{template "card" .}}{{end}}
|
||||
{{/* The client filter only hides cards, so without this the list area goes
|
||||
blank on a query that matches nothing. filter.js fills in the query and
|
||||
unhides it; it lives inside #list so a tab swap re-creates it. */}}
|
||||
<div class="empty" id="no-match" hidden>
|
||||
<strong>No titles match “<span class="no-match-q"></span>”.</strong>
|
||||
<button type="button" class="clear-search">Clear search</button>
|
||||
</div>
|
||||
{{else if eq .Tab "fav"}}
|
||||
<div class="empty"><strong>No favourites yet.</strong><p>Star a series to pin it here.</p></div>
|
||||
{{else if eq .Tab "new"}}
|
||||
<div class="empty"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
|
||||
{{else if eq .Tab "archived"}}
|
||||
<div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
|
||||
{{else if eq .Tab "finished"}}
|
||||
<div class="empty"><strong>Nothing finished yet.</strong><p>Mark a series finished and it moves out of your reading list.</p></div>
|
||||
{{else}}
|
||||
<div class="empty"><strong>Nothing here yet.</strong><p>Bookmarks appear once the userscript records a chapter.</p></div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "login"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>mangaBookmark</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-card">
|
||||
<div>
|
||||
<span class="eyebrow">Private library</span>
|
||||
<h1 class="brand">{{template "mark" .}}<span>manga<em>Bookmark</em></span></h1>
|
||||
</div>
|
||||
<form method="post" action="/login">
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password"
|
||||
autocomplete="current-password" autofocus required>
|
||||
</div>
|
||||
{{/* The page reloads on a failed sign-in, so the message is present from
|
||||
the start; role=alert is what gets it announced anyway. */}}
|
||||
<p class="error" role="alert">{{.Error}}</p>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user