b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
92 lines
4.3 KiB
HTML
92 lines
4.3 KiB
HTML
{{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>BookmarkManager</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>Bookmark<em>Manager</em></span></h1>
|
|
{{/* Plain full-page links, not htmx swaps: switching library replaces the
|
|
tab row and the chrome, which is a page, not a fragment. */}}
|
|
<nav class="libswitch" aria-label="Library">
|
|
<a href="/?tab=all" class="{{if eq .Lib "manga"}}active{{end}}"
|
|
{{if eq .Lib "manga"}}aria-current="page"{{end}}>Manga</a>
|
|
<a href="/?lib=novel&tab=all" class="{{if eq .Lib "novel"}}active{{end}}"
|
|
{{if eq .Lib "novel"}}aria-current="page"{{end}}>Novels</a>
|
|
</nav>
|
|
<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="{{.PageURL "all"}}" class="{{if eq .Tab "all"}}active{{end}}"
|
|
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
|
hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
|
|
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
|
|
{{/* The one bucket novels do not have: without a poller-fed "what is out
|
|
that I have not read", the tab would only ever restate All. */}}
|
|
{{if eq .Lib "manga"}}
|
|
<a href="{{.PageURL "new"}}" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
|
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
|
hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
|
|
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
|
|
{{template "newcount" .}}</a>
|
|
{{end}}
|
|
<a href="{{.PageURL "fav"}}" class="{{if eq .Tab "fav"}}active{{end}}"
|
|
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
|
hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
|
|
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
|
<a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
|
|
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
|
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
|
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
|
<a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
|
|
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
|
hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
|
|
hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
|
|
</nav>
|
|
</div>
|
|
|
|
{{template "setup" .}}
|
|
|
|
{{if .Owner}}{{template "readers" .}}{{end}}
|
|
|
|
{{template "keyrow" .}}
|
|
|
|
{{template "recent" .}}
|
|
|
|
<main id="list" class="list">
|
|
{{template "list" .}}
|
|
</main>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
{{end}}
|