Web UI: Updated tab, inline errors, mobile card fixes #3

Merged
sulthan merged 3 commits from web-ui-revamp into main 2026-07-27 00:42:20 +07:00
8 changed files with 366 additions and 37 deletions
Showing only changes of commit b2aa814982 - Show all commits
+23
View File
@@ -7,3 +7,26 @@ backend/server
graphify-out/ graphify-out/
plans/ plans/
docs/superpowers/ docs/superpowers/
# impeccable-ignore-start
# Ephemeral output, runtime state, and per-dev overrides.
# Unanchored: .impeccable may sit at the repo root or under a nested
# workspace (apps/web/.impeccable/...); anchored patterns would miss it.
# Shared artifacts stay tracked: config.json, live/config.json,
# design.json, critique/*.md.
.impeccable/config.local.json
.impeccable/hook.cache.json
.impeccable/hook.pending.json
.impeccable/*.png
.impeccable/live/server.json
.impeccable/live/sessions/
.impeccable/live/previews/
.impeccable/live/annotations/
.impeccable/live/cache/
.impeccable/live/manual-edit-apply-transaction.json
.impeccable/live/manual-edit-events.jsonl
.impeccable/live/manual-edit-evidence/
.impeccable/live/pending-manual-edits.json
.impeccable/live/deferred-svelte-component-accepts.json
.impeccable/live/*.png
# impeccable-ignore-end
+44 -1
View File
@@ -25,9 +25,52 @@ function setActiveTab(el) {
}); });
} }
// The chapter-edit form and the delete confirm row are the two per-card
// disclosure panels; only one makes sense open at a time.
function closeCardPanels(key) {
var form = document.getElementById("chapter-form-" + key);
var confirm = document.getElementById("confirm-row-" + key);
if (form) form.hidden = true;
if (confirm) confirm.hidden = true;
}
function toggleChapterForm(key) { function toggleChapterForm(key) {
var form = document.getElementById("chapter-form-" + key); var form = document.getElementById("chapter-form-" + key);
if (!form) return; if (!form) return;
form.hidden = !form.hidden; var opening = form.hidden;
closeCardPanels(key);
form.hidden = !opening;
if (!form.hidden) form.querySelector("input").focus(); if (!form.hidden) form.querySelector("input").focus();
} }
function toggleConfirmRow(key) {
var row = document.getElementById("confirm-row-" + key);
if (!row) return;
var opening = row.hidden;
closeCardPanels(key);
row.hidden = !opening;
}
// A failed favourite/chapter/delete request leaves the card in place (htmx
// does not swap on a non-2xx response) but otherwise gives no sign anything
// went wrong. Surface it inline instead of leaving the tap looking ignored.
(function () {
function showError(elt, message) {
var card = elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
if (!slot) return;
slot.textContent = message;
slot.hidden = false;
clearTimeout(slot._hideTimer);
slot._hideTimer = setTimeout(function () {
slot.hidden = true;
}, 5000);
}
document.body.addEventListener("htmx:responseError", function (e) {
showError(e.detail.elt, "Couldn't save — try again.");
});
document.body.addEventListener("htmx:sendError", function (e) {
showError(e.detail.elt, "No connection — try again.");
});
})();
+194 -22
View File
@@ -8,11 +8,28 @@
--text: #e8eaed; --text: #e8eaed;
--muted: #9aa1ac; --muted: #9aa1ac;
--accent: #6aa9ff; --accent: #6aa9ff;
--accent-ink: #0b1220;
--danger: #ff6a6a; --danger: #ff6a6a;
--danger-ink: #2a0a0a;
--star: #ffc857; --star: #ffc857;
/* A newly-published chapter is its own signal, distinct from "tap to act"
(accent) or "favourited" (star) — a third hue keeps those three meanings
from collapsing into one overloaded blue. */
--new: #34d8a6;
--new-ink: #0b1220;
/* Per-site tint so the two source sites this app exists to unify are
still tellable apart at a glance, not just by reading the label text. */
--site-asura-bg: #3a2a20;
--site-asura-text: #ffb37a;
--site-demonic-bg: #2e2440;
--site-demonic-text: #c9a8ff;
--radius: 12px; --radius: 12px;
} }
/* Light theme darkens the four semantic hues (and flips their ink) rather
than reusing the dark-theme values verbatim: the originals were tuned to
sit on near-black surfaces and fall well under WCAG AA the moment the
surface goes to white — verified via computed contrast, not eyeballed. */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--bg: #f4f5f7; --bg: #f4f5f7;
@@ -20,6 +37,17 @@
--surface-2: #eceef2; --surface-2: #eceef2;
--text: #1a1d22; --text: #1a1d22;
--muted: #5d646e; --muted: #5d646e;
--accent: #1f5fd6;
--accent-ink: #ffffff;
--danger: #b42323;
--danger-ink: #ffffff;
--star: #7d5f00;
--new: #087a5c;
--new-ink: #ffffff;
--site-asura-bg: #fde8d8;
--site-asura-text: #8a4a1f;
--site-demonic-bg: #f0e6fa;
--site-demonic-text: #6b2fa8;
} }
} }
@@ -57,7 +85,7 @@ button, .primary, [role="tab"] {
border-radius: var(--radius); border-radius: var(--radius);
} }
.login-card h1 { margin: 0 0 20px; font-size: 1.25rem; } .login-card h1 { margin: 0 0 20px; font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; }
.login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; } .login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; }
.login-card input { .login-card input {
@@ -73,7 +101,7 @@ button, .primary, [role="tab"] {
} }
.login-card input:focus-visible { outline: 2px solid var(--accent); } .login-card input:focus-visible { outline: 2px solid var(--accent); }
.login-card button { width: 100%; background: var(--accent); color: #0b1220; font-weight: 600; } .login-card button { width: 100%; background: var(--accent); color: var(--accent-ink); font-weight: 600; }
.error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; } .error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; }
/* --- chrome --- */ /* --- chrome --- */
@@ -86,7 +114,7 @@ button, .primary, [role="tab"] {
padding: 12px 0; padding: 12px 0;
} }
.topbar h1 { margin: 0; font-size: 1.125rem; } .topbar h1 { margin: 0; font-size: 1.125rem; font-weight: 700; letter-spacing: -.01em; }
.ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); } .ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); }
.search { .search {
@@ -114,11 +142,12 @@ button, .primary, [role="tab"] {
text-decoration: none; text-decoration: none;
} }
.tabs [role="tab"].active { background: var(--accent); color: #0b1220; font-weight: 600; } .tabs [role="tab"].active { background: var(--accent); color: var(--accent-ink); font-weight: 600; }
/* --- continue reading --- */ /* --- continue reading --- */
.recent h2 { margin: 0 0 8px; font-size: .8125rem; text-transform: uppercase; color: var(--muted); } .recent h2 { margin: 0 0 8px; font-size: .8125rem; font-weight: 600; text-transform: uppercase;
letter-spacing: .04em; color: var(--muted); }
.recent-strip { .recent-strip {
display: flex; display: flex;
@@ -141,10 +170,21 @@ button, .primary, [role="tab"] {
text-decoration: none; text-decoration: none;
} }
.recent-card img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } /* A reserved slot even without cover art, so the ribbon always has a
.recent-title { display: block; margin-top: 6px; font-size: .8125rem; line-height: 1.25; dedicated area to sit in instead of overlapping the title text. */
.recent-cover {
position: relative;
display: block;
aspect-ratio: 3 / 4;
background: var(--surface-2);
border-radius: 8px;
overflow: hidden;
}
.recent-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.recent-title { display: block; margin-top: 6px; font-size: .8125rem; font-weight: 600; line-height: 1.35;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.recent-chapter { display: block; color: var(--muted); font-size: .75rem; } .recent-chapter { display: block; color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
/* --- list --- */ /* --- list --- */
@@ -152,44 +192,140 @@ button, .primary, [role="tab"] {
.card { .card {
display: grid; display: grid;
grid-template-columns: 72px 1fr; grid-template-columns: 96px 1fr;
gap: 12px; gap: 12px;
padding: 10px; padding: 10px;
background: var(--surface); background: var(--surface);
border-radius: var(--radius); border-radius: var(--radius);
} }
.card .cover img { width: 72px; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } .card .cover {
position: relative;
display: block;
width: 96px;
aspect-ratio: 3 / 4;
background: var(--surface-2);
border-radius: 8px;
overflow: hidden;
}
.card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* No og:image was ever scraped for this series. A bare --surface-2 rectangle
read as a cover still loading; the title's initial says the slot is filled
and nothing more is coming. Muted, not accent — a missing cover is an
absence, not a signal, and it must never outrank the New ribbon it sits
under. The two cover contexts share this rule and differ only in size. */
.cover-blank {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
font-size: 2rem;
font-weight: 700;
line-height: 1;
letter-spacing: -.02em;
opacity: .65;
user-select: none;
}
.recent-cover .cover-blank { font-size: 1.625rem; }
.card .body { min-width: 0; } .card .body { min-width: 0; }
.card .title { margin: 0 0 4px; font-size: 1rem; line-height: 1.25; } /* 17px, not the 16px body floor: the title is the one thing per card the
squint test must catch first, so it needs a size step of its own, not
just the browser's default h3 bold riding on an unchanged size. */
.card .title { margin: 0 0 4px; font-size: 1.0625rem; font-weight: 700; line-height: 1.35; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; .meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px;
font-size: .75rem; color: var(--muted); } font-size: .75rem; color: var(--muted); }
.site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase; } .site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase;
.new { padding: 2px 6px; background: var(--accent); color: #0b1220; border-radius: 6px; font-weight: 700; } letter-spacing: .03em; }
/* Per-site tint: the two source sites this app unifies are otherwise
identical chrome, so this is the one glanceable "which site" cue beyond
the label text itself. */
.site-asura { background: var(--site-asura-bg); color: var(--site-asura-text); }
.site-demonic { background: var(--site-demonic-bg); color: var(--site-demonic-text); }
/* Read progress is data, not metadata, so it takes the body text color
instead of the muted tone the label pills around it use. */
.chapter { color: var(--text); font-variant-numeric: tabular-nums; }
.new-chapter { color: var(--new); font-weight: 600; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; } /* The signal the poller exists to surface: a corner ribbon on the cover
itself outranks any line of card metadata, so it reads on the squint
test even in a dense grid, not just on close inspection. Its own hue
(not accent) keeps "new chapter" distinct from "tap to act". */
.new-ribbon {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 7px;
background: var(--new);
color: var(--new-ink);
font-size: .6875rem; /* 11px: the functional-text legibility floor */
font-weight: 700;
letter-spacing: .02em;
text-transform: uppercase;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, .35);
}
.new-ribbon-sm { top: 4px; left: 4px; padding: 1px 5px; }
/* One row of equal-sized icon buttons: Continue no longer stretches to
fill leftover space (that was both the wrap bug at narrow widths and
what made it read as arbitrarily oversized). It carries the same 44px
footprint as the utility icons and is told apart by fill color alone.
Grid columns capped at 44px, not flex items fixed at 44px: on the
narrowest real Android widths four 44px targets plus gaps don't quite
fit beside a 96px cover, and minmax(0, 44px) lets the row compress a
couple of px instead of wrapping — imperceptible, and never wraps. */
.actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 44px));
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--surface-2);
}
.primary { .primary {
flex: 1 1 auto;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 0 14px;
background: var(--accent); background: var(--accent);
color: #0b1220; color: var(--accent-ink);
font-weight: 600; font-size: 1.125rem;
text-decoration: none; text-decoration: none;
} }
.icon { width: 44px; background: var(--surface-2); color: var(--text); font-size: 1.125rem; } .icon { display: grid; place-items: center; padding: 0;
background: var(--surface-2); color: var(--text); font-size: 1.125rem; }
.icon.on { color: var(--star); } .icon.on { color: var(--star); }
.icon.danger { color: var(--danger); } .icon.danger { color: var(--danger); }
.chapter-form { display: flex; gap: 8px; margin-top: 8px; } /* Hand-drawn inline SVG rather than the OS emoji font: ☆ ✎ 🗑 ▶ each render in
a different face at a different optical weight and colour (some fonts force
their own), which is the one place the card ignored its own type and colour
system. currentColor keeps them on .icon / .icon.on / .icon.danger /
.primary without a second palette. 20px, not the 1.125rem font-size the
glyphs used: a glyph inks about two thirds of its em box, so matching the
old optical size means an SVG a step larger than the font-size it replaces. */
.icon svg, .primary svg { width: 20px; height: 20px; display: block; }
/* Wraps, like .actions and .confirm-row: the hint claims its own full-width
row (flex-basis 100%) so input + Save keep the whole card width below it.
Without the wrap the three children fought for one unwrapped row and pushed
Save off-screen — ~97px of page overflow at 390px, 127px at 360px. */
.chapter-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.chapter-form input { .chapter-form input {
flex: 1; /* flex-basis 0 + min-width 0, not flex: 1: a number input's default
intrinsic width is ~20 characters, so on `flex: 1` (basis auto) it refused
to shrink and pushed Save onto a third row. A chapter number needs five
characters, and the field can have whatever is left over. */
flex: 1 1 0;
min-width: 0;
min-height: 44px; min-height: 44px;
padding: 0 12px; padding: 0 12px;
background: var(--surface-2); background: var(--surface-2);
@@ -197,16 +333,52 @@ button, .primary, [role="tab"] {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
font: inherit; font: inherit;
font-variant-numeric: tabular-nums;
} }
.chapter-form button { padding: 0 14px; background: var(--accent); color: #0b1220; font-weight: 600; } .chapter-form button { padding: 0 14px; background: var(--accent); color: var(--accent-ink); font-weight: 600; }
/* flex: 1 0 100% — own row above the field. The margin-bottom is 0 because the
parent's 8px gap already spaces it; keeping both double-spaced the hint. */
.hint { flex: 1 0 100%; margin: 0; font-size: .75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
/* Delete confirm, themed in place of a native confirm() dialog: that OS
chrome renders outside the app's own dark surface, breaking the
night-reading brand constraint at the one moment attention matters most. */
.confirm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
/* Own full-width row, same as .chapter-form's hint. Sharing one row with both
buttons broke the prompt into "Remove / this?" while its own answer sat
whole beside it; nowrap alone only converted that into 26px of page
overflow, because a nowrap flex item will not shrink past its text. */
.confirm-row span { flex: 1 0 100%; font-size: .8125rem; white-space: nowrap; }
.confirm-row .danger-solid { padding: 0 14px; background: var(--danger); color: var(--danger-ink); font-weight: 600; }
.confirm-row button[type="button"] { padding: 0 14px; background: var(--surface-2); color: var(--text); }
.empty { padding: 32px 12px; text-align: center; color: var(--muted); } .empty { padding: 32px 12px; text-align: center; color: var(--muted); }
/* --- mutation feedback --- */
/* htmx adds .htmx-request to the element named by hx-indicator while a
request is in flight; dimming the whole card is the visible "it's working"
signal the templates otherwise never give. */
.card.htmx-request { opacity: .6; }
.error-inline {
margin: 8px 0 0;
padding: 6px 10px;
background: var(--surface-2);
color: var(--danger);
border-radius: 8px;
font-size: .8125rem;
}
.error-inline[hidden] { display: none; }
/* Cards hidden by the search filter. The .chapter-form { display: flex } /* Cards hidden by the search filter. The .chapter-form { display: flex }
rule above has equal specificity to the UA [hidden] rule, so without this rule above has equal specificity to the UA [hidden] rule, so without this
override the form stays visible even when the hidden attribute is set. */ override the form stays visible even when the hidden attribute is set. */
.card[hidden] { display: none; } .card[hidden] { display: none; }
.chapter-form[hidden] { display: none; } .chapter-form[hidden] { display: none; }
.confirm-row[hidden] { display: none; }
/* --- wide screens --- */ /* --- wide screens --- */
+8 -1
View File
@@ -25,6 +25,9 @@
<a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}" <a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML" 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> hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
<a role="tab" href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{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</a>
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}" <a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML" 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> hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
@@ -36,7 +39,11 @@
<div class="recent-strip"> <div class="recent-strip">
{{range .Recent}} {{range .Recent}}
<a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"> <a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}} <span class="recent-cover">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="new-ribbon new-ribbon-sm">New</span>{{end}}
</span>
<span class="recent-title">{{.Title}}</span> <span class="recent-title">{{.Title}}</span>
<span class="recent-chapter">Ch {{.LastChapter}}</span> <span class="recent-chapter">Ch {{.LastChapter}}</span>
</a> </a>
+42 -10
View File
@@ -1,37 +1,69 @@
{{define "card"}} {{define "card"}}
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}"> <article class="card" id="card-{{.Key}}" data-title="{{.Title}}">
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"> <a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}} {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="new-ribbon">New</span>{{end}}
</a> </a>
<div class="body"> <div class="body">
<h3 class="title">{{.Title}}</h3> <h3 class="title">{{.Title}}</h3>
<p class="meta"> <p class="meta">
<span class="site site-{{.Site}}">{{.Site}}</span> <span class="site site-{{.Site}}">{{.Site}}</span>
<span class="chapter">Ch {{.LastChapter}}</span> <span class="chapter">Ch {{.LastChapter}}</span>
{{if .HasNewChapter}}<span class="new">NEW {{.LatestChapter}}</span>{{end}} {{if .HasNewChapter}}<span class="new-chapter">Ch {{.LatestChapter}} out</span>{{end}}
</p> </p>
<div class="actions"> <div class="actions">
<a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">Continue</a> <a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
title="Continue reading" aria-label="Continue reading">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M8 4.8a1 1 0 0 1 1.52-.85l10 7.2a1 1 0 0 1 0 1.7l-10 7.2A1 1 0 0 1 8 19.2z"/>
</svg>
</a>
<button class="icon {{if .Favorite}}on{{end}}" <button class="icon {{if .Favorite}}on{{end}}"
title="Favourite" aria-label="Toggle favourite" title="Favourite" aria-label="Toggle favourite"
hx-post="/ui/bookmarks/{{.Key}}/favorite" hx-post="/ui/bookmarks/{{.Key}}/favorite"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"> hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
{{if .Favorite}}★{{else}}☆{{end}} hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="{{if .Favorite}}currentColor{{else}}none{{end}}"
stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<path d="M12 3.7l2.63 5.33 5.87.86-4.25 4.14 1 5.85L12 17.31l-5.25 2.57 1-5.85L3.5 9.89l5.87-.86z"/>
</svg>
</button> </button>
<button class="icon" title="Set chapter" aria-label="Set chapter" <button class="icon" title="Set chapter" aria-label="Set chapter"
onclick="toggleChapterForm('{{.Key}}')">✎</button> onclick="toggleChapterForm('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 20h4L19.3 8.7a1.5 1.5 0 0 0 0-2.12l-1.88-1.88a1.5 1.5 0 0 0-2.12 0L4 16z"/>
<path d="M13.8 7.2l3 3"/>
</svg>
</button>
<button class="icon danger" title="Remove" aria-label="Remove" <button class="icon danger" title="Remove" aria-label="Remove"
hx-delete="/ui/bookmarks/{{.Key}}" onclick="toggleConfirmRow('{{.Key}}')">
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
hx-confirm="Remove {{.Title}} from the list?">🗑</button> stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 6.8h15M9.8 6.8V4.6h4.4v2.2M7.3 6.8l.9 12.6h7.6l.9-12.6"/>
</svg>
</button>
</div> </div>
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden <form class="chapter-form" id="chapter-form-{{.Key}}" hidden
hx-post="/ui/bookmarks/{{.Key}}/chapter" hx-post="/ui/bookmarks/{{.Key}}/chapter"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"> hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
<input name="chapter" type="number" step="0.1" min="0" <input name="chapter" type="number" step="0.1" min="0"
value="{{.LastChapterNum}}" aria-label="Chapter number" required> value="{{.LastChapterNum}}" aria-label="Chapter number" required>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
<span>Remove this?</span>
<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}}')">Cancel</button>
</div>
<p class="error-inline" hidden></p>
</div> </div>
</article> </article>
{{end}} {{end}}
+4
View File
@@ -1,6 +1,10 @@
{{define "list"}} {{define "list"}}
{{if .Items}} {{if .Items}}
{{range .Items}}{{template "card" .}}{{end}} {{range .Items}}{{template "card" .}}{{end}}
{{else if eq .Tab "fav"}}
<p class="empty">No favourites yet. Star a series to pin it here.</p>
{{else if eq .Tab "new"}}
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p>
{{else}} {{else}}
<p class="empty"> <p class="empty">
Nothing here yet. Bookmarks appear once the userscript records a chapter. Nothing here yet. Bookmarks appear once the userscript records a chapter.
+21 -3
View File
@@ -35,11 +35,21 @@ type webHandler struct {
// listView is what every list-rendering template receives. // listView is what every list-rendering template receives.
type listView struct { type listView struct {
Tab string // "all" or "fav" Tab string // "all", "fav", or "new"
Recent []Bookmark Recent []Bookmark
Items []Bookmark Items []Bookmark
} }
// Initial is the monogram the templates show in place of a cover when the
// source site never gave us an og:image. First rune, uppercased; "?" when even
// the title is missing, so the slot is never empty.
func (b Bookmark) Initial() string {
for _, r := range b.Title {
return strings.ToUpper(string(r))
}
return "?"
}
// loginView is what the login template receives. // loginView is what the login template receives.
type loginView struct { type loginView struct {
Error string Error string
@@ -150,14 +160,22 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
} }
items := all items := all
if tab == "fav" { switch tab {
case "fav":
items = []Bookmark{} items = []Bookmark{}
for _, b := range all { for _, b := range all {
if b.Favorite { if b.Favorite {
items = append(items, b) items = append(items, b)
} }
} }
} else { case "new":
items = []Bookmark{}
for _, b := range all {
if b.HasNewChapter() {
items = append(items, b)
}
}
default:
tab = "all" tab = "all"
} }
return listView{Tab: tab, Recent: recent, Items: items}, nil return listView{Tab: tab, Recent: recent, Items: items}, nil
+30
View File
@@ -483,3 +483,33 @@ func TestUIListFavouritesTab(t *testing.T) {
t.Fatal("favourites tab included a non-favourite") t.Fatal("favourites tab included a non-favourite")
} }
} }
func TestUIListNewTab(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapterNum: 10,
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
UpdatedAt: 2_000_000,
})
seed(t, store, Bookmark{
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
Title: "Tower of God", LastChapterNum: 5,
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
UpdatedAt: 1_000_000,
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=new", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "Solo Leveling") {
t.Fatal("new tab omitted the series with an unread chapter")
}
if strings.Contains(body, "Tower of God") {
t.Fatal("new tab included a series already caught up")
}
}