feat: manga bookmark sync backend + Bromite userscript
Backend (Go, stdlib net/http + modernc.org/sqlite, CGO-free static binary):
- GET/PUT/DELETE /bookmarks{,/key} + /healthz
- bearer auth (constant-time), CORS origin reflection + 204 preflight
- SQLite store keyed <site>:<series_id>, last-write-wins, server-set updated_at
- httptest + temp-sqlite tests (auth, CORS, round-trip); go vet clean
- multi-stage Dockerfile (distroless static nonroot) + compose (base + prod proxy override)
Userscript (single Bromite-compatible IIFE, no GM_* APIs):
- Asura + Demonic adapters, URL-regex ids + og: title/cover
- Shadow-DOM floating UI, localStorage cache, optimistic sync
- auto-progress (no regress) + manual override; framework-agnostic nav watcher
Live-verified adapters (Playwright, 2026-07-24): asurascans.com /comics/<slug-hash>,
demonicscans.org /manga/<slug> + /title/<slug>/chapter/<n> — corrects the plan's
assumed /series/ paths and asuracomic.net domain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Copy to .env and fill in. Never commit the real .env.
|
||||
|
||||
# Long random secret shared with the userscript's API_TOKEN. Generate one:
|
||||
# openssl rand -hex 32
|
||||
API_TOKEN=changeme-generate-a-long-random-token
|
||||
|
||||
# Comma-separated origins allowed to call the API (CORS). Both Asura domains
|
||||
# plus Demonic. Add/remove as the sites' hostnames change.
|
||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
|
||||
|
||||
# Only needed for the prod override, if your proxy network isn't named "proxy".
|
||||
# PROXY_NETWORK=proxy
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
backend/server
|
||||
.playwright-mcp/
|
||||
@@ -0,0 +1,167 @@
|
||||
# Manga Bookmark
|
||||
|
||||
Track manga read-progress on **asurascans.com** (a.k.a. asuracomic.net) and
|
||||
**demonicscans.org** from a phone (Bromite / mobile Chromium), synced to a
|
||||
self-hosted Go backend so bookmarks unify across both sites and all devices.
|
||||
|
||||
Two parts:
|
||||
|
||||
- **`backend/`** — tiny Go (`net/http` + pure-Go SQLite) sync service. 4 routes,
|
||||
static binary, distroless container.
|
||||
- **`userscript/manga-bookmark.user.js`** — single Bromite-compatible userscript
|
||||
(no `GM_*` APIs) that injects an on-page bookmark UI and syncs via `fetch()`.
|
||||
|
||||
```
|
||||
Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
|
||||
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend
|
||||
|
||||
### Config (env)
|
||||
|
||||
| Var | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `API_TOKEN` | *(required)* | Bearer token shared with the userscript. |
|
||||
| `ALLOWED_ORIGINS` | Asura + Demonic origins | Comma-separated CORS allowlist. |
|
||||
| `DB_PATH` | `/data/bookmarks.db` | SQLite file location. |
|
||||
| `PORT` | `8080` | Plain HTTP; TLS terminated by the proxy. |
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/bookmarks` | Bearer | All bookmarks (single-user). |
|
||||
| `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; server sets `updated_at`. |
|
||||
| `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. |
|
||||
| `GET` | `/healthz` | none | `200 ok`. |
|
||||
|
||||
`key` is `<site>:<series_id>` — e.g. `asura:trash-of-the-counts-family-f886a8af`
|
||||
or `demonic:Infinite-Level-Up-in-Murim`. Sync is last-write-wins.
|
||||
|
||||
### Develop / test
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go test ./... # unit + handler tests
|
||||
CGO_ENABLED=0 go build # static binary
|
||||
```
|
||||
|
||||
### Run the stack
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env: set API_TOKEN (openssl rand -hex 32)
|
||||
|
||||
docker compose up -d --build # binds 127.0.0.1:8080
|
||||
```
|
||||
|
||||
Smoke test:
|
||||
|
||||
```bash
|
||||
TOKEN=$(grep '^API_TOKEN=' .env | cut -d= -f2)
|
||||
curl -s localhost:8080/healthz # ok
|
||||
curl -s localhost:8080/bookmarks # 401
|
||||
curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/bookmarks # []
|
||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"Test","last_chapter":"Chapter 1","last_chapter_num":1}' \
|
||||
localhost:8080/bookmarks/asura:test-1
|
||||
curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
|
||||
-H 'Access-Control-Request-Method: PUT' \
|
||||
localhost:8080/bookmarks/asura:test-1 | grep -i access-control # 204 + CORS headers
|
||||
```
|
||||
|
||||
### Deploy behind your reverse proxy
|
||||
|
||||
Route `https://manga-api.<domain>` → the service on `:8080` (TLS at the proxy).
|
||||
|
||||
- **Host proxy** (nginx/Caddy on the host): the base compose already binds
|
||||
`127.0.0.1:8080`; point the proxy `proxy_pass http://127.0.0.1:8080;`.
|
||||
- **Docker proxy** (Traefik/nginx in a container on its own network): use the
|
||||
override, which drops the published port and joins the shared network:
|
||||
|
||||
```bash
|
||||
docker network create proxy # once, if it doesn't exist
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
Set `PROXY_NETWORK` in `.env` if your network isn't named `proxy`.
|
||||
|
||||
Verify: `https://manga-api.<domain>/healthz` returns `ok` over valid TLS (no
|
||||
mixed-content), and an `OPTIONS` preflight from a real site origin returns the
|
||||
CORS headers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Userscript
|
||||
|
||||
### Configure
|
||||
|
||||
Edit the config block at the top of `userscript/manga-bookmark.user.js`:
|
||||
|
||||
```js
|
||||
const API_BASE = "https://manga-api.<domain>"; // no trailing slash
|
||||
const API_TOKEN = "<same token as backend>";
|
||||
```
|
||||
|
||||
The token lives in the userscript's **isolated world** — the manga sites' own
|
||||
JS cannot read it.
|
||||
|
||||
### Install on Bromite (mobile)
|
||||
|
||||
Bromite runs Chromium's native userscript engine (no Tampermonkey needed):
|
||||
|
||||
1. Bromite → **Settings → User scripts** → enable user scripts (allow the
|
||||
permission prompt).
|
||||
2. Save the configured `manga-bookmark.user.js` to the device (or open its raw
|
||||
URL). Bromite detects the `.user.js` and offers to install it.
|
||||
3. Confirm the install; the `@match` list covers both sites.
|
||||
4. Open a series on either site — a 📑 button appears bottom-right.
|
||||
|
||||
> Exact menu wording varies by Bromite build; if "User scripts" is absent,
|
||||
> update Bromite or use a build with userscript support.
|
||||
|
||||
### Desktop iteration (optional)
|
||||
|
||||
The script is `GM_*`-free, so it also runs in Tampermonkey/Violentmonkey on
|
||||
desktop for faster testing — install the same file unchanged.
|
||||
|
||||
### Use
|
||||
|
||||
- **Bookmark**: on a series or chapter page, open the panel → **+ Bookmark this**.
|
||||
- **Auto-progress**: opening a chapter of a bookmarked series records it when the
|
||||
chapter number ≥ the stored one (re-reading older chapters never regresses
|
||||
progress; unparseable numbers set the current chapter).
|
||||
- **Manual override**: panel → **Edit** on any row forces a specific chapter.
|
||||
- **Continue**: jumps to the last-read chapter (or the series page).
|
||||
- Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
|
||||
versa — the backend is the shared store.
|
||||
|
||||
Offline / backend down: changes are cached in `localStorage` and retried on the
|
||||
next successful load (last-write-wins).
|
||||
|
||||
---
|
||||
|
||||
## Adapter reference (verified live 2026-07-24)
|
||||
|
||||
The site adapters key everything off URL regex, with `title`/`cover` from
|
||||
`og:title` / `og:image`. Confirmed against live pages via Playwright:
|
||||
|
||||
| Site | Series URL | Chapter URL | `series_id` |
|
||||
|------|-----------|-------------|-------------|
|
||||
| **Asura** (`asurascans.com`; `asuracomic.net` 301s here) | `/comics/<slug-hash>` | `/comics/<slug-hash>/chapter/<n>` | `<slug-hash>` |
|
||||
| **Demonic** (`demonicscans.org`) | `/manga/<slug>` | `/title/<slug>/chapter/<n>/<page>` (`chaptered.php?manga=<id>&chapter=<n>` 301s here) | `<slug>` |
|
||||
|
||||
Notes:
|
||||
- Asura `og:title` carries a `Chapter N - Read Online \| Asura Scans` suffix that
|
||||
the adapter strips; Demonic chapter `og:title` is `<Title> Chapter N`.
|
||||
- Demonic's `<slug>` is identical on `/manga/…` and the canonical `/title/…`
|
||||
reader, so a bookmark set from the series page and the auto-update from the
|
||||
reader resolve to the **same key**.
|
||||
- Asura showed no Next.js markers on the live site, so navigation uses a
|
||||
framework-agnostic watcher (history patch + polling) rather than a Next-only
|
||||
hook — works for client-routed and full-reload sites alike.
|
||||
|
||||
If either site changes its URL shape, update the regex in the matching adapter
|
||||
in `userscript/manga-bookmark.user.js` and re-verify.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Only go source + module files are needed in the build context.
|
||||
*
|
||||
!go.mod
|
||||
!go.sum
|
||||
!*.go
|
||||
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- build stage: compile a static, CGO-free binary ---
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Dependencies first for layer caching (changes rarely).
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Then source (changes often).
|
||||
COPY *.go ./
|
||||
|
||||
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
|
||||
# -trimpath + -ldflags strip paths and debug info for a smaller image.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server .
|
||||
|
||||
# Data dir with the runtime user's ownership so the mounted volume inherits it.
|
||||
RUN mkdir -p /out/data
|
||||
|
||||
# --- runtime stage: distroless static, non-root ---
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
WORKDIR /
|
||||
COPY --from=build /out/server /server
|
||||
COPY --from=build --chown=65532:65532 /out/data /data
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENV DB_PATH=/data/bookmarks.db PORT=8080
|
||||
ENTRYPOINT ["/server"]
|
||||
@@ -0,0 +1,21 @@
|
||||
module mangabm/backend
|
||||
|
||||
go 1.23
|
||||
|
||||
require modernc.org/sqlite v1.34.4
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
|
||||
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bookmarkHandler struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("encode response: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// list returns all bookmarks. GET /bookmarks
|
||||
func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.store.List()
|
||||
if err != nil {
|
||||
log.Printf("list: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// put upserts one bookmark. PUT /bookmarks/{key}
|
||||
func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var b Bookmark
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Path key is authoritative; derive site/series_id from it when the body
|
||||
// omits them so the stored row is always self-consistent.
|
||||
b.Key = key
|
||||
if b.Site == "" || b.SeriesID == "" {
|
||||
if site, series, ok := strings.Cut(key, ":"); ok {
|
||||
if b.Site == "" {
|
||||
b.Site = site
|
||||
}
|
||||
if b.SeriesID == "" {
|
||||
b.SeriesID = series
|
||||
}
|
||||
}
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli() // server-assigned, ignore client value
|
||||
|
||||
if err := h.store.Upsert(b); err != nil {
|
||||
log.Printf("upsert: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// delete removes one bookmark. DELETE /bookmarks/{key}
|
||||
func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(key); err != nil {
|
||||
log.Printf("delete: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all runtime settings, sourced from environment variables.
|
||||
type Config struct {
|
||||
Token string
|
||||
AllowedOrigins []string
|
||||
DBPath string
|
||||
Port string
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
c := Config{
|
||||
Token: os.Getenv("API_TOKEN"),
|
||||
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
|
||||
Port: envOr("PORT", "8080"),
|
||||
}
|
||||
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
c.AllowedOrigins = append(c.AllowedOrigins, o)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// newRouter wires routes and middleware. CORS is the outermost layer so
|
||||
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
||||
// /healthz is public.
|
||||
func newRouter(store *Store, cfg Config) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", healthz)
|
||||
|
||||
h := &bookmarkHandler{store: store}
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("GET /bookmarks", h.list)
|
||||
protected.HandleFunc("PUT /bookmarks/{key}", h.put)
|
||||
protected.HandleFunc("DELETE /bookmarks/{key}", h.delete)
|
||||
|
||||
auth := withAuth(cfg.Token, protected)
|
||||
mux.Handle("/bookmarks", auth)
|
||||
mux.Handle("/bookmarks/", auth)
|
||||
|
||||
return withCORS(cfg.AllowedOrigins, mux)
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
if cfg.Token == "" {
|
||||
log.Fatal("API_TOKEN is required")
|
||||
}
|
||||
|
||||
store, err := OpenStore(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: newRouter(store, cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("listening on :%s (db=%s, origins=%v)", cfg.Port, cfg.DBPath, cfg.AllowedOrigins)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
log.Println("shutting down")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
|
||||
// withAuth guards a handler with a constant-time bearer-token check.
|
||||
func withAuth(token string, next http.Handler) http.Handler {
|
||||
want := []byte(token)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, bearerPrefix) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
got := []byte(strings.TrimPrefix(h, bearerPrefix))
|
||||
if subtle.ConstantTimeCompare(got, want) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// withCORS reflects the request Origin only when it is in allowed, answers
|
||||
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
||||
// auth middleware so preflight (which carries no Authorization header) is never
|
||||
// rejected by auth.
|
||||
func withCORS(allowed []string, next http.Handler) http.Handler {
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
for _, o := range allowed {
|
||||
set[o] = struct{}{}
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if _, ok := set[origin]; ok && origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,PUT,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Bookmark is one tracked series, keyed "<site>:<series_id>" across both sites.
|
||||
type Bookmark struct {
|
||||
Key string `json:"key"`
|
||||
Site string `json:"site"`
|
||||
SeriesID string `json:"series_id"`
|
||||
Title string `json:"title"`
|
||||
SeriesURL string `json:"series_url"`
|
||||
Cover string `json:"cover"`
|
||||
LastChapter string `json:"last_chapter"`
|
||||
LastChapterNum float64 `json:"last_chapter_num"`
|
||||
LastChapterURL string `json:"last_chapter_url"`
|
||||
UpdatedAt int64 `json:"updated_at"` // unix ms, server-assigned
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
key TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
series_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
series_url TEXT,
|
||||
cover TEXT,
|
||||
last_chapter TEXT,
|
||||
last_chapter_num REAL,
|
||||
last_chapter_url TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
// Store is the SQLite-backed bookmark store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
||||
// concurrent requests; a single writer connection keeps writes serialized.
|
||||
dsn := path
|
||||
if !strings.Contains(dsn, "?") {
|
||||
dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database handle.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// List returns every bookmark, newest activity first.
|
||||
func (s *Store) List() ([]Bookmark, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT key, site, series_id, title, series_url, cover,
|
||||
last_chapter, last_chapter_num, last_chapter_url, updated_at
|
||||
FROM bookmarks
|
||||
ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query bookmarks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Bookmark{}
|
||||
for rows.Next() {
|
||||
var b Bookmark
|
||||
if err := rows.Scan(
|
||||
&b.Key, &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &b.Cover,
|
||||
&b.LastChapter, &b.LastChapterNum, &b.LastChapterURL, &b.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan bookmark: %w", err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert inserts or replaces a bookmark by key (last-write-wins).
|
||||
func (s *Store) Upsert(b Bookmark) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO bookmarks
|
||||
(key, site, series_id, title, series_url, cover,
|
||||
last_chapter, last_chapter_num, last_chapter_url, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
site=excluded.site, series_id=excluded.series_id, title=excluded.title,
|
||||
series_url=excluded.series_url, cover=excluded.cover,
|
||||
last_chapter=excluded.last_chapter, last_chapter_num=excluded.last_chapter_num,
|
||||
last_chapter_url=excluded.last_chapter_url, updated_at=excluded.updated_at`,
|
||||
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
|
||||
b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert %q: %w", b.Key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a bookmark by key. Deleting a missing key is not an error.
|
||||
func (s *Store) Delete(key string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM bookmarks WHERE key = ?`, key); err != nil {
|
||||
return fmt.Errorf("delete %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testToken = "s3cret-token"
|
||||
|
||||
func testConfig() Config {
|
||||
return Config{
|
||||
Token: testToken,
|
||||
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
||||
Port: "8080",
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return newRouter(store, testConfig())
|
||||
}
|
||||
|
||||
func auth(req *http.Request) *http.Request {
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
return req
|
||||
}
|
||||
|
||||
func TestHealthzNoAuth(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr.Body.String() != "ok" {
|
||||
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
}{
|
||||
{"no header", ""},
|
||||
{"bad token", "Bearer wrong"},
|
||||
{"not bearer", "Basic " + testToken},
|
||||
{"empty bearer", "Bearer "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAccepted(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Body.String(); got != "[]\n" {
|
||||
t.Fatalf("empty list body = %q, want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
|
||||
req.Header.Set("Origin", "https://asuracomic.net")
|
||||
req.Header.Set("Access-Control-Request-Method", "PUT")
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight status = %d, want 204", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
|
||||
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
|
||||
}
|
||||
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
||||
t.Fatal("Allow-Methods missing")
|
||||
}
|
||||
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
||||
t.Fatal("Allow-Headers missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSDisallowedOrigin(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookmarkRoundTrip(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
key := "asura:solo-leveling-123"
|
||||
in := Bookmark{
|
||||
Title: "Solo Leveling",
|
||||
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
|
||||
Cover: "https://asuracomic.net/cover.jpg",
|
||||
LastChapter: "Chapter 10",
|
||||
LastChapterNum: 10,
|
||||
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
|
||||
}
|
||||
body, _ := json.Marshal(in)
|
||||
|
||||
// PUT
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
||||
}
|
||||
var stored Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
|
||||
t.Fatalf("decode PUT response: %v", err)
|
||||
}
|
||||
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
|
||||
t.Fatalf("derived fields wrong: %+v", stored)
|
||||
}
|
||||
if stored.UpdatedAt == 0 {
|
||||
t.Fatal("server did not set updated_at")
|
||||
}
|
||||
|
||||
// GET
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||
var list []Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
|
||||
t.Fatalf("GET list wrong: %+v", list)
|
||||
}
|
||||
|
||||
// PUT again (upsert, progress advance)
|
||||
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
|
||||
body, _ = json.Marshal(in)
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("second PUT status = %d", rr.Code)
|
||||
}
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||
json.Unmarshal(rr.Body.Bytes(), &list)
|
||||
if len(list) != 1 || list[0].LastChapterNum != 11 {
|
||||
t.Fatalf("upsert did not update in place: %+v", list)
|
||||
}
|
||||
|
||||
// DELETE
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE status = %d, want 204", rr.Code)
|
||||
}
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||
json.Unmarshal(rr.Body.Bytes(), &list)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("after delete list = %+v, want empty", list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Production override: attach to an existing external reverse-proxy Docker
|
||||
# network instead of publishing a host port. The proxy routes
|
||||
# manga-api.<domain> -> manga-api:8080 over the shared network.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# The network must already exist (created by your proxy stack):
|
||||
# docker network create proxy # if it doesn't yet
|
||||
# Override the name with PROXY_NETWORK in .env if yours differs.
|
||||
|
||||
services:
|
||||
manga-api:
|
||||
# Drop the loopback port publish; traffic comes over the proxy network.
|
||||
ports: !reset []
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
name: ${PROXY_NETWORK:-proxy}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Base stack — works standalone for local smoke testing (`docker compose up`).
|
||||
# The service binds 127.0.0.1:8080; a host reverse proxy (nginx/Caddy/Traefik)
|
||||
# terminates TLS for manga-api.<domain> and forwards to it.
|
||||
#
|
||||
# If your proxy runs in Docker on its own network, use the prod override which
|
||||
# attaches to that network instead of publishing a port:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
manga-api:
|
||||
build: ./backend
|
||||
image: mangabm-backend:latest
|
||||
container_name: manga-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# API_TOKEN is required — compose refuses to start without it.
|
||||
API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org}
|
||||
DB_PATH: /data/bookmarks.db
|
||||
PORT: "8080"
|
||||
volumes:
|
||||
- bookmarks-data:/data
|
||||
# Bound to loopback only: the proxy (or curl during smoke test) reaches it,
|
||||
# the public internet does not.
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
|
||||
volumes:
|
||||
bookmarks-data:
|
||||
@@ -0,0 +1,601 @@
|
||||
// ==UserScript==
|
||||
// @name Manga Bookmark Sync
|
||||
// @namespace mangabm
|
||||
// @version 1.0.0
|
||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||
// @author you
|
||||
// @match https://asuracomic.net/*
|
||||
// @match https://asurascans.com/*
|
||||
// @match https://demonicscans.org/*
|
||||
// @run-at document-idle
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ============================================================
|
||||
// CONFIG — fill these in before installing.
|
||||
// ============================================================
|
||||
const API_BASE = "https://manga-api.example.com"; // your backend origin, no trailing slash
|
||||
const API_TOKEN = "PASTE_YOUR_API_TOKEN_HERE"; // must equal backend API_TOKEN
|
||||
|
||||
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
||||
const CACHE_KEY = "mangabm:cache";
|
||||
|
||||
// ============================================================
|
||||
// Site adapters
|
||||
//
|
||||
// Page type + IDs come from URL regex (most stable); title/cover come from
|
||||
// og: meta tags. Verified live 2026-07-24 against asurascans.com and
|
||||
// demonicscans.org — see README "Adapter reference".
|
||||
// ============================================================
|
||||
|
||||
function meta(prop) {
|
||||
const el = document.querySelector('meta[property="' + prop + '"]');
|
||||
return el ? el.getAttribute("content") : null;
|
||||
}
|
||||
|
||||
const asura = {
|
||||
site: "asura",
|
||||
// asuracomic.net currently 301s to asurascans.com; match both.
|
||||
matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname),
|
||||
detect(loc) {
|
||||
const path = loc.pathname;
|
||||
// /comics/<slug-hash>/chapter/<n>
|
||||
let m = path.match(/^\/comics\/([^/]+)\/chapter\/([^/?#]+)/);
|
||||
if (m) {
|
||||
const num = parseFloat(m[2]);
|
||||
return {
|
||||
type: "chapter",
|
||||
site: this.site,
|
||||
seriesId: m[1],
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: meta("og:image") || "",
|
||||
seriesUrl: loc.origin + "/comics/" + m[1],
|
||||
chapterLabel: "Chapter " + m[2],
|
||||
chapterNum: isNaN(num) ? null : num,
|
||||
chapterUrl: loc.href,
|
||||
};
|
||||
}
|
||||
// /comics/<slug-hash>
|
||||
m = path.match(/^\/comics\/([^/?#]+)\/?$/);
|
||||
if (m) {
|
||||
return {
|
||||
type: "series",
|
||||
site: this.site,
|
||||
seriesId: m[1],
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: meta("og:image") || "",
|
||||
seriesUrl: loc.origin + "/comics/" + m[1],
|
||||
chapterLabel: null,
|
||||
chapterNum: null,
|
||||
chapterUrl: null,
|
||||
};
|
||||
}
|
||||
return { type: "other" };
|
||||
|
||||
// Strips "Chapter N - Read Online" and the " | Asura Scans" suffix.
|
||||
function cleanTitle(t) {
|
||||
if (!t) return "";
|
||||
return t
|
||||
.replace(/\s*Chapter\s+[\d.]+\s*-\s*Read Online\s*/i, " ")
|
||||
.replace(/\s*\|\s*Asura Scans\s*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const demonic = {
|
||||
site: "demonic",
|
||||
matches: (loc) => /(^|\.)demonicscans\.org$/.test(loc.hostname),
|
||||
detect(loc) {
|
||||
const path = loc.pathname;
|
||||
// Canonical reader: /title/<slug>/chapter/<n>/<page>
|
||||
// (chaptered.php?manga=<id>&chapter=<n> 301s to this before we run.)
|
||||
let m = path.match(/^\/title\/([^/]+)\/chapter\/([^/?#]+)/);
|
||||
if (m) {
|
||||
const num = parseFloat(m[2]);
|
||||
return {
|
||||
type: "chapter",
|
||||
site: this.site,
|
||||
seriesId: decodeURIComponent(m[1]),
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: meta("og:image") || "",
|
||||
seriesUrl: loc.origin + "/manga/" + m[1],
|
||||
chapterLabel: "Chapter " + m[2],
|
||||
chapterNum: isNaN(num) ? null : num,
|
||||
chapterUrl: loc.href,
|
||||
};
|
||||
}
|
||||
// Series: /manga/<slug>
|
||||
m = path.match(/^\/manga\/([^/?#]+)/);
|
||||
if (m) {
|
||||
return {
|
||||
type: "series",
|
||||
site: this.site,
|
||||
seriesId: decodeURIComponent(m[1]),
|
||||
title: cleanTitle(meta("og:title")),
|
||||
cover: meta("og:image") || "",
|
||||
seriesUrl: loc.origin + "/manga/" + m[1],
|
||||
chapterLabel: null,
|
||||
chapterNum: null,
|
||||
chapterUrl: null,
|
||||
};
|
||||
}
|
||||
return { type: "other" };
|
||||
|
||||
// Demonic chapter og:title is "<Title> Chapter N"; series is clean.
|
||||
function cleanTitle(t) {
|
||||
if (!t) return "";
|
||||
return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ADAPTERS = [asura, demonic];
|
||||
|
||||
function detect() {
|
||||
const loc = window.location;
|
||||
for (const a of ADAPTERS) {
|
||||
if (a.matches(loc)) return a.detect(loc);
|
||||
}
|
||||
return { type: "other" };
|
||||
}
|
||||
|
||||
function keyOf(page) {
|
||||
return page.site + ":" + page.seriesId;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API client + local cache
|
||||
// ============================================================
|
||||
|
||||
function loadCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveCache(list) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(list));
|
||||
} catch (e) {
|
||||
/* quota / private mode — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(extra) {
|
||||
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
||||
}
|
||||
|
||||
async function apiGet() {
|
||||
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPut(key, obj) {
|
||||
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
||||
method: "PUT",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(obj),
|
||||
});
|
||||
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiDelete(key) {
|
||||
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("DELETE /bookmarks " + res.status);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
const state = {
|
||||
list: loadCache(), // last-known bookmarks (array)
|
||||
byKey: {},
|
||||
page: { type: "other" },
|
||||
};
|
||||
reindex();
|
||||
|
||||
function reindex() {
|
||||
state.byKey = {};
|
||||
for (const b of state.list) state.byKey[b.key] = b;
|
||||
state.list.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
||||
}
|
||||
|
||||
function setList(list) {
|
||||
state.list = Array.isArray(list) ? list : [];
|
||||
reindex();
|
||||
saveCache(state.list);
|
||||
}
|
||||
|
||||
function upsertLocal(bm) {
|
||||
const i = state.list.findIndex((b) => b.key === bm.key);
|
||||
if (i >= 0) state.list[i] = bm;
|
||||
else state.list.push(bm);
|
||||
reindex();
|
||||
saveCache(state.list);
|
||||
}
|
||||
|
||||
function removeLocal(key) {
|
||||
state.list = state.list.filter((b) => b.key !== key);
|
||||
reindex();
|
||||
saveCache(state.list);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mutations (optimistic: update UI/cache first, then sync)
|
||||
// ============================================================
|
||||
|
||||
async function bookmarkCurrent() {
|
||||
const p = state.page;
|
||||
if (p.type !== "series" && p.type !== "chapter") return;
|
||||
const key = keyOf(p);
|
||||
const existing = state.byKey[key];
|
||||
const bm = {
|
||||
key: key,
|
||||
site: p.site,
|
||||
series_id: p.seriesId,
|
||||
title: p.title || (existing && existing.title) || p.seriesId,
|
||||
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
||||
cover: p.cover || (existing && existing.cover) || "",
|
||||
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
|
||||
last_chapter_num:
|
||||
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
|
||||
last_chapter_url: p.chapterUrl || (existing && existing.last_chapter_url) || "",
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
await syncUpsert(bm, "Bookmarked");
|
||||
}
|
||||
|
||||
async function updateToCurrentChapter() {
|
||||
const p = state.page;
|
||||
if (p.type !== "chapter") return;
|
||||
const key = keyOf(p);
|
||||
const existing = state.byKey[key] || {};
|
||||
const bm = Object.assign({}, existing, {
|
||||
key: key,
|
||||
site: p.site,
|
||||
series_id: p.seriesId,
|
||||
title: existing.title || p.title || p.seriesId,
|
||||
series_url: existing.series_url || p.seriesUrl || "",
|
||||
cover: existing.cover || p.cover || "",
|
||||
last_chapter: p.chapterLabel || "",
|
||||
last_chapter_num: p.chapterNum,
|
||||
last_chapter_url: p.chapterUrl || "",
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
await syncUpsert(bm, "Progress updated");
|
||||
}
|
||||
|
||||
// Manual override from the panel input — forces any value.
|
||||
async function setChapterManual(key, label, num, url) {
|
||||
const existing = state.byKey[key];
|
||||
if (!existing) return;
|
||||
const bm = Object.assign({}, existing, {
|
||||
last_chapter: label,
|
||||
last_chapter_num: isNaN(num) ? existing.last_chapter_num : num,
|
||||
last_chapter_url: url || existing.last_chapter_url,
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
await syncUpsert(bm, "Progress set");
|
||||
}
|
||||
|
||||
async function syncUpsert(bm, okMsg) {
|
||||
upsertLocal(bm); // optimistic
|
||||
render();
|
||||
try {
|
||||
const saved = await apiPut(bm.key, bm);
|
||||
upsertLocal(saved); // adopt server updated_at
|
||||
render();
|
||||
toast(okMsg);
|
||||
} catch (e) {
|
||||
toast("Offline — saved locally, will retry", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBookmark(key) {
|
||||
removeLocal(key); // optimistic
|
||||
render();
|
||||
try {
|
||||
await apiDelete(key);
|
||||
toast("Removed");
|
||||
} catch (e) {
|
||||
toast("Offline — remove will retry", true);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-record on opening a chapter of a bookmarked series.
|
||||
async function maybeAutoUpdate() {
|
||||
const p = state.page;
|
||||
if (p.type !== "chapter") return;
|
||||
const existing = state.byKey[keyOf(p)];
|
||||
if (!existing) return; // only track series the user bookmarked
|
||||
|
||||
const cur = p.chapterNum;
|
||||
const prev = existing.last_chapter_num;
|
||||
// Advance when current >= stored, or when either is unparseable (set current).
|
||||
const advance = cur == null || prev == null || cur >= prev;
|
||||
if (!advance) return;
|
||||
if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) {
|
||||
return; // already recorded
|
||||
}
|
||||
await updateToCurrentChapter();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UI — Shadow DOM (isolated from site CSS)
|
||||
// ============================================================
|
||||
|
||||
let root, panelOpen = false;
|
||||
|
||||
function buildUI() {
|
||||
const host = document.createElement("div");
|
||||
host.id = "mangabm-host";
|
||||
host.style.cssText = "all: initial;";
|
||||
(document.body || document.documentElement).appendChild(host);
|
||||
root = host.attachShadow({ mode: "open" });
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = CSS;
|
||||
root.appendChild(style);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "wrap";
|
||||
wrap.innerHTML = TEMPLATE;
|
||||
root.appendChild(wrap);
|
||||
|
||||
root.getElementById("fab").addEventListener("click", togglePanel);
|
||||
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
||||
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen = !panelOpen;
|
||||
render();
|
||||
if (panelOpen) refresh(); // pull latest from server on open
|
||||
}
|
||||
|
||||
function el(tag, props, children) {
|
||||
const e = document.createElement(tag);
|
||||
if (props) for (const k in props) {
|
||||
if (k === "text") e.textContent = props[k];
|
||||
else if (k === "html") e.innerHTML = props[k];
|
||||
else if (k.slice(0, 2) === "on") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
||||
else e.setAttribute(k, props[k]);
|
||||
}
|
||||
if (children) for (const c of children) if (c) e.appendChild(c);
|
||||
return e;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!root) return;
|
||||
root.getElementById("panel").classList.toggle("open", panelOpen);
|
||||
root.getElementById("backdrop").classList.toggle("open", panelOpen);
|
||||
|
||||
// Context header for the current page.
|
||||
const ctx = root.getElementById("context");
|
||||
ctx.innerHTML = "";
|
||||
const p = state.page;
|
||||
if (p.type === "series" || p.type === "chapter") {
|
||||
const key = keyOf(p);
|
||||
const existing = state.byKey[key];
|
||||
ctx.appendChild(el("div", { class: "ctx-title", text: p.title || p.seriesId }));
|
||||
if (!existing) {
|
||||
ctx.appendChild(el("button", { class: "btn primary", text: "+ Bookmark this", onclick: () => bookmarkCurrent() }));
|
||||
} else {
|
||||
const cur = existing.last_chapter || "—";
|
||||
ctx.appendChild(el("div", { class: "ctx-sub", text: "Tracked · last read: " + cur }));
|
||||
if (p.type === "chapter" && p.chapterLabel && p.chapterLabel !== existing.last_chapter) {
|
||||
ctx.appendChild(el("button", { class: "btn primary", text: "Update to " + p.chapterLabel, onclick: () => updateToCurrentChapter() }));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
||||
}
|
||||
|
||||
// Bookmark list.
|
||||
const listEl = root.getElementById("list");
|
||||
listEl.innerHTML = "";
|
||||
if (state.list.length === 0) {
|
||||
listEl.appendChild(el("div", { class: "empty", text: "No bookmarks yet." }));
|
||||
}
|
||||
for (const b of state.list) {
|
||||
listEl.appendChild(renderItem(b));
|
||||
}
|
||||
}
|
||||
|
||||
function renderItem(b) {
|
||||
const cont = b.last_chapter_url || b.series_url || "#";
|
||||
const row = el("div", { class: "item" }, [
|
||||
b.cover
|
||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
||||
: el("div", { class: "cover ph" }),
|
||||
el("div", { class: "meta" }, [
|
||||
el("div", { class: "t", text: b.title || b.series_id }),
|
||||
el("div", { class: "c", text: (b.last_chapter || "—") + " · " + b.site }),
|
||||
el("div", { class: "actions" }, [
|
||||
el("a", { class: "btn small", href: cont, text: "Continue" }),
|
||||
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
||||
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
return row;
|
||||
}
|
||||
|
||||
function editChapter(b) {
|
||||
const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || "");
|
||||
if (raw == null) return;
|
||||
const label = raw.trim();
|
||||
const num = parseFloat((label.match(/[\d.]+/) || [])[0]);
|
||||
setChapterManual(b.key, label, num, b.last_chapter_url);
|
||||
}
|
||||
|
||||
// Toast inside the shadow root.
|
||||
function toast(msg, isErr) {
|
||||
if (!root) return;
|
||||
const t = el("div", { class: "toast" + (isErr ? " err" : ""), text: msg });
|
||||
root.getElementById("toasts").appendChild(t);
|
||||
setTimeout(() => t.classList.add("show"), 10);
|
||||
setTimeout(() => {
|
||||
t.classList.remove("show");
|
||||
setTimeout(() => t.remove(), 300);
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Refresh + navigation
|
||||
// ============================================================
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const list = await apiGet();
|
||||
setList(list);
|
||||
render();
|
||||
} catch (e) {
|
||||
render(); // fall back to cache
|
||||
}
|
||||
}
|
||||
|
||||
let lastUrl = location.href;
|
||||
function onNavigate() {
|
||||
state.page = detect();
|
||||
render();
|
||||
maybeAutoUpdate();
|
||||
}
|
||||
|
||||
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
||||
// so it works whether the site is client-routed (Asura) or reloads (Demonic).
|
||||
function installNavWatcher() {
|
||||
const fire = () => {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
// Meta tags update slightly after the URL on client routes.
|
||||
setTimeout(onNavigate, 300);
|
||||
}
|
||||
};
|
||||
const wrap = (name) => {
|
||||
const orig = history[name];
|
||||
history[name] = function () {
|
||||
const r = orig.apply(this, arguments);
|
||||
fire();
|
||||
return r;
|
||||
};
|
||||
};
|
||||
wrap("pushState");
|
||||
wrap("replaceState");
|
||||
window.addEventListener("popstate", fire);
|
||||
setInterval(fire, 1500); // catch routes that bypass history
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Boot
|
||||
// ============================================================
|
||||
|
||||
function init() {
|
||||
buildUI();
|
||||
state.page = detect();
|
||||
render();
|
||||
installNavWatcher();
|
||||
refresh().then(() => maybeAutoUpdate()); // sync, then auto-record this page
|
||||
}
|
||||
|
||||
if (document.body) init();
|
||||
else document.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
// ============================================================
|
||||
// Markup + styles (kept at bottom for readability)
|
||||
// ============================================================
|
||||
|
||||
const TEMPLATE = `
|
||||
<button id="fab" aria-label="Manga bookmarks">📑</button>
|
||||
<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>
|
||||
</header>
|
||||
<section id="context"></section>
|
||||
<div id="list"></div>
|
||||
</aside>
|
||||
<div id="toasts"></div>
|
||||
`;
|
||||
|
||||
const CSS = `
|
||||
:host { all: initial; }
|
||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||
#fab {
|
||||
position: fixed; right: 16px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
width: 52px; height: 52px; border-radius: 50%; border: none;
|
||||
background: #6d28d9; color: #fff; font-size: 22px; line-height: 52px;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
||||
}
|
||||
#fab:active { transform: scale(.94); }
|
||||
#backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
#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;
|
||||
}
|
||||
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
||||
#context {
|
||||
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.ctx-title { font-weight: 600; font-size: 15px; }
|
||||
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
||||
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
||||
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
||||
.item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; }
|
||||
.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; }
|
||||
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
|
||||
.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;
|
||||
}
|
||||
.btn:active { opacity: .8; }
|
||||
.btn.primary { background: #6d28d9; color: #fff; }
|
||||
.btn.small { padding: 6px 10px; font-size: 12px; }
|
||||
.btn.danger { background: #7f1d1d; }
|
||||
#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;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.err { background: #7f1d1d; }
|
||||
`;
|
||||
})();
|
||||
Reference in New Issue
Block a user