Rebuild both UIs on the Cinder design (#10)
Implements the **Cinder** design (Claude Design doc `cfa39183`) across both UI surfaces, self-hosts the fonts it depends on, and writes down the two documents that keep the result maintainable. ## What changed **Web UI** — rebuilt on the design's visual language: editorial serif, containerless sheets divided by ash hairlines, one 760px measure, no radii and no shadows. The organising rule is that *heat is typographic*: only a series with an unread chapter is crimson (title on an ember underline, cover foot rule, `Ch N out`, play icon), and favourites get brass rather than borrowing the accent. Both states hang off two classes on the `<article>` (`is-new`, `is-dim`), so sub-elements inherit the state instead of re-deriving it. The six-cell action strip is `flex-basis: 100%` inside the row, which is what lets one piece of markup be a full-width strip with 46px thumb targets on a phone and a group of 40px squares beside the row on a desktop — no duplicate template branches. Icons moved to a sprite (`templates/icons.html`); htmx-swapped cards reference the page's symbols, so a row no longer carries a screenful of inline SVG. **Userscript panel** — repainted in the same tokens. The panel and the web UI are the same product on the same phone, and the old purple-on-charcoal panel read as a different application once the web UI moved. Structure, ids and classes are untouched, and the edge tab keeps its geometry, `touch-action` and `#hit` sizing. **Fonts are self-hosted** — five latin-subset woff2 files (~120 KB) embedded via the existing `//go:embed static`. Loading them from Google would lose the design's character exactly where it is used most: Bromite users routinely block Google's font domains, and the backend is reachable over a LAN with no internet route. `staticHandler` registers the `.woff2` MIME type, which Go's table lacks and the scratch image has no `/etc/mime.types` for. **Docs** — `docs/design-system.md` records the rules a stylesheet cannot state (what the ember is reserved for, why light mode is a re-tuning rather than an inversion, which details are load-bearing) so a future agent does not re-derive them from the CSS. `REDEPLOY.md` covers the operation actually performed every time, which `DEPLOY.md` reduced to two lines. ## Commits Each is one logical change and builds on its own: | | | |---|---| | `4d69e54` | `listView.NewCount` — data for the Updated badge, no markup | | `e940b96` | Web UI rebuilt on Cinder (CSS, templates, sprite, `filter.js`) | | `686fcc1` | Userscript panel repainted in the same tokens | | `ce7e93d` | Self-hosted webfonts + `.woff2` MIME registration | | `a547cc9` | `docs/design-system.md` | | `b20ecf1` | `REDEPLOY.md` | ## Verification - `go test ./...` — 187 pass. Userscript logic tests — 14 pass. - Screenshotted at 390px and 1180px, in dark and light, across All / Archived / Finished / empty / chapter-form / confirm-row. - Fonts: a run with `fonts.googleapis.com` and `fonts.gstatic.com` blocked still reports all five faces `loaded`; served as `200 font/woff2`. - The three `REDEPLOY.md` backup/restore commands were run, not assumed. `:ro` on the source volume fails (`unable to open database file` — WAL needs to create `-shm`), and a restored file lands root-owned while the container runs as uid 65532, so reads succeed and writes fail. Both are documented with the reason. ## Note for the reviewer The panel restyle is token-level only: the design doc covers the web UI, so the panel's *structure* has no reference to follow and was deliberately left alone. Deploying this needs a rebuild — templates, CSS and fonts are `//go:embed`ed, so pulling alone changes nothing. Reviewed-on: #10 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #10.
This commit is contained in:
+342
@@ -0,0 +1,342 @@
|
||||
# Redeploy runbook
|
||||
|
||||
Shipping new code to a server that is already running. First-time setup (DNS,
|
||||
`.env`, Traefik, installing the userscript) is `DEPLOY.md` — this file assumes
|
||||
all of that exists and picks up at "there is a running stack and I want it to
|
||||
run the new commit."
|
||||
|
||||
Whole thing is ~5 minutes, most of it waiting on `docker build`. Order matters:
|
||||
**back up before you pull.** A backup taken after a bad migration is a backup of
|
||||
the damage.
|
||||
|
||||
Paths below assume the checkout is at `/opt/mangabm`; substitute your own. The
|
||||
one absolute rule about paths: **backups live in `../mangabm-backups/`**, a
|
||||
sibling of the project directory (`/opt/mangabm-backups`), never inside it. It
|
||||
sits outside the repo so `git pull`, `git clean -fd` and a bad `rm -rf` inside
|
||||
the checkout cannot take the backups with them.
|
||||
|
||||
```
|
||||
/opt/
|
||||
├── mangabm/ <- the checkout (this repo)
|
||||
└── mangabm-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Preflight
|
||||
|
||||
```bash
|
||||
cd /opt/mangabm
|
||||
|
||||
# Both -f flags, every time. The prod override is not standalone.
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
|
||||
$COMPOSE ps # manga-api should be Up
|
||||
git status --short # expect empty
|
||||
git log --oneline -1 # note this hash — it is your rollback target
|
||||
df -h /var/lib/docker | tail -1 # a build needs room
|
||||
```
|
||||
|
||||
If `git status` is dirty, someone edited files on the server. Decide before you
|
||||
pull: `git stash` to keep it, `git checkout -- .` to discard. A `git pull` onto a
|
||||
dirty tree fails halfway and leaves you in a worse spot than either.
|
||||
|
||||
Create the backup directory once, and make sure it is a sibling, not a child:
|
||||
|
||||
```bash
|
||||
mkdir -p ../mangabm-backups
|
||||
BACKUP_DIR="$(cd .. && pwd)/mangabm-backups" # absolute — Docker needs it
|
||||
echo "$BACKUP_DIR" # -> /opt/mangabm-backups
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Back up the database
|
||||
|
||||
The database is a single SQLite file in the named Docker volume, at
|
||||
`/data/bookmarks.db` inside the container. Find the volume's real name — Compose
|
||||
prefixes it with the project directory:
|
||||
|
||||
```bash
|
||||
docker volume ls --filter name=bookmarks-data
|
||||
# -> local mangabm_bookmarks-data
|
||||
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||
```
|
||||
|
||||
### Preferred: hot backup, no downtime
|
||||
|
||||
The store runs in **WAL mode**, so recent writes may still be sitting in
|
||||
`bookmarks.db-wal`. Copying `bookmarks.db` alone while the container runs can
|
||||
therefore silently drop the newest bookmarks. `VACUUM INTO` folds the WAL in and
|
||||
writes one consistent file, safe to run against a live database:
|
||||
|
||||
```bash
|
||||
STAMP=$(date -u +%Y%m%d-%H%M%S) # UTC, sorts chronologically as text
|
||||
docker run --rm \
|
||||
-v "$VOL":/data \
|
||||
-v "$BACKUP_DIR":/backup \
|
||||
alpine sh -c "apk add -q sqlite &&
|
||||
sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\""
|
||||
|
||||
ls -lh "$BACKUP_DIR"/bookmarks-$STAMP.db
|
||||
```
|
||||
|
||||
`$STAMP` is the "time in the name" — `bookmarks-20260730-014233.db`. UTC, so the
|
||||
files sort in real order and never collide across a DST shift.
|
||||
|
||||
Note the source volume is mounted **read-write**, which looks wrong for a backup
|
||||
and is not. Opening a WAL database requires creating the `-shm` shared-memory
|
||||
file; with `:ro` the command fails with `unable to open database file` and no
|
||||
backup is produced. `VACUUM INTO` never writes to the source itself.
|
||||
|
||||
Verify it before you trust it. An unreadable backup is worse than none, because
|
||||
you will act as though you have one:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$BACKUP_DIR":/backup alpine sh -c "apk add -q sqlite &&
|
||||
sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;' &&
|
||||
sqlite3 /backup/bookmarks-$STAMP.db 'SELECT count(*) FROM bookmarks;'"
|
||||
# -> ok
|
||||
# -> 37
|
||||
```
|
||||
|
||||
The count should match what the web UI shows. Zero rows on a server you know has
|
||||
bookmarks means you backed up the wrong volume.
|
||||
|
||||
### Fallback: cold copy (no network for `apk add sqlite`)
|
||||
|
||||
Stop the service first, then copy the database **and its sidecars** — the `-wal`
|
||||
is not optional, it is where the newest writes are:
|
||||
|
||||
```bash
|
||||
$COMPOSE stop
|
||||
docker run --rm -v "$VOL":/data:ro -v "$BACKUP_DIR":/backup alpine sh -c "
|
||||
cp /data/bookmarks.db /backup/bookmarks-$STAMP.db
|
||||
[ -f /data/bookmarks.db-wal ] && cp /data/bookmarks.db-wal /backup/bookmarks-$STAMP.db-wal
|
||||
[ -f /data/bookmarks.db-shm ] && cp /data/bookmarks.db-shm /backup/bookmarks-$STAMP.db-shm
|
||||
ls -1 /backup"
|
||||
$COMPOSE start
|
||||
```
|
||||
|
||||
Costs ~10 seconds of downtime. A clean shutdown usually checkpoints the WAL away,
|
||||
so seeing only the `.db` file is normal and fine — the `[ -f ]` guards exist for
|
||||
the case where it did not. Restoring this variant means putting whichever files
|
||||
you got back together, under their original names.
|
||||
|
||||
Read-only is safe here precisely because nothing opens the database: it is a file
|
||||
copy, not a SQLite connection.
|
||||
|
||||
### Retention
|
||||
|
||||
Keep a month, drop the rest — a bookmark database this small compresses the
|
||||
decision to "disk is free, but not infinite":
|
||||
|
||||
```bash
|
||||
ls -1t "$BACKUP_DIR"/bookmarks-*.db | tail -n +31 | xargs -r rm -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Pull the new code
|
||||
|
||||
```bash
|
||||
git pull --ff-only
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
`--ff-only` so a diverged history fails loudly instead of opening a merge you
|
||||
did not plan on the production box.
|
||||
|
||||
Check whether `.env` needs anything new. New config lands in `.env.example`, and
|
||||
compose fails at start for a missing required var — better to find out now:
|
||||
|
||||
```bash
|
||||
git diff HEAD@{1} HEAD -- .env.example docker-compose.yml docker-compose.prod.yml
|
||||
```
|
||||
|
||||
If a variable was added there, add it to `.env` before continuing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
```bash
|
||||
$COMPOSE up -d --build
|
||||
```
|
||||
|
||||
**A rebuild is mandatory for any UI change.** The HTML templates, CSS,
|
||||
JavaScript and fonts are compiled into the binary by `//go:embed`, so editing
|
||||
them on the server — or pulling them — changes nothing until the image is
|
||||
rebuilt. The one exception is `userscript/manga-bookmark.user.js`, which is
|
||||
bindmounted read-only and read fresh per request.
|
||||
|
||||
```bash
|
||||
$COMPOSE ps # Up, and recently (re)created
|
||||
docker logs manga-api --tail 20 # -> "listening on :8080 ..."
|
||||
```
|
||||
|
||||
Nothing in the log about the database or the poller failing. The image is tagged
|
||||
`mangabm-backend:latest`, so the previous image is still on disk untagged —
|
||||
that is what makes the rollback in §6 quick.
|
||||
|
||||
---
|
||||
|
||||
## 4. Verify the deploy
|
||||
|
||||
Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names once:
|
||||
|
||||
```bash
|
||||
API=https://manga-api.violetcrown.my.id
|
||||
WEB=https://manga.violetcrown.my.id
|
||||
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
|
||||
|
||||
curl -s $API/healthz # -> ok
|
||||
curl -s -o /dev/null -w '%{http_code}\n' $API/bookmarks # -> 401
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
|
||||
# -> your data, not []
|
||||
curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
|
||||
-H 'Access-Control-Request-Method: PUT' \
|
||||
$API/bookmarks/x | grep -i access-control # -> allow-origin echoed
|
||||
```
|
||||
|
||||
`[]` from the third call is the alarm that matters: the volume is not attached
|
||||
and you are looking at an empty database. Stop and check `$COMPOSE config
|
||||
--volumes` before touching anything else.
|
||||
|
||||
Web UI and its assets:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' $WEB/ # -> 200 (login page)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' $WEB/static/style.css # -> 200
|
||||
|
||||
# The fonts are self-hosted; this is what tells you they shipped and are typed.
|
||||
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||||
$WEB/static/fonts/instrument-serif-400-latin.woff2
|
||||
# -> 200 font/woff2
|
||||
|
||||
# The served userscript, whose @version is its mtime — a pull bumps it.
|
||||
curl -s $API/u/$TOKEN/manga-bookmark.user.js | grep '@version'
|
||||
```
|
||||
|
||||
`404` on the font means `static/fonts/` did not make it into the image; the UI
|
||||
will still render, in Georgia, which is easy to miss on a phone. `application/
|
||||
octet-stream` instead of `font/woff2` means an older binary is running.
|
||||
|
||||
Then open `$WEB` in a browser and confirm, in one glance:
|
||||
|
||||
- Serif brand and serif row titles — not the system sans fallback.
|
||||
- A series with an unread chapter has a **crimson title on an ember underline**;
|
||||
everything else is cool grey. That single detail exercises the whole design
|
||||
path (template class, CSS, and the poller's `latest_chapter`).
|
||||
- Tapping the pencil opens the chapter form; Save closes it and the row keeps
|
||||
its place in the list.
|
||||
|
||||
---
|
||||
|
||||
## 5. Smoke-test the full loop
|
||||
|
||||
The API answering is not the same as the product working. Do this on the phone,
|
||||
against the real sites — it is the only check that covers the userscript, CORS
|
||||
and the shared store together:
|
||||
|
||||
1. Open a series on **asurascans.com**, open the panel, **+ Bookmark this**.
|
||||
2. On the server: `curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks`
|
||||
→ the series is in the JSON.
|
||||
3. Open a chapter of it, reopen the panel → last-read shows that chapter, and
|
||||
opening an *older* chapter does not move it backwards.
|
||||
4. Open **demonicscans.org**, open the panel → the Asura bookmark is listed
|
||||
there too. Different origin, one store — this is the whole point of the
|
||||
backend, and the check that fails first when CORS or TLS regressed.
|
||||
5. Open `$WEB` → the same series appears, with the same chapter.
|
||||
6. Turn airplane mode on, tap ★ on a row, turn it off, reopen the panel → the
|
||||
star stuck. That exercises the retry queue.
|
||||
|
||||
If 1–5 pass, the deploy is good.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rollback
|
||||
|
||||
Two independent things can be wrong, so undo only what broke.
|
||||
|
||||
**Bad code, database fine** — go back to the previous commit and rebuild:
|
||||
|
||||
```bash
|
||||
git log --oneline -5
|
||||
git checkout <previous-hash>
|
||||
$COMPOSE up -d --build
|
||||
```
|
||||
|
||||
**Database damaged** — restore the backup from §1. Stop first: the running
|
||||
process holds the WAL, and dropping a file under a live SQLite connection
|
||||
corrupts what you were trying to save.
|
||||
|
||||
```bash
|
||||
$COMPOSE stop
|
||||
|
||||
docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c '
|
||||
rm -f /data/bookmarks.db /data/bookmarks.db-wal /data/bookmarks.db-shm &&
|
||||
cp /backup/bookmarks-<STAMP>.db /data/bookmarks.db &&
|
||||
chown 65532:65532 /data/bookmarks.db &&
|
||||
ls -l /data'
|
||||
|
||||
$COMPOSE start
|
||||
docker logs manga-api --tail 20
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
|
||||
```
|
||||
|
||||
Two steps here are easy to skip and both bite:
|
||||
|
||||
- **Delete the stale `-wal` and `-shm`.** Leaving them beside a restored database
|
||||
mixes two different histories; SQLite will either refuse to open it or quietly
|
||||
reapply writes you meant to discard.
|
||||
- **`chown 65532:65532`.** The image is `distroless/static:nonroot` and runs as
|
||||
that uid, while the helper container above writes as root. A root-owned
|
||||
database opens read-only-ish: reads work, so `/bookmarks` looks fine, and then
|
||||
every write fails. That is the worst possible failure mode — it looks restored.
|
||||
|
||||
---
|
||||
|
||||
## 7. The whole thing, as one block
|
||||
|
||||
For a routine redeploy where nothing needs deciding:
|
||||
|
||||
```bash
|
||||
cd /opt/mangabm
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
BACKUP_DIR="$(cd .. && pwd)/mangabm-backups"; mkdir -p "$BACKUP_DIR"
|
||||
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||
|
||||
docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c \
|
||||
"apk add -q sqlite && sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\" &&
|
||||
sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;'" &&
|
||||
git pull --ff-only &&
|
||||
$COMPOSE up -d --build &&
|
||||
sleep 5 &&
|
||||
curl -sf https://manga-api.violetcrown.my.id/healthz && echo " deploy ok"
|
||||
```
|
||||
|
||||
The `&&` chain is deliberate: if the backup or its integrity check fails,
|
||||
nothing is pulled and nothing is rebuilt. Then still do §5 by hand — no shell
|
||||
command can tell you the panel works on the phone.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---|---|
|
||||
| `/bookmarks` returns `[]` after redeploy | Volume not attached — check `$COMPOSE config --volumes` and that you passed both `-f` files. Do **not** re-bookmark; the data is still in the volume. |
|
||||
| UI looks like plain Georgia / system sans | `static/fonts/` missing from the image, or the browser cached an old `style.css`. `/static/*` is served `max-age=3600`, so hard-reload or wait an hour. |
|
||||
| CSS or template change did not appear | You restarted without `--build`. Assets are `//go:embed`ed. |
|
||||
| Font answers `application/octet-stream` | Old binary — the `.woff2` MIME registration is in `web.go`. Rebuild. |
|
||||
| Everyone logged out of the web UI | `API_TOKEN` or `WEB_PASSWORD` changed; sessions are derived from both. Expected, just log in again. |
|
||||
| `compose` errors about `MANGA_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. |
|
||||
| Userscript did not update on the phone | Violentmonkey polls on its own schedule; force a check. `@version` comes from the file's mtime, so confirm the pull actually touched it. |
|
||||
| `apk add sqlite` fails (no network) | Use the cold-copy fallback in §1 — and copy `bookmarks.db-wal` too. |
|
||||
| Reads work but every write fails after a restore | Restored file is root-owned; the container is uid 65532. `chown 65532:65532` it (§6). |
|
||||
| Backup command: `unable to open database file` | Source volume mounted `:ro`. WAL needs to create `-shm`; mount it read-write (§1). |
|
||||
|
||||
Full first-time setup: `DEPLOY.md`. Config reference and endpoints: `README.md`.
|
||||
UI conventions: `docs/design-system.md`.
|
||||
+24
-12
@@ -26,29 +26,41 @@ 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.
|
||||
// disclosure panels; only one makes sense open at a time. The button that
|
||||
// owns an open panel carries .open, which is how the strip shows which cell
|
||||
// the panel belongs to.
|
||||
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;
|
||||
var card = document.getElementById("card-" + key);
|
||||
if (card) {
|
||||
card.querySelectorAll(".actions .open").forEach(function (b) {
|
||||
b.classList.remove("open");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel(key, panelId, buttonSelector) {
|
||||
var panel = document.getElementById(panelId + key);
|
||||
if (!panel) return null;
|
||||
var opening = panel.hidden;
|
||||
closeCardPanels(key);
|
||||
panel.hidden = !opening;
|
||||
var card = document.getElementById("card-" + key);
|
||||
var button = card && card.querySelector(buttonSelector);
|
||||
if (button) button.classList.toggle("open", opening);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function toggleChapterForm(key) {
|
||||
var form = document.getElementById("chapter-form-" + key);
|
||||
if (!form) return;
|
||||
var opening = form.hidden;
|
||||
closeCardPanels(key);
|
||||
form.hidden = !opening;
|
||||
if (!form.hidden) form.querySelector("input").focus();
|
||||
var form = togglePanel(key, "chapter-form-", ".actions .pencil");
|
||||
if (form && !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;
|
||||
togglePanel(key, "confirm-row-", ".actions .remove");
|
||||
}
|
||||
|
||||
// A failed favourite/chapter/delete request leaves the card in place (htmx
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+618
-367
File diff suppressed because it is too large
Load Diff
@@ -7,19 +7,29 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>mangaBookmark</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/static/fonts/ibm-plex-mono-500-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>mangaBookmark</h1>
|
||||
<h1 class="brand">manga<em>Bookmark</em></h1>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<input id="search" class="search" type="search" placeholder="Search titles…"
|
||||
autocomplete="off" aria-label="Search titles">
|
||||
{{/* 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>
|
||||
|
||||
<nav class="tabs" role="tablist">
|
||||
<a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
|
||||
@@ -27,7 +37,8 @@
|
||||
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>
|
||||
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated{{if .NewCount}}
|
||||
<span class="count">{{.NewCount}}</span>{{end}}</a>
|
||||
<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-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
@@ -38,20 +49,23 @@
|
||||
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>
|
||||
|
||||
{{if .Recent}}
|
||||
<section class="recent">
|
||||
<h2>Continue reading</h2>
|
||||
<div class="recent-strip">
|
||||
{{range .Recent}}
|
||||
<a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
|
||||
<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="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
{{if .HasNewChapter}}<span class="new-ribbon new-ribbon-sm">New</span>{{end}}
|
||||
{{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">Ch {{.LastChapter}}</span>
|
||||
<span class="recent-chapter">Ch {{.LastChapter}}{{if .HasNewChapter}} · New{{end}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -61,6 +75,7 @@
|
||||
<main id="list" class="list">
|
||||
{{template "list" .}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
+67
-64
@@ -1,111 +1,114 @@
|
||||
{{define "card"}}
|
||||
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}">
|
||||
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
|
||||
{{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>
|
||||
<div class="body">
|
||||
<h3 class="title">{{.Title}}</h3>
|
||||
<p class="meta">
|
||||
<span class="site site-{{.Site}}">{{.Site}}</span>
|
||||
<span class="chapter">Ch {{.LastChapter}}</span>
|
||||
{{if .HasNewChapter}}<span class="new-chapter">Ch {{.LatestChapter}} out</span>{{end}}
|
||||
</p>
|
||||
{{/* 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">
|
||||
{{else}}<span class="monogram">{{.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">Ch {{.LastChapter}}</span>
|
||||
{{if and (eq .Status "reading") .HasNewChapter}}
|
||||
<span class="sep">/</span>
|
||||
<span class="new-chapter">Ch {{.LatestChapter}} 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="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
|
||||
<a class="play" 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>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg>
|
||||
</a>
|
||||
<button class="icon {{if .Favorite}}on{{end}}"
|
||||
title="Favourite" aria-label="Toggle favourite"
|
||||
<button class="{{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" 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>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star{{if .Favorite}}-on{{end}}"/></svg>
|
||||
</button>
|
||||
<button class="icon" title="Set chapter" aria-label="Set chapter"
|
||||
<button class="pencil" title="Set chapter" aria-label="Set chapter"
|
||||
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>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
||||
</button>
|
||||
{{if eq .Status "finished"}}
|
||||
<button class="icon on" title="Restore to reading" aria-label="Restore to reading"
|
||||
<button class="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" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
||||
</button>
|
||||
{{else}}
|
||||
{{if eq .Status "archived"}}
|
||||
<button class="icon on" title="Restore to reading" aria-label="Restore to reading"
|
||||
<button class="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" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="icon" title="Archive" aria-label="Archive"
|
||||
<button title="Archive" aria-label="Archive"
|
||||
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">
|
||||
<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="M3.5 6.4h17v3.2h-17zM5 9.6h14V19H5zM10 13h4"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="icon" title="Mark finished" aria-label="Mark finished"
|
||||
<button title="Mark finished" aria-label="Mark finished"
|
||||
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">
|
||||
<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.5 12.5l5 5 10-11"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="icon danger" title="Remove" aria-label="Remove"
|
||||
<button class="remove" title="Remove" aria-label="Remove"
|
||||
onclick="toggleConfirmRow('{{.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.5 6.8h15M9.8 6.8V4.6h4.4v2.2M7.3 6.8l.9 12.6h7.6l.9-12.6"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg>
|
||||
</button>
|
||||
</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">
|
||||
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
|
||||
</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">
|
||||
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
|
||||
<div class="field">
|
||||
<input name="chapter" type="number" step="0.1" min="0"
|
||||
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
|
||||
<span>Remove this?</span>
|
||||
</div>
|
||||
</form>
|
||||
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
|
||||
<span>Remove this?</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}}')">Cancel</button>
|
||||
</div>
|
||||
<p class="error-inline" hidden></p>
|
||||
</div>
|
||||
<p class="error-inline" hidden></p>
|
||||
</article>
|
||||
{{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}}
|
||||
@@ -2,16 +2,14 @@
|
||||
{{if .Items}}
|
||||
{{range .Items}}{{template "card" .}}{{end}}
|
||||
{{else if eq .Tab "fav"}}
|
||||
<p class="empty">No favourites yet. Star a series to pin it here.</p>
|
||||
<div class="empty"><strong>No favourites yet.</strong><p>Star a series to pin it here.</p></div>
|
||||
{{else if eq .Tab "new"}}
|
||||
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p>
|
||||
<div class="empty hot"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
|
||||
{{else if eq .Tab "archived"}}
|
||||
<p class="empty">Nothing archived. Shelve a series to park it here — it keeps getting checked for new chapters.</p>
|
||||
<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"}}
|
||||
<p class="empty">Nothing finished yet.</p>
|
||||
<div class="empty"><strong>Nothing finished yet.</strong><p>Mark a series finished and it moves out of your reading list.</p></div>
|
||||
{{else}}
|
||||
<p class="empty">
|
||||
Nothing here yet. Bookmarks appear once the userscript records a chapter.
|
||||
</p>
|
||||
<div class="empty"><strong>Nothing here yet.</strong><p>Bookmarks appear once the userscript records a chapter.</p></div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -7,15 +7,21 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>mangaBookmark</title>
|
||||
<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 class="login-body">
|
||||
<body>
|
||||
<main class="login-card">
|
||||
<h1>mangaBookmark</h1>
|
||||
<div>
|
||||
<span class="eyebrow">Private library</span>
|
||||
<h1>manga<em>Bookmark</em></h1>
|
||||
</div>
|
||||
<form method="post" action="/login">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password"
|
||||
autocomplete="current-password" autofocus required>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password"
|
||||
autocomplete="current-password" autofocus required>
|
||||
</div>
|
||||
<p class="error">{{.Error}}</p>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
+16
-2
@@ -7,6 +7,7 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -38,6 +39,11 @@ type listView struct {
|
||||
Tab string // "all", "fav", or "new"
|
||||
Recent []Bookmark
|
||||
Items []Bookmark
|
||||
// NewCount is the badge on the Updated tab: how many series being read
|
||||
// have a chapter out that has not been read. It is counted over the whole
|
||||
// reading set, not the active tab, so the badge does not change meaning as
|
||||
// the user moves between tabs.
|
||||
NewCount int
|
||||
}
|
||||
|
||||
// Initial is the monogram the templates show in place of a cover when the
|
||||
@@ -93,6 +99,12 @@ func staticHandler() http.Handler {
|
||||
if err != nil {
|
||||
panic("embed static: " + err.Error())
|
||||
}
|
||||
// Go's built-in table has no .woff2 and the scratch image has no
|
||||
// /etc/mime.types, so without this the fonts go out as
|
||||
// application/octet-stream.
|
||||
if err := mime.AddExtensionType(".woff2", "font/woff2"); err != nil {
|
||||
panic("woff2 mime: " + err.Error())
|
||||
}
|
||||
files := http.FileServer(http.FS(sub))
|
||||
return http.StripPrefix("/static/", http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -178,12 +190,14 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||
recent = recent[:recentCount]
|
||||
}
|
||||
|
||||
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
||||
|
||||
var items []Bookmark
|
||||
switch tab {
|
||||
case "fav":
|
||||
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
||||
case "new":
|
||||
items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
||||
items = withNew
|
||||
case "archived":
|
||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
||||
case "finished":
|
||||
@@ -192,7 +206,7 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||
tab = "all"
|
||||
items = reading
|
||||
}
|
||||
return listView{Tab: tab, Recent: recent, Items: items}, nil
|
||||
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||
}
|
||||
|
||||
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Cinder — mangaBookmark design system
|
||||
|
||||
Source of truth: the Claude Design doc **Cinder Sheet**
|
||||
(`cfa39183-8874-4f76-987c-afef14dceebb`, files `Cinder Sheet.dc.html` for the
|
||||
static spec and `Cinder Sheet App.dc.html` for the interactive one). This file
|
||||
records the rules that got implemented so a future agent can extend the UI
|
||||
without re-reading the design.
|
||||
|
||||
Implemented in:
|
||||
|
||||
| Surface | Files |
|
||||
| --- | --- |
|
||||
| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,icons}.html`, `backend/static/filter.js` |
|
||||
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
|
||||
|
||||
## 1. The one idea
|
||||
|
||||
**Heat is typographic.** A series with an unread chapter is the only thing
|
||||
allowed to be crimson: its title turns `--paper-hot` and sits on a 1px ember
|
||||
underline sized to the text, its cover gains a 3px ember rule at the foot, and
|
||||
its `Ch N out` meta and play icon go ember. Everything else — favourites,
|
||||
status, chrome — stays cool. If a new feature wants to be noticed, it does *not*
|
||||
get to borrow the ember; find a typographic answer (weight, italic, a rule) or
|
||||
use brass, which is already spoken for by favourites.
|
||||
|
||||
Corollaries:
|
||||
|
||||
- **No cards, no corners, no shadows.** Rows are *sheets* separated by 1px ash
|
||||
hairlines (`--rule`). `border-radius` is `0` everywhere. Depth comes from a
|
||||
recessed background (`--ash`, `--dim`), never from elevation.
|
||||
- **One measure.** The app is a single `max-width: 760px` column with drawn side
|
||||
edges (`.sheet`), identical on phone and desktop. There is no multi-column
|
||||
grid and no sidebar.
|
||||
- **Three type roles, never mixed.** Display serif for anything a human reads as
|
||||
a name (brand, titles, tabs, primary buttons, empty-state headings). Mono
|
||||
small-caps for machine facts (site, chapter numbers, labels, status, badges,
|
||||
ghost buttons). Sans for prose only (empty-state body, hints).
|
||||
|
||||
## 2. Tokens
|
||||
|
||||
Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
|
||||
`:host`. **Never hardcode a hex outside those two blocks.**
|
||||
|
||||
| Token | Dark | Light | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ink` | `#100f0e` | `#f7f4ef` | page |
|
||||
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form, toast) |
|
||||
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
|
||||
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
|
||||
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
|
||||
| `--field-line` | `#2c2926` | `#d4cdc4` | input borders, ghost-button underline |
|
||||
| `--hover` | `#1a1816` | `#efeae3` | neutral pressed/open surface |
|
||||
| `--paper` | `#f2ece5` | `#191715` | highest-contrast text, primary button fill |
|
||||
| `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter |
|
||||
| `--paper-dim` | `#ddd5cb` | `#191715` | resting title |
|
||||
| `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons |
|
||||
| `--mute-2` | `#5a5450` | `#857d75` | eyebrow labels, hints |
|
||||
| `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line |
|
||||
| `--faint-2` | `#57504b` | `#a8a098` | cover monogram |
|
||||
| `--ember` | `#e0452c` | `#c23a22` | heat — see §1 |
|
||||
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface (confirm, error) |
|
||||
| `--ember-ink` | `#150907` | `#fff` | text on solid ember |
|
||||
| `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash |
|
||||
| `--brass` | `#b8912f` | `#8a681c` | favourites, and only favourites |
|
||||
| `--trash` | `#6b5450` | `#a98276` | remove, at rest |
|
||||
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
|
||||
| `--demonic` | `#a98a78` | `#8a6a55` | site tag |
|
||||
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
|
||||
|
||||
Dark is the default (`color-scheme: dark light`); light is a
|
||||
`@media (prefers-color-scheme: light)` override of the same names. **Any new
|
||||
colour must be added in both branches** — light is not a filter over dark, the
|
||||
hues are re-tuned.
|
||||
|
||||
## 3. Type
|
||||
|
||||
| Role | Web UI | Userscript panel |
|
||||
| --- | --- | --- |
|
||||
| Display | `Instrument Serif` → `Georgia, serif` | `Georgia, serif` (no webfont) |
|
||||
| Mono | `IBM Plex Mono` → system mono | system mono |
|
||||
| Sans | `DM Sans` → system UI | system UI |
|
||||
|
||||
The web UI **self-hosts** all three: five latin-subset woff2 files in
|
||||
`backend/static/fonts/` (~120 KB total), declared by the `@font-face` block at
|
||||
the top of `style.css` and embedded in the binary by the existing
|
||||
`//go:embed static`. There is no request to Google — this UI is read in Bromite,
|
||||
where `fonts.googleapis.com` is routinely blocked, and over a LAN with no
|
||||
internet route. `staticHandler()` in `web.go` registers the `.woff2` MIME type
|
||||
because Go's built-in table lacks it and the scratch image has no
|
||||
`/etc/mime.types`.
|
||||
|
||||
Adding a weight means adding a file: grab the *latin* `@font-face` block from
|
||||
`https://fonts.googleapis.com/css2?...` **with a browser User-Agent** (Google
|
||||
serves woff2 only to modern UAs; the latin block is the last one per family),
|
||||
download that URL into `static/fonts/`, and add a matching `@font-face`. DM Sans
|
||||
is a variable file covering `400 700`, so sans weights in that range are free.
|
||||
`app.html` and `login.html` preload only the two faces above the fold —
|
||||
Instrument Serif 400 and, for the app, IBM Plex Mono 500.
|
||||
|
||||
The userscript deliberately ships **no** webfont: an `@import` inside the shadow
|
||||
root is at the mercy of the host site's CSP.
|
||||
|
||||
Recurring specs (copy these rather than inventing sizes):
|
||||
|
||||
- Brand: `400 26px/1 display`, with `<em>` in ember italic — `manga<em>Bookmark</em>`.
|
||||
- Row title: `400 19px/1.2 display` (21px ≥720px).
|
||||
- Tab: `400 17px display` (18px ≥720px), active gets `border-bottom: 2px` in
|
||||
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
|
||||
the row's own hairline.
|
||||
- Meta / label / badge: `500 10px mono`, `letter-spacing: .12em`,
|
||||
`text-transform: uppercase`. Eyebrows ("CONTINUE READING") use `.2em`.
|
||||
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
|
||||
- Primary button: `--paper` fill, `--ink` text, `400 17px display`, no border radius.
|
||||
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
|
||||
|
||||
## 4. Components (web UI)
|
||||
|
||||
```
|
||||
.sheet
|
||||
.topbar .brand + .ghost (log out)
|
||||
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
|
||||
.recent h2 eyebrow + .recent-strip > a.recent-card
|
||||
main#list article.card … | .empty
|
||||
```
|
||||
|
||||
`article.card` — the row, and the only per-series component:
|
||||
|
||||
```
|
||||
article.card[.is-new|.is-dim]#card-<key>[data-title]
|
||||
.row
|
||||
a.cover img | span.monogram, + span.foot-rule[.brass]
|
||||
.body .title-line (h3.title + svg.fav-mark) , p.meta
|
||||
.actions play, favourite, chapter, archive|restore, finish, remove
|
||||
form.chapter-form[hidden] .hint + .field(input + Save)
|
||||
.confirm-row[hidden] span + (Remove, Cancel)
|
||||
p.error-inline[hidden]
|
||||
```
|
||||
|
||||
Rules that are easy to break:
|
||||
|
||||
- `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for
|
||||
`archived` and `finished`. Both are set on the `<article>` — every heat and
|
||||
dim rule is a descendant selector off those two classes, so a new sub-element
|
||||
inherits the state for free.
|
||||
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
|
||||
full-width strip under the row on a phone and a group of 40px squares beside
|
||||
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
|
||||
`border-right: 1px var(--rule)`, last child none.
|
||||
- Icons are `<use href="#i-…">` against the sprite in `templates/icons.html`,
|
||||
included once by `app.html`. htmx-swapped card fragments reference the
|
||||
page's sprite, so a card never inlines a path. New icon → add a `<symbol>`
|
||||
there, keep `viewBox="0 0 24 24"` and `currentColor`.
|
||||
- Meta line is `site / Ch N [/ Ch M out] [/ state]` with each `/` as
|
||||
`<span class="sep">`.
|
||||
- Cover foot rule: ember when new, brass when favourite-and-not-new. Never both.
|
||||
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
|
||||
panel is a flex container, and `display` beats `hidden`.
|
||||
- Busy state is `.card.htmx-request::before`, a 1px ember bar sliding across the
|
||||
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
|
||||
spinner.
|
||||
- `.open` on the pencil / trash cell marks which panel is showing; `filter.js`
|
||||
`togglePanel()` owns that class alongside `hidden`.
|
||||
|
||||
## 5. Components (userscript panel)
|
||||
|
||||
Same tokens, same heat rule, structure unchanged from before the revamp
|
||||
(`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`).
|
||||
Cinder-specific: `.item.hot` (new chapter) and `.item.dim` (archived) mirror
|
||||
`.is-new` / `.is-dim`; chips and `.btn`s are mono small-caps with hairline
|
||||
borders instead of pills; loading is the same sliding ember hairline (`.spinner`
|
||||
is now a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
||||
ember-washed when `.err`.
|
||||
|
||||
**Do not touch** the FAB geometry while restyling: `#fab` keeps
|
||||
`touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the
|
||||
28×72 inward hit area. See
|
||||
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
|
||||
|
||||
## 6. Motion
|
||||
|
||||
Three animations, all ≤ 1.15s and all disabled under
|
||||
`prefers-reduced-motion: reduce`:
|
||||
|
||||
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
|
||||
- `barSlide` — the burning hairline, for any busy state.
|
||||
- `emberPulse` — the 5px dot on `.error-inline`.
|
||||
|
||||
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||
|
||||
## 7. Accessibility floor (not negotiable)
|
||||
|
||||
- Touch targets on the phone layout are 44–46px; the 40px desktop cells are
|
||||
pointer-only (≥720px).
|
||||
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
|
||||
`aria-hidden`.
|
||||
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link
|
||||
and the play cell already reach the same URL — do not make it a third tab stop.
|
||||
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
|
||||
and `setActiveTab()` in `filter.js` maintains it after an htmx swap.
|
||||
- Light and dark are both first-class. Check any new colour in both.
|
||||
|
||||
## 8. Adding something new — checklist
|
||||
|
||||
1. Can it be a hairline, a small-caps label, or a serif line instead of a new
|
||||
component? Prefer that.
|
||||
2. Tokens only, both colour branches.
|
||||
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
|
||||
third state class.
|
||||
4. Icon → `templates/icons.html`; nothing inlines SVG paths.
|
||||
5. Phone first (44px targets, single column), then the ≥720px block.
|
||||
6. Verify: `cd backend && go test ./...`, then run the binary and screenshot
|
||||
both widths and both colour schemes (Playwright: `emulateMedia`,
|
||||
`setViewportSize`; disable the browser cache — `/static/*` is served with
|
||||
`max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt
|
||||
to see markup changes).
|
||||
@@ -1173,7 +1173,10 @@
|
||||
// Cover and title are the only continue links. The subtitle and the action
|
||||
// row live in the same text column but are not navigation targets — a thumb
|
||||
// that misses ★ must not land on one, and Remove must never be one.
|
||||
return el("div", { class: "item" }, [
|
||||
// hot = a chapter is out that has not been read (crimson title, ember rule);
|
||||
// dim = archived (italic grey). Same two states the web UI paints.
|
||||
const heat = (behind ? " hot" : "") + (statusOf(b) === "archived" ? " dim" : "");
|
||||
return el("div", { class: "item" + heat }, [
|
||||
el("a", { class: "go", href: cont }, [
|
||||
b.cover
|
||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
||||
@@ -1380,11 +1383,11 @@
|
||||
<div id="backdrop"></div>
|
||||
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
||||
<header>
|
||||
<span>Manga Bookmarks</span>
|
||||
<button id="closeBtn" aria-label="Close">✕</button>
|
||||
<span>manga<em>Bookmark</em></span>
|
||||
<button id="closeBtn" aria-label="Close">close</button>
|
||||
</header>
|
||||
<div id="nav">
|
||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">Web</a>
|
||||
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
||||
@@ -1392,7 +1395,7 @@
|
||||
<section id="context"></section>
|
||||
<div id="tabs" role="tablist">
|
||||
<button id="tabAll" class="tab active" role="tab">All</button>
|
||||
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
||||
<button id="tabFav" class="tab" role="tab">Favourites</button>
|
||||
<button id="tabArc" class="tab" role="tab">Archived</button>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
@@ -1401,22 +1404,35 @@
|
||||
`;
|
||||
|
||||
const CSS = `
|
||||
:host { all: initial; }
|
||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||
/* Cinder, ported to the panel: same tokens and the same rule that heat is
|
||||
typographic. Fonts are system stacks, not the web UI's Google fonts — an
|
||||
@import here would be at the mercy of the host page's CSP.
|
||||
See docs/design-system.md. */
|
||||
:host {
|
||||
all: initial;
|
||||
--ink: #100f0e; --ash: #161413; --dim: #0d0c0b;
|
||||
--rule: #221f1d; --field-line: #2c2926; --hover: #1a1816;
|
||||
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
|
||||
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
|
||||
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
|
||||
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450;
|
||||
--font-display: Georgia, "Times New Roman", serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--font-body: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; font-family: var(--font-body); }
|
||||
#fab {
|
||||
position: fixed;
|
||||
width: 7px; height: 44px; border: none; padding: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: #6d28d9; opacity: .45;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
||||
background: var(--ember); opacity: .5;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer;
|
||||
touch-action: none; user-select: none; -webkit-touch-callout: none;
|
||||
}
|
||||
#fab.left { border-radius: 0 4px 4px 0; }
|
||||
#fab:active, #fab.armed { opacity: 1; }
|
||||
#fab.armed { box-shadow: 0 0 0 2px #c4b5fd; }
|
||||
#fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); }
|
||||
#fill {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
||||
background: #c4b5fd; pointer-events: none; border-radius: inherit;
|
||||
background: var(--paper); pointer-events: none;
|
||||
}
|
||||
/* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72.
|
||||
It grows inward (toward the screen centre) because the tab is docked flush
|
||||
@@ -1427,90 +1443,132 @@
|
||||
}
|
||||
#fab.left #hit { left: 0; right: -21px; }
|
||||
#backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
||||
}
|
||||
#backdrop.open { opacity: 1; pointer-events: auto; }
|
||||
#panel {
|
||||
position: fixed; top: 0; right: 0; height: 100%;
|
||||
width: min(400px, 92vw); background: #1b1b22; color: #eee;
|
||||
width: min(400px, 92vw); background: var(--ink); color: var(--paper-dim);
|
||||
transform: translateX(105%); transition: transform .25s ease;
|
||||
z-index: 2147483647; display: flex; flex-direction: column;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,.5);
|
||||
border-left: 1px solid var(--rule);
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,.6);
|
||||
}
|
||||
#panel.open { transform: translateX(0); }
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
padding: 16px; border-bottom: 1px solid var(--rule);
|
||||
font: 400 22px/1 var(--font-display); color: var(--paper);
|
||||
}
|
||||
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
||||
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
|
||||
header em { color: var(--ember); font-style: italic; }
|
||||
#closeBtn {
|
||||
background: none; border: none; color: var(--mute); cursor: pointer;
|
||||
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--field-line); padding: 0 0 2px;
|
||||
}
|
||||
#closeBtn:active { color: var(--paper); }
|
||||
#nav { display: flex; gap: 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); }
|
||||
/* Chips are set as small caps rules, not pills — the sheet has no corners. */
|
||||
.chip {
|
||||
background: #2a2a33; color: #c4b5fd; text-decoration: none;
|
||||
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||
background: none; border: none; padding: 0 0 2px;
|
||||
border-bottom: 1px solid var(--field-line);
|
||||
color: var(--mute); text-decoration: none; cursor: pointer;
|
||||
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:active { opacity: .8; }
|
||||
.chip.pending { background: #4c1d95; border: none; cursor: pointer; }
|
||||
.chip:active { color: var(--paper); border-bottom-color: var(--paper); }
|
||||
.chip.pending { color: var(--ember); border-bottom-color: var(--ember); }
|
||||
.chip.pending[hidden] { display: none; }
|
||||
#context {
|
||||
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
||||
display: flex; flex-direction: column; gap: 9px; align-items: flex-start;
|
||||
}
|
||||
.ctx-title { font-weight: 600; font-size: 15px; }
|
||||
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
||||
#tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; }
|
||||
.ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); }
|
||||
.ctx-sub {
|
||||
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--mute);
|
||||
}
|
||||
#tabs { display: flex; gap: 16px; padding: 2px 16px 0; border-bottom: 1px solid var(--rule); }
|
||||
.tab {
|
||||
background: none; border: none; color: #9a9aa5; cursor: pointer;
|
||||
font-size: 13px; font-weight: 600; padding: 4px 0 9px;
|
||||
background: none; border: none; cursor: pointer; color: var(--mute);
|
||||
font: 400 16px var(--font-display); padding: 8px 0 12px;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
}
|
||||
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
||||
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
||||
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
||||
.tab.active { color: var(--paper); border-bottom-color: var(--paper); }
|
||||
#list { overflow-y: auto; flex: 1; }
|
||||
.empty {
|
||||
color: var(--mute); padding: 34px 16px;
|
||||
font: 400 15px/1.6 var(--font-body);
|
||||
}
|
||||
/* Loading is the web UI's burning hairline, laid flat across the list. */
|
||||
.spinner {
|
||||
width: 26px; height: 26px; margin: 0 auto;
|
||||
border: 3px solid #33333d; border-top-color: #6d28d9; border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner::after {
|
||||
content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 30%;
|
||||
background: var(--ember); animation: barSlide 1.15s linear infinite;
|
||||
}
|
||||
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
|
||||
.item {
|
||||
display: flex; flex-direction: row; gap: 10px;
|
||||
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
|
||||
display: flex; flex-direction: row; gap: 12px;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.item.dim { background: var(--dim); }
|
||||
.go { display: block; flex: none; }
|
||||
.go:active, .go-t:active { opacity: .7; }
|
||||
.go-t { display: block; text-decoration: none; color: inherit; }
|
||||
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
||||
.cover.ph { display: block; }
|
||||
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.c { color: #9a9aa5; font-size: 12px; margin: 0; }
|
||||
.c.behind { color: #c4b5fd; }
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
background: #33333d; color: #eee; border: none; border-radius: 6px;
|
||||
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
|
||||
.cover {
|
||||
width: 52px; height: 70px; object-fit: cover; flex: none;
|
||||
background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
|
||||
}
|
||||
.btn:active { opacity: .8; }
|
||||
.btn.primary { background: #6d28d9; color: #fff; }
|
||||
.btn.small { padding: 6px 10px; font-size: 12px; }
|
||||
.btn.danger { background: #7f1d1d; }
|
||||
.btn.star { line-height: 1; }
|
||||
.btn.star.on { background: #6d28d9; color: #fff; }
|
||||
.cover.ph { display: block; }
|
||||
.item.dim .cover { filter: grayscale(1); opacity: .85; }
|
||||
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
|
||||
.t {
|
||||
font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
|
||||
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; }
|
||||
.item.dim .t { font-style: italic; color: var(--mute); }
|
||||
.c {
|
||||
margin: 0; color: var(--mute);
|
||||
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.c.behind { color: var(--ember); }
|
||||
.actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; }
|
||||
.btn {
|
||||
background: none; color: var(--mute); border: 1px solid var(--rule);
|
||||
margin: 0 -1px -1px 0; padding: 0 12px; height: 38px; line-height: 36px;
|
||||
cursor: pointer; text-decoration: none; display: inline-block;
|
||||
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.btn:active { color: var(--paper); background: var(--hover); }
|
||||
.btn.primary {
|
||||
border-color: var(--paper); background: var(--paper); color: var(--ink);
|
||||
font: 400 16px var(--font-display); letter-spacing: 0; text-transform: none;
|
||||
height: 44px; line-height: 42px; padding: 0 18px;
|
||||
}
|
||||
.btn.small { height: 34px; line-height: 32px; padding: 0 10px; }
|
||||
.btn.danger { color: var(--trash); }
|
||||
.btn.danger:active { color: var(--ember); background: var(--ember-wash); }
|
||||
.btn.star { font-size: 13px; letter-spacing: 0; }
|
||||
.btn.star.on { color: var(--brass); border-color: #332b14; }
|
||||
#toasts {
|
||||
position: fixed; left: 50%; transform: translateX(-50%);
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center;
|
||||
}
|
||||
.toast {
|
||||
background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px;
|
||||
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw;
|
||||
background: var(--ash); color: var(--paper); padding: 12px 16px;
|
||||
border-left: 2px solid var(--paper);
|
||||
font: 400 16px var(--font-display);
|
||||
opacity: 0; transform: translateY(10px); transition: all .3s;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 80vw;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.err { background: #7f1d1d; }
|
||||
.toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); }
|
||||
`;
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user