Initial TopDeck: self-hosted ranked favorites
A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
data
|
||||
.env
|
||||
topdeck
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copy to .env and fill in what you need. Everything here is optional.
|
||||
|
||||
# Create the owner account on first start. If unset, TopDeck prints a one-time
|
||||
# setup code to the log and you create the account at http://<host>:4242/setup.
|
||||
TOPDECK_ADMIN_USERNAME=
|
||||
TOPDECK_ADMIN_PASSWORD=
|
||||
|
||||
# API keys. You can also enter these in Settings → Integrations; values set
|
||||
# here take precedence and are locked in the web UI.
|
||||
TOPDECK_TMDB_API_KEY=
|
||||
TOPDECK_TVDB_API_KEY=
|
||||
TOPDECK_TVDB_PIN=
|
||||
TOPDECK_IGDB_CLIENT_ID=
|
||||
TOPDECK_IGDB_CLIENT_SECRET=
|
||||
|
||||
# Behind a reverse proxy that terminates HTTPS:
|
||||
# TOPDECK_TRUST_PROXY=true # honor X-Forwarded-For / -Proto / -Host
|
||||
# TOPDECK_SECURE_COOKIES=true # always mark the session cookie Secure
|
||||
|
||||
# TOPDECK_PORT=4242
|
||||
# TOPDECK_LOG_LEVEL=debug
|
||||
@@ -0,0 +1,3 @@
|
||||
/data/
|
||||
/topdeck
|
||||
.env
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM golang:1-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/topdeck ./cmd/topdeck
|
||||
|
||||
FROM alpine:3
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& adduser -D -u 1000 topdeck \
|
||||
&& mkdir -p /data && chown topdeck:topdeck /data
|
||||
COPY --from=build /out/topdeck /usr/local/bin/topdeck
|
||||
USER topdeck
|
||||
ENV TOPDECK_DATA_DIR=/data \
|
||||
TOPDECK_PORT=4242
|
||||
VOLUME /data
|
||||
EXPOSE 4242
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||
CMD wget -qO- http://127.0.0.1:${TOPDECK_PORT}/healthz >/dev/null || exit 1
|
||||
ENTRYPOINT ["topdeck"]
|
||||
@@ -0,0 +1,122 @@
|
||||
# TopDeck
|
||||
|
||||
A self-hosted page for **your** ranked favorites: movies, TV shows, albums, songs, video games and theater. One owner, as many lists as you like, each with its own size (Top 5, Top 100, … up to 65,535), with proper posters and cover art pulled from the best free metadata sources.
|
||||
|
||||
Visitors just see your lists. There's no sign-up, no feed and no login button. You manage everything from `/login`.
|
||||
|
||||
- **Ranked lists per category**: Movies, TV Shows, Albums, Songs, Video Games and Theater. Each list sets its own size, title, description and layout (artwork grid or ranked list).
|
||||
- **Search-to-add** from TMDB, TheTVDB, IGDB, Deezer/MusicBrainz and Wikipedia, with add-manually as a fallback for anything they miss.
|
||||
- **Theater by production**: record *which* staging you mean (Broadway, West End, North American Tour, a revival…) plus venue and cast, and use that production's own poster.
|
||||
- **Drag to rank**, or use the arrow buttons or type a new number. Paginated editing keeps huge lists usable.
|
||||
- **Art is cached on your server**, so pages never hotlink and keep working if a source changes. You can also paste an image URL or upload your own.
|
||||
- **Personal notes** on any entry, shown in a detail view (toggleable).
|
||||
- **12 theme presets** plus a custom CSS box. Every color is a CSS variable.
|
||||
- **Single binary + SQLite**, no external database. Around 15 MB, and it runs anywhere Docker does.
|
||||
|
||||
## Quick start (Docker Compose)
|
||||
|
||||
```sh
|
||||
git clone https://git.iamchrisama.com/cdnutter/topdeck && cd topdeck
|
||||
cp .env.example .env # optional: admin account + API keys
|
||||
docker compose up -d
|
||||
docker compose logs topdeck # shows the one-time setup code if you didn't set an admin in .env
|
||||
```
|
||||
|
||||
Open `http://localhost:4242/setup`, enter the setup code and create your account. Then go to **Settings → Integrations** to add API keys.
|
||||
|
||||
To skip the setup page, set `TOPDECK_ADMIN_USERNAME` and `TOPDECK_ADMIN_PASSWORD` in `.env` before the first start. They're only used when no account exists yet.
|
||||
|
||||
### Without Compose
|
||||
|
||||
```sh
|
||||
docker build -t topdeck .
|
||||
docker run -d --name topdeck -p 4242:4242 -v topdeck-data:/data topdeck
|
||||
```
|
||||
|
||||
Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`.
|
||||
|
||||
## Data sources and API keys
|
||||
|
||||
| Category | Source | Key? | Get one |
|
||||
|---|---|---|---|
|
||||
| Movies | [TMDB](https://www.themoviedb.org) | Yes (free) | [themoviedb.org/settings/api](https://www.themoviedb.org/settings/api). The v3 key or the v4 Read Access Token both work. |
|
||||
| TV Shows | [TheTVDB](https://thetvdb.com) (default) or TMDB | Yes | [thetvdb.com/api-information](https://thetvdb.com/api-information). Switch TV to TMDB in Settings to reuse your TMDB key. |
|
||||
| Video Games | [IGDB](https://www.igdb.com) | Yes (free, via Twitch) | Register an app at [dev.twitch.tv/console](https://dev.twitch.tv/console/apps) and use its Client ID + Secret ([guide](https://api-docs.igdb.com/#account-creation)). |
|
||||
| Albums & Songs | [Deezer](https://developers.deezer.com/api) (default) or [MusicBrainz](https://musicbrainz.org) + [Cover Art Archive](https://coverartarchive.org) | No | Nothing to set up. |
|
||||
| Theater | [Wikipedia](https://en.wikipedia.org) | No | Nothing to set up. |
|
||||
|
||||
**Why these for music and theater?** Neither needs a key.
|
||||
- **Music:** Deezer's public catalogue API has no key, generous limits (about 50 requests per 5 seconds) and 1000×1000 covers for both tracks and albums. MusicBrainz is the fully open alternative, but it's limited to one request per second and art coverage is patchier. Spotify, Apple Music and Last.fm all need keys or OAuth and have stricter terms.
|
||||
- **Theater:** there's no open theater database with an API (IBDB and Playbill don't offer one). Wikipedia covers nearly every musical, play and opera, usually with poster or logo art. TopDeck filters search to pages that use the musical, play or opera infobox. Wikipedia describes the *work*, so you pick the production yourself and can swap in that production's poster.
|
||||
|
||||
Keys entered in the UI are stored in the SQLite database on your server. Keys set as environment variables take precedence and show as locked in the UI.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | |
|
||||
|---|---|---|
|
||||
| `TOPDECK_PORT` | `4242` | Listen port |
|
||||
| `TOPDECK_DATA_DIR` | `/data` (Docker), `./data` | Database + cached images |
|
||||
| `TOPDECK_ADMIN_USERNAME` / `_PASSWORD` | | Create the owner account on first start |
|
||||
| `TOPDECK_TMDB_API_KEY` | | |
|
||||
| `TOPDECK_TVDB_API_KEY` / `TOPDECK_TVDB_PIN` | | The PIN is only for user-supported keys |
|
||||
| `TOPDECK_IGDB_CLIENT_ID` / `_SECRET` | | Twitch app credentials |
|
||||
| `TOPDECK_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` headers (set behind a reverse proxy) |
|
||||
| `TOPDECK_SECURE_COOKIES` | `false` | Force `Secure` cookies (set if served over HTTPS) |
|
||||
| `TOPDECK_LOG_LEVEL` | `info` | `debug` logs every request |
|
||||
|
||||
### Reverse proxy
|
||||
|
||||
TopDeck serves plain HTTP. Put it behind Caddy, Traefik or nginx for HTTPS, and set `TOPDECK_TRUST_PROXY=true` so logins are rate-limited per real client and cookies are marked secure. Caddy example:
|
||||
|
||||
```
|
||||
favorites.example.com {
|
||||
reverse_proxy topdeck:4242
|
||||
}
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
Pick a preset under **Settings → Appearance**: Midnight, Obsidian, Nord, Dracula, Mocha, Rosé Pine, Gruvbox, Forest, Synthwave, Daylight, Paper or Latte. Then add your own CSS if you like. It loads after the theme on every page, so overriding variables is enough for a whole new look:
|
||||
|
||||
```css
|
||||
:root, [data-theme] {
|
||||
--bg: #0d0b12;
|
||||
--surface: #16131d;
|
||||
--accent: #ff5a7a;
|
||||
--font-display: "Georgia", serif;
|
||||
--radius-art: 14px;
|
||||
}
|
||||
```
|
||||
|
||||
See [`web/static/css/themes.css`](web/static/css/themes.css) for every variable.
|
||||
|
||||
## List sizes
|
||||
|
||||
A list can hold 1 to 65,535 entries. The editor recommends **100 or fewer** and warns past 1,000. Long lists work (public pages and the editor paginate at 100), but they're hard for visitors to take in.
|
||||
|
||||
## Backups
|
||||
|
||||
Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running:
|
||||
|
||||
```sh
|
||||
docker compose exec topdeck sh -c 'cd /data && tar c topdeck.db* images' > topdeck-backup.tar
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
go run ./cmd/topdeck # http://localhost:4242
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Templates and static assets in `web/` are embedded into the binary, so rebuild after editing them. The front end is plain ES modules with no build step.
|
||||
|
||||
```
|
||||
cmd/topdeck entry point, first-run bootstrap
|
||||
internal/store SQLite schema, lists, items, ranking, settings, sessions
|
||||
internal/providers TMDB, TheTVDB, IGDB, Deezer, MusicBrainz, Wikipedia
|
||||
internal/imgcache downloads and stores artwork
|
||||
internal/server HTTP routes, auth, JSON API, page rendering
|
||||
web/ templates, CSS themes, JS, icons
|
||||
```
|
||||
@@ -0,0 +1,151 @@
|
||||
// Command topdeck serves a personal, self-hosted collection of ranked
|
||||
// favorites.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/auth"
|
||||
"github.com/cdnutter/topdeck/internal/config"
|
||||
"github.com/cdnutter/topdeck/internal/imgcache"
|
||||
"github.com/cdnutter/topdeck/internal/providers"
|
||||
"github.com/cdnutter/topdeck/internal/server"
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
)
|
||||
|
||||
// version is set at build time with -ldflags "-X main.version=…".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
level := slog.LevelInfo
|
||||
if strings.EqualFold(os.Getenv("TOPDECK_LOG_LEVEL"), "debug") {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
if err := run(log); err != nil {
|
||||
log.Error("fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(log *slog.Logger) error {
|
||||
cfg := config.Load()
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating data dir: %w", err)
|
||||
}
|
||||
st, err := store.Open(cfg.DataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
images, err := imgcache.New(cfg.DataDir, providers.UserAgent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image cache: %w", err)
|
||||
}
|
||||
ver := version
|
||||
if ver == "dev" {
|
||||
ver = fmt.Sprintf("dev-%d", time.Now().Unix())
|
||||
}
|
||||
srv, err := server.New(cfg, st, images, log, ver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := bootstrapAdmin(ctx, cfg, st, srv, log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(6 * time.Hour)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := st.PruneSessions(ctx); err != nil {
|
||||
log.Warn("pruning sessions", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("TopDeck is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version)
|
||||
errc <- httpSrv.ListenAndServe()
|
||||
}()
|
||||
select {
|
||||
case err := <-errc:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
log.Info("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return httpSrv.Shutdown(shutdownCtx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootstrapAdmin creates the owner account from the environment, or else
|
||||
// prints a one-time code that's required to claim the instance in the
|
||||
// browser, so a stranger can't get there first.
|
||||
func bootstrapAdmin(ctx context.Context, cfg config.Config, st *store.Store, srv *server.Server, log *slog.Logger) error {
|
||||
n, err := st.UserCount(ctx)
|
||||
if err != nil || n > 0 {
|
||||
return err
|
||||
}
|
||||
if cfg.AdminUsername != "" && cfg.AdminPassword != "" {
|
||||
if len(cfg.AdminPassword) < auth.MinPasswordLength {
|
||||
return fmt.Errorf("TOPDECK_ADMIN_PASSWORD must be at least %d characters", auth.MinPasswordLength)
|
||||
}
|
||||
hash, err := auth.HashPassword(cfg.AdminPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.CreateUser(ctx, cfg.AdminUsername, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("created admin account from environment", "username", cfg.AdminUsername)
|
||||
return nil
|
||||
}
|
||||
code := setupCode()
|
||||
srv.SetSetupCode(code)
|
||||
log.Info("no admin account yet: open /setup in your browser and enter this setup code", "code", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupCode() string {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
b := make([]byte, 8)
|
||||
for i := range b {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b[i] = alphabet[n.Int64()]
|
||||
}
|
||||
return string(b[:4]) + "-" + string(b[4:])
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
topdeck:
|
||||
build: .
|
||||
image: topdeck:latest
|
||||
container_name: topdeck
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4242:4242"
|
||||
volumes:
|
||||
- topdeck-data:/data
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
||||
volumes:
|
||||
topdeck-data:
|
||||
@@ -0,0 +1,20 @@
|
||||
module github.com/cdnutter/topdeck
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.57.0
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package auth has password hashing, session tokens and a login rate limiter.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const MinPasswordLength = 8
|
||||
|
||||
func HashPassword(pw string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(pw), 12)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func CheckPassword(hash, pw string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
|
||||
}
|
||||
|
||||
// dummyHash lets failed lookups of unknown users take as long as real ones.
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), 12)
|
||||
|
||||
func BurnTime(pw string) { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(pw)) }
|
||||
|
||||
// NewToken returns a random session token and the hash stored server side.
|
||||
func NewToken() (token, hash string) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(b)
|
||||
return token, HashToken(token)
|
||||
}
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Limiter allows a fixed number of failed attempts per key per window.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
window time.Duration
|
||||
hits map[string][]time.Time
|
||||
}
|
||||
|
||||
func NewLimiter(max int, window time.Duration) *Limiter {
|
||||
return &Limiter{max: max, window: window, hits: map[string][]time.Time{}}
|
||||
}
|
||||
|
||||
func (l *Limiter) prune(key string, now time.Time) []time.Time {
|
||||
kept := l.hits[key][:0]
|
||||
for _, t := range l.hits[key] {
|
||||
if now.Sub(t) < l.window {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(l.hits, key)
|
||||
return nil
|
||||
}
|
||||
l.hits[key] = kept
|
||||
return kept
|
||||
}
|
||||
|
||||
// Blocked reports whether key has used up its attempts.
|
||||
func (l *Limiter) Blocked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.prune(key, time.Now())) >= l.max
|
||||
}
|
||||
|
||||
func (l *Limiter) Fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
l.hits[key] = append(l.prune(key, now), now)
|
||||
}
|
||||
|
||||
func (l *Limiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package config loads runtime configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds process-level settings. Anything the owner can change from the
|
||||
// web UI lives in the database instead (see store.Settings); the API-key
|
||||
// fields here only act as overrides when set.
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
DataDir string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
SecureCookies bool
|
||||
TrustProxy bool
|
||||
|
||||
// Optional overrides for provider credentials. When set, the matching
|
||||
// setting is locked in the web UI.
|
||||
TMDBKey string
|
||||
TVDBKey string
|
||||
TVDBPin string
|
||||
IGDBClientID string
|
||||
IGDBClientSecret string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
port := env("TOPDECK_PORT", "4242")
|
||||
return Config{
|
||||
ListenAddr: env("TOPDECK_LISTEN", ":"+port),
|
||||
DataDir: env("TOPDECK_DATA_DIR", "./data"),
|
||||
AdminUsername: env("TOPDECK_ADMIN_USERNAME", ""),
|
||||
AdminPassword: env("TOPDECK_ADMIN_PASSWORD", ""),
|
||||
SecureCookies: envBool("TOPDECK_SECURE_COOKIES", false),
|
||||
TrustProxy: envBool("TOPDECK_TRUST_PROXY", false),
|
||||
TMDBKey: env("TOPDECK_TMDB_API_KEY", ""),
|
||||
TVDBKey: env("TOPDECK_TVDB_API_KEY", ""),
|
||||
TVDBPin: env("TOPDECK_TVDB_PIN", ""),
|
||||
IGDBClientID: env("TOPDECK_IGDB_CLIENT_ID", ""),
|
||||
IGDBClientSecret: env("TOPDECK_IGDB_CLIENT_SECRET", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
b, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package imgcache downloads and stores artwork locally so pages never
|
||||
// hotlink third-party image servers.
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxBytes = 15 << 20
|
||||
|
||||
var ErrNotImage = errors.New("that file isn't a supported image (JPEG, PNG, WebP, GIF or AVIF)")
|
||||
|
||||
type Cache struct {
|
||||
dir string
|
||||
client *http.Client
|
||||
ua string
|
||||
}
|
||||
|
||||
func New(dataDir, userAgent string) (*Cache, error) {
|
||||
dir := filepath.Join(dataDir, "images")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Cache{dir: dir, ua: userAgent, client: &http.Client{Timeout: 30 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Dir() string { return c.dir }
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[0-9a-f]{40}\.(jpg|png|webp|gif|avif)$`)
|
||||
|
||||
// ValidName reports whether name looks like a file this cache created.
|
||||
func ValidName(name string) bool { return nameRe.MatchString(name) }
|
||||
|
||||
// Fetch downloads an image URL and returns the cached file name.
|
||||
func (c *Cache) Fetch(ctx context.Context, url string) (string, error) {
|
||||
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
|
||||
return "", fmt.Errorf("image URL must start with http:// or https://")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", c.ua)
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("downloading image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", fmt.Errorf("no artwork found at that address")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("downloading image: server returned %d", resp.StatusCode)
|
||||
}
|
||||
return c.Save(resp.Body)
|
||||
}
|
||||
|
||||
// Save stores image data from r and returns the cached file name. The name
|
||||
// is derived from the content, so identical images are stored once.
|
||||
func (c *Cache) Save(r io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, MaxBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) > MaxBytes {
|
||||
return "", fmt.Errorf("image is larger than %d MB", MaxBytes>>20)
|
||||
}
|
||||
ext := sniff(data)
|
||||
if ext == "" {
|
||||
return "", ErrNotImage
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
name := hex.EncodeToString(sum[:20]) + "." + ext
|
||||
path := filepath.Join(c.dir, name)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return name, nil
|
||||
}
|
||||
tmp, err := os.CreateTemp(c.dir, ".upload-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// Remove deletes a cached image. Missing files are not an error.
|
||||
func (c *Cache) Remove(name string) error {
|
||||
if !ValidName(name) {
|
||||
return nil
|
||||
}
|
||||
err := os.Remove(filepath.Join(c.dir, name))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func sniff(b []byte) string {
|
||||
switch http.DetectContentType(b) {
|
||||
case "image/jpeg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
}
|
||||
if len(b) > 12 && bytes.Equal(b[4:8], []byte("ftyp")) && (bytes.Equal(b[8:12], []byte("avif")) || bytes.Equal(b[8:12], []byte("avis"))) {
|
||||
return "avif"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// igdb searches the Internet Game Database, authenticating through Twitch.
|
||||
type igdb struct {
|
||||
r *Registry
|
||||
id string
|
||||
secret string
|
||||
}
|
||||
|
||||
type igdbTokens struct {
|
||||
mu sync.Mutex
|
||||
credKey string
|
||||
token string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (g *igdb) Name() string { return "igdb" }
|
||||
func (g *igdb) Label() string { return "IGDB" }
|
||||
|
||||
func (g *igdb) token(ctx context.Context, fresh bool) (string, error) {
|
||||
c := g.r.igdb
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
credKey := g.id + "\x00" + g.secret
|
||||
if !fresh && c.credKey == credKey && c.token != "" && time.Now().Before(c.expires) {
|
||||
return c.token, nil
|
||||
}
|
||||
q := url.Values{"client_id": {g.id}, "client_secret": {g.secret}, "grant_type": {"client_credentials"}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://id.twitch.tv/oauth2/token?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
if err := g.r.do(req, &resp); err != nil {
|
||||
if isStatus(err, 400) || isStatus(err, 403) {
|
||||
return "", fmt.Errorf("Twitch rejected the IGDB client ID/secret; check them under Settings → Integrations")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
c.credKey, c.token = credKey, resp.AccessToken
|
||||
c.expires = time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second - time.Hour)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query)
|
||||
body := `search "` + q + `";
|
||||
fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.company.name,platforms.abbreviation,platforms.name;
|
||||
limit 25;`
|
||||
var games []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FirstReleaseDate int64 `json:"first_release_date"`
|
||||
Summary string `json:"summary"`
|
||||
URL string `json:"url"`
|
||||
Cover struct {
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
InvolvedCompanies []struct {
|
||||
Developer bool `json:"developer"`
|
||||
Company struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"company"`
|
||||
} `json:"involved_companies"`
|
||||
Platforms []struct {
|
||||
Abbreviation string `json:"abbreviation"`
|
||||
Name string `json:"name"`
|
||||
} `json:"platforms"`
|
||||
}
|
||||
for attempt := 0; ; attempt++ {
|
||||
tok, err := g.token(ctx, attempt > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.igdb.com/v4/games", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Client-ID", g.id)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
err = g.r.do(req, &games)
|
||||
if isStatus(err, 401) && attempt == 0 {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
out := make([]Result, 0, len(games))
|
||||
for _, gm := range games {
|
||||
var devs, plats []string
|
||||
for _, ic := range gm.InvolvedCompanies {
|
||||
if ic.Developer && ic.Company.Name != "" {
|
||||
devs = append(devs, ic.Company.Name)
|
||||
}
|
||||
}
|
||||
for _, p := range gm.Platforms {
|
||||
name := p.Abbreviation
|
||||
if name == "" {
|
||||
name = p.Name
|
||||
}
|
||||
plats = append(plats, name)
|
||||
}
|
||||
r := Result{
|
||||
Provider: "igdb",
|
||||
ProviderID: strconv.FormatInt(gm.ID, 10),
|
||||
Title: gm.Name,
|
||||
Subtitle: strings.Join(devs, ", "),
|
||||
ExternalURL: gm.URL,
|
||||
Overview: truncate(gm.Summary, 600),
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if gm.FirstReleaseDate > 0 {
|
||||
r.Year = strconv.Itoa(time.Unix(gm.FirstReleaseDate, 0).UTC().Year())
|
||||
}
|
||||
if gm.Cover.ImageID != "" {
|
||||
r.ImageURL = "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/" + gm.Cover.ImageID + ".jpg"
|
||||
}
|
||||
if len(plats) > 0 {
|
||||
r.Extra["platforms"] = strings.Join(plats, ", ")
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deezer uses Deezer's public catalogue API: no key, generous limits and
|
||||
// 1000px cover art.
|
||||
type deezer struct {
|
||||
r *Registry
|
||||
kind string // "album" or "song"
|
||||
}
|
||||
|
||||
func (d *deezer) Name() string { return "deezer" }
|
||||
func (d *deezer) Label() string { return "Deezer" }
|
||||
|
||||
type deezerErr struct {
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Deezer reports errors with a 200 status and an "error" object.
|
||||
func (e *deezerErr) check() error {
|
||||
if e.Error != nil {
|
||||
return fmt.Errorf("Deezer: %s", e.Error.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *deezer) get(ctx context.Context, path string, out any, e *deezerErr) error {
|
||||
if err := d.r.getJSON(ctx, "https://api.deezer.com"+path, nil, out); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.check()
|
||||
}
|
||||
|
||||
type deezerArtist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type deezerAlbum struct {
|
||||
deezerErr
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
RecordType string `json:"record_type"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Label string `json:"label"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Genres struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
} `json:"genres"`
|
||||
}
|
||||
|
||||
type deezerTrack struct {
|
||||
deezerErr
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Duration int `json:"duration"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbum `json:"album"`
|
||||
}
|
||||
|
||||
func cover(a deezerAlbum) string {
|
||||
if a.CoverXL != "" {
|
||||
return a.CoverXL
|
||||
}
|
||||
return a.CoverBig
|
||||
}
|
||||
|
||||
func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
q := url.Values{"q": {query}, "limit": {"25"}}.Encode()
|
||||
var out []Result
|
||||
if d.kind == "album" {
|
||||
var resp struct {
|
||||
deezerErr
|
||||
Data []deezerAlbum `json:"data"`
|
||||
}
|
||||
if err := d.get(ctx, "/search/album?"+q, &resp, &resp.deezerErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range resp.Data {
|
||||
r := Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
|
||||
Title: a.Title,
|
||||
Subtitle: a.Artist.Name,
|
||||
ImageURL: cover(a),
|
||||
ExternalURL: a.Link,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if a.RecordType != "" && a.RecordType != "album" {
|
||||
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
|
||||
}
|
||||
if a.NbTracks > 0 {
|
||||
r.Overview = fmt.Sprintf("%d tracks", a.NbTracks)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var resp struct {
|
||||
deezerErr
|
||||
Data []deezerTrack `json:"data"`
|
||||
}
|
||||
if err := d.get(ctx, "/search/track?"+q, &resp, &resp.deezerErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range resp.Data {
|
||||
out = append(out, Result{
|
||||
Provider: "deezer",
|
||||
ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
|
||||
Title: t.Title,
|
||||
Subtitle: t.Artist.Name,
|
||||
ImageURL: cover(t.Album),
|
||||
ExternalURL: t.Link,
|
||||
Overview: "From " + t.Album.Title,
|
||||
Extra: map[string]string{"album": t.Album.Title},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
|
||||
kind, id, _ := strings.Cut(res.ProviderID, ":")
|
||||
if res.Extra == nil {
|
||||
res.Extra = map[string]string{}
|
||||
}
|
||||
switch kind {
|
||||
case "album":
|
||||
var a deezerAlbum
|
||||
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Year = yearOf(a.ReleaseDate)
|
||||
if a.Label != "" {
|
||||
res.Extra["label"] = a.Label
|
||||
}
|
||||
var g []string
|
||||
for _, x := range a.Genres.Data {
|
||||
g = append(g, x.Name)
|
||||
}
|
||||
if len(g) > 0 {
|
||||
res.Extra["genres"] = strings.Join(g, ", ")
|
||||
}
|
||||
case "track":
|
||||
var t deezerTrack
|
||||
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Year = yearOf(t.ReleaseDate)
|
||||
if res.Year == "" {
|
||||
res.Year = yearOf(t.Album.ReleaseDate)
|
||||
}
|
||||
if t.Duration > 0 {
|
||||
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// musicbrainz uses the MusicBrainz database with art from the Cover Art
|
||||
// Archive. Fully open data, but limited to one request per second.
|
||||
type musicbrainz struct {
|
||||
r *Registry
|
||||
kind string
|
||||
}
|
||||
|
||||
func (m *musicbrainz) Name() string { return "musicbrainz" }
|
||||
func (m *musicbrainz) Label() string { return "MusicBrainz" }
|
||||
|
||||
type mbCredit struct {
|
||||
Name string `json:"name"`
|
||||
JoinPhrase string `json:"joinphrase"`
|
||||
}
|
||||
|
||||
func mbArtists(credits []mbCredit) string {
|
||||
var b strings.Builder
|
||||
for _, c := range credits {
|
||||
b.WriteString(c.Name)
|
||||
b.WriteString(c.JoinPhrase)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
if err := m.r.mb.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Result
|
||||
if m.kind == "album" {
|
||||
var resp struct {
|
||||
ReleaseGroups []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
} `json:"release-groups"`
|
||||
}
|
||||
u := "https://musicbrainz.org/ws/2/release-group?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
||||
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rg := range resp.ReleaseGroups {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "release-group:" + rg.ID,
|
||||
Title: rg.Title,
|
||||
Subtitle: mbArtists(rg.ArtistCredit),
|
||||
Year: yearOf(rg.FirstReleaseDate),
|
||||
ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500",
|
||||
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
|
||||
r.Extra["type"] = rg.PrimaryType
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var resp struct {
|
||||
Recordings []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
FirstReleaseDate string `json:"first-release-date"`
|
||||
ArtistCredit []mbCredit `json:"artist-credit"`
|
||||
Releases []struct {
|
||||
Title string `json:"title"`
|
||||
ReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
} `json:"release-group"`
|
||||
} `json:"releases"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
u := "https://musicbrainz.org/ws/2/recording?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
|
||||
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rec := range resp.Recordings {
|
||||
r := Result{
|
||||
Provider: "musicbrainz",
|
||||
ProviderID: "recording:" + rec.ID,
|
||||
Title: rec.Title,
|
||||
Subtitle: mbArtists(rec.ArtistCredit),
|
||||
Year: yearOf(rec.FirstReleaseDate),
|
||||
ExternalURL: "https://musicbrainz.org/recording/" + rec.ID,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
// Prefer an album release for the artwork.
|
||||
for i, rel := range rec.Releases {
|
||||
if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500"
|
||||
r.Extra["album"] = rel.Title
|
||||
r.Overview = "From " + rel.Title
|
||||
if rel.ReleaseGroup.PrimaryType == "Album" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec.Length > 0 {
|
||||
s := rec.Length / 1000
|
||||
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Package providers searches external metadata services for things to rank.
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const UserAgent = "TopDeck/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/topdeck)"
|
||||
|
||||
// Result is one search hit, already shaped like a list item.
|
||||
type Result struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Year string `json:"year"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
Overview string `json:"overview"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
type Searcher interface {
|
||||
// Name is the stable id stored with items, e.g. "tmdb".
|
||||
Name() string
|
||||
// Label is the human-readable service name.
|
||||
Label() string
|
||||
Search(ctx context.Context, query string) ([]Result, error)
|
||||
}
|
||||
|
||||
// Detailer is implemented by searchers that can fill in more metadata (a
|
||||
// director, a release year) once the owner picks a result.
|
||||
type Detailer interface {
|
||||
Details(ctx context.Context, r Result) (Result, error)
|
||||
}
|
||||
|
||||
// NotConfiguredError means the owner hasn't supplied credentials yet.
|
||||
type NotConfiguredError struct{ Service, Hint string }
|
||||
|
||||
func (e NotConfiguredError) Error() string {
|
||||
return fmt.Sprintf("%s isn't set up yet. %s", e.Service, e.Hint)
|
||||
}
|
||||
|
||||
// Credentials are resolved fresh for every search so settings changes apply
|
||||
// immediately.
|
||||
type Credentials struct {
|
||||
TMDBKey string
|
||||
TVDBKey string
|
||||
TVDBPin string
|
||||
IGDBClientID string
|
||||
IGDBClientSecret string
|
||||
MusicProvider string // "deezer" or "musicbrainz"
|
||||
TVProvider string // "tvdb" or "tmdb"
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
creds func(context.Context) Credentials
|
||||
client *http.Client
|
||||
|
||||
tvdb *tvdbTokens
|
||||
igdb *igdbTokens
|
||||
mb *throttle
|
||||
}
|
||||
|
||||
func NewRegistry(creds func(context.Context) Credentials) *Registry {
|
||||
return &Registry{
|
||||
creds: creds,
|
||||
client: &http.Client{Timeout: 20 * time.Second},
|
||||
tvdb: &tvdbTokens{},
|
||||
igdb: &igdbTokens{},
|
||||
mb: &throttle{gap: 1100 * time.Millisecond},
|
||||
}
|
||||
}
|
||||
|
||||
// For returns the searcher for a category.
|
||||
func (r *Registry) For(ctx context.Context, category string) (Searcher, error) {
|
||||
c := r.creds(ctx)
|
||||
switch category {
|
||||
case "movie":
|
||||
if c.TMDBKey == "" {
|
||||
return nil, NotConfiguredError{"TMDB", "Add a TMDB API key under Settings → Integrations."}
|
||||
}
|
||||
return &tmdb{r: r, key: c.TMDBKey, kind: "movie"}, nil
|
||||
case "tv":
|
||||
if c.TVProvider == "tmdb" {
|
||||
if c.TMDBKey == "" {
|
||||
return nil, NotConfiguredError{"TMDB", "Add a TMDB API key under Settings → Integrations, or switch TV shows back to TheTVDB."}
|
||||
}
|
||||
return &tmdb{r: r, key: c.TMDBKey, kind: "tv"}, nil
|
||||
}
|
||||
if c.TVDBKey == "" {
|
||||
return nil, NotConfiguredError{"TheTVDB", "Add a TheTVDB API key under Settings → Integrations."}
|
||||
}
|
||||
return &tvdb{r: r, key: c.TVDBKey, pin: c.TVDBPin}, nil
|
||||
case "album", "song":
|
||||
if c.MusicProvider == "musicbrainz" {
|
||||
return &musicbrainz{r: r, kind: category}, nil
|
||||
}
|
||||
return &deezer{r: r, kind: category}, nil
|
||||
case "game":
|
||||
if c.IGDBClientID == "" || c.IGDBClientSecret == "" {
|
||||
return nil, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."}
|
||||
}
|
||||
return &igdb{r: r, id: c.IGDBClientID, secret: c.IGDBClientSecret}, nil
|
||||
case "theater":
|
||||
return &wikipedia{r: r}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no provider for category %q", category)
|
||||
}
|
||||
|
||||
// ---- HTTP helpers ----
|
||||
|
||||
type httpError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string {
|
||||
return fmt.Sprintf("upstream returned %d: %s", e.Status, e.Body)
|
||||
}
|
||||
|
||||
func (r *Registry) do(req *http.Request, out any) error {
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
if req.Header.Get("Accept") == "" {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
snippet := strings.TrimSpace(string(body))
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200] + "…"
|
||||
}
|
||||
return &httpError{Status: resp.StatusCode, Body: snippet}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func (r *Registry) getJSON(ctx context.Context, url string, header map[string]string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return r.do(req, out)
|
||||
}
|
||||
|
||||
func isStatus(err error, code int) bool {
|
||||
var he *httpError
|
||||
return errors.As(err, &he) && he.Status == code
|
||||
}
|
||||
|
||||
// throttle spaces out calls for services with strict rate limits.
|
||||
type throttle struct {
|
||||
mu sync.Mutex
|
||||
gap time.Duration
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (t *throttle) wait(ctx context.Context) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if d := t.gap - time.Since(t.last); d > 0 {
|
||||
select {
|
||||
case <-time.After(d):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
t.last = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
var yearRe = regexp.MustCompile(`\b(1[5-9]\d\d|20\d\d)\b`)
|
||||
|
||||
// yearOf pulls the first plausible year out of a date or free text.
|
||||
func yearOf(s string) string {
|
||||
return yearRe.FindString(s)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) <= n {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string([]rune(s)[:n])) + "…"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// tmdb searches The Movie Database. It accepts either a v3 API key or a v4
|
||||
// "API Read Access Token".
|
||||
type tmdb struct {
|
||||
r *Registry
|
||||
key string
|
||||
kind string // "movie" or "tv"
|
||||
}
|
||||
|
||||
func (t *tmdb) Name() string { return "tmdb" }
|
||||
func (t *tmdb) Label() string { return "TMDB" }
|
||||
|
||||
func (t *tmdb) get(ctx context.Context, path string, q url.Values, out any) error {
|
||||
if q == nil {
|
||||
q = url.Values{}
|
||||
}
|
||||
header := map[string]string{}
|
||||
if strings.HasPrefix(t.key, "eyJ") {
|
||||
header["Authorization"] = "Bearer " + t.key
|
||||
} else {
|
||||
q.Set("api_key", t.key)
|
||||
}
|
||||
err := t.r.getJSON(ctx, "https://api.themoviedb.org/3"+path+"?"+q.Encode(), header, out)
|
||||
if isStatus(err, 401) {
|
||||
return fmt.Errorf("TMDB rejected the API key; check it under Settings → Integrations")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func tmdbImage(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://image.tmdb.org/t/p/w780" + path
|
||||
}
|
||||
|
||||
type tmdbHit struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
}
|
||||
|
||||
func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
var resp struct {
|
||||
Results []tmdbHit `json:"results"`
|
||||
}
|
||||
q := url.Values{"query": {query}, "include_adult": {"false"}}
|
||||
if err := t.get(ctx, "/search/"+t.kind, q, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Result, 0, len(resp.Results))
|
||||
for _, h := range resp.Results {
|
||||
title, date := h.Title, h.ReleaseDate
|
||||
if t.kind == "tv" {
|
||||
title, date = h.Name, h.FirstAirDate
|
||||
}
|
||||
id := strconv.FormatInt(h.ID, 10)
|
||||
out = append(out, Result{
|
||||
Provider: "tmdb",
|
||||
ProviderID: t.kind + ":" + id,
|
||||
Title: title,
|
||||
Year: yearOf(date),
|
||||
ImageURL: tmdbImage(h.PosterPath),
|
||||
ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id,
|
||||
Overview: truncate(h.Overview, 600),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
|
||||
_, id, ok := strings.Cut(res.ProviderID, ":")
|
||||
if !ok {
|
||||
return res, nil
|
||||
}
|
||||
var d struct {
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Runtime int `json:"runtime"`
|
||||
Networks []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"networks"`
|
||||
CreatedBy []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"created_by"`
|
||||
Credits struct {
|
||||
Crew []struct {
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
} `json:"crew"`
|
||||
} `json:"credits"`
|
||||
}
|
||||
q := url.Values{}
|
||||
if t.kind == "movie" {
|
||||
q.Set("append_to_response", "credits")
|
||||
}
|
||||
if err := t.get(ctx, "/"+t.kind+"/"+id, q, &d); err != nil {
|
||||
return res, err
|
||||
}
|
||||
var names []string
|
||||
if t.kind == "movie" {
|
||||
for _, c := range d.Credits.Crew {
|
||||
if c.Job == "Director" {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, n := range d.Networks {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
res.Subtitle = strings.Join(names, ", ")
|
||||
}
|
||||
var genres []string
|
||||
for _, g := range d.Genres {
|
||||
genres = append(genres, g.Name)
|
||||
}
|
||||
if res.Extra == nil {
|
||||
res.Extra = map[string]string{}
|
||||
}
|
||||
if len(genres) > 0 {
|
||||
res.Extra["genres"] = strings.Join(genres, ", ")
|
||||
}
|
||||
if d.Runtime > 0 {
|
||||
res.Extra["runtime"] = fmt.Sprintf("%dh %02dm", d.Runtime/60, d.Runtime%60)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tvdb searches TheTVDB (API v4).
|
||||
type tvdb struct {
|
||||
r *Registry
|
||||
key string
|
||||
pin string
|
||||
}
|
||||
|
||||
type tvdbTokens struct {
|
||||
mu sync.Mutex
|
||||
credKey string
|
||||
token string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (t *tvdb) Name() string { return "tvdb" }
|
||||
func (t *tvdb) Label() string { return "TheTVDB" }
|
||||
|
||||
func (t *tvdb) token(ctx context.Context, fresh bool) (string, error) {
|
||||
c := t.r.tvdb
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
credKey := t.key + "\x00" + t.pin
|
||||
if !fresh && c.credKey == credKey && c.token != "" && time.Now().Before(c.expires) {
|
||||
return c.token, nil
|
||||
}
|
||||
body := map[string]string{"apikey": t.key}
|
||||
if t.pin != "" {
|
||||
body["pin"] = t.pin
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api4.thetvdb.com/v4/login", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := t.r.do(req, &resp); err != nil {
|
||||
if isStatus(err, 401) {
|
||||
return "", fmt.Errorf("TheTVDB rejected the API key/PIN; check them under Settings → Integrations")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// Tokens are valid for a month; refresh daily to be safe.
|
||||
c.credKey, c.token, c.expires = credKey, resp.Data.Token, time.Now().Add(24*time.Hour)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
TVDBID string `json:"tvdb_id"`
|
||||
Name string `json:"name"`
|
||||
Year string `json:"year"`
|
||||
FirstAired string `json:"first_air_time"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Overview string `json:"overview"`
|
||||
Network string `json:"network"`
|
||||
Slug string `json:"slug"`
|
||||
Translations map[string]string `json:"translations"`
|
||||
Overviews map[string]string `json:"overviews"`
|
||||
} `json:"data"`
|
||||
}
|
||||
u := "https://api4.thetvdb.com/v4/search?" + url.Values{"query": {query}, "type": {"series"}, "limit": {"20"}}.Encode()
|
||||
for attempt := 0; ; attempt++ {
|
||||
tok, err := t.token(ctx, attempt > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = t.r.getJSON(ctx, u, map[string]string{"Authorization": "Bearer " + tok}, &resp)
|
||||
if isStatus(err, 401) && attempt == 0 {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
out := make([]Result, 0, len(resp.Data))
|
||||
for _, s := range resp.Data {
|
||||
name := s.Name
|
||||
if eng := s.Translations["eng"]; eng != "" {
|
||||
name = eng
|
||||
}
|
||||
overview := s.Overview
|
||||
if eng := s.Overviews["eng"]; eng != "" {
|
||||
overview = eng
|
||||
}
|
||||
year := s.Year
|
||||
if year == "" {
|
||||
year = yearOf(s.FirstAired)
|
||||
}
|
||||
img := s.ImageURL
|
||||
if img == "" {
|
||||
img = s.Thumbnail
|
||||
}
|
||||
ext := "https://thetvdb.com/?tab=series&id=" + s.TVDBID
|
||||
if s.Slug != "" {
|
||||
ext = "https://thetvdb.com/series/" + s.Slug
|
||||
}
|
||||
out = append(out, Result{
|
||||
Provider: "tvdb",
|
||||
ProviderID: s.TVDBID,
|
||||
Title: name,
|
||||
Subtitle: s.Network,
|
||||
Year: year,
|
||||
ImageURL: img,
|
||||
ExternalURL: ext,
|
||||
Overview: truncate(overview, 600),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// wikipedia finds stage works (musicals, plays, operas) on English
|
||||
// Wikipedia. There's no free, open theater database with an API, and
|
||||
// Wikipedia has the best coverage plus poster/logo art for most shows. The
|
||||
// specific production (Broadway, a tour, …) is chosen by the owner.
|
||||
type wikipedia struct {
|
||||
r *Registry
|
||||
}
|
||||
|
||||
func (w *wikipedia) Name() string { return "wikipedia" }
|
||||
func (w *wikipedia) Label() string { return "Wikipedia" }
|
||||
|
||||
const theaterTemplates = `hastemplate:"Infobox musical|Infobox play|Infobox opera"`
|
||||
|
||||
func (w *wikipedia) Search(ctx context.Context, query string) ([]Result, error) {
|
||||
res, err := w.search(ctx, query+" "+theaterTemplates)
|
||||
if err == nil && len(res) == 0 {
|
||||
res, err = w.search(ctx, query)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
var leadingYear = regexp.MustCompile(`^(1[5-9]\d\d|20\d\d)\s+`)
|
||||
|
||||
func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error) {
|
||||
q := url.Values{
|
||||
"action": {"query"},
|
||||
"format": {"json"},
|
||||
"formatversion": {"2"},
|
||||
"generator": {"search"},
|
||||
"gsrsearch": {query},
|
||||
"gsrlimit": {"15"},
|
||||
"prop": {"pageimages|description|info|extracts"},
|
||||
"piprop": {"thumbnail"},
|
||||
"pithumbsize": {"800"},
|
||||
"pilicense": {"any"},
|
||||
"inprop": {"url"},
|
||||
"exintro": {"1"},
|
||||
"explaintext": {"1"},
|
||||
"exsentences": {"3"},
|
||||
"exlimit": {"max"},
|
||||
}
|
||||
var resp struct {
|
||||
Query struct {
|
||||
Pages []struct {
|
||||
PageID int64 `json:"pageid"`
|
||||
Title string `json:"title"`
|
||||
Index int `json:"index"`
|
||||
Description string `json:"description"`
|
||||
Extract string `json:"extract"`
|
||||
FullURL string `json:"fullurl"`
|
||||
Thumbnail struct {
|
||||
Source string `json:"source"`
|
||||
} `json:"thumbnail"`
|
||||
} `json:"pages"`
|
||||
} `json:"query"`
|
||||
}
|
||||
if err := w.r.getJSON(ctx, "https://en.wikipedia.org/w/api.php?"+q.Encode(), nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages := resp.Query.Pages
|
||||
sort.Slice(pages, func(i, j int) bool { return pages[i].Index < pages[j].Index })
|
||||
out := make([]Result, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
title := p.Title
|
||||
// "Hamilton (musical)" → "Hamilton"
|
||||
if i := strings.LastIndex(title, " ("); i > 0 && strings.HasSuffix(title, ")") {
|
||||
title = title[:i]
|
||||
}
|
||||
desc := leadingYear.ReplaceAllString(p.Description, "")
|
||||
if desc != "" {
|
||||
desc = strings.ToUpper(desc[:1]) + desc[1:]
|
||||
}
|
||||
img := p.Thumbnail.Source
|
||||
if i := strings.Index(img, "?"); i > 0 {
|
||||
img = img[:i]
|
||||
}
|
||||
out = append(out, Result{
|
||||
Provider: "wikipedia",
|
||||
ProviderID: strconv.FormatInt(p.PageID, 10),
|
||||
Title: title,
|
||||
Subtitle: desc,
|
||||
Year: yearOf(p.Description),
|
||||
ImageURL: img,
|
||||
ExternalURL: p.FullURL,
|
||||
Overview: truncate(p.Extract, 600),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
)
|
||||
|
||||
// adminPage carries a JSON blob the page script boots from.
|
||||
type adminPage struct {
|
||||
Boot template.JS
|
||||
List *store.List
|
||||
}
|
||||
|
||||
func bootJSON(v any) template.JS {
|
||||
b, _ := json.Marshal(v)
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
type sizeLimits struct {
|
||||
Max int `json:"max"`
|
||||
Recommended int `json:"recommended"`
|
||||
Warn int `json:"warn"`
|
||||
}
|
||||
|
||||
var limits = sizeLimits{store.MaxListSize, store.RecommendedListSize, store.SoftListSizeWarning}
|
||||
|
||||
func (s *Server) handleAdminLists(w http.ResponseWriter, r *http.Request) {
|
||||
lists, err := s.store.Lists(r.Context(), true)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if lists == nil {
|
||||
lists = []store.List{}
|
||||
}
|
||||
covers, err := s.store.ListCovers(r.Context(), 3)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "admin_lists", "Your lists", adminPage{Boot: bootJSON(map[string]any{
|
||||
"lists": lists,
|
||||
"covers": covers,
|
||||
"categories": store.Categories,
|
||||
"limits": limits,
|
||||
})})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
l, err := s.store.ListByID(r.Context(), id)
|
||||
if err != nil {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
cat, _ := store.CategoryByKey(l.Category)
|
||||
provider := ""
|
||||
if p, err := s.providers.For(r.Context(), l.Category); err == nil {
|
||||
provider = p.Label()
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "admin_list", l.Title, adminPage{List: &l, Boot: bootJSON(map[string]any{
|
||||
"list": l,
|
||||
"category": cat,
|
||||
"categories": store.Categories,
|
||||
"limits": limits,
|
||||
"provider": provider,
|
||||
"productions": store.TheaterProductions,
|
||||
"pageSize": listPageSize,
|
||||
})})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, http.StatusOK, "admin_settings", "Settings", adminPage{Boot: bootJSON(map[string]any{
|
||||
"categories": store.Categories,
|
||||
})})
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/auth"
|
||||
"github.com/cdnutter/topdeck/internal/imgcache"
|
||||
"github.com/cdnutter/topdeck/internal/providers"
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
)
|
||||
|
||||
// ---- lists ----
|
||||
|
||||
func (s *Server) apiLists(w http.ResponseWriter, r *http.Request) {
|
||||
lists, err := s.store.Lists(r.Context(), true)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if lists == nil {
|
||||
lists = []store.List{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
|
||||
func (s *Server) apiCreateList(w http.ResponseWriter, r *http.Request) {
|
||||
var l store.List
|
||||
if err := decodeJSON(r, &l); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
l, err := s.store.CreateList(r.Context(), l)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, l)
|
||||
}
|
||||
|
||||
func (s *Server) apiUpdateList(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var l store.List
|
||||
if err := decodeJSON(r, &l); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
l.ID = id
|
||||
l, err = s.store.UpdateList(r.Context(), l)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (s *Server) apiDeleteList(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
images, err := s.store.ListImages(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteList(r.Context(), id); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
for _, img := range images {
|
||||
s.releaseImage(r.Context(), img)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) apiReorderLists(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.ReorderLists(r.Context(), body.IDs); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- items ----
|
||||
|
||||
func (s *Server) apiItems(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
items, err := s.store.Items(r.Context(), id, max(0, offset), limit)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
type itemInput struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Year string `json:"year"`
|
||||
Note string `json:"note"`
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
ClearImage bool `json:"clearImage"`
|
||||
|
||||
// Only used when adding.
|
||||
Provider string `json:"provider"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Rank int `json:"rank"`
|
||||
Details bool `json:"details"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type itemResponse struct {
|
||||
Item store.Item `json:"item"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
listID, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var in itemInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListByID(ctx, listID)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if list.ItemCount >= list.MaxItems {
|
||||
s.writeErr(w, store.ValidationError{Msg: fmt.Sprintf("This list is full (%d of %d). Increase its size or remove an item first.", list.ItemCount, list.MaxItems)})
|
||||
return
|
||||
}
|
||||
if !in.Force {
|
||||
if dup, err := s.store.FindItemByProvider(ctx, listID, in.Provider, in.ProviderID); err == nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": fmt.Sprintf("“%s” is already on this list at #%d.", dup.Title, dup.Rank),
|
||||
"rank": dup.Rank,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res := providers.Result{
|
||||
Provider: in.Provider, ProviderID: in.ProviderID, Title: in.Title, Subtitle: in.Subtitle,
|
||||
Year: in.Year, ImageURL: in.ImageURL, ExternalURL: in.ExternalURL, Extra: in.Extra,
|
||||
}
|
||||
if in.Details && in.Provider != "" {
|
||||
res = s.fillDetails(ctx, list.Category, res)
|
||||
}
|
||||
|
||||
it := store.Item{
|
||||
ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year,
|
||||
Provider: res.Provider, ProviderID: res.ProviderID, ExternalURL: res.ExternalURL,
|
||||
Note: in.Note, Extra: res.Extra,
|
||||
}
|
||||
var warning string
|
||||
if res.ImageURL != "" {
|
||||
name, err := s.fetchImage(ctx, res.ImageURL)
|
||||
if err != nil {
|
||||
warning = "Added without artwork: " + err.Error()
|
||||
} else {
|
||||
it.Image, it.ImageSource = name, res.ImageURL
|
||||
}
|
||||
}
|
||||
saved, err := s.store.AddItem(ctx, it, in.Rank)
|
||||
if err != nil {
|
||||
s.releaseImage(ctx, it.Image)
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, itemResponse{Item: saved, Warning: warning})
|
||||
}
|
||||
|
||||
// fillDetails asks the provider for richer metadata. Failures are logged
|
||||
// and the search result is used as-is.
|
||||
func (s *Server) fillDetails(ctx context.Context, category string, res providers.Result) providers.Result {
|
||||
p, err := s.providers.For(ctx, category)
|
||||
if err != nil || p.Name() != res.Provider {
|
||||
return res
|
||||
}
|
||||
d, ok := p.(providers.Detailer)
|
||||
if !ok {
|
||||
return res
|
||||
}
|
||||
dctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
full, err := d.Details(dctx, res)
|
||||
if err != nil {
|
||||
s.log.Warn("fetching details", "provider", res.Provider, "id", res.ProviderID, "err", err)
|
||||
return res
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
func (s *Server) fetchImage(ctx context.Context, url string) (string, error) {
|
||||
fctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return s.images.Fetch(fctx, url)
|
||||
}
|
||||
|
||||
// releaseImage deletes a cached image once nothing references it.
|
||||
func (s *Server) releaseImage(ctx context.Context, name string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
inUse, err := s.store.ImageInUse(ctx, name)
|
||||
if err != nil || inUse {
|
||||
return
|
||||
}
|
||||
if err := s.images.Remove(name); err != nil {
|
||||
s.log.Warn("removing image", "name", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var in itemInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
cur, err := s.store.ItemByID(ctx, id)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
it := cur
|
||||
it.Title, it.Subtitle, it.Year, it.Note, it.ExternalURL = in.Title, in.Subtitle, in.Year, in.Note, in.ExternalURL
|
||||
if in.Extra != nil {
|
||||
it.Extra = in.Extra
|
||||
}
|
||||
if in.ClearImage {
|
||||
it.Image, it.ImageSource = "", ""
|
||||
}
|
||||
if url := strings.TrimSpace(in.ImageURL); url != "" && url != cur.ImageSource {
|
||||
name, err := s.fetchImage(ctx, url)
|
||||
if err != nil {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Couldn't use that image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
it.Image, it.ImageSource = name, url
|
||||
}
|
||||
saved, err := s.store.UpdateItem(ctx, it)
|
||||
if err != nil {
|
||||
if it.Image != cur.Image {
|
||||
s.releaseImage(ctx, it.Image)
|
||||
}
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if cur.Image != saved.Image {
|
||||
s.releaseImage(ctx, cur.Image)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, itemResponse{Item: saved})
|
||||
}
|
||||
|
||||
func (s *Server) saveUpload(r *http.Request) (string, error) {
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, imgcache.MaxBytes+1<<20)
|
||||
f, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return "", store.ValidationError{Msg: "Choose an image file (up to 15 MB)."}
|
||||
}
|
||||
defer f.Close()
|
||||
name, err := s.images.Save(f)
|
||||
if err != nil && !errors.Is(err, imgcache.ErrNotImage) {
|
||||
return "", store.ValidationError{Msg: err.Error()}
|
||||
}
|
||||
return name, err
|
||||
}
|
||||
|
||||
func (s *Server) apiUploadItemImage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
cur, err := s.store.ItemByID(ctx, id)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
name, err := s.saveUpload(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
it := cur
|
||||
it.Image, it.ImageSource = name, "upload"
|
||||
saved, err := s.store.UpdateItem(ctx, it)
|
||||
if err != nil {
|
||||
s.releaseImage(ctx, name)
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if cur.Image != name {
|
||||
s.releaseImage(ctx, cur.Image)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, itemResponse{Item: saved})
|
||||
}
|
||||
|
||||
func (s *Server) apiMoveItem(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.MoveItem(r.Context(), id, body.Rank); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
it, err := s.store.DeleteItem(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
s.releaseImage(r.Context(), it.Image)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- search ----
|
||||
|
||||
type searchResult struct {
|
||||
providers.Result
|
||||
ExistingRank int `json:"existingRank,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) apiSearch(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
category := r.URL.Query().Get("category")
|
||||
if q == "" || utf8.RuneCountInString(q) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "Type something to search for.")
|
||||
return
|
||||
}
|
||||
p, err := s.providers.For(ctx, category)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
sctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
results, err := p.Search(sctx, q)
|
||||
if err != nil {
|
||||
s.log.Warn("search failed", "provider", p.Name(), "err", err)
|
||||
writeError(w, http.StatusBadGateway, fmt.Sprintf("%s search failed: %v", p.Label(), err))
|
||||
return
|
||||
}
|
||||
listID, _ := strconv.ParseInt(r.URL.Query().Get("list"), 10, 64)
|
||||
out := make([]searchResult, 0, len(results))
|
||||
for _, res := range results {
|
||||
sr := searchResult{Result: res}
|
||||
if listID > 0 {
|
||||
if it, err := s.store.FindItemByProvider(ctx, listID, res.Provider, res.ProviderID); err == nil {
|
||||
sr.ExistingRank = it.Rank
|
||||
}
|
||||
}
|
||||
out = append(out, sr)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider": p.Name(), "label": p.Label(), "results": out})
|
||||
}
|
||||
|
||||
// ---- settings ----
|
||||
|
||||
type secretStatus struct {
|
||||
Set bool `json:"set"`
|
||||
Hint string `json:"hint"`
|
||||
Locked bool `json:"locked"` // provided by an environment variable
|
||||
}
|
||||
|
||||
var secretKeys = []string{store.SettingTMDBKey, store.SettingTVDBKey, store.SettingTVDBPin, store.SettingIGDBClientID, store.SettingIGDBClientSecret}
|
||||
|
||||
func (s *Server) envSecret(key string) string {
|
||||
switch key {
|
||||
case store.SettingTMDBKey:
|
||||
return s.cfg.TMDBKey
|
||||
case store.SettingTVDBKey:
|
||||
return s.cfg.TVDBKey
|
||||
case store.SettingTVDBPin:
|
||||
return s.cfg.TVDBPin
|
||||
case store.SettingIGDBClientID:
|
||||
return s.cfg.IGDBClientID
|
||||
case store.SettingIGDBClientSecret:
|
||||
return s.cfg.IGDBClientSecret
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hint(v string) string {
|
||||
if len(v) <= 4 {
|
||||
return strings.Repeat("•", len(v))
|
||||
}
|
||||
return "••••" + v[len(v)-4:]
|
||||
}
|
||||
|
||||
func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
|
||||
set, err := s.store.Settings(r.Context())
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
site := s.site(r.Context())
|
||||
secrets := map[string]secretStatus{}
|
||||
for _, k := range secretKeys {
|
||||
v, locked := set[k], false
|
||||
if env := s.envSecret(k); env != "" {
|
||||
v, locked = env, true
|
||||
}
|
||||
secrets[k] = secretStatus{Set: v != "", Hint: hint(v), Locked: locked}
|
||||
}
|
||||
u, _ := s.currentUser(r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"siteTitle": set[store.SettingSiteTitle],
|
||||
"ownerName": site.OwnerName,
|
||||
"tagline": site.Tagline,
|
||||
"avatar": site.AvatarURL,
|
||||
"theme": site.Theme,
|
||||
"customCss": set[store.SettingCustomCSS],
|
||||
"previewCount": site.PreviewCount,
|
||||
"showNotes": site.ShowNotes,
|
||||
"musicProvider": orDefault(set[store.SettingMusicProvider], "deezer"),
|
||||
"tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"),
|
||||
"secrets": secrets,
|
||||
"themes": Themes,
|
||||
"username": u.Username,
|
||||
})
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var in map[string]any
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
out := map[string]string{}
|
||||
str := func(field, key string, maxLen int) error {
|
||||
v, ok := in[field]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
sv, ok := v.(string)
|
||||
if !ok {
|
||||
return store.ValidationError{Msg: field + " must be text"}
|
||||
}
|
||||
sv = strings.TrimSpace(sv)
|
||||
if len(sv) > maxLen {
|
||||
return store.ValidationError{Msg: fmt.Sprintf("%s is too long (max %d characters)", field, maxLen)}
|
||||
}
|
||||
out[key] = sv
|
||||
return nil
|
||||
}
|
||||
checks := []error{
|
||||
str("siteTitle", store.SettingSiteTitle, 100),
|
||||
str("ownerName", store.SettingOwnerName, 100),
|
||||
str("tagline", store.SettingTagline, 300),
|
||||
str("theme", store.SettingTheme, 50),
|
||||
str("customCss", store.SettingCustomCSS, 200_000),
|
||||
str("musicProvider", store.SettingMusicProvider, 20),
|
||||
str("tvProvider", store.SettingTVProvider, 20),
|
||||
}
|
||||
for _, k := range secretKeys {
|
||||
checks = append(checks, str(k, k, 500))
|
||||
}
|
||||
for _, err := range checks {
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v, ok := out[store.SettingTheme]; ok && !validTheme(v) {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Unknown theme"})
|
||||
return
|
||||
}
|
||||
if v, ok := out[store.SettingMusicProvider]; ok && v != "deezer" && v != "musicbrainz" {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Unknown music provider"})
|
||||
return
|
||||
}
|
||||
if v, ok := out[store.SettingTVProvider]; ok && v != "tvdb" && v != "tmdb" {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Unknown TV provider"})
|
||||
return
|
||||
}
|
||||
if v, ok := in["previewCount"]; ok {
|
||||
n, ok := v.(float64)
|
||||
if !ok || n < 0 || n > 50 || n != float64(int(n)) {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Home page preview must be between 0 and 50 items"})
|
||||
return
|
||||
}
|
||||
out[store.SettingPreviewCount] = strconv.Itoa(int(n))
|
||||
}
|
||||
if v, ok := in["showNotes"]; ok {
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
s.writeErr(w, store.ValidationError{Msg: "showNotes must be true or false"})
|
||||
return
|
||||
}
|
||||
out[store.SettingShowNotes] = strconv.FormatBool(b)
|
||||
}
|
||||
if err := s.store.SetSettings(r.Context(), out); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
s.apiSettings(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) apiUploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
name, err := s.saveUpload(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
set, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
old := set[store.SettingAvatar]
|
||||
if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: name}); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if old != name {
|
||||
s.releaseImage(ctx, old)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"avatar": "/images/" + name})
|
||||
}
|
||||
|
||||
func (s *Server) apiDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
set, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: ""}); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
s.releaseImage(ctx, set[store.SettingAvatar])
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
var testQueries = map[string]string{
|
||||
"movie": "Inception",
|
||||
"tv": "Breaking Bad",
|
||||
"album": "Abbey Road",
|
||||
"song": "Bohemian Rhapsody",
|
||||
"game": "Zelda",
|
||||
"theater": "Hamilton",
|
||||
}
|
||||
|
||||
func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Category string `json:"category"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
q, ok := testQueries[body.Category]
|
||||
if !ok {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Unknown category"})
|
||||
return
|
||||
}
|
||||
p, err := s.providers.For(r.Context(), body.Category)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
results, err := p.Search(ctx, q)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": fmt.Sprintf("%s: %v", p.Label(), err)})
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("%s is working. Searching “%s” returned %d results.", p.Label(), q, len(results))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": msg})
|
||||
}
|
||||
|
||||
// ---- account ----
|
||||
|
||||
func (s *Server) apiUpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
var body struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
Username string `json:"username"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
su, _ := s.currentUser(r)
|
||||
u, err := s.store.UserByID(ctx, su.ID)
|
||||
if err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(u.PasswordHash, body.CurrentPassword) {
|
||||
writeError(w, http.StatusBadRequest, "Your current password isn't right.")
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(body.Username)
|
||||
if username == "" || len(username) > 64 {
|
||||
writeError(w, http.StatusBadRequest, "Pick a username (up to 64 characters).")
|
||||
return
|
||||
}
|
||||
hash := u.PasswordHash
|
||||
if body.NewPassword != "" {
|
||||
if len(body.NewPassword) < auth.MinPasswordLength {
|
||||
writeError(w, http.StatusBadRequest, "Use a password of at least 8 characters.")
|
||||
return
|
||||
}
|
||||
if hash, err = auth.HashPassword(body.NewPassword); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateUser(ctx, u.ID, username, hash); err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
writeError(w, http.StatusBadRequest, "That username is taken.")
|
||||
return
|
||||
}
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if body.NewPassword != "" {
|
||||
// Sign out every other browser.
|
||||
s.store.DeleteUserSessions(ctx, u.ID, su.TokenHash)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"username": username})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/auth"
|
||||
)
|
||||
|
||||
type loginPage struct {
|
||||
Error string
|
||||
Username string
|
||||
Next string
|
||||
}
|
||||
|
||||
// safeNext only allows local redirect targets.
|
||||
func safeNext(next string) string {
|
||||
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
|
||||
return "/admin"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) needsSetup(r *http.Request) bool {
|
||||
n, err := s.store.UserCount(r.Context())
|
||||
return err == nil && n == 0
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.needsSetup(r) {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, ok := s.currentUser(r); ok {
|
||||
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "login", "Sign in", loginPage{Next: r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
next := r.PostFormValue("next")
|
||||
page := loginPage{Username: username, Next: next}
|
||||
ip := s.clientIP(r)
|
||||
|
||||
if s.loginLimit.Blocked(ip) {
|
||||
page.Error = "Too many failed attempts. Try again in a few minutes."
|
||||
s.render(w, r, http.StatusTooManyRequests, "login", "Sign in", page)
|
||||
return
|
||||
}
|
||||
u, err := s.store.UserByName(r.Context(), username)
|
||||
if err != nil {
|
||||
auth.BurnTime(password)
|
||||
}
|
||||
if err != nil || !auth.CheckPassword(u.PasswordHash, password) {
|
||||
s.loginLimit.Fail(ip)
|
||||
page.Error = "That username and password don't match."
|
||||
s.render(w, r, http.StatusUnauthorized, "login", "Sign in", page)
|
||||
return
|
||||
}
|
||||
s.loginLimit.Reset(ip)
|
||||
if err := s.startSession(w, r, u.ID); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, safeNext(next), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID int64) error {
|
||||
token, hash := auth.NewToken()
|
||||
expires := time.Now().Add(sessionTTL)
|
||||
if err := s.store.CreateSession(r.Context(), hash, userID, expires); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
Secure: s.isHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
s.store.DeleteSession(r.Context(), auth.HashToken(c.Value))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type setupPage struct {
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.needsSetup(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "setup", "Welcome", setupPage{})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !s.needsSetup(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
ip := s.clientIP(r)
|
||||
page := setupPage{Username: strings.TrimSpace(r.PostFormValue("username"))}
|
||||
password := r.PostFormValue("password")
|
||||
code := normalizeCode(r.PostFormValue("code"))
|
||||
|
||||
fail := func(status int, msg string) {
|
||||
page.Error = msg
|
||||
s.render(w, r, status, "setup", "Welcome", page)
|
||||
}
|
||||
if s.loginLimit.Blocked(ip) {
|
||||
fail(http.StatusTooManyRequests, "Too many failed attempts. Try again in a few minutes.")
|
||||
return
|
||||
}
|
||||
if s.setupCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(normalizeCode(s.setupCode))) != 1 {
|
||||
s.loginLimit.Fail(ip)
|
||||
fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs topdeck).")
|
||||
return
|
||||
}
|
||||
if page.Username == "" || len(page.Username) > 64 {
|
||||
fail(http.StatusBadRequest, "Pick a username (up to 64 characters).")
|
||||
return
|
||||
}
|
||||
if len(password) < auth.MinPasswordLength {
|
||||
fail(http.StatusBadRequest, "Use a password of at least 8 characters.")
|
||||
return
|
||||
}
|
||||
if password != r.PostFormValue("confirm") {
|
||||
fail(http.StatusBadRequest, "The passwords don't match.")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), page.Username, hash); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("admin account created", "username", page.Username)
|
||||
u, err := s.store.UserByName(r.Context(), page.Username)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, u.ID); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// normalizeCode makes setup codes forgiving of case, spaces and dashes.
|
||||
func normalizeCode(c string) string {
|
||||
return strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(c)))
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
)
|
||||
|
||||
// ItemView is an item shaped for public display.
|
||||
type ItemView struct {
|
||||
ID int64 `json:"id"`
|
||||
Rank int `json:"rank"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Year string `json:"year"`
|
||||
Image string `json:"image"`
|
||||
ExternalURL string `json:"link"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note"`
|
||||
Badge string `json:"badge"` // e.g. theater production
|
||||
Meta []MetaKV `json:"meta"`
|
||||
}
|
||||
|
||||
type MetaKV struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
var providerLabels = map[string]string{
|
||||
"tmdb": "TMDB",
|
||||
"tvdb": "TheTVDB",
|
||||
"igdb": "IGDB",
|
||||
"deezer": "Deezer",
|
||||
"musicbrainz": "MusicBrainz",
|
||||
"wikipedia": "Wikipedia",
|
||||
}
|
||||
|
||||
// extraFields lists the known item extras in display order.
|
||||
var extraFields = []struct{ Key, Label string }{
|
||||
{"production", "Production"},
|
||||
{"venue", "Venue"},
|
||||
{"cast", "Notable cast"},
|
||||
{"album", "Album"},
|
||||
{"type", "Type"},
|
||||
{"platforms", "Platforms"},
|
||||
{"genres", "Genres"},
|
||||
{"runtime", "Runtime"},
|
||||
{"length", "Length"},
|
||||
{"label", "Label"},
|
||||
}
|
||||
|
||||
func itemView(it store.Item, showNotes bool) ItemView {
|
||||
v := ItemView{
|
||||
ID: it.ID,
|
||||
Rank: it.Rank,
|
||||
Title: it.Title,
|
||||
Subtitle: it.Subtitle,
|
||||
Year: it.Year,
|
||||
ExternalURL: it.ExternalURL,
|
||||
Source: providerLabels[it.Provider],
|
||||
Badge: it.Extra["production"],
|
||||
Meta: []MetaKV{},
|
||||
}
|
||||
if it.Image != "" {
|
||||
v.Image = "/images/" + it.Image
|
||||
}
|
||||
if showNotes {
|
||||
v.Note = it.Note
|
||||
}
|
||||
for _, f := range extraFields {
|
||||
if val := it.Extra[f.Key]; val != "" {
|
||||
v.Meta = append(v.Meta, MetaKV{f.Label, val})
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type listSection struct {
|
||||
List store.List
|
||||
Category store.Category
|
||||
Items []ItemView
|
||||
More int // items not shown in the preview
|
||||
}
|
||||
|
||||
type homePage struct {
|
||||
Sections []listSection
|
||||
TotalItems int
|
||||
TotalLists int
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_, loggedIn := s.currentUser(r)
|
||||
site := s.site(ctx)
|
||||
lists, err := s.store.Lists(ctx, false)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
var page homePage
|
||||
for _, l := range lists {
|
||||
if l.ItemCount == 0 {
|
||||
continue
|
||||
}
|
||||
items, err := s.store.Items(ctx, l.ID, 0, site.PreviewCount)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
sec := listSection{List: l, More: l.ItemCount - len(items)}
|
||||
sec.Category, _ = store.CategoryByKey(l.Category)
|
||||
for _, it := range items {
|
||||
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
|
||||
}
|
||||
page.Sections = append(page.Sections, sec)
|
||||
page.TotalItems += l.ItemCount
|
||||
page.TotalLists++
|
||||
}
|
||||
// With nothing to show yet, send a fresh owner straight to the editor.
|
||||
if len(page.Sections) == 0 && loggedIn && len(lists) == 0 {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "home", "", page)
|
||||
}
|
||||
|
||||
const listPageSize = 100
|
||||
|
||||
type listPage struct {
|
||||
List store.List
|
||||
Category store.Category
|
||||
Items []ItemView
|
||||
Hero *ItemView
|
||||
Page int
|
||||
Pages int
|
||||
PageNums []int
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
l, err := s.store.ListBySlug(ctx, r.PathValue("slug"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !l.Visible {
|
||||
if _, ok := s.currentUser(r); !ok {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
site := s.site(ctx)
|
||||
p := listPage{List: l, Page: 1}
|
||||
p.Category, _ = store.CategoryByKey(l.Category)
|
||||
p.Pages = max(1, (l.ItemCount+listPageSize-1)/listPageSize)
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 {
|
||||
p.Page = min(n, p.Pages)
|
||||
}
|
||||
items, err := s.store.Items(ctx, l.ID, (p.Page-1)*listPageSize, listPageSize)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
p.Items = append(p.Items, itemView(it, site.ShowNotes))
|
||||
}
|
||||
if len(p.Items) > 0 && p.Items[0].Rank == 1 && p.Items[0].Image != "" {
|
||||
p.Hero = &p.Items[0]
|
||||
}
|
||||
p.PageNums = pageWindow(p.Page, p.Pages)
|
||||
s.render(w, r, http.StatusOK, "list", l.Title, p)
|
||||
}
|
||||
|
||||
// pageWindow returns page numbers to show around the current page, with 0
|
||||
// standing in for an ellipsis.
|
||||
func pageWindow(cur, total int) []int {
|
||||
if total <= 1 {
|
||||
return nil
|
||||
}
|
||||
var out []int
|
||||
last := 0
|
||||
for i := 1; i <= total; i++ {
|
||||
if i == 1 || i == total || (i >= cur-2 && i <= cur+2) {
|
||||
if last != 0 && i-last > 1 {
|
||||
out = append(out, 0)
|
||||
}
|
||||
out = append(out, i)
|
||||
last = i
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
"github.com/cdnutter/topdeck/web"
|
||||
)
|
||||
|
||||
// Site is the owner-configurable identity shown on every page.
|
||||
type Site struct {
|
||||
Title string
|
||||
OwnerName string
|
||||
Tagline string
|
||||
AvatarURL string
|
||||
Theme string
|
||||
CustomCSS bool
|
||||
CSSVersion string
|
||||
PreviewCount int
|
||||
ShowNotes bool
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Site Site
|
||||
PageTitle string
|
||||
LoggedIn bool
|
||||
Username string
|
||||
Path string
|
||||
AssetVer string
|
||||
Data any
|
||||
}
|
||||
|
||||
func (s *Server) site(ctx context.Context) Site {
|
||||
set, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("loading settings", "err", err)
|
||||
set = map[string]string{}
|
||||
}
|
||||
site := Site{
|
||||
Title: set[store.SettingSiteTitle],
|
||||
OwnerName: set[store.SettingOwnerName],
|
||||
Tagline: set[store.SettingTagline],
|
||||
Theme: set[store.SettingTheme],
|
||||
PreviewCount: 10,
|
||||
ShowNotes: set[store.SettingShowNotes] != "false",
|
||||
}
|
||||
if site.Title == "" {
|
||||
site.Title = "TopDeck"
|
||||
}
|
||||
if !validTheme(site.Theme) {
|
||||
site.Theme = DefaultTheme
|
||||
}
|
||||
if n, err := strconv.Atoi(set[store.SettingPreviewCount]); err == nil && n >= 0 && n <= 50 {
|
||||
site.PreviewCount = n
|
||||
}
|
||||
if a := set[store.SettingAvatar]; a != "" {
|
||||
site.AvatarURL = "/images/" + a
|
||||
}
|
||||
if css := strings.TrimSpace(set[store.SettingCustomCSS]); css != "" {
|
||||
site.CustomCSS = true
|
||||
site.CSSVersion = shortHash(css)
|
||||
}
|
||||
return site
|
||||
}
|
||||
|
||||
func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) {
|
||||
set, err := s.store.Settings(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
w.Write([]byte(set[store.SettingCustomCSS]))
|
||||
}
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"icon": func(name string) template.HTML {
|
||||
return template.HTML(fmt.Sprintf(`<svg class="icon" aria-hidden="true"><use href="/static/img/icons.svg#%s"></use></svg>`, template.HTMLEscapeString(name)))
|
||||
},
|
||||
"category": func(key string) store.Category {
|
||||
c, _ := store.CategoryByKey(key)
|
||||
return c
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
// dict builds a map for passing several values to a partial.
|
||||
"dict": func(kv ...any) map[string]any {
|
||||
m := map[string]any{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i].(string)] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
},
|
||||
"num": formatNumber,
|
||||
"plural": func(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
},
|
||||
"initial": func(s string) string {
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
return strings.ToUpper(string(r))
|
||||
}
|
||||
return "?"
|
||||
},
|
||||
"pct": func(a, b int) int {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
p := a * 100 / b
|
||||
if a > 0 && p == 0 {
|
||||
p = 1
|
||||
}
|
||||
return p
|
||||
},
|
||||
}
|
||||
|
||||
func formatNumber(n int) string {
|
||||
s := strconv.Itoa(n)
|
||||
if n < 0 {
|
||||
return "-" + formatNumber(-n)
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseTemplates builds one template set per page, each combining the base
|
||||
// layout, shared partials and the page itself.
|
||||
func (s *Server) parseTemplates() error {
|
||||
shared, err := fs.Glob(web.FS, "templates/partials/*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pages, err := fs.Glob(web.FS, "templates/pages/*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.pages = map[string]*template.Template{}
|
||||
for _, p := range pages {
|
||||
files := append([]string{"templates/layout.html"}, shared...)
|
||||
files = append(files, p)
|
||||
t, err := template.New("layout.html").Funcs(funcs).ParseFS(web.FS, files...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing %s: %w", p, err)
|
||||
}
|
||||
s.pages[strings.TrimSuffix(path.Base(p), ".html")] = t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page, title string, data any) {
|
||||
t, ok := s.pages[page]
|
||||
if !ok {
|
||||
http.Error(w, "missing template "+page, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pd := pageData{
|
||||
Site: s.site(r.Context()),
|
||||
PageTitle: title,
|
||||
Path: r.URL.Path,
|
||||
AssetVer: s.assetVer,
|
||||
Data: data,
|
||||
}
|
||||
if u, ok := s.currentUser(r); ok {
|
||||
pd.LoggedIn, pd.Username = true, u.Username
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, pd); err != nil {
|
||||
s.log.Error("rendering", "page", page, "err", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(status)
|
||||
buf.WriteTo(w)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Package server wires HTTP routes, templates and handlers together.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/auth"
|
||||
"github.com/cdnutter/topdeck/internal/config"
|
||||
"github.com/cdnutter/topdeck/internal/imgcache"
|
||||
"github.com/cdnutter/topdeck/internal/providers"
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
"github.com/cdnutter/topdeck/web"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "topdeck_session"
|
||||
sessionTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
images *imgcache.Cache
|
||||
providers *providers.Registry
|
||||
log *slog.Logger
|
||||
|
||||
pages map[string]*template.Template
|
||||
static fs.FS
|
||||
assetVer string
|
||||
loginLimit *auth.Limiter
|
||||
setupCode string
|
||||
}
|
||||
|
||||
func New(cfg config.Config, st *store.Store, images *imgcache.Cache, log *slog.Logger, version string) (*Server, error) {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
images: images,
|
||||
log: log,
|
||||
assetVer: version,
|
||||
loginLimit: auth.NewLimiter(10, 15*time.Minute),
|
||||
}
|
||||
s.providers = providers.NewRegistry(s.credentials)
|
||||
static, err := fs.Sub(web.FS, "static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.static = static
|
||||
if err := s.parseTemplates(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetSetupCode enables first-run account creation guarded by code.
|
||||
func (s *Server) SetSetupCode(code string) { s.setupCode = code }
|
||||
|
||||
func (s *Server) credentials(ctx context.Context) providers.Credentials {
|
||||
set, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("loading settings", "err", err)
|
||||
set = map[string]string{}
|
||||
}
|
||||
pick := func(env, key string) string {
|
||||
if env != "" {
|
||||
return env
|
||||
}
|
||||
return set[key]
|
||||
}
|
||||
return providers.Credentials{
|
||||
TMDBKey: pick(s.cfg.TMDBKey, store.SettingTMDBKey),
|
||||
TVDBKey: pick(s.cfg.TVDBKey, store.SettingTVDBKey),
|
||||
TVDBPin: pick(s.cfg.TVDBPin, store.SettingTVDBPin),
|
||||
IGDBClientID: pick(s.cfg.IGDBClientID, store.SettingIGDBClientID),
|
||||
IGDBClientSecret: pick(s.cfg.IGDBClientSecret, store.SettingIGDBClientSecret),
|
||||
MusicProvider: set[store.SettingMusicProvider],
|
||||
TVProvider: set[store.SettingTVProvider],
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public
|
||||
mux.HandleFunc("GET /{$}", s.handleHome)
|
||||
mux.HandleFunc("GET /l/{slug}", s.handleList)
|
||||
mux.HandleFunc("GET /images/{name}", s.handleImage)
|
||||
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
||||
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/static/img/favicon.svg", http.StatusMovedPermanently)
|
||||
})
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
|
||||
|
||||
// Auth
|
||||
mux.HandleFunc("GET /login", s.handleLoginPage)
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /setup", s.handleSetupPage)
|
||||
mux.HandleFunc("POST /setup", s.handleSetup)
|
||||
|
||||
// Admin pages
|
||||
mux.HandleFunc("GET /admin", s.requireUser(s.handleAdminLists))
|
||||
mux.HandleFunc("GET /admin/lists/{id}", s.requireUser(s.handleAdminList))
|
||||
mux.HandleFunc("GET /admin/settings", s.requireUser(s.handleAdminSettings))
|
||||
|
||||
// Admin JSON API
|
||||
api := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, s.requireAPI(h)) }
|
||||
api("GET /api/lists", s.apiLists)
|
||||
api("POST /api/lists", s.apiCreateList)
|
||||
api("POST /api/lists/order", s.apiReorderLists)
|
||||
api("PUT /api/lists/{id}", s.apiUpdateList)
|
||||
api("DELETE /api/lists/{id}", s.apiDeleteList)
|
||||
api("GET /api/lists/{id}/items", s.apiItems)
|
||||
api("POST /api/lists/{id}/items", s.apiAddItem)
|
||||
api("PUT /api/items/{id}", s.apiUpdateItem)
|
||||
api("POST /api/items/{id}/image", s.apiUploadItemImage)
|
||||
api("POST /api/items/{id}/move", s.apiMoveItem)
|
||||
api("DELETE /api/items/{id}", s.apiDeleteItem)
|
||||
api("GET /api/search", s.apiSearch)
|
||||
api("GET /api/settings", s.apiSettings)
|
||||
api("PUT /api/settings", s.apiUpdateSettings)
|
||||
api("POST /api/settings/avatar", s.apiUploadAvatar)
|
||||
api("DELETE /api/settings/avatar", s.apiDeleteAvatar)
|
||||
api("POST /api/integrations/test", s.apiTestIntegration)
|
||||
api("PUT /api/account", s.apiUpdateAccount)
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.notFound(w, r) })
|
||||
|
||||
return s.recoverer(s.securityHeaders(s.logRequests(mux)))
|
||||
}
|
||||
|
||||
// ---- middleware ----
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(sw, r)
|
||||
if strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/images/") {
|
||||
return
|
||||
}
|
||||
s.log.Debug("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dur", time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
if v == http.ErrAbortHandler {
|
||||
panic(v)
|
||||
}
|
||||
s.log.Error("panic", "path", r.URL.Path, "err", v)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
// Remote https images are allowed so search results can preview art
|
||||
// before it's cached locally.
|
||||
const csp = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; " +
|
||||
"object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", csp)
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const userKey ctxKey = 0
|
||||
|
||||
type sessionUser struct {
|
||||
store.User
|
||||
TokenHash string
|
||||
}
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) (sessionUser, bool) {
|
||||
if u, ok := r.Context().Value(userKey).(sessionUser); ok {
|
||||
return u, true
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return sessionUser{}, false
|
||||
}
|
||||
hash := auth.HashToken(c.Value)
|
||||
u, err := s.store.SessionUser(r.Context(), hash)
|
||||
if err != nil {
|
||||
return sessionUser{}, false
|
||||
}
|
||||
return sessionUser{User: u, TokenHash: hash}, true
|
||||
}
|
||||
|
||||
func (s *Server) requireUser(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := s.currentUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
|
||||
}
|
||||
}
|
||||
|
||||
// requireAPI guards JSON endpoints. Besides the session cookie, it requires a
|
||||
// custom header that browsers won't send cross-site without a CORS
|
||||
// preflight, which blocks CSRF.
|
||||
func (s *Server) requireAPI(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-TopDeck") != "1" || !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "Missing request header")
|
||||
return
|
||||
}
|
||||
u, ok := s.currentUser(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Your session expired. Please sign in again.")
|
||||
return
|
||||
}
|
||||
h(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
|
||||
}
|
||||
}
|
||||
|
||||
// sameOrigin rejects requests whose Origin header names another site.
|
||||
func (s *Server) sameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" || origin == "null" {
|
||||
return origin == "" // absent: non-browser or same-origin GET
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := r.Host
|
||||
if s.cfg.TrustProxy {
|
||||
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||
host = fh
|
||||
}
|
||||
}
|
||||
return strings.EqualFold(u.Host, host)
|
||||
}
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.cfg.TrustProxy {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
first, _, _ := strings.Cut(xff, ",")
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
||||
return xr
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
if s.cfg.SecureCookies || r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return s.cfg.TrustProxy && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
// ---- static files ----
|
||||
|
||||
func (s *Server) staticHandler() http.Handler {
|
||||
fsrv := http.FileServerFS(s.static)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("v") != "" {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
fsrv.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if !imgcache.ValidName(name) {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeFile(w, r, s.images.Dir()+"/"+name)
|
||||
}
|
||||
|
||||
// ---- JSON helpers ----
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// writeErr maps store/provider errors to HTTP responses.
|
||||
func (s *Server) writeErr(w http.ResponseWriter, err error) {
|
||||
var ve store.ValidationError
|
||||
var nc providers.NotConfiguredError
|
||||
switch {
|
||||
case errors.As(err, &ve):
|
||||
writeError(w, http.StatusBadRequest, ve.Msg)
|
||||
case errors.As(err, &nc):
|
||||
writeError(w, http.StatusFailedDependency, nc.Error())
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "Not found")
|
||||
case errors.Is(err, imgcache.ErrNotImage):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
s.log.Error("request failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "Something went wrong: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return store.ValidationError{Msg: "Invalid request body"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, store.ErrNotFound
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func shortHash(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])[:10]
|
||||
}
|
||||
|
||||
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeError(w, http.StatusNotFound, "Not found")
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusNotFound, "error", "Not found", errorPage{
|
||||
Code: 404,
|
||||
Message: "There's nothing ranked here.",
|
||||
})
|
||||
}
|
||||
|
||||
type errorPage struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
s.log.Error("page failed", "path", r.URL.Path, "err", err)
|
||||
s.render(w, r, http.StatusInternalServerError, "error", "Error", errorPage{
|
||||
Code: 500,
|
||||
Message: fmt.Sprintf("Something went wrong: %v", err),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
// Theme is a built-in color preset. The actual colors live in
|
||||
// web/static/css/themes.css under [data-theme="<key>"].
|
||||
type Theme struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Dark bool `json:"dark"`
|
||||
}
|
||||
|
||||
var Themes = []Theme{
|
||||
{"midnight", "Midnight", true},
|
||||
{"obsidian", "Obsidian", true},
|
||||
{"nord", "Nord", true},
|
||||
{"dracula", "Dracula", true},
|
||||
{"mocha", "Mocha", true},
|
||||
{"rose-pine", "Rosé Pine", true},
|
||||
{"gruvbox", "Gruvbox", true},
|
||||
{"forest", "Forest", true},
|
||||
{"synthwave", "Synthwave", true},
|
||||
{"daylight", "Daylight", false},
|
||||
{"paper", "Paper", false},
|
||||
{"latte", "Latte", false},
|
||||
}
|
||||
|
||||
const DefaultTheme = "midnight"
|
||||
|
||||
func validTheme(key string) bool {
|
||||
for _, t := range Themes {
|
||||
if t.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package store
|
||||
|
||||
// Category describes one kind of list. The Key is what's stored in the
|
||||
// database; everything else is presentation.
|
||||
type Category struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"` // plural, e.g. "Movies"
|
||||
Singular string `json:"singular"` // e.g. "Movie"
|
||||
Icon string `json:"icon"` // name of an inline SVG icon
|
||||
Aspect string `json:"aspect"` // "poster" (2:3) or "square" (1:1)
|
||||
// SubtitleHint labels the free-text subtitle field in the editor.
|
||||
SubtitleHint string `json:"subtitleHint"`
|
||||
}
|
||||
|
||||
var Categories = []Category{
|
||||
{Key: "movie", Label: "Movies", Singular: "Movie", Icon: "film", Aspect: "poster", SubtitleHint: "Director"},
|
||||
{Key: "tv", Label: "TV Shows", Singular: "TV Show", Icon: "tv", Aspect: "poster", SubtitleHint: "Network"},
|
||||
{Key: "album", Label: "Albums", Singular: "Album", Icon: "disc", Aspect: "square", SubtitleHint: "Artist"},
|
||||
{Key: "song", Label: "Songs", Singular: "Song", Icon: "music", Aspect: "square", SubtitleHint: "Artist"},
|
||||
{Key: "game", Label: "Video Games", Singular: "Video Game", Icon: "gamepad", Aspect: "poster", SubtitleHint: "Developer"},
|
||||
{Key: "theater", Label: "Theater", Singular: "Show", Icon: "masks", Aspect: "poster", SubtitleHint: "Creators"},
|
||||
}
|
||||
|
||||
func CategoryByKey(key string) (Category, bool) {
|
||||
for _, c := range Categories {
|
||||
if c.Key == key {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return Category{}, false
|
||||
}
|
||||
|
||||
// TheaterProductions are the suggested production labels for theater items.
|
||||
// The owner can also type anything else.
|
||||
var TheaterProductions = []string{
|
||||
"Broadway",
|
||||
"Broadway Revival",
|
||||
"Off-Broadway",
|
||||
"West End",
|
||||
"West End Revival",
|
||||
"Off-West End",
|
||||
"North American Tour",
|
||||
"US National Tour",
|
||||
"UK & Ireland Tour",
|
||||
"Australian Production",
|
||||
"International Tour",
|
||||
"Regional",
|
||||
"Concert / Staged Reading",
|
||||
"Filmed / Pro-shot",
|
||||
"Original Cast Recording",
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
ID int64 `json:"id"`
|
||||
ListID int64 `json:"listId"`
|
||||
Rank int `json:"rank"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Year string `json:"year"`
|
||||
Image string `json:"image"` // cached file name under /images/
|
||||
ImageSource string `json:"imageSource"` // where the image came from
|
||||
Provider string `json:"provider"`
|
||||
ProviderID string `json:"providerId"`
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
Note string `json:"note"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra`
|
||||
|
||||
func scanItem(row interface{ Scan(...any) error }) (Item, error) {
|
||||
var it Item
|
||||
var extra string
|
||||
err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource,
|
||||
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Note, &extra)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return it, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
it.Extra = map[string]string{}
|
||||
_ = json.Unmarshal([]byte(extra), &it.Extra)
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// Items returns a page of a list's items in rank order. limit <= 0 means all.
|
||||
func (s *Store) Items(ctx context.Context, listID int64, offset, limit int) ([]Item, error) {
|
||||
if limit <= 0 {
|
||||
limit = -1
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? ORDER BY rank LIMIT ? OFFSET ?`, listID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Item{}
|
||||
for rows.Next() {
|
||||
it, err := scanItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ItemByID(ctx context.Context, id int64) (Item, error) {
|
||||
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
// FindItemByProvider returns an item in the list that came from the same
|
||||
// provider entry, if any.
|
||||
func (s *Store) FindItemByProvider(ctx context.Context, listID int64, provider, providerID string) (Item, error) {
|
||||
if provider == "" || providerID == "" {
|
||||
return Item{}, ErrNotFound
|
||||
}
|
||||
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? AND provider = ? AND provider_id = ? LIMIT 1`,
|
||||
listID, provider, providerID))
|
||||
}
|
||||
|
||||
func (it *Item) normalize() error {
|
||||
it.Title = strings.TrimSpace(it.Title)
|
||||
it.Subtitle = strings.TrimSpace(it.Subtitle)
|
||||
it.Year = strings.TrimSpace(it.Year)
|
||||
it.Note = strings.TrimSpace(it.Note)
|
||||
it.ExternalURL = strings.TrimSpace(it.ExternalURL)
|
||||
if it.Title == "" {
|
||||
return invalid("Title is required")
|
||||
}
|
||||
if len(it.Title) > 500 || len(it.Subtitle) > 500 || len(it.Year) > 20 || len(it.Note) > 10000 || len(it.ExternalURL) > 2000 {
|
||||
return invalid("One of the fields is too long")
|
||||
}
|
||||
if it.ExternalURL != "" && !strings.HasPrefix(it.ExternalURL, "https://") && !strings.HasPrefix(it.ExternalURL, "http://") {
|
||||
return invalid("Links must start with http:// or https://")
|
||||
}
|
||||
clean := map[string]string{}
|
||||
for k, v := range it.Extra {
|
||||
if v = strings.TrimSpace(v); v != "" && len(k) <= 50 && len(v) <= 500 {
|
||||
clean[k] = v
|
||||
}
|
||||
}
|
||||
it.Extra = clean
|
||||
return nil
|
||||
}
|
||||
|
||||
func extraJSON(m map[string]string) string {
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// AddItem inserts it into the list at rank (1-based); rank <= 0 or beyond the
|
||||
// end appends. Items at or below the rank shift down by one.
|
||||
func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) {
|
||||
if err := it.normalize(); err != nil {
|
||||
return it, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var maxItems, count int
|
||||
err = tx.QueryRowContext(ctx, `SELECT max_items, (SELECT COUNT(*) FROM items WHERE list_id = lists.id) FROM lists WHERE id = ?`, it.ListID).Scan(&maxItems, &count)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return it, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
if count >= maxItems {
|
||||
return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, maxItems)
|
||||
}
|
||||
if rank <= 0 || rank > count+1 {
|
||||
rank = count + 1
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil {
|
||||
return it, err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.Provider, it.ProviderID, it.ExternalURL, it.Note, extraJSON(it.Extra))
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
if err := touchList(ctx, tx, it.ListID); err != nil {
|
||||
return it, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return it, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.ItemByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateItem saves everything except rank and list.
|
||||
func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
|
||||
if err := it.normalize(); err != nil {
|
||||
return it, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, external_url=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
|
||||
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, it.Note, extraJSON(it.Extra), it.ID)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return it, ErrNotFound
|
||||
}
|
||||
cur, err := s.ItemByID(ctx, it.ID)
|
||||
if err == nil {
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, cur.ListID)
|
||||
}
|
||||
return cur, err
|
||||
}
|
||||
|
||||
// MoveItem moves an item to a new 1-based rank, shifting the items between.
|
||||
func (s *Store) MoveItem(ctx context.Context, id int64, newRank int) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var listID int64
|
||||
var oldRank, count int
|
||||
err = tx.QueryRowContext(ctx, `SELECT list_id, rank, (SELECT COUNT(*) FROM items x WHERE x.list_id = items.list_id) FROM items WHERE id = ?`, id).Scan(&listID, &oldRank, &count)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newRank < 1 {
|
||||
newRank = 1
|
||||
}
|
||||
if newRank > count {
|
||||
newRank = count
|
||||
}
|
||||
if newRank == oldRank {
|
||||
return nil
|
||||
}
|
||||
if newRank < oldRank {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ? AND rank < ?`, listID, newRank, oldRank)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ? AND rank <= ?`, listID, oldRank, newRank)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`, newRank, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := touchList(ctx, tx, listID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteItem(ctx context.Context, id int64) (Item, error) {
|
||||
it, err := s.ItemByID(ctx, id)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil {
|
||||
return it, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ?`, it.ListID, it.Rank); err != nil {
|
||||
return it, err
|
||||
}
|
||||
if err := touchList(ctx, tx, it.ListID); err != nil {
|
||||
return it, err
|
||||
}
|
||||
return it, tx.Commit()
|
||||
}
|
||||
|
||||
// ImageInUse reports whether any item or setting still references a cached
|
||||
// image file.
|
||||
func (s *Store) ImageInUse(ctx context.Context, name string) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM items WHERE image = ?) + (SELECT COUNT(*) FROM settings WHERE value = ?)`, name, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ListImages returns the image file names used by a list's items.
|
||||
func (s *Store) ListImages(ctx context.Context, listID int64) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT image FROM items WHERE list_id = ? AND image != ''`, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func touchList(ctx context.Context, tx *sql.Tx, listID int64) error {
|
||||
_, err := tx.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, listID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxListSize = 65535
|
||||
RecommendedListSize = 100
|
||||
SoftListSizeWarning = 1000
|
||||
)
|
||||
|
||||
type List struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
MaxItems int `json:"maxItems"`
|
||||
Position int `json:"position"`
|
||||
Visible bool `json:"visible"`
|
||||
Layout string `json:"layout"` // "grid" or "rows"
|
||||
ItemCount int `json:"itemCount"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.updated_at,
|
||||
(SELECT COUNT(*) FROM items i WHERE i.list_id = l.id)`
|
||||
|
||||
func scanList(row interface{ Scan(...any) error }) (List, error) {
|
||||
var l List
|
||||
err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.UpdatedAt, &l.ItemCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return l, ErrNotFound
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
|
||||
func (s *Store) Lists(ctx context.Context, includeHidden bool) ([]List, error) {
|
||||
q := `SELECT ` + listColumns + ` FROM lists l`
|
||||
if !includeHidden {
|
||||
q += ` WHERE l.visible = 1`
|
||||
}
|
||||
q += ` ORDER BY l.position, l.id`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []List
|
||||
for rows.Next() {
|
||||
l, err := scanList(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListByID(ctx context.Context, id int64) (List, error) {
|
||||
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.id = ?`, id))
|
||||
}
|
||||
|
||||
func (s *Store) ListBySlug(ctx context.Context, slug string) (List, error) {
|
||||
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.slug = ?`, slug))
|
||||
}
|
||||
|
||||
// ValidationError is returned for bad user input; its message is safe to
|
||||
// show to the user.
|
||||
type ValidationError struct{ Msg string }
|
||||
|
||||
func (e ValidationError) Error() string { return e.Msg }
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return ValidationError{Msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func (l *List) normalize() error {
|
||||
l.Title = strings.TrimSpace(l.Title)
|
||||
l.Description = strings.TrimSpace(l.Description)
|
||||
if l.Title == "" {
|
||||
return invalid("Title is required")
|
||||
}
|
||||
if len(l.Title) > 200 {
|
||||
return invalid("Title is too long")
|
||||
}
|
||||
if _, ok := CategoryByKey(l.Category); !ok {
|
||||
return invalid("Unknown category %q", l.Category)
|
||||
}
|
||||
if l.MaxItems < 1 || l.MaxItems > MaxListSize {
|
||||
return invalid("List size must be between 1 and %d", MaxListSize)
|
||||
}
|
||||
if l.Layout != "rows" {
|
||||
l.Layout = "grid"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func slugify(s string) string {
|
||||
s = slugStrip.ReplaceAllString(strings.ToLower(s), "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > 60 {
|
||||
s = strings.Trim(s[:60], "-")
|
||||
}
|
||||
if s == "" {
|
||||
s = "list"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Store) uniqueSlug(ctx context.Context, base string, exceptID int64) (string, error) {
|
||||
slug := base
|
||||
for i := 2; ; i++ {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lists WHERE slug = ? AND id != ?`, slug, exceptID).Scan(&n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n == 0 {
|
||||
return slug, nil
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
|
||||
if err := l.normalize(); err != nil {
|
||||
return l, err
|
||||
}
|
||||
// Default to the category name so the address stays sensible if the
|
||||
// list's size (and so its "Top N" title) changes later.
|
||||
base := slugify(l.Slug)
|
||||
if strings.TrimSpace(l.Slug) == "" {
|
||||
c, _ := CategoryByKey(l.Category)
|
||||
base = slugify(c.Label)
|
||||
}
|
||||
slug, err := s.uniqueSlug(ctx, base, 0)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout)
|
||||
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?)`,
|
||||
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.ListByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
|
||||
if err := l.normalize(); err != nil {
|
||||
return l, err
|
||||
}
|
||||
cur, err := s.ListByID(ctx, l.ID)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
if l.MaxItems < cur.ItemCount {
|
||||
return l, invalid("This list already has %d items; remove some before shrinking it to %d", cur.ItemCount, l.MaxItems)
|
||||
}
|
||||
if l.Category != cur.Category && cur.ItemCount > 0 {
|
||||
return l, invalid("Can't change the category of a list that has items")
|
||||
}
|
||||
slug := cur.Slug
|
||||
if strings.TrimSpace(l.Slug) != "" && slugify(l.Slug) != cur.Slug {
|
||||
if slug, err = s.uniqueSlug(ctx, slugify(l.Slug), l.ID); err != nil {
|
||||
return l, err
|
||||
}
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, updated_at=unixepoch() WHERE id=?`,
|
||||
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.ID)
|
||||
if err != nil {
|
||||
return l, err
|
||||
}
|
||||
return s.ListByID(ctx, l.ID)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteList(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM lists WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReorderLists sets list positions to match the order of ids.
|
||||
func (s *Store) ReorderLists(ctx context.Context, ids []int64) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for i, id := range ids {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE lists SET position = ? WHERE id = ?`, i+1, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListCovers returns up to n image file names per list, from the top ranks.
|
||||
func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT list_id, image FROM (
|
||||
SELECT list_id, image, ROW_NUMBER() OVER (PARTITION BY list_id ORDER BY rank) AS rn
|
||||
FROM items WHERE image != '') WHERE rn <= ? ORDER BY list_id, rn`, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64][]string{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var img string
|
||||
if err := rows.Scan(&id, &img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = append(out[id], "/images/"+img)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Setting keys.
|
||||
const (
|
||||
SettingSiteTitle = "site_title"
|
||||
SettingOwnerName = "owner_name"
|
||||
SettingTagline = "tagline"
|
||||
SettingAvatar = "avatar" // cached image file name
|
||||
SettingTheme = "theme"
|
||||
SettingCustomCSS = "custom_css"
|
||||
SettingPreviewCount = "preview_count" // items shown per list on the home page
|
||||
SettingShowNotes = "show_notes"
|
||||
SettingMusicProvider = "music_provider"
|
||||
SettingTVProvider = "tv_provider"
|
||||
|
||||
SettingTMDBKey = "tmdb_api_key"
|
||||
SettingTVDBKey = "tvdb_api_key"
|
||||
SettingTVDBPin = "tvdb_pin"
|
||||
SettingIGDBClientID = "igdb_client_id"
|
||||
SettingIGDBClientSecret = "igdb_client_secret"
|
||||
)
|
||||
|
||||
func (s *Store) Settings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetSettings(ctx context.Context, kv map[string]string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for k, v := range kv {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ---- users & sessions ----
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func (s *Store) UserCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, username, hash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) UserByID(ctx context.Context, id int64) (User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE id = ?`, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(ctx context.Context, id int64, username, hash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ? WHERE id = ?`, username, hash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expires time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)`, tokenHash, userID, expires.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionUser returns the user for a live session.
|
||||
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.password_hash FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > unixepoch()`, tokenHash).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserSessions logs a user out everywhere except the given session.
|
||||
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keepTokenHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, userID, keepTokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PruneSessions(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= unixepoch()`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package store is the SQLite persistence layer.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
path := filepath.Join(dataDir, "topdeck.db")
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_txlock=immediate", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// migrations are applied in order; the index into this slice (plus one) is
|
||||
// the schema version stored in PRAGMA user_version.
|
||||
var migrations = []string{
|
||||
`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE lists (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
max_items INTEGER NOT NULL DEFAULT 10 CHECK (max_items BETWEEN 1 AND 65535),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
visible INTEGER NOT NULL DEFAULT 1,
|
||||
layout TEXT NOT NULL DEFAULT 'grid',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE TABLE items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
rank INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
year TEXT NOT NULL DEFAULT '',
|
||||
image TEXT NOT NULL DEFAULT '',
|
||||
image_source TEXT NOT NULL DEFAULT '',
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
provider_id TEXT NOT NULL DEFAULT '',
|
||||
external_url TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
extra TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE INDEX items_list_rank ON items(list_id, rank);
|
||||
`,
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
var version int
|
||||
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := version; i < len(migrations); i++ {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func titles(t *testing.T, s *Store, listID int64) string {
|
||||
t.Helper()
|
||||
items, err := s.Items(context.Background(), listID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for i, it := range items {
|
||||
if it.Rank != i+1 {
|
||||
t.Fatalf("rank gap: item %q has rank %d at position %d", it.Title, it.Rank, i+1)
|
||||
}
|
||||
out = append(out, it.Title)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func TestRanking(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
l, err := s.CreateList(ctx, List{Title: "Top 5 Movies", Category: "movie", MaxItems: 5, Visible: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Slug != "movies" {
|
||||
t.Errorf("slug = %q, want movies", l.Slug)
|
||||
}
|
||||
ids := map[string]int64{}
|
||||
for _, title := range []string{"A", "B", "C", "D"} {
|
||||
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[title] = it.ID
|
||||
}
|
||||
if got := titles(t, s, l.ID); got != "A,B,C,D" {
|
||||
t.Fatalf("append: %s", got)
|
||||
}
|
||||
|
||||
e, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "E"}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids["E"] = e.ID
|
||||
if got := titles(t, s, l.ID); got != "A,E,B,C,D" {
|
||||
t.Fatalf("insert at 2: %s", got)
|
||||
}
|
||||
|
||||
var ve ValidationError
|
||||
if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "F"}, 0); !errors.As(err, &ve) {
|
||||
t.Fatalf("expected full-list error, got %v", err)
|
||||
}
|
||||
|
||||
steps := []struct {
|
||||
id string
|
||||
rank int
|
||||
want string
|
||||
}{
|
||||
{"A", 5, "E,B,C,D,A"},
|
||||
{"D", 1, "D,E,B,C,A"},
|
||||
{"B", 99, "D,E,C,A,B"}, // clamped to the end
|
||||
{"C", 0, "C,D,E,A,B"}, // clamped to the top
|
||||
}
|
||||
for _, st := range steps {
|
||||
if err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := titles(t, s, l.ID); got != st.want {
|
||||
t.Fatalf("move %s to %d: got %s, want %s", st.id, st.rank, got, st.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.DeleteItem(ctx, ids["E"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := titles(t, s, l.ID); got != "C,D,A,B" {
|
||||
t.Fatalf("delete: %s", got)
|
||||
}
|
||||
|
||||
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "movie", MaxItems: 3}); !errors.As(err, &ve) {
|
||||
t.Fatalf("expected shrink error, got %v", err)
|
||||
}
|
||||
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "tv", MaxItems: 5}); !errors.As(err, &ve) {
|
||||
t.Fatalf("expected category change error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLimits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
for _, n := range []int{0, MaxListSize + 1} {
|
||||
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: n}); err == nil {
|
||||
t.Errorf("size %d accepted", n)
|
||||
}
|
||||
}
|
||||
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: MaxListSize}); err != nil {
|
||||
t.Errorf("max size rejected: %v", err)
|
||||
}
|
||||
if _, err := s.CreateList(ctx, List{Title: "x", Category: "podcast", MaxItems: 5}); err == nil {
|
||||
t.Error("unknown category accepted")
|
||||
}
|
||||
// Second list in a category gets a unique slug.
|
||||
l, err := s.CreateList(ctx, List{Title: "y", Category: "game", MaxItems: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Slug != "video-games-2" {
|
||||
t.Errorf("slug = %q", l.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMoveInLargeList(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(b.TempDir())
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
l, _ := s.CreateList(ctx, List{Title: "big", Category: "song", MaxItems: 5000})
|
||||
var first int64
|
||||
for i := 0; i < 5000; i++ {
|
||||
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: fmt.Sprint(i)}, 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if i == 0 {
|
||||
first = it.ID
|
||||
}
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.MoveItem(ctx, first, 5000-(i%2)*4999)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package web holds the embedded templates and static assets.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates static
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,579 @@
|
||||
/* ==========================================================================
|
||||
TopDeck. Colors and fonts come from themes.css.
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; scroll-padding-top: 80px; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
main { flex: 1; }
|
||||
img { display: block; max-width: 100%; }
|
||||
a { color: inherit; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
h1, h2, h3 { font-family: var(--font-display); line-height: 1.15; margin: 0; letter-spacing: -0.015em; }
|
||||
p { margin: 0; }
|
||||
code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--surface-2); padding: 0.1em 0.35em; border-radius: 4px; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
::selection { background: var(--accent); color: var(--accent-text); }
|
||||
|
||||
.icon {
|
||||
width: 1.1em; height: 1.1em; flex: none;
|
||||
fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
.skip-link { position: absolute; left: -999px; top: 8px; z-index: 100; background: var(--accent); color: var(--accent-text); padding: 8px 14px; border-radius: var(--radius); }
|
||||
.skip-link:focus { left: 8px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 13px; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
.container { width: 100%; max-width: 1240px; margin: 0 auto; padding: 0 24px; }
|
||||
@media (max-width: 640px) { .container { padding: 0 16px; } }
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||
backdrop-filter: saturate(160%) blur(14px);
|
||||
-webkit-backdrop-filter: saturate(160%) blur(14px);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
.topbar-inner { display: flex; align-items: center; justify-content: space-between; height: 60px; gap: 16px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 650; min-width: 0; }
|
||||
.brand-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.brand-avatar { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
|
||||
.brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); }
|
||||
.brand-mark .icon { width: 18px; height: 18px; }
|
||||
.topnav { display: flex; align-items: center; gap: 4px; }
|
||||
.topnav a, .topnav-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 7px 12px; border-radius: 8px; border: 0; background: none; cursor: pointer;
|
||||
color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 500;
|
||||
}
|
||||
.topnav a:hover, .topnav-btn:hover { color: var(--text); background: var(--surface-2); }
|
||||
.topnav a[aria-current="page"] { color: var(--text); background: var(--surface-2); }
|
||||
.topnav-form { margin: 0; }
|
||||
@media (max-width: 520px) { .topnav a span { display: none; } }
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
.footer { margin-top: 72px; border-top: 1px solid var(--border); color: var(--faint); font-size: 13px; }
|
||||
.footer-inner { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; padding-top: 22px; padding-bottom: 28px; }
|
||||
.footer a { color: var(--muted); text-decoration: none; }
|
||||
.footer a:hover { color: var(--accent); }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
height: 38px; padding: 0 16px; border-radius: var(--radius);
|
||||
border: 1px solid var(--border); background: var(--surface); color: var(--text);
|
||||
font-size: 14px; font-weight: 550; text-decoration: none; cursor: pointer; white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.05s;
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn--primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.btn--primary:hover { background: color-mix(in srgb, var(--accent) 88%, var(--text)); }
|
||||
.btn--ghost { background: transparent; border-color: transparent; color: var(--muted); }
|
||||
.btn--ghost:hover { color: var(--text); background: var(--surface-2); }
|
||||
.btn--danger { color: var(--danger); }
|
||||
.btn--danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
|
||||
.btn--danger.btn--solid { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn--sm { height: 30px; padding: 0 10px; font-size: 13px; gap: 6px; }
|
||||
.btn--block { width: 100%; }
|
||||
.icon-btn {
|
||||
display: inline-grid; place-items: center; width: 32px; height: 32px; border-radius: 8px;
|
||||
border: 0; background: none; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.icon-btn:disabled { opacity: 0.35; cursor: default; background: none; }
|
||||
.icon-btn--danger:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||
|
||||
/* ---------- chips, badges ---------- */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 7px; flex: none;
|
||||
height: 34px; padding: 0 14px; border-radius: 999px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--muted); font-size: 13.5px; font-weight: 500; text-decoration: none;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.chip:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 5px; width: fit-content;
|
||||
padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.01em;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
.badge--warn { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
|
||||
.badge--muted { background: var(--surface-2); color: var(--muted); }
|
||||
|
||||
/* ---------- profile / home ---------- */
|
||||
.profile { display: flex; align-items: center; gap: 28px; padding-top: 56px; padding-bottom: 28px; }
|
||||
.avatar {
|
||||
flex: none; display: grid; place-items: center; overflow: hidden; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 45%, var(--bg)));
|
||||
color: var(--accent-text); font-weight: 700;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar--lg { width: 112px; height: 112px; font-size: 44px; box-shadow: 0 0 0 4px var(--bg), 0 0 0 5px var(--border); }
|
||||
.profile-name { font-size: clamp(30px, 5vw, 46px); font-weight: 780; }
|
||||
.profile-tagline { margin-top: 8px; color: var(--muted); font-size: 17px; max-width: 60ch; }
|
||||
.profile-stats { margin-top: 12px; color: var(--faint); font-size: 14px; }
|
||||
.profile-stats strong { color: var(--text); font-weight: 650; }
|
||||
@media (max-width: 640px) {
|
||||
.profile { flex-direction: column; text-align: center; gap: 18px; padding-top: 36px; }
|
||||
.profile-tagline { margin-inline: auto; }
|
||||
.avatar--lg { width: 92px; height: 92px; font-size: 36px; }
|
||||
}
|
||||
|
||||
.jump { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 8px; scrollbar-width: none; }
|
||||
.jump::-webkit-scrollbar { display: none; }
|
||||
@media (max-width: 640px) { .jump { justify-content: flex-start; } }
|
||||
|
||||
.shelf { margin-top: 44px; }
|
||||
.shelf-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
|
||||
.shelf-icon {
|
||||
flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 11px;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
.shelf-icon .icon { width: 20px; height: 20px; }
|
||||
.shelf-titles { flex: 1; min-width: 0; }
|
||||
.shelf-head h2 { font-size: 22px; font-weight: 720; }
|
||||
.shelf-head h2 a { text-decoration: none; }
|
||||
.shelf-head h2 a:hover { color: var(--accent); }
|
||||
.shelf-desc { color: var(--muted); font-size: 14px; margin-top: 2px; }
|
||||
.see-all {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex: none;
|
||||
color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 550;
|
||||
padding: 6px 10px; border-radius: 8px;
|
||||
}
|
||||
.see-all:hover { color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ---------- ranked items: grid ---------- */
|
||||
.items { list-style: none; margin: 0; padding: 0; }
|
||||
.items--grid { display: grid; gap: 22px 18px; grid-template-columns: repeat(auto-fill, minmax(156px, 1fr)); }
|
||||
.items--grid.aspect-square { grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); }
|
||||
@media (max-width: 640px) {
|
||||
.items--grid { grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 16px 12px; }
|
||||
.items--grid.aspect-square { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); }
|
||||
}
|
||||
.item-btn {
|
||||
all: unset; box-sizing: border-box; display: flex; flex-direction: column; gap: 10px;
|
||||
width: 100%; cursor: pointer; border-radius: var(--radius-art);
|
||||
}
|
||||
.item-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||
.item-art {
|
||||
position: relative; display: block; overflow: hidden; aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-art); background: var(--surface-2);
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent) inset, 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||
transition: transform 0.25s cubic-bezier(.2,.8,.2,1), box-shadow 0.25s;
|
||||
}
|
||||
.aspect-square .item-art { aspect-ratio: 1; }
|
||||
.item-art img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.item-btn:hover .item-art { transform: translateY(-4px); box-shadow: var(--shadow); }
|
||||
.art-fallback {
|
||||
position: absolute; inset: 0; display: grid; place-items: center; color: var(--faint);
|
||||
background: linear-gradient(160deg, var(--surface-2), var(--surface));
|
||||
}
|
||||
.art-fallback .icon { width: 34%; height: 34%; stroke-width: 1.4; }
|
||||
.rank-badge {
|
||||
position: absolute; left: 8px; top: 8px; min-width: 28px; height: 28px; padding: 0 8px;
|
||||
display: grid; place-items: center; border-radius: 8px;
|
||||
background: rgba(10, 10, 14, 0.72); color: #fff;
|
||||
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
|
||||
font-size: 13px; font-weight: 750; font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.item--top1 .rank-badge { background: var(--gold); color: #1b1405; }
|
||||
.item--top2 .rank-badge { background: var(--silver); color: #14171d; }
|
||||
.item--top3 .rank-badge { background: var(--bronze); color: #1d1008; }
|
||||
.item-rank { display: none; }
|
||||
.item-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.item-title { font-weight: 620; font-size: 14.5px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.item-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dot { margin: 0 6px; color: var(--faint); }
|
||||
.items--grid .item-note { display: none; }
|
||||
.item-tags { display: flex; align-items: center; gap: 8px; margin-top: 3px; min-width: 0; }
|
||||
.item-tags .badge { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-block; }
|
||||
.item-year { color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; flex: none; }
|
||||
|
||||
/* ---------- ranked items: rows ---------- */
|
||||
.items--rows { display: flex; flex-direction: column; border-top: 1px solid var(--border); }
|
||||
.items--rows .item { border-bottom: 1px solid var(--border); }
|
||||
.items--rows .item-btn {
|
||||
flex-direction: row; align-items: center; gap: 18px; padding: 12px 10px; border-radius: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.items--rows .item-btn:hover { background: var(--surface); }
|
||||
.items--rows .item-btn:hover .item-art { transform: none; }
|
||||
.items--rows .item-art { order: 2; width: 58px; flex: none; border-radius: calc(var(--radius-art) * 0.7); box-shadow: none; }
|
||||
.items--rows .rank-badge { display: none; }
|
||||
.items--rows .item-rank {
|
||||
order: 1; display: block; width: 3.2ch; flex: none; text-align: right;
|
||||
font: 700 22px/1 var(--font-display); color: var(--faint); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.items--rows .item--top1 .item-rank { color: var(--gold); }
|
||||
.items--rows .item--top2 .item-rank { color: var(--silver); }
|
||||
.items--rows .item--top3 .item-rank { color: var(--bronze); }
|
||||
.items--rows .item-body { order: 3; flex: 1; }
|
||||
.items--rows .item-title { font-size: 16px; -webkit-line-clamp: 1; }
|
||||
.items--rows .item-note { color: var(--muted); font-size: 13.5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; }
|
||||
@media (min-width: 900px) {
|
||||
.page-home .items--rows { display: grid; grid-template-columns: 1fr 1fr; column-gap: 32px; border-top: 0; }
|
||||
.page-home .items--rows .item:nth-child(-n+2) { border-top: 1px solid var(--border); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.items--rows .item-btn { gap: 12px; padding: 10px 4px; }
|
||||
.items--rows .item-art { width: 48px; }
|
||||
.items--rows .item-rank { font-size: 18px; }
|
||||
}
|
||||
|
||||
/* ---------- list page ---------- */
|
||||
.list-hero { position: relative; overflow: hidden; padding: 28px 0 34px; border-bottom: 1px solid var(--border); }
|
||||
.list-hero-backdrop { position: absolute; inset: 0; z-index: 0; }
|
||||
.list-hero-backdrop img {
|
||||
width: 100%; height: 100%; object-fit: cover; transform: scale(1.3);
|
||||
filter: blur(60px) saturate(1.4); opacity: 0.35;
|
||||
}
|
||||
.list-hero-backdrop::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 0%, var(--bg) 100%); }
|
||||
.list-hero-inner { position: relative; z-index: 1; }
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px; margin-bottom: 22px;
|
||||
color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 500;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.list-hero-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
|
||||
.eyebrow { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); font-size: 13px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.list-title { font-size: clamp(30px, 5vw, 52px); font-weight: 800; margin-top: 8px; }
|
||||
.list-desc { color: var(--muted); font-size: 16px; margin-top: 10px; max-width: 70ch; }
|
||||
.list-meta { display: flex; align-items: center; gap: 10px; margin-top: 14px; font-size: 14px; font-weight: 550; }
|
||||
.list-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.list-body { padding-top: 30px; }
|
||||
|
||||
.segmented { display: inline-flex; padding: 3px; gap: 2px; border-radius: calc(var(--radius) + 2px); background: var(--surface); border: 1px solid var(--border); }
|
||||
.segmented button { display: grid; place-items: center; width: 34px; height: 30px; border: 0; border-radius: calc(var(--radius) - 2px); background: none; color: var(--muted); cursor: pointer; }
|
||||
.segmented button:hover { color: var(--text); }
|
||||
.segmented button[aria-pressed="true"] { background: var(--surface-2); color: var(--text); }
|
||||
|
||||
.pager { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 40px; flex-wrap: wrap; }
|
||||
.pager:empty { display: none; }
|
||||
.pager-btn {
|
||||
display: inline-grid; place-items: center; min-width: 36px; height: 36px; padding: 0 10px;
|
||||
border-radius: 9px; border: 1px solid var(--border); background: var(--surface);
|
||||
text-decoration: none; font-size: 14px; font-weight: 550; font-variant-numeric: tabular-nums; cursor: pointer; color: var(--text);
|
||||
}
|
||||
.pager-btn:hover { border-color: var(--accent); }
|
||||
.pager-btn.is-current { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.pager-gap { color: var(--faint); padding: 0 4px; }
|
||||
|
||||
/* ---------- empty / error ---------- */
|
||||
.empty { text-align: center; padding-top: 80px; padding-bottom: 40px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.empty-art { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); margin-bottom: 10px; }
|
||||
.empty-art .icon { width: 32px; height: 32px; }
|
||||
.empty h1, .empty h2 { font-size: 26px; }
|
||||
.empty p { color: var(--muted); max-width: 46ch; }
|
||||
.empty .btn { margin-top: 14px; }
|
||||
.error-code { font: 800 64px/1 var(--font-display); color: var(--accent); }
|
||||
|
||||
/* ---------- dialogs ---------- */
|
||||
dialog {
|
||||
padding: 0; border: 1px solid var(--border); border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45);
|
||||
max-width: min(640px, calc(100vw - 32px)); width: 100%; max-height: calc(100dvh - 48px);
|
||||
}
|
||||
dialog::backdrop { background: rgba(5, 6, 10, 0.6); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
||||
dialog[open] { animation: pop 0.18s cubic-bezier(.2,.8,.2,1); }
|
||||
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(0.98); } }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 20px 0; }
|
||||
.dialog-head h2 { font-size: 19px; font-weight: 700; }
|
||||
.dialog-body { padding: 18px 20px; }
|
||||
.dialog-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 0 20px 20px; flex-wrap: wrap; }
|
||||
.dialog-foot .spacer { flex: 1; }
|
||||
|
||||
.item-dialog { max-width: min(860px, calc(100vw - 32px)); overflow: hidden; }
|
||||
.item-dialog-inner { display: grid; grid-template-columns: minmax(200px, 320px) 1fr; max-height: calc(100dvh - 48px); }
|
||||
.item-dialog .dlg-art { position: relative; background: var(--surface-2); }
|
||||
.item-dialog .dlg-art img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.item-dialog .dlg-art .art-fallback .icon { width: 30%; height: 30%; }
|
||||
.item-dialog.is-square .item-dialog-inner { grid-template-columns: minmax(220px, 360px) 1fr; }
|
||||
.item-dialog.is-square .dlg-art { aspect-ratio: 1; align-self: start; }
|
||||
.dlg-body { position: relative; padding: 28px 28px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
|
||||
.dlg-close { position: absolute; top: 10px; right: 10px; }
|
||||
.dlg-rank { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; font-weight: 550; padding-right: 36px; }
|
||||
.dlg-rank strong { font: 800 30px/1 var(--font-display); color: var(--accent); }
|
||||
.dlg-rank.top1 strong { color: var(--gold); }
|
||||
.dlg-rank.top2 strong { color: var(--silver); }
|
||||
.dlg-rank.top3 strong { color: var(--bronze); }
|
||||
.dlg-title { font-size: 28px; font-weight: 780; }
|
||||
.dlg-sub { color: var(--muted); font-size: 15px; margin-top: -4px; }
|
||||
.dlg-meta { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 4px 0 0; font-size: 14px; }
|
||||
.dlg-meta dt { color: var(--faint); }
|
||||
.dlg-meta dd { margin: 0; }
|
||||
.dlg-note {
|
||||
margin: 4px 0 0; padding: 14px 16px; border-left: 3px solid var(--accent);
|
||||
background: var(--accent-soft); border-radius: 0 var(--radius) var(--radius) 0;
|
||||
font-size: 15px; white-space: pre-line;
|
||||
}
|
||||
.dlg-actions { display: flex; align-items: center; gap: 8px; margin-top: auto; padding-top: 12px; flex-wrap: wrap; }
|
||||
.dlg-nav { margin-left: auto; display: flex; gap: 4px; }
|
||||
@media (max-width: 680px) {
|
||||
.item-dialog-inner, .item-dialog.is-square .item-dialog-inner { grid-template-columns: 1fr; overflow-y: auto; }
|
||||
.item-dialog .dlg-art { max-height: 46vh; aspect-ratio: auto; }
|
||||
.item-dialog .dlg-art img { max-height: 46vh; object-fit: contain; background: var(--surface-2); }
|
||||
.item-dialog .dlg-art .art-fallback { position: static; height: 180px; }
|
||||
.dlg-body { overflow: visible; padding: 22px 20px 20px; }
|
||||
.dlg-title { font-size: 23px; }
|
||||
}
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
.stack { display: flex; flex-direction: column; gap: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.field-label { font-size: 13px; font-weight: 600; color: var(--muted); }
|
||||
.field-hint { font-size: 12.5px; color: var(--faint); }
|
||||
.field-hint.is-warn { color: var(--warn); }
|
||||
.field-hint.is-error { color: var(--danger); }
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), select, textarea {
|
||||
width: 100%; height: 40px; padding: 0 12px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
textarea { height: auto; min-height: 90px; padding: 10px 12px; resize: vertical; line-height: 1.5; }
|
||||
textarea.code { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; min-height: 220px; tab-size: 2; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
input::placeholder, textarea::placeholder { color: var(--faint); }
|
||||
input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; margin: 0; }
|
||||
.check { display: flex; align-items: center; gap: 10px; font-size: 14px; cursor: pointer; }
|
||||
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; }
|
||||
.input-group { display: flex; gap: 8px; }
|
||||
.input-group > input { flex: 1; }
|
||||
|
||||
.alert { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: var(--radius); font-size: 14px; }
|
||||
.alert .icon { margin-top: 2px; }
|
||||
.alert--error { background: color-mix(in srgb, var(--danger) 13%, transparent); color: var(--danger); }
|
||||
.alert--info { background: var(--accent-soft); color: var(--text); }
|
||||
.alert--warn { background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--text); }
|
||||
|
||||
/* ---------- auth ---------- */
|
||||
.page-auth main { display: grid; place-items: center; padding: 48px 16px; }
|
||||
.auth-card {
|
||||
width: 100%; max-width: 400px; padding: 34px 30px 26px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--radius) + 8px);
|
||||
box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.auth-card h1 { font-size: 26px; }
|
||||
.auth-mark { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: var(--accent-soft); color: var(--accent); }
|
||||
.auth-mark .icon { width: 22px; height: 22px; }
|
||||
.auth-back { display: inline-flex; align-items: center; gap: 6px; justify-content: center; color: var(--faint); font-size: 13px; text-decoration: none; margin-top: 4px; }
|
||||
.auth-back:hover { color: var(--text); }
|
||||
|
||||
/* ---------- toasts ---------- */
|
||||
.toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; width: max-content; max-width: calc(100vw - 32px); }
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 10px; padding: 11px 16px; border-radius: 12px;
|
||||
background: var(--text); color: var(--bg); font-size: 14px; font-weight: 550; box-shadow: var(--shadow);
|
||||
animation: toast-in 0.2s ease-out; pointer-events: auto;
|
||||
}
|
||||
.toast--error { background: var(--danger); color: #fff; }
|
||||
.toast.is-leaving { opacity: 0; transform: translateY(6px); transition: all 0.2s; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }
|
||||
|
||||
.spinner { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: spin 0.7s linear infinite; flex: none; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ==========================================================================
|
||||
Admin
|
||||
========================================================================== */
|
||||
.admin { padding-top: 32px; }
|
||||
.admin-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.admin-head h1 { font-size: 30px; font-weight: 780; }
|
||||
.admin-head p { margin-top: 4px; }
|
||||
.admin-head-title { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.admin-head-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.admin .back-link { margin-bottom: 14px; }
|
||||
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--radius) + 4px); padding: 18px; }
|
||||
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.panel-head h2 { font-size: 17px; font-weight: 700; }
|
||||
|
||||
.meter { height: 6px; border-radius: 99px; background: var(--surface-2); overflow: hidden; margin: -8px 0 24px; }
|
||||
.meter span { display: block; height: 100%; background: var(--accent); border-radius: inherit; transition: width 0.3s; }
|
||||
|
||||
/* list dashboard */
|
||||
.admin-lists { display: flex; flex-direction: column; gap: 10px; }
|
||||
.list-card {
|
||||
display: flex; align-items: center; gap: 14px; padding: 12px 14px 12px 8px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--radius) + 2px);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.list-card:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
|
||||
.list-card-main { flex: 1; min-width: 0; text-decoration: none; display: flex; align-items: center; gap: 14px; }
|
||||
.list-card-covers { display: flex; flex: none; }
|
||||
.list-card-covers span { width: 34px; height: 48px; border-radius: 5px; background: var(--surface-2) center / cover; border: 2px solid var(--surface); margin-left: -14px; box-shadow: 0 2px 6px rgba(0,0,0,.25); }
|
||||
.list-card-covers span:first-child { margin-left: 0; }
|
||||
.list-card-covers.is-square span { height: 34px; }
|
||||
.list-card-title { display: block; font-weight: 650; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.list-card-meta { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; margin-top: 2px; flex-wrap: wrap; }
|
||||
.list-card-bar { width: 80px; height: 4px; border-radius: 9px; background: var(--surface-2); overflow: hidden; }
|
||||
.list-card-bar span { display: block; height: 100%; background: var(--accent); }
|
||||
.list-card-actions { display: flex; gap: 2px; }
|
||||
|
||||
.onboard { text-align: center; padding: 48px 20px; border: 1px dashed var(--border); border-radius: calc(var(--radius) + 6px); }
|
||||
.onboard h2 { font-size: 22px; margin-bottom: 6px; }
|
||||
.onboard p { color: var(--muted); }
|
||||
.quick-cats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 24px; }
|
||||
|
||||
.cat-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
@media (max-width: 480px) { .cat-picker { grid-template-columns: repeat(2, 1fr); } }
|
||||
.cat-option { position: relative; }
|
||||
.cat-option input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.cat-option span, .quick-cats button {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 14px 8px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
|
||||
font-size: 13.5px; font-weight: 550; cursor: pointer; text-align: center; color: var(--text);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.cat-option .icon, .quick-cats .icon { width: 22px; height: 22px; color: var(--accent); }
|
||||
.cat-option span:hover, .quick-cats button:hover { border-color: var(--accent); }
|
||||
.cat-option input:checked + span { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.cat-option input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cat-option input:disabled + span { opacity: 0.45; cursor: not-allowed; }
|
||||
.size-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.size-chips button { height: 30px; padding: 0 12px; border-radius: 99px; border: 1px solid var(--border); background: var(--bg); cursor: pointer; font-size: 13px; font-weight: 600; }
|
||||
.size-chips button:hover { border-color: var(--accent); }
|
||||
.size-chips button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
|
||||
/* list editor */
|
||||
.editor { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 20px; align-items: start; }
|
||||
.editor-search { position: sticky; top: 76px; max-height: calc(100vh - 92px); display: flex; flex-direction: column; gap: 12px; }
|
||||
.editor-search .panel-head { margin-bottom: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.editor { grid-template-columns: 1fr; }
|
||||
.editor-search { position: static; max-height: none; }
|
||||
}
|
||||
.search-bar { position: relative; display: flex; align-items: center; }
|
||||
.search-bar > .icon { position: absolute; left: 12px; color: var(--faint); pointer-events: none; }
|
||||
.search-bar input { padding-left: 38px !important; padding-right: 38px !important; }
|
||||
.search-bar .spinner { position: absolute; right: 12px; }
|
||||
.search-results { overflow-y: auto; margin: 0 -8px; padding: 0 8px; display: flex; flex-direction: column; gap: 4px; min-height: 0; }
|
||||
.search-results:empty { display: none; }
|
||||
.search-msg { color: var(--muted); font-size: 14px; padding: 8px 2px; }
|
||||
.result {
|
||||
display: grid; grid-template-columns: 48px 1fr auto; gap: 12px; align-items: center;
|
||||
padding: 8px; border-radius: var(--radius); transition: background 0.12s;
|
||||
}
|
||||
.result:hover { background: var(--surface-2); }
|
||||
.result-art { width: 48px; aspect-ratio: 2 / 3; border-radius: 5px; background: var(--surface-2) center / cover no-repeat; display: grid; place-items: center; color: var(--faint); overflow: hidden; }
|
||||
.result-art img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.is-square .result-art { aspect-ratio: 1; }
|
||||
.result-title { font-weight: 600; font-size: 14px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.result-sub { color: var(--muted); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.result-overview { color: var(--faint); font-size: 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; }
|
||||
.result-added { font-size: 12px; font-weight: 600; color: var(--success); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
|
||||
.rank-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.rank-row {
|
||||
display: grid; grid-template-columns: 28px 64px 44px 1fr auto; align-items: center; gap: 10px;
|
||||
padding: 8px 6px; border-radius: var(--radius); background: var(--surface);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
||||
}
|
||||
.rank-row:hover { background: var(--surface-2); }
|
||||
.rank-row.is-dragging { position: relative; z-index: 5; box-shadow: var(--shadow); background: var(--surface-2); border-color: var(--accent); opacity: 0.96; }
|
||||
.rank-row.is-placeholder { background: var(--accent-soft); border: 1px dashed var(--accent); }
|
||||
.rank-row.is-placeholder > * { visibility: hidden; }
|
||||
.rank-row.is-flash { animation: flash 1.2s ease-out; }
|
||||
@keyframes flash { from { background: var(--accent-soft); } }
|
||||
.drag-handle { display: grid; place-items: center; width: 28px; height: 36px; color: var(--faint); cursor: grab; touch-action: none; border: 0; background: none; border-radius: 6px; }
|
||||
.drag-handle:hover { color: var(--text); background: var(--surface-2); }
|
||||
.dragging-active, .dragging-active * { cursor: grabbing !important; user-select: none; }
|
||||
.rank-input {
|
||||
width: 64px !important; height: 34px !important; padding: 0 6px !important; text-align: center;
|
||||
font-weight: 750; font-variant-numeric: tabular-nums; background: transparent !important; border-color: transparent !important;
|
||||
}
|
||||
.rank-input:hover { border-color: var(--border) !important; }
|
||||
.rank-input:focus { background: var(--bg) !important; border-color: var(--accent) !important; }
|
||||
.rank-row .result-art { width: 44px; }
|
||||
.rank-row-body { min-width: 0; }
|
||||
.rank-row-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rank-row-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; gap: 8px; align-items: center; }
|
||||
.rank-row-actions { display: flex; gap: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.rank-row { grid-template-columns: 24px 50px 38px 1fr auto; gap: 6px; }
|
||||
.rank-row .result-art { width: 38px; }
|
||||
.rank-input { width: 50px !important; }
|
||||
.rank-row-actions .hide-sm { display: none; }
|
||||
}
|
||||
.rank-empty { text-align: center; padding: 40px 16px; color: var(--muted); }
|
||||
|
||||
/* item editor dialog */
|
||||
.art-edit { display: grid; grid-template-columns: 110px 1fr; gap: 16px; align-items: start; }
|
||||
.art-edit .result-art { width: 110px; }
|
||||
.art-edit-actions { display: flex; flex-direction: column; gap: 8px; }
|
||||
.art-edit-actions .row-btns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
@media (max-width: 480px) { .art-edit { grid-template-columns: 80px 1fr; } .art-edit .result-art { width: 80px; } }
|
||||
|
||||
/* settings */
|
||||
.settings { display: grid; grid-template-columns: 200px 1fr; gap: 32px; align-items: start; }
|
||||
.settings-nav { position: sticky; top: 80px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.settings-nav a { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-decoration: none; font-weight: 550; font-size: 14px; }
|
||||
.settings-nav a:hover { background: var(--surface); color: var(--text); }
|
||||
.settings-body { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
|
||||
.settings-section { scroll-margin-top: 80px; }
|
||||
.settings-section > .panel { display: flex; flex-direction: column; gap: 18px; }
|
||||
.settings-section h2 { font-size: 20px; margin-bottom: 4px; }
|
||||
.settings-section .section-desc { color: var(--muted); font-size: 14px; margin-bottom: 14px; }
|
||||
.settings-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
@media (max-width: 860px) {
|
||||
.settings { grid-template-columns: 1fr; gap: 16px; }
|
||||
.settings-nav { position: static; flex-direction: row; overflow-x: auto; }
|
||||
}
|
||||
.avatar-edit { display: flex; align-items: center; gap: 16px; }
|
||||
.avatar-edit .avatar { width: 72px; height: 72px; font-size: 28px; }
|
||||
|
||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||
.theme-card { position: relative; cursor: pointer; }
|
||||
.theme-card input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.theme-swatch {
|
||||
display: block; border-radius: var(--radius); overflow: hidden; border: 2px solid var(--border);
|
||||
background: var(--bg); color: var(--text); transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.theme-card:hover .theme-swatch { transform: translateY(-2px); }
|
||||
.theme-card input:checked + .theme-swatch { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.theme-card input:focus-visible + .theme-swatch { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.theme-preview { display: flex; gap: 6px; padding: 12px; background: var(--bg); }
|
||||
.theme-preview i { flex: 1; aspect-ratio: 2 / 3; border-radius: 4px; background: var(--surface-2); }
|
||||
.theme-preview i:first-child { background: var(--accent); }
|
||||
.theme-name { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: var(--surface); font-size: 13px; font-weight: 600; }
|
||||
.theme-kind { color: var(--muted); font-size: 11px; font-weight: 500; }
|
||||
|
||||
.integration { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }
|
||||
.integration-head { display: flex; align-items: center; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
|
||||
.integration-head h3 { font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
||||
.integration-head p { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||||
.integration .field-hint a { color: var(--accent); }
|
||||
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; }
|
||||
.status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||||
.status.is-ok { color: var(--success); }
|
||||
.status.is-ok::before { background: var(--success); }
|
||||
.status.is-off { color: var(--faint); }
|
||||
.test-result { font-size: 13px; }
|
||||
.test-result.is-ok { color: var(--success); }
|
||||
.test-result.is-error { color: var(--danger); }
|
||||
.radio-cards { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.radio-cards label { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 14px; }
|
||||
.radio-cards label:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.radio-cards input { accent-color: var(--accent); margin: 0; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* TopDeck theme presets.
|
||||
*
|
||||
* Every color on the site comes from these variables, so a custom theme only
|
||||
* needs to override them (Settings → Appearance → Custom CSS), e.g.
|
||||
*
|
||||
* :root, [data-theme] { --accent: #ff4f81; --bg: #120d14; }
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme="midnight"] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1117;
|
||||
--surface: #171a22;
|
||||
--surface-2: #1f232e;
|
||||
--border: #2a2f3c;
|
||||
--text: #eceef4;
|
||||
--muted: #9aa1b2;
|
||||
--faint: #636a7c;
|
||||
--accent: #8b7dff;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: rgba(139, 125, 255, 0.14);
|
||||
--danger: #ff6b7a;
|
||||
--success: #4fd1a1;
|
||||
--warn: #f5b84b;
|
||||
--gold: #f5c451;
|
||||
--silver: #cfd6e4;
|
||||
--bronze: #d99a6c;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: var(--font);
|
||||
--radius: 10px;
|
||||
--radius-art: 8px;
|
||||
}
|
||||
|
||||
[data-theme="obsidian"] {
|
||||
color-scheme: dark;
|
||||
--bg: #000000;
|
||||
--surface: #0c0c0c;
|
||||
--surface-2: #161616;
|
||||
--border: #262626;
|
||||
--text: #f5f5f5;
|
||||
--muted: #a3a3a3;
|
||||
--faint: #5f5f5f;
|
||||
--accent: #f5f5f5;
|
||||
--accent-text: #000000;
|
||||
--accent-soft: rgba(255, 255, 255, 0.09);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
color-scheme: dark;
|
||||
--bg: #242933;
|
||||
--surface: #2e3440;
|
||||
--surface-2: #3b4252;
|
||||
--border: #434c5e;
|
||||
--text: #eceff4;
|
||||
--muted: #b4bccb;
|
||||
--faint: #7b8598;
|
||||
--accent: #88c0d0;
|
||||
--accent-text: #1f242d;
|
||||
--accent-soft: rgba(136, 192, 208, 0.15);
|
||||
--danger: #bf616a;
|
||||
--success: #a3be8c;
|
||||
--warn: #ebcb8b;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
color-scheme: dark;
|
||||
--bg: #21222c;
|
||||
--surface: #282a36;
|
||||
--surface-2: #343746;
|
||||
--border: #44475a;
|
||||
--text: #f8f8f2;
|
||||
--muted: #b6b9cf;
|
||||
--faint: #6272a4;
|
||||
--accent: #ff79c6;
|
||||
--accent-text: #21222c;
|
||||
--accent-soft: rgba(255, 121, 198, 0.14);
|
||||
--danger: #ff5555;
|
||||
--success: #50fa7b;
|
||||
--warn: #f1fa8c;
|
||||
}
|
||||
|
||||
[data-theme="mocha"] {
|
||||
color-scheme: dark;
|
||||
--bg: #181825;
|
||||
--surface: #1e1e2e;
|
||||
--surface-2: #313244;
|
||||
--border: #45475a;
|
||||
--text: #cdd6f4;
|
||||
--muted: #a6adc8;
|
||||
--faint: #6c7086;
|
||||
--accent: #cba6f7;
|
||||
--accent-text: #1e1e2e;
|
||||
--accent-soft: rgba(203, 166, 247, 0.14);
|
||||
--danger: #f38ba8;
|
||||
--success: #a6e3a1;
|
||||
--warn: #f9e2af;
|
||||
}
|
||||
|
||||
[data-theme="rose-pine"] {
|
||||
color-scheme: dark;
|
||||
--bg: #191724;
|
||||
--surface: #1f1d2e;
|
||||
--surface-2: #26233a;
|
||||
--border: #393552;
|
||||
--text: #e0def4;
|
||||
--muted: #a8a4c4;
|
||||
--faint: #6e6a86;
|
||||
--accent: #ebbcba;
|
||||
--accent-text: #191724;
|
||||
--accent-soft: rgba(235, 188, 186, 0.14);
|
||||
--danger: #eb6f92;
|
||||
--success: #9ccfd8;
|
||||
--warn: #f6c177;
|
||||
}
|
||||
|
||||
[data-theme="gruvbox"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1d2021;
|
||||
--surface: #282828;
|
||||
--surface-2: #32302f;
|
||||
--border: #45403d;
|
||||
--text: #ebdbb2;
|
||||
--muted: #bdae93;
|
||||
--faint: #7c6f64;
|
||||
--accent: #fe8019;
|
||||
--accent-text: #1d2021;
|
||||
--accent-soft: rgba(254, 128, 25, 0.14);
|
||||
--danger: #fb4934;
|
||||
--success: #b8bb26;
|
||||
--warn: #fabd2f;
|
||||
}
|
||||
|
||||
[data-theme="forest"] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1612;
|
||||
--surface: #151f19;
|
||||
--surface-2: #1c2921;
|
||||
--border: #29392f;
|
||||
--text: #e4efe7;
|
||||
--muted: #9fb5a6;
|
||||
--faint: #62786a;
|
||||
--accent: #7fd49b;
|
||||
--accent-text: #0f1612;
|
||||
--accent-soft: rgba(127, 212, 155, 0.13);
|
||||
}
|
||||
|
||||
[data-theme="synthwave"] {
|
||||
color-scheme: dark;
|
||||
--bg: #140b24;
|
||||
--surface: #1d1133;
|
||||
--surface-2: #281846;
|
||||
--border: #3b2663;
|
||||
--text: #f4eaff;
|
||||
--muted: #bca8dc;
|
||||
--faint: #7a649e;
|
||||
--accent: #ff4fd8;
|
||||
--accent-text: #140b24;
|
||||
--accent-soft: rgba(255, 79, 216, 0.15);
|
||||
--warn: #ffd166;
|
||||
--success: #36f1cd;
|
||||
--shadow: 0 10px 40px rgba(255, 79, 216, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="daylight"] {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef0f4;
|
||||
--border: #e0e3ea;
|
||||
--text: #15171c;
|
||||
--muted: #5d6475;
|
||||
--faint: #9299a8;
|
||||
--accent: #5b4ef0;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: rgba(91, 78, 240, 0.1);
|
||||
--danger: #d93a4b;
|
||||
--success: #17915f;
|
||||
--warn: #b7791f;
|
||||
--gold: #d69e1a;
|
||||
--silver: #8f98a8;
|
||||
--bronze: #b8734a;
|
||||
--shadow: 0 10px 30px rgba(20, 24, 40, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="paper"] {
|
||||
color-scheme: light;
|
||||
--bg: #f4efe6;
|
||||
--surface: #fbf8f2;
|
||||
--surface-2: #ece5d8;
|
||||
--border: #ddd3c2;
|
||||
--text: #2b2520;
|
||||
--muted: #6e6457;
|
||||
--faint: #a2978a;
|
||||
--accent: #b5452f;
|
||||
--accent-text: #fbf8f2;
|
||||
--accent-soft: rgba(181, 69, 47, 0.1);
|
||||
--danger: #b5452f;
|
||||
--success: #3f7d4f;
|
||||
--warn: #a8741a;
|
||||
--gold: #b88a1b;
|
||||
--silver: #8a8a8a;
|
||||
--bronze: #a0623b;
|
||||
--shadow: 0 10px 30px rgba(60, 40, 20, 0.1);
|
||||
--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, ui-serif, serif;
|
||||
--radius: 4px;
|
||||
--radius-art: 3px;
|
||||
}
|
||||
|
||||
[data-theme="latte"] {
|
||||
color-scheme: light;
|
||||
--bg: #eff1f5;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #e6e9ef;
|
||||
--border: #ccd0da;
|
||||
--text: #4c4f69;
|
||||
--muted: #6c6f85;
|
||||
--faint: #9ca0b0;
|
||||
--accent: #8839ef;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: rgba(136, 57, 239, 0.1);
|
||||
--danger: #d20f39;
|
||||
--success: #40a02b;
|
||||
--warn: #df8e1d;
|
||||
--gold: #df8e1d;
|
||||
--silver: #8c8fa1;
|
||||
--bronze: #c06d3a;
|
||||
--shadow: 0 10px 30px rgba(76, 79, 105, 0.12);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="9" y="3" width="19" height="24" rx="4" fill="#6d5dfc" opacity=".45"/><rect x="4" y="6" width="19" height="24" rx="4" fill="#8b7dff"/><path d="m13.5 11.5 1.9 3.9 4.3.6-3.1 3 .7 4.3-3.8-2-3.8 2 .7-4.3-3.1-3 4.3-.6z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="deck" viewBox="0 0 24 24"><rect x="3" y="6" width="13" height="16" rx="2"/><path d="M7 6V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/><path d="m9.5 10.4 1 2.1 2.3.3-1.7 1.6.4 2.3-2-1.1-2 1.1.4-2.3-1.7-1.6 2.3-.3z"/></symbol>
|
||||
<symbol id="film" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18M3 7.5h4M3 12h18M3 16.5h4M17 3v18M17 7.5h4M17 16.5h4"/></symbol>
|
||||
<symbol id="tv" viewBox="0 0 24 24"><rect width="20" height="15" x="2" y="7" rx="2"/><path d="m17 2-5 5-5-5"/></symbol>
|
||||
<symbol id="disc" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 5a7 7 0 0 0-7 7"/></symbol>
|
||||
<symbol id="music" viewBox="0 0 24 24"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></symbol>
|
||||
<symbol id="gamepad" viewBox="0 0 24 24"><path d="M6 12h4M8 10v4M15 13h.01M18 11h.01"/><rect width="20" height="12" x="2" y="6" rx="4"/></symbol>
|
||||
<symbol id="masks" viewBox="0 0 24 24"><path d="M2 4.5c0-.8.6-1.4 1.4-1.3C5 3.4 7 3.4 8.6 3.1c.8-.1 1.4.5 1.4 1.3V9c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M4.5 7.5h.01M7.5 7.5h.01M4.8 11.2c.7-.5 1.7-.5 2.4 0"/><path d="M14 9.6c0-.8.6-1.4 1.4-1.3 1.6.3 3.6.3 5.2 0 .8-.1 1.4.5 1.4 1.3V14c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M16.5 12.5h.01M19.5 12.5h.01M16.8 15.6c.7.5 1.7.5 2.4 0"/><path d="M10 12.5c.9.7 2.3.8 4 .3"/></symbol>
|
||||
<symbol id="grip" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></symbol>
|
||||
<symbol id="pencil" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></symbol>
|
||||
<symbol id="trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></symbol>
|
||||
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14M12 5v14"/></symbol>
|
||||
<symbol id="search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></symbol>
|
||||
<symbol id="external" viewBox="0 0 24 24"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
|
||||
<symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="eye-off" viewBox="0 0 24 24"><path d="M9.9 9.9a3 3 0 1 0 4.2 4.2M10.7 5.1A10.4 10.4 0 0 1 12 5c6.4 0 10 7 10 7a13 13 0 0 1-1.7 2.7M6.6 6.6A13.5 13.5 0 0 0 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6M2 2l20 20"/></symbol>
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M20 7h-9M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></symbol>
|
||||
<symbol id="list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||
<symbol id="rows" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></symbol>
|
||||
<symbol id="grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="14" rx="1.5"/><rect width="7" height="7" x="3" y="14" rx="1.5"/></symbol>
|
||||
<symbol id="logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
|
||||
<symbol id="upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></symbol>
|
||||
<symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></symbol>
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7M19 12H5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></symbol>
|
||||
<symbol id="chevron-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
|
||||
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol>
|
||||
<symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
|
||||
<symbol id="link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></symbol>
|
||||
<symbol id="palette" viewBox="0 0 24 24"><path d="M12 2a10 10 0 0 0 0 20c1 0 1.7-.8 1.7-1.7 0-.4-.2-.8-.5-1.1a1.6 1.6 0 0 1 1.3-2.8h2A5.5 5.5 0 0 0 22 11c0-5-4.5-9-10-9Z"/><circle cx="13.5" cy="6.5" r=".8"/><circle cx="17.5" cy="10.5" r=".8"/><circle cx="8.5" cy="7.5" r=".8"/><circle cx="6.5" cy="12.5" r=".8"/></symbol>
|
||||
<symbol id="user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol>
|
||||
<symbol id="lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
|
||||
<symbol id="plug" viewBox="0 0 24 24"><path d="M12 22v-5M9 8V2M15 8V2M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></symbol>
|
||||
<symbol id="alert" viewBox="0 0 24 24"><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3ZM12 9v4M12 17h.01"/></symbol>
|
||||
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
|
||||
<symbol id="crown" viewBox="0 0 24 24"><path d="M11.6 3.3a.5.5 0 0 1 .8 0l2.9 5.5a1 1 0 0 0 1.5.3l4.4-3.7a.5.5 0 0 1 .8.5l-2.8 10.4a1 1 0 0 1-1 .7H5.8a1 1 0 0 1-1-.7L2 5.9a.5.5 0 0 1 .8-.5l4.4 3.7a1 1 0 0 0 1.5-.3zM5 21h14"/></symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M8 16H3v5"/></symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,480 @@
|
||||
// List editor: search providers, add entries, rank and edit them.
|
||||
import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
|
||||
import { sortable } from "./sortable.js";
|
||||
import { openListForm } from "./list-form.js";
|
||||
|
||||
const boot = readBoot();
|
||||
const { categories, limits, productions } = boot;
|
||||
const cat = boot.category;
|
||||
const pageSize = boot.pageSize;
|
||||
let list = boot.list;
|
||||
let items = [];
|
||||
let page = 1;
|
||||
|
||||
const square = cat.aspect === "square";
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const rankList = $("[data-rank-list]");
|
||||
const pager = $("[data-pager]");
|
||||
const results = $("[data-results]");
|
||||
const searchForm = $("[data-search]");
|
||||
const searchInput = searchForm.querySelector("input");
|
||||
const spinner = searchForm.querySelector(".spinner");
|
||||
if (square) {
|
||||
rankList.classList.add("is-square");
|
||||
results.classList.add("is-square");
|
||||
}
|
||||
|
||||
// Category-specific extra fields, stored in item.extra.
|
||||
const EXTRA_FIELDS = {
|
||||
movie: [["genres", "Genres"], ["runtime", "Runtime"]],
|
||||
tv: [["genres", "Genres"]],
|
||||
album: [["type", "Type (EP, Live…)"], ["label", "Label"], ["genres", "Genres"]],
|
||||
song: [["album", "Album"], ["length", "Length"]],
|
||||
game: [["platforms", "Platforms"], ["genres", "Genres"]],
|
||||
theater: [["venue", "Venue / Theatre"], ["cast", "Notable cast"]],
|
||||
};
|
||||
|
||||
// ---------- header ----------
|
||||
|
||||
function renderHeader() {
|
||||
document.querySelector("[data-bind=title]").textContent = list.title;
|
||||
document.querySelector("[data-bind=count]").textContent = fmt.format(list.itemCount);
|
||||
document.querySelector("[data-bind=max]").textContent = fmt.format(list.maxItems);
|
||||
document.querySelector("[data-bind=hidden]").textContent = list.visible ? "" : " · Hidden";
|
||||
document.querySelector("[data-bind=public]").href = `/l/${list.slug}`;
|
||||
document.querySelector("[data-bind=meter]").style.width = Math.min(100, (list.itemCount / list.maxItems) * 100) + "%";
|
||||
document.querySelector("[data-bind=provider]").textContent = boot.provider ? `via ${boot.provider}` : "";
|
||||
document.title = `${list.title} · ${document.title.split(" · ").pop()}`;
|
||||
}
|
||||
|
||||
const isFull = () => list.itemCount >= list.maxItems;
|
||||
|
||||
document.querySelector("[data-action=edit-list]").addEventListener("click", () => {
|
||||
openListForm({
|
||||
categories, limits, list,
|
||||
onSaved: (saved) => {
|
||||
list = saved;
|
||||
renderHeader();
|
||||
renderResults(lastResults);
|
||||
toast("List saved");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- ranked items ----------
|
||||
|
||||
const pages = () => Math.max(1, Math.ceil(list.itemCount / pageSize));
|
||||
|
||||
async function load(p = page, flashId) {
|
||||
page = Math.min(Math.max(1, p), pages());
|
||||
try {
|
||||
items = await api("GET", `/api/lists/${list.id}/items?offset=${(page - 1) * pageSize}&limit=${pageSize}`);
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
return;
|
||||
}
|
||||
renderItems(flashId);
|
||||
}
|
||||
|
||||
function artBox(src, alt = "") {
|
||||
const box = h("span", { class: "result-art" });
|
||||
if (src) {
|
||||
const img = h("img", { src, alt, loading: "lazy", referrerpolicy: "no-referrer" });
|
||||
img.addEventListener("error", () => img.replaceWith(icon(cat.icon)), { once: true });
|
||||
box.append(img);
|
||||
} else {
|
||||
box.append(icon(cat.icon));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function row(it) {
|
||||
const rankInput = h("input", {
|
||||
class: "rank-input", type: "number", min: 1, max: list.itemCount, value: it.rank,
|
||||
"aria-label": `Rank of ${it.title}`, inputmode: "numeric",
|
||||
});
|
||||
const commit = () => {
|
||||
const n = Number(rankInput.value);
|
||||
if (!Number.isInteger(n) || n === it.rank) { rankInput.value = it.rank; return; }
|
||||
moveTo(it, Math.min(Math.max(1, n), list.itemCount));
|
||||
};
|
||||
rankInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); rankInput.blur(); } });
|
||||
rankInput.addEventListener("change", commit);
|
||||
|
||||
const sub = [it.subtitle, it.year].filter(Boolean).join(" · ");
|
||||
return h("li", { class: "rank-row", "data-id": it.id },
|
||||
h("button", { type: "button", class: "drag-handle", "aria-label": `Drag to reorder ${it.title}, or use arrow keys` }, icon("grip")),
|
||||
rankInput,
|
||||
artBox(it.image ? `/images/${it.image}` : ""),
|
||||
h("div", { class: "rank-row-body" },
|
||||
h("div", { class: "rank-row-title" }, it.title),
|
||||
h("div", { class: "rank-row-sub" },
|
||||
it.extra?.production && h("span", { class: "badge" }, it.extra.production),
|
||||
sub && h("span", {}, sub),
|
||||
it.note && h("span", { title: "Has a note" }, icon("pencil")),
|
||||
),
|
||||
),
|
||||
h("div", { class: "rank-row-actions" },
|
||||
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
|
||||
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
|
||||
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
|
||||
h("button", { type: "button", class: "icon-btn icon-btn--danger", "aria-label": `Remove ${it.title}`, title: "Remove", onclick: () => removeItem(it) }, icon("trash")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderItems(flashId) {
|
||||
if (!items.length) {
|
||||
rankList.replaceChildren(h("li", { class: "rank-empty" },
|
||||
h("div", { class: "empty-art", style: { margin: "0 auto 12px" } }, icon(cat.icon)),
|
||||
h("p", {}, `No ${cat.label.toLowerCase()} yet. Search on the left to add your first.`)));
|
||||
} else {
|
||||
rankList.replaceChildren(...items.map(row));
|
||||
}
|
||||
if (flashId) {
|
||||
const el = rankList.querySelector(`[data-id="${flashId}"]`);
|
||||
if (el) {
|
||||
el.classList.add("is-flash");
|
||||
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
renderPager();
|
||||
}
|
||||
|
||||
function renderPager() {
|
||||
const total = pages();
|
||||
if (total <= 1) { pager.replaceChildren(); return; }
|
||||
const btn = (p, label = String(p), aria) => h("button", {
|
||||
type: "button", class: "pager-btn" + (p === page ? " is-current" : ""), "aria-label": aria,
|
||||
"aria-current": p === page ? "page" : null, onclick: () => load(p),
|
||||
}, label);
|
||||
const nums = [];
|
||||
let last = 0;
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (i === 1 || i === total || Math.abs(i - page) <= 2) {
|
||||
if (last && i - last > 1) nums.push(h("span", { class: "pager-gap" }, "…"));
|
||||
nums.push(btn(i, `${fmt.format((i - 1) * pageSize + 1)}–${fmt.format(Math.min(i * pageSize, list.itemCount))}`));
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
pager.replaceChildren(...nums);
|
||||
}
|
||||
|
||||
async function moveTo(it, rank) {
|
||||
try {
|
||||
await api("POST", `/api/items/${it.id}/move`, { rank });
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
}
|
||||
const targetPage = Math.ceil(rank / pageSize);
|
||||
await load(targetPage, it.id);
|
||||
}
|
||||
|
||||
sortable(rankList, {
|
||||
onMove: async (el, from, to) => {
|
||||
const id = Number(el.dataset.id);
|
||||
const it = items.find((x) => x.id === id);
|
||||
const rank = (page - 1) * pageSize + to + 1;
|
||||
// Renumber right away so the UI feels instant; the reload confirms.
|
||||
[...rankList.children].forEach((li, i) => {
|
||||
const input = li.querySelector(".rank-input");
|
||||
if (input) input.value = (page - 1) * pageSize + i + 1;
|
||||
});
|
||||
try {
|
||||
await api("POST", `/api/items/${id}/move`, { rank });
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
}
|
||||
await load(page, it?.id);
|
||||
},
|
||||
});
|
||||
|
||||
async function removeItem(it) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Remove from list?",
|
||||
message: `“${it.title}” will be removed from #${it.rank}, and everything below moves up one spot.`,
|
||||
confirm: "Remove",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api("DELETE", `/api/items/${it.id}`);
|
||||
list.itemCount--;
|
||||
renderHeader();
|
||||
toast(`Removed “${it.title}”`);
|
||||
await load();
|
||||
markResult(it, 0);
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- search ----------
|
||||
|
||||
let lastResults = null;
|
||||
let searchSeq = 0;
|
||||
|
||||
async function search(q) {
|
||||
q = q.trim();
|
||||
if (!q) { renderResults(null); return; }
|
||||
const seq = ++searchSeq;
|
||||
spinner.hidden = false;
|
||||
try {
|
||||
const data = await api("GET", `/api/search?category=${cat.key}&list=${list.id}&q=${encodeURIComponent(q)}`);
|
||||
if (seq !== searchSeq) return;
|
||||
renderResults(data.results);
|
||||
} catch (err) {
|
||||
if (seq !== searchSeq) return;
|
||||
lastResults = null;
|
||||
const needsSetup = err.status === 424;
|
||||
results.replaceChildren(h("div", { class: "alert " + (needsSetup ? "alert--warn" : "alert--error") },
|
||||
icon(needsSetup ? "key" : "alert"),
|
||||
h("div", {}, err.message, needsSetup && h("div", {}, h("a", { href: "/admin/settings#integrations" }, "Open integrations →")))));
|
||||
} finally {
|
||||
if (seq === searchSeq) spinner.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function resultKey(r) { return `${r.provider}:${r.providerId}`; }
|
||||
|
||||
function markResult(it, rank) {
|
||||
if (!lastResults || !it.provider) return;
|
||||
const r = lastResults.find((x) => x.provider === it.provider && x.providerId === it.providerId);
|
||||
if (r) { r.existingRank = rank; renderResults(lastResults); }
|
||||
}
|
||||
|
||||
function renderResults(res) {
|
||||
lastResults = res;
|
||||
if (res == null) { results.replaceChildren(); return; }
|
||||
if (!res.length) {
|
||||
results.replaceChildren(h("p", { class: "search-msg" }, "No matches. Try another spelling, or add it manually below."));
|
||||
return;
|
||||
}
|
||||
const full = isFull();
|
||||
const nodes = res.map((r) => {
|
||||
const sub = [r.subtitle, r.year, r.extra?.type].filter(Boolean).join(" · ");
|
||||
const action = r.existingRank
|
||||
? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`)
|
||||
: h("button", {
|
||||
type: "button", class: "btn btn--sm btn--primary", disabled: full,
|
||||
title: full ? "This list is full" : `Add to #${list.itemCount + 1}`,
|
||||
onclick: (e) => addResult(r, e.currentTarget),
|
||||
}, icon("plus"), "Add");
|
||||
return h("div", { class: "result", "data-key": resultKey(r) },
|
||||
artBox(r.imageUrl, ""),
|
||||
h("div", { style: { minWidth: 0 } },
|
||||
h("div", { class: "result-title" }, r.title),
|
||||
sub && h("div", { class: "result-sub" }, sub),
|
||||
r.overview && h("div", { class: "result-overview" }, r.overview),
|
||||
),
|
||||
action,
|
||||
);
|
||||
});
|
||||
if (full) nodes.unshift(h("p", { class: "alert alert--warn" }, icon("info"), `This list is full (${fmt.format(list.maxItems)}). Raise its size in List settings to add more.`));
|
||||
results.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
async function addResult(r, button) {
|
||||
// Theater entries need the owner to say which production they mean.
|
||||
if (cat.key === "theater") {
|
||||
openItemForm({ result: r });
|
||||
return;
|
||||
}
|
||||
busy(button, true);
|
||||
await createItem({ ...fromResult(r), details: true }, () => busy(button, false));
|
||||
}
|
||||
|
||||
function fromResult(r) {
|
||||
return {
|
||||
title: r.title, subtitle: r.subtitle, year: r.year, externalUrl: r.externalUrl,
|
||||
imageUrl: r.imageUrl, provider: r.provider, providerId: r.providerId, extra: { ...(r.extra || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
async function createItem(body, done) {
|
||||
try {
|
||||
let res;
|
||||
try {
|
||||
res = await api("POST", `/api/lists/${list.id}/items`, body);
|
||||
} catch (err) {
|
||||
if (err.status !== 409) throw err;
|
||||
const again = await confirmDialog({ title: "Already ranked", message: `${err.message} Add it again anyway?`, confirm: "Add again" });
|
||||
if (!again) return null;
|
||||
res = await api("POST", `/api/lists/${list.id}/items`, { ...body, force: true });
|
||||
}
|
||||
list.itemCount++;
|
||||
renderHeader();
|
||||
toast(`Added “${res.item.title}” at #${res.item.rank}`);
|
||||
if (res.warning) toast(res.warning, { error: true });
|
||||
markResult(res.item, res.item.rank);
|
||||
await load(Math.ceil(res.item.rank / pageSize), res.item.id);
|
||||
return res.item;
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
return null;
|
||||
} finally {
|
||||
done?.();
|
||||
}
|
||||
}
|
||||
|
||||
const runSearch = debounce((q) => search(q), 450);
|
||||
searchInput.addEventListener("input", () => runSearch(searchInput.value));
|
||||
searchForm.addEventListener("submit", (e) => { e.preventDefault(); search(searchInput.value); });
|
||||
|
||||
document.querySelector("[data-action=manual]").addEventListener("click", () => openItemForm({}));
|
||||
|
||||
// ---------- item form (add manually / edit / theater add) ----------
|
||||
|
||||
function openItemForm({ item, result }) {
|
||||
const editing = !!item;
|
||||
const src = item || (result ? fromResult(result) : { extra: {} });
|
||||
const extra = { ...(src.extra || {}) };
|
||||
let clearImage = false;
|
||||
let dlg;
|
||||
|
||||
const field = (label, input, hint) => h("label", { class: "field" },
|
||||
h("span", { class: "field-label" }, label), input, hint && h("span", { class: "field-hint" }, hint));
|
||||
|
||||
const title = h("input", { required: true, maxlength: 500, value: src.title || "" });
|
||||
const subtitle = h("input", { maxlength: 500, value: src.subtitle || "" });
|
||||
const year = h("input", { maxlength: 20, value: src.year || "", inputmode: "numeric", style: { maxWidth: "140px" } });
|
||||
const note = h("textarea", { rows: 3, maxlength: 10000, value: item?.note || "", placeholder: "Why it's here. Shown to visitors." });
|
||||
const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" });
|
||||
|
||||
// Theater production gets top billing with suggestions.
|
||||
let production, productionList;
|
||||
if (cat.key === "theater") {
|
||||
productionList = h("datalist", { id: "productions" }, productions.map((p) => h("option", { value: p })));
|
||||
production = h("input", { list: "productions", maxlength: 200, value: extra.production || "", placeholder: "e.g. Broadway, North American Tour" });
|
||||
}
|
||||
const extraInputs = (EXTRA_FIELDS[cat.key] || []).map(([key, label]) => {
|
||||
const input = h("input", { maxlength: 500, value: extra[key] || "" });
|
||||
return { key, label, input };
|
||||
});
|
||||
|
||||
// Artwork
|
||||
const currentArt = item?.image ? `/images/${item.image}` : (src.imageUrl || "");
|
||||
const preview = h("div", {}, artBox(currentArt));
|
||||
const imageUrl = h("input", {
|
||||
type: "url", placeholder: "Paste an image URL",
|
||||
value: editing ? (item.imageSource && item.imageSource !== "upload" ? item.imageSource : "") : (src.imageUrl || ""),
|
||||
});
|
||||
imageUrl.addEventListener("change", () => {
|
||||
clearImage = false;
|
||||
preview.replaceChildren(artBox(imageUrl.value.trim()));
|
||||
});
|
||||
const fileInput = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp,image/gif,image/avif", hidden: true });
|
||||
const uploadBtn = h("button", { type: "button", class: "btn btn--sm", onclick: () => fileInput.click() }, icon("upload"), "Upload");
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
if (!editing) {
|
||||
toast("Save the entry first, then upload art for it.", { error: true });
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
busy(uploadBtn, true);
|
||||
try {
|
||||
const res = await api("POST", `/api/items/${item.id}/image`, fd);
|
||||
Object.assign(item, res.item);
|
||||
imageUrl.value = "";
|
||||
clearImage = false;
|
||||
preview.replaceChildren(artBox(`/images/${res.item.image}`));
|
||||
toast("Artwork updated");
|
||||
await load(page);
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
busy(uploadBtn, false);
|
||||
fileInput.value = "";
|
||||
}
|
||||
});
|
||||
const removeArt = h("button", {
|
||||
type: "button", class: "btn btn--sm btn--ghost",
|
||||
onclick: () => { clearImage = true; imageUrl.value = ""; preview.replaceChildren(artBox("")); },
|
||||
}, icon("x"), "Remove");
|
||||
|
||||
const artHint = cat.key === "theater"
|
||||
? "Tip: use a poster from the specific production (a tour's key art, a revival's Playbill…)."
|
||||
: "Art is saved to your server, so it keeps working if the source changes.";
|
||||
|
||||
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
|
||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save" : `Add at #${list.itemCount + 1}`);
|
||||
const heading = editing ? `Edit #${item.rank}` : result ? `Add “${result.title}”` : `Add a ${cat.singular.toLowerCase()}`;
|
||||
|
||||
const form = h("form", { novalidate: true },
|
||||
dialogHead(heading, () => dlg),
|
||||
h("div", { class: "dialog-body stack" },
|
||||
production && field("Production", production, "Which staging this is about. Pick one or type your own."),
|
||||
productionList,
|
||||
h("div", { class: "art-edit" + (square ? " is-square" : "") },
|
||||
preview,
|
||||
h("div", { class: "art-edit-actions" },
|
||||
h("span", { class: "field-label" }, "Artwork"),
|
||||
imageUrl,
|
||||
h("div", { class: "row-btns" }, editing && uploadBtn, removeArt, fileInput),
|
||||
h("span", { class: "field-hint" }, artHint),
|
||||
),
|
||||
),
|
||||
field("Title", title),
|
||||
h("div", { class: "row" },
|
||||
field(cat.subtitleHint, subtitle),
|
||||
field(cat.key === "theater" ? "Year (of this production)" : "Year", year),
|
||||
),
|
||||
extraInputs.length > 0 && h("div", { class: "row" }, extraInputs.map((x) => field(x.label, x.input))),
|
||||
field("Your note", note),
|
||||
field("Link", link, "Where the title links to on your page."),
|
||||
error,
|
||||
),
|
||||
h("div", { class: "dialog-foot" },
|
||||
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
|
||||
save,
|
||||
),
|
||||
);
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
error.hidden = true;
|
||||
if (!title.value.trim()) {
|
||||
error.hidden = false;
|
||||
error.textContent = "Give it a title.";
|
||||
title.focus();
|
||||
return;
|
||||
}
|
||||
const newExtra = { ...extra };
|
||||
if (production) newExtra.production = production.value.trim();
|
||||
for (const x of extraInputs) newExtra[x.key] = x.input.value.trim();
|
||||
const body = {
|
||||
title: title.value, subtitle: subtitle.value, year: year.value, note: note.value,
|
||||
externalUrl: link.value.trim(), extra: newExtra,
|
||||
imageUrl: clearImage ? "" : imageUrl.value.trim(), clearImage,
|
||||
};
|
||||
busy(save, true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api("PUT", `/api/items/${item.id}`, body);
|
||||
dlg.close();
|
||||
toast("Saved");
|
||||
await load(page, item.id);
|
||||
} else {
|
||||
Object.assign(body, {
|
||||
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
|
||||
});
|
||||
const created = await createItem(body);
|
||||
if (created) dlg.close();
|
||||
}
|
||||
} catch (err) {
|
||||
error.hidden = false;
|
||||
error.textContent = err.message;
|
||||
} finally {
|
||||
busy(save, false);
|
||||
}
|
||||
});
|
||||
|
||||
dlg = openDialog(form);
|
||||
(production || title).focus();
|
||||
}
|
||||
|
||||
renderHeader();
|
||||
load(1);
|
||||
@@ -0,0 +1,110 @@
|
||||
// Admin dashboard: all lists, their order, create/edit/delete.
|
||||
import { h, icon, api, readBoot, toast, confirmDialog, fmt } from "./common.js";
|
||||
import { sortable } from "./sortable.js";
|
||||
import { openListForm } from "./list-form.js";
|
||||
|
||||
const boot = readBoot();
|
||||
const { categories, limits } = boot;
|
||||
const covers = boot.covers || {};
|
||||
const catByKey = Object.fromEntries(categories.map((c) => [c.key, c]));
|
||||
let lists = boot.lists;
|
||||
|
||||
const root = document.getElementById("lists");
|
||||
|
||||
function newList(category) {
|
||||
openListForm({
|
||||
categories, limits, category,
|
||||
onSaved: (l) => { location.href = `/admin/lists/${l.id}`; },
|
||||
});
|
||||
}
|
||||
|
||||
function editList(list) {
|
||||
openListForm({
|
||||
categories, limits, list,
|
||||
onSaved: (saved) => {
|
||||
lists = lists.map((l) => (l.id === saved.id ? saved : l));
|
||||
render();
|
||||
toast("List saved");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteList(list) {
|
||||
const entries = list.itemCount ? ` Its ${fmt.format(list.itemCount)} ${list.itemCount === 1 ? "entry" : "entries"} will be removed.` : "";
|
||||
const ok = await confirmDialog({
|
||||
title: "Delete list?",
|
||||
message: `“${list.title}” will be deleted.${entries} This can't be undone.`,
|
||||
confirm: "Delete list",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api("DELETE", `/api/lists/${list.id}`);
|
||||
lists = lists.filter((l) => l.id !== list.id);
|
||||
render();
|
||||
toast("List deleted");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
}
|
||||
}
|
||||
|
||||
function card(list) {
|
||||
const cat = catByKey[list.category];
|
||||
const pct = Math.min(100, Math.round((list.itemCount / list.maxItems) * 100));
|
||||
const imgs = covers[list.id] || [];
|
||||
const art = imgs.length
|
||||
? h("span", { class: "list-card-covers" + (cat.aspect === "square" ? " is-square" : "") },
|
||||
imgs.map((src) => h("span", { style: { backgroundImage: `url("${src}")` } })))
|
||||
: h("span", { class: "shelf-icon" }, icon(cat.icon));
|
||||
return h("li", { class: "list-card", "data-id": list.id },
|
||||
h("button", { type: "button", class: "drag-handle", "aria-label": `Reorder ${list.title}. Use arrow keys to move.` }, icon("grip")),
|
||||
h("a", { class: "list-card-main", href: `/admin/lists/${list.id}` },
|
||||
art,
|
||||
h("span", { style: { minWidth: 0 } },
|
||||
h("span", { class: "list-card-title" }, list.title),
|
||||
h("span", { class: "list-card-meta" },
|
||||
h("span", {}, cat.label),
|
||||
h("span", { "aria-hidden": "true" }, "·"),
|
||||
h("span", {}, `${fmt.format(list.itemCount)} / ${fmt.format(list.maxItems)}`),
|
||||
h("span", { class: "list-card-bar", "aria-hidden": "true" }, h("span", { style: { width: pct + "%" } })),
|
||||
!list.visible && h("span", { class: "badge badge--muted" }, icon("eye-off"), "Hidden"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h("div", { class: "list-card-actions" },
|
||||
h("a", { class: "icon-btn", href: `/l/${list.slug}`, title: "View public page", "aria-label": "View public page" }, icon("external")),
|
||||
h("button", { type: "button", class: "icon-btn", title: "List settings", "aria-label": "List settings", onclick: () => editList(list) }, icon("settings")),
|
||||
h("button", { type: "button", class: "icon-btn icon-btn--danger", title: "Delete", "aria-label": "Delete list", onclick: () => deleteList(list) }, icon("trash")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!lists.length) {
|
||||
root.replaceChildren(h("div", { class: "onboard" },
|
||||
h("div", { class: "empty-art", style: { margin: "0 auto 16px" } }, icon("deck")),
|
||||
h("h2", {}, "Start your first list"),
|
||||
h("p", {}, "Pick a category. You'll choose how many spots it has next."),
|
||||
h("div", { class: "quick-cats" },
|
||||
categories.map((c) => h("button", { type: "button", onclick: () => newList(c.key) }, icon(c.icon), c.label))),
|
||||
));
|
||||
return;
|
||||
}
|
||||
const ul = h("ul", { class: "admin-lists", style: { listStyle: "none", margin: 0, padding: 0 } }, lists.map(card));
|
||||
sortable(ul, {
|
||||
onMove: async () => {
|
||||
const ids = [...ul.children].map((li) => Number(li.dataset.id));
|
||||
lists = ids.map((id) => lists.find((l) => l.id === id));
|
||||
try {
|
||||
await api("POST", "/api/lists/order", { ids });
|
||||
toast("Order saved");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
root.replaceChildren(ul);
|
||||
}
|
||||
|
||||
document.querySelector("[data-action=new-list]").addEventListener("click", () => newList());
|
||||
render();
|
||||
@@ -0,0 +1,360 @@
|
||||
// Settings: profile, appearance, integrations, account.
|
||||
import { h, icon, api, toast, busy } from "./common.js";
|
||||
|
||||
const root = document.getElementById("settings");
|
||||
let settings;
|
||||
|
||||
const field = (label, input, hint) => h("label", { class: "field" },
|
||||
h("span", { class: "field-label" }, label), input, hint && h("span", { class: "field-hint" }, hint));
|
||||
|
||||
function section(id, title, desc, ...body) {
|
||||
return h("section", { class: "settings-section", id },
|
||||
h("h2", {}, title),
|
||||
desc && h("p", { class: "section-desc" }, desc),
|
||||
h("div", { class: "panel" }, ...body));
|
||||
}
|
||||
|
||||
async function saveSettings(patch, button, message = "Saved") {
|
||||
if (button) busy(button, true);
|
||||
try {
|
||||
settings = await api("PUT", "/api/settings", patch);
|
||||
toast(message);
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
return false;
|
||||
} finally {
|
||||
if (button) busy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- profile ----------
|
||||
|
||||
function profileSection() {
|
||||
const avatarBox = h("div", { class: "avatar" });
|
||||
const renderAvatar = () => {
|
||||
avatarBox.replaceChildren(settings.avatar
|
||||
? h("img", { src: settings.avatar, alt: "" })
|
||||
: h("span", {}, (settings.ownerName || settings.siteTitle || "T").trim().charAt(0).toUpperCase()));
|
||||
removeBtn.hidden = !settings.avatar;
|
||||
};
|
||||
const file = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp,image/gif,image/avif", hidden: true });
|
||||
const uploadBtn = h("button", { type: "button", class: "btn btn--sm", onclick: () => file.click() }, icon("upload"), "Upload picture");
|
||||
const removeBtn = h("button", {
|
||||
type: "button", class: "btn btn--sm btn--ghost",
|
||||
onclick: async () => {
|
||||
try {
|
||||
await api("DELETE", "/api/settings/avatar");
|
||||
settings.avatar = "";
|
||||
renderAvatar();
|
||||
toast("Picture removed");
|
||||
} catch (err) { toast(err.message, { error: true }); }
|
||||
},
|
||||
}, "Remove");
|
||||
file.addEventListener("change", async () => {
|
||||
if (!file.files[0]) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.files[0]);
|
||||
busy(uploadBtn, true);
|
||||
try {
|
||||
const res = await api("POST", "/api/settings/avatar", fd);
|
||||
settings.avatar = res.avatar;
|
||||
renderAvatar();
|
||||
toast("Picture updated");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
busy(uploadBtn, false);
|
||||
file.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const ownerName = h("input", { maxlength: 100, value: settings.ownerName, placeholder: "Your name" });
|
||||
const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: "TopDeck" });
|
||||
const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" });
|
||||
const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } });
|
||||
const notes = h("input", { type: "checkbox", checked: settings.showNotes });
|
||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Save profile");
|
||||
|
||||
const form = h("form", { class: "stack" },
|
||||
h("div", { class: "avatar-edit" }, avatarBox, h("div", { class: "row-btns", style: { display: "flex", gap: "6px", flexWrap: "wrap" } }, uploadBtn, removeBtn, file)),
|
||||
h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab.")),
|
||||
field("Tagline", tagline),
|
||||
field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."),
|
||||
h("label", { class: "check" }, notes, "Show my notes to visitors"),
|
||||
h("div", { class: "settings-actions" }, save),
|
||||
);
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const ok = await saveSettings({
|
||||
ownerName: ownerName.value, siteTitle: siteTitle.value, tagline: tagline.value,
|
||||
previewCount: Number(preview.value), showNotes: notes.checked,
|
||||
}, save, "Profile saved");
|
||||
if (ok) {
|
||||
renderAvatar();
|
||||
const brand = document.querySelector(".brand-name");
|
||||
if (brand) brand.textContent = settings.siteTitle || "TopDeck";
|
||||
}
|
||||
});
|
||||
renderAvatar();
|
||||
return section("profile", "Profile", "Who's behind the lists.", form);
|
||||
}
|
||||
|
||||
// ---------- appearance ----------
|
||||
|
||||
const CSS_EXAMPLE = `/* Override any theme variable. */
|
||||
:root, [data-theme] {
|
||||
--accent: #ff5a7a;
|
||||
--radius-art: 14px;
|
||||
}
|
||||
|
||||
/* Or restyle anything, e.g. bigger posters: */
|
||||
.items--grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
|
||||
`;
|
||||
|
||||
function appearanceSection() {
|
||||
const html = document.documentElement;
|
||||
const cards = h("div", { class: "themes", role: "radiogroup", "aria-label": "Theme" },
|
||||
settings.themes.map((t) => h("label", { class: "theme-card" },
|
||||
h("input", {
|
||||
type: "radio", name: "theme", value: t.key, checked: t.key === settings.theme,
|
||||
onchange: async () => {
|
||||
html.dataset.theme = t.key;
|
||||
await saveSettings({ theme: t.key }, null, `Theme set to ${t.label}`);
|
||||
},
|
||||
}),
|
||||
h("span", { class: "theme-swatch", "data-theme": t.key },
|
||||
h("span", { class: "theme-preview" }, h("i"), h("i"), h("i"), h("i")),
|
||||
h("span", { class: "theme-name" }, t.label, h("small", { class: "theme-kind" }, t.dark ? "Dark" : "Light")),
|
||||
),
|
||||
)),
|
||||
);
|
||||
|
||||
const css = h("textarea", { class: "code", spellcheck: false, value: settings.customCss, placeholder: CSS_EXAMPLE, "aria-label": "Custom CSS" });
|
||||
css.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
css.setRangeText(" ", css.selectionStart, css.selectionEnd, "end");
|
||||
}
|
||||
});
|
||||
let live = document.querySelector("link[href^='/custom.css']");
|
||||
const previewStyle = h("style");
|
||||
const previewBtn = h("button", {
|
||||
type: "button", class: "btn",
|
||||
onclick: () => {
|
||||
live?.remove();
|
||||
live = null;
|
||||
previewStyle.textContent = css.value;
|
||||
document.head.append(previewStyle);
|
||||
toast("Previewing on this page. Save to publish.");
|
||||
},
|
||||
}, icon("eye"), "Preview");
|
||||
const exampleBtn = h("button", { type: "button", class: "btn btn--ghost", onclick: () => { css.value = CSS_EXAMPLE; css.focus(); } }, "Insert example");
|
||||
const save = h("button", { type: "button", class: "btn btn--primary" }, "Save CSS");
|
||||
save.addEventListener("click", async () => {
|
||||
if (await saveSettings({ customCss: css.value }, save, "Custom CSS saved")) {
|
||||
live?.remove();
|
||||
live = null;
|
||||
previewStyle.textContent = css.value;
|
||||
document.head.append(previewStyle);
|
||||
}
|
||||
});
|
||||
|
||||
return section("appearance", "Appearance", "Pick a preset, then fine-tune it with your own CSS if you like.",
|
||||
h("div", { class: "field" }, h("span", { class: "field-label" }, "Theme"), cards),
|
||||
h("div", { class: "field" },
|
||||
h("span", { class: "field-label" }, "Custom CSS"),
|
||||
css,
|
||||
h("span", { class: "field-hint" }, "Loaded after the theme on every page. Every color is a CSS variable (see themes.css), so a few lines can make a whole new theme."),
|
||||
),
|
||||
h("div", { class: "settings-actions" }, exampleBtn, previewBtn, save),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- integrations ----------
|
||||
|
||||
function secretInput(key, label, hintHTML) {
|
||||
const st = settings.secrets[key];
|
||||
const input = h("input", {
|
||||
type: "password", autocomplete: "off", spellcheck: false,
|
||||
placeholder: st.set ? `Saved (${st.hint}). Type to replace.` : "Not set",
|
||||
disabled: st.locked,
|
||||
});
|
||||
const hint = h("span", { class: "field-hint" });
|
||||
if (st.locked) hint.textContent = `Set by the ${"TOPDECK_" + key.toUpperCase()} environment variable.`;
|
||||
else if (hintHTML) hint.append(...hintHTML);
|
||||
const wrap = field(label, input);
|
||||
wrap.append(hint);
|
||||
return { key, input, wrap, st };
|
||||
}
|
||||
|
||||
function testButton(category, out) {
|
||||
const btn = h("button", { type: "button", class: "btn btn--sm" }, icon("refresh"), "Test");
|
||||
btn.addEventListener("click", async () => {
|
||||
busy(btn, true);
|
||||
out.textContent = "Testing…";
|
||||
out.className = "test-result";
|
||||
try {
|
||||
const res = await api("POST", "/api/integrations/test", { category });
|
||||
out.textContent = res.message;
|
||||
out.className = "test-result " + (res.ok ? "is-ok" : "is-error");
|
||||
} catch (err) {
|
||||
out.textContent = err.message;
|
||||
out.className = "test-result is-error";
|
||||
} finally {
|
||||
busy(btn, false);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
const extLink = (href, text) => h("a", { href, target: "_blank", rel: "noopener noreferrer" }, text);
|
||||
|
||||
function integration({ title, iconName, desc, category, secrets = [], extra = [], ready }) {
|
||||
const out = h("span", { class: "test-result", "aria-live": "polite" });
|
||||
const status = h("span", { class: "status" });
|
||||
const setStatus = () => {
|
||||
const ok = ready();
|
||||
status.textContent = ok ? "Ready" : "Needs a key";
|
||||
status.className = "status " + (ok ? "is-ok" : "is-off");
|
||||
};
|
||||
const editable = secrets.filter((s) => !s.st.locked);
|
||||
const save = editable.length > 0 && h("button", { type: "button", class: "btn btn--sm btn--primary" }, "Save");
|
||||
const clear = editable.some((s) => s.st.set) && h("button", { type: "button", class: "btn btn--sm btn--ghost" }, "Clear");
|
||||
const collect = (clearAll) => {
|
||||
const patch = {};
|
||||
for (const s of editable) {
|
||||
if (clearAll) patch[s.key] = "";
|
||||
else if (s.input.value.trim()) patch[s.key] = s.input.value.trim();
|
||||
}
|
||||
return patch;
|
||||
};
|
||||
const refresh = () => {
|
||||
for (const s of secrets) {
|
||||
s.st = settings.secrets[s.key];
|
||||
s.input.value = "";
|
||||
s.input.placeholder = s.st.set ? `Saved (${s.st.hint}). Type to replace.` : "Not set";
|
||||
}
|
||||
setStatus();
|
||||
};
|
||||
save && save.addEventListener("click", async () => {
|
||||
const patch = collect(false);
|
||||
if (!Object.keys(patch).length) { toast("Type a key first", { error: true }); return; }
|
||||
if (await saveSettings(patch, save, `${title} saved`)) refresh();
|
||||
});
|
||||
clear && clear.addEventListener("click", async () => {
|
||||
if (await saveSettings(collect(true), clear, `${title} keys cleared`)) refresh();
|
||||
});
|
||||
setStatus();
|
||||
return h("div", { class: "integration" },
|
||||
h("div", { class: "integration-head" },
|
||||
h("div", {}, h("h3", {}, icon(iconName), title), h("p", {}, desc)),
|
||||
status,
|
||||
),
|
||||
...extra,
|
||||
...secrets.map((s) => s.wrap),
|
||||
h("div", { style: { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" } },
|
||||
testButton(category, out), save, clear, out),
|
||||
);
|
||||
}
|
||||
|
||||
function providerChoice(settingKey, options) {
|
||||
const name = "choice-" + settingKey;
|
||||
return h("div", { class: "radio-cards", role: "radiogroup" },
|
||||
options.map(([value, label]) => h("label", {},
|
||||
h("input", {
|
||||
type: "radio", name, value, checked: settings[settingKey] === value,
|
||||
onchange: () => saveSettings({ [settingKey]: value }, null, `Now using ${label.split(" (")[0]}`),
|
||||
}),
|
||||
label)));
|
||||
}
|
||||
|
||||
function integrationsSection() {
|
||||
const tmdb = secretInput("tmdb_api_key", "API key or Read Access Token", [
|
||||
"Free with a TMDB account: ", extLink("https://www.themoviedb.org/settings/api", "themoviedb.org/settings/api"), ". Either the v3 key or the v4 token works."]);
|
||||
const tvdb = secretInput("tvdb_api_key", "API key", [
|
||||
"Get one at ", extLink("https://thetvdb.com/api-information", "thetvdb.com/api-information"), "."]);
|
||||
const tvdbPin = secretInput("tvdb_pin", "Subscriber PIN (optional)", ["Only needed for user-supported keys."]);
|
||||
const igdbId = secretInput("igdb_client_id", "Twitch Client ID", [
|
||||
"IGDB uses Twitch logins. Register an app at ", extLink("https://dev.twitch.tv/console/apps", "dev.twitch.tv/console"), " (see ",
|
||||
extLink("https://api-docs.igdb.com/#account-creation", "IGDB's guide"), ")."]);
|
||||
const igdbSecret = secretInput("igdb_client_secret", "Twitch Client Secret");
|
||||
|
||||
const has = (k) => settings.secrets[k].set;
|
||||
return section("integrations", "Integrations", "Where search results and artwork come from. Keys are stored on your server and never shown on your public page.",
|
||||
integration({
|
||||
title: "Movies · TMDB", iconName: "film", category: "movie",
|
||||
desc: "The Movie Database: posters, directors, genres.",
|
||||
secrets: [tmdb], ready: () => has("tmdb_api_key"),
|
||||
}),
|
||||
integration({
|
||||
title: "TV Shows", iconName: "tv", category: "tv",
|
||||
desc: "TheTVDB by default. TMDB also works if you'd rather use one key for both.",
|
||||
extra: [providerChoice("tvProvider", [["tvdb", "TheTVDB"], ["tmdb", "TMDB (uses the key above)"]])],
|
||||
secrets: [tvdb, tvdbPin],
|
||||
ready: () => settings.tvProvider === "tmdb" ? has("tmdb_api_key") : has("tvdb_api_key"),
|
||||
}),
|
||||
integration({
|
||||
title: "Video Games · IGDB", iconName: "gamepad", category: "game",
|
||||
desc: "The Internet Game Database: covers, developers, platforms.",
|
||||
secrets: [igdbId, igdbSecret], ready: () => has("igdb_client_id") && has("igdb_client_secret"),
|
||||
}),
|
||||
integration({
|
||||
title: "Music · Songs & Albums", iconName: "disc", category: "album",
|
||||
desc: "No key needed. Deezer has the best cover art and fast search; MusicBrainz is fully open data but slower (1 request/second).",
|
||||
extra: [providerChoice("musicProvider", [["deezer", "Deezer (recommended)"], ["musicbrainz", "MusicBrainz + Cover Art Archive"]])],
|
||||
ready: () => true,
|
||||
}),
|
||||
integration({
|
||||
title: "Theater · Wikipedia", iconName: "masks", category: "theater",
|
||||
desc: "No key needed. Finds musicals, plays and operas on Wikipedia. You choose the production (Broadway, a tour…) and can swap in its own poster.",
|
||||
ready: () => true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- account ----------
|
||||
|
||||
function accountSection() {
|
||||
const username = h("input", { autocomplete: "username", maxlength: 64, value: settings.username, required: true });
|
||||
const current = h("input", { type: "password", autocomplete: "current-password", required: true });
|
||||
const next = h("input", { type: "password", autocomplete: "new-password", minlength: 8, placeholder: "Leave blank to keep" });
|
||||
const confirm = h("input", { type: "password", autocomplete: "new-password" });
|
||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, "Update account");
|
||||
const form = h("form", { class: "stack" },
|
||||
field("Username", username),
|
||||
h("div", { class: "row" }, field("New password", next), field("Confirm new password", confirm)),
|
||||
field("Current password", current, "Required to make changes. Changing your password signs out other browsers."),
|
||||
h("div", { class: "settings-actions" }, save),
|
||||
);
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (next.value && next.value !== confirm.value) { toast("The new passwords don't match", { error: true }); return; }
|
||||
if (next.value && next.value.length < 8) { toast("Use at least 8 characters", { error: true }); return; }
|
||||
busy(save, true);
|
||||
try {
|
||||
const res = await api("PUT", "/api/account", { username: username.value, currentPassword: current.value, newPassword: next.value });
|
||||
settings.username = res.username;
|
||||
current.value = next.value = confirm.value = "";
|
||||
toast("Account updated");
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
busy(save, false);
|
||||
}
|
||||
});
|
||||
return section("account", "Account", "", form);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
settings = await api("GET", "/api/settings");
|
||||
} catch (err) {
|
||||
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
|
||||
return;
|
||||
}
|
||||
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), accountSection());
|
||||
root.removeAttribute("aria-busy");
|
||||
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
|
||||
}
|
||||
init();
|
||||
@@ -0,0 +1,160 @@
|
||||
// Shared helpers for TopDeck's pages. No build step, no dependencies.
|
||||
|
||||
/** Create an element: h("button", { class: "btn", onclick }, "Save") */
|
||||
export function h(tag, attrs = {}, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
let value;
|
||||
for (const [k, v] of Object.entries(attrs || {})) {
|
||||
if (v === false || v == null) continue;
|
||||
if (k === "value") value = v;
|
||||
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
||||
else if (k === "class") el.className = v;
|
||||
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
|
||||
else if (k in el && typeof v !== "string") el[k] = v;
|
||||
else el.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
append(el, children);
|
||||
// Set after children so <select> options exist.
|
||||
if (value !== undefined) el.value = value;
|
||||
return el;
|
||||
}
|
||||
|
||||
function append(el, children) {
|
||||
for (const c of children.flat(Infinity)) {
|
||||
if (c == null || c === false) continue;
|
||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
}
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
|
||||
export function icon(name) {
|
||||
const svg = document.createElementNS(SVG, "svg");
|
||||
svg.setAttribute("class", "icon");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const use = document.createElementNS(SVG, "use");
|
||||
use.setAttribute("href", `/static/img/icons.svg#${name}`);
|
||||
svg.append(use);
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function readBoot() {
|
||||
const el = document.getElementById("boot");
|
||||
return el ? JSON.parse(el.textContent) : {};
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(message, status, body) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/** Call the admin JSON API. Throws APIError with a human message. */
|
||||
export async function api(method, path, body) {
|
||||
const opts = { method, headers: { "X-TopDeck": "1" }, credentials: "same-origin" };
|
||||
if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
} else if (body !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, opts);
|
||||
} catch {
|
||||
throw new APIError("Can't reach the server. Check your connection.", 0);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.status === 401) {
|
||||
location.href = "/login?next=" + encodeURIComponent(location.pathname);
|
||||
}
|
||||
if (!res.ok) throw new APIError(data.error || `Request failed (${res.status})`, res.status, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
let toastBox;
|
||||
export function toast(message, { error = false, timeout = 3200 } = {}) {
|
||||
if (!toastBox) {
|
||||
toastBox = h("div", { class: "toasts", role: "status", "aria-live": "polite" });
|
||||
document.body.append(toastBox);
|
||||
}
|
||||
const t = h("div", { class: "toast" + (error ? " toast--error" : "") }, icon(error ? "alert" : "check"), message);
|
||||
toastBox.append(t);
|
||||
setTimeout(() => {
|
||||
t.classList.add("is-leaving");
|
||||
setTimeout(() => t.remove(), 250);
|
||||
}, error ? Math.max(timeout, 5000) : timeout);
|
||||
}
|
||||
|
||||
/** Open a modal dialog. Returns the <dialog>; it removes itself on close. */
|
||||
export function openDialog(content, { className = "", onClose } = {}) {
|
||||
const dlg = h("dialog", { class: className }, content);
|
||||
dlg.addEventListener("click", (e) => {
|
||||
// Clicking the backdrop (outside the box) closes.
|
||||
if (e.target !== dlg) return;
|
||||
const r = dlg.getBoundingClientRect();
|
||||
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
|
||||
if (!inside) dlg.close();
|
||||
});
|
||||
dlg.addEventListener("close", () => {
|
||||
onClose?.();
|
||||
dlg.remove();
|
||||
});
|
||||
document.body.append(dlg);
|
||||
dlg.showModal();
|
||||
return dlg;
|
||||
}
|
||||
|
||||
export function dialogHead(title, dlg) {
|
||||
return h("div", { class: "dialog-head" },
|
||||
h("h2", {}, title),
|
||||
h("button", { type: "button", class: "icon-btn", "aria-label": "Close", onclick: () => dlg().close() }, icon("x")),
|
||||
);
|
||||
}
|
||||
|
||||
/** Ask a yes/no question. Resolves true when confirmed. */
|
||||
export function confirmDialog({ title, message, confirm = "Confirm", danger = false }) {
|
||||
return new Promise((resolve) => {
|
||||
let ok = false;
|
||||
let dlg;
|
||||
const content = h("form", { method: "dialog" },
|
||||
dialogHead(title, () => dlg),
|
||||
h("div", { class: "dialog-body" }, h("p", { class: "muted" }, message)),
|
||||
h("div", { class: "dialog-foot" },
|
||||
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
|
||||
h("button", {
|
||||
type: "submit",
|
||||
class: "btn " + (danger ? "btn--danger btn--solid" : "btn--primary"),
|
||||
onclick: () => { ok = true; },
|
||||
}, confirm),
|
||||
),
|
||||
);
|
||||
dlg = openDialog(content, { onClose: () => resolve(ok) });
|
||||
dlg.style.maxWidth = "440px";
|
||||
});
|
||||
}
|
||||
|
||||
export function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
export function busy(button, on) {
|
||||
button.disabled = on;
|
||||
button.setAttribute("aria-busy", on ? "true" : "false");
|
||||
}
|
||||
|
||||
export const fmt = new Intl.NumberFormat();
|
||||
|
||||
export function storageGet(key) {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
export function storageSet(key, val) {
|
||||
try { localStorage.setItem(key, val); } catch { /* private mode */ }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Create / edit list dialog, shared by the dashboard and the list editor.
|
||||
import { h, icon, api, openDialog, dialogHead, busy, fmt } from "./common.js";
|
||||
|
||||
const SIZE_PRESETS = [5, 10, 25, 50, 100];
|
||||
|
||||
function sizeHint(n, limits) {
|
||||
if (!Number.isInteger(n) || n < 1) return { text: "Pick a size of at least 1.", cls: "is-error" };
|
||||
if (n > limits.max) return { text: `The most a list can hold is ${fmt.format(limits.max)}.`, cls: "is-error" };
|
||||
if (n > limits.warn) return { text: `That's a lot. Lists this long work, but they're hard to browse and rank. ${limits.recommended} or fewer is recommended.`, cls: "is-warn" };
|
||||
if (n > limits.recommended) return { text: `Heads up: ${limits.recommended} or fewer is recommended so visitors can take it all in.`, cls: "is-warn" };
|
||||
return { text: `Up to ${fmt.format(limits.max)} allowed; ${limits.recommended} or fewer recommended.`, cls: "" };
|
||||
}
|
||||
|
||||
const autoTitle = (n, cat) => `Top ${n} ${cat.label}`;
|
||||
|
||||
/**
|
||||
* @param {object} o
|
||||
* @param {object[]} o.categories
|
||||
* @param {object} o.limits
|
||||
* @param {object} [o.list] existing list to edit
|
||||
* @param {string} [o.category] preselected category for a new list
|
||||
* @param {(list: object) => void} o.onSaved
|
||||
*/
|
||||
export function openListForm({ categories, limits, list, category, onSaved }) {
|
||||
const editing = !!list;
|
||||
const catByKey = Object.fromEntries(categories.map((c) => [c.key, c]));
|
||||
const state = {
|
||||
category: list?.category || category || categories[0].key,
|
||||
size: list?.maxItems || 10,
|
||||
};
|
||||
// Keep the title in sync with size/category until the owner types their own.
|
||||
let titleTouched = editing && list.title !== autoTitle(list.maxItems, catByKey[list.category]);
|
||||
|
||||
const title = h("input", { name: "title", required: true, maxlength: 200, value: list?.title || autoTitle(state.size, catByKey[state.category]) });
|
||||
title.addEventListener("input", () => { titleTouched = true; });
|
||||
const syncTitle = () => { if (!titleTouched) title.value = autoTitle(state.size, catByKey[state.category]); };
|
||||
|
||||
const lockedCat = editing && list.itemCount > 0;
|
||||
const catPicker = h("div", { class: "cat-picker", role: "radiogroup", "aria-label": "Category" },
|
||||
categories.map((c) => h("label", { class: "cat-option" },
|
||||
h("input", {
|
||||
type: "radio", name: "category", value: c.key, checked: c.key === state.category,
|
||||
disabled: lockedCat && c.key !== state.category,
|
||||
onchange: () => { state.category = c.key; syncTitle(); },
|
||||
}),
|
||||
h("span", {}, icon(c.icon), c.label),
|
||||
)),
|
||||
);
|
||||
|
||||
const sizeInput = h("input", { type: "number", min: 1, max: limits.max, step: 1, value: state.size, inputmode: "numeric", style: { maxWidth: "140px" } });
|
||||
const hint = h("span", { class: "field-hint" });
|
||||
const chips = h("div", { class: "size-chips" });
|
||||
const renderSize = () => {
|
||||
const { text, cls } = sizeHint(state.size, limits);
|
||||
hint.textContent = text;
|
||||
hint.className = "field-hint " + cls;
|
||||
chips.replaceChildren(...SIZE_PRESETS.map((n) => h("button", {
|
||||
type: "button", "aria-pressed": String(n === state.size),
|
||||
onclick: () => { state.size = n; sizeInput.value = n; renderSize(); syncTitle(); },
|
||||
}, `Top ${n}`)));
|
||||
};
|
||||
sizeInput.addEventListener("input", () => { state.size = Number(sizeInput.value); renderSize(); syncTitle(); });
|
||||
renderSize();
|
||||
|
||||
const desc = h("textarea", { name: "description", rows: 2, maxlength: 1000, placeholder: "Optional: a line about what makes this list", value: list?.description || "" });
|
||||
const slug = editing && h("input", { name: "slug", value: list.slug, maxlength: 60, pattern: "[a-z0-9-]+", spellcheck: false });
|
||||
const layout = list?.layout || "grid";
|
||||
const layoutGroup = h("div", { class: "radio-cards" },
|
||||
h("label", {}, h("input", { type: "radio", name: "layout", value: "grid", checked: layout === "grid" }), icon("grid"), "Artwork grid"),
|
||||
h("label", {}, h("input", { type: "radio", name: "layout", value: "rows", checked: layout === "rows" }), icon("rows"), "Ranked list"),
|
||||
);
|
||||
const visible = h("input", { type: "checkbox", name: "visible", checked: list ? list.visible : true });
|
||||
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
|
||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save changes" : "Create list");
|
||||
|
||||
let dlg;
|
||||
const form = h("form", { novalidate: true },
|
||||
dialogHead(editing ? "List settings" : "New list", () => dlg),
|
||||
h("div", { class: "dialog-body stack" },
|
||||
h("div", { class: "field" }, h("span", { class: "field-label" }, "Category"), catPicker,
|
||||
lockedCat && h("span", { class: "field-hint" }, "The category can't change once a list has entries.")),
|
||||
h("div", { class: "field" }, h("span", { class: "field-label" }, "How many?"),
|
||||
h("div", { class: "input-group", style: { alignItems: "center", flexWrap: "wrap" } }, chips, sizeInput), hint),
|
||||
h("label", { class: "field" }, h("span", { class: "field-label" }, "Title"), title),
|
||||
h("label", { class: "field" }, h("span", { class: "field-label" }, "Description"), desc),
|
||||
editing && h("label", { class: "field" }, h("span", { class: "field-label" }, "Address"),
|
||||
h("div", { class: "input-group", style: { alignItems: "center" } }, h("span", { class: "muted" }, "/l/"), slug)),
|
||||
h("div", { class: "field" }, h("span", { class: "field-label" }, "Layout"), layoutGroup),
|
||||
h("label", { class: "check" }, visible, "Show this list on my public page"),
|
||||
error,
|
||||
),
|
||||
h("div", { class: "dialog-foot" },
|
||||
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
|
||||
save,
|
||||
),
|
||||
);
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
error.hidden = true;
|
||||
const n = Number(sizeInput.value);
|
||||
if (!Number.isInteger(n) || n < 1 || n > limits.max) {
|
||||
error.hidden = false;
|
||||
error.textContent = sizeHint(n, limits).text;
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
title: title.value,
|
||||
category: state.category,
|
||||
maxItems: n,
|
||||
description: desc.value,
|
||||
layout: form.querySelector("input[name=layout]:checked").value,
|
||||
visible: visible.checked,
|
||||
};
|
||||
if (editing) body.slug = slug.value;
|
||||
busy(save, true);
|
||||
try {
|
||||
const saved = editing
|
||||
? await api("PUT", `/api/lists/${list.id}`, body)
|
||||
: await api("POST", "/api/lists", body);
|
||||
dlg.close();
|
||||
onSaved(saved);
|
||||
} catch (err) {
|
||||
error.hidden = false;
|
||||
error.textContent = err.message;
|
||||
} finally {
|
||||
busy(save, false);
|
||||
}
|
||||
});
|
||||
|
||||
dlg = openDialog(form);
|
||||
(editing ? title : catPicker.querySelector("input:checked"))?.focus();
|
||||
return dlg;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Public pages: item detail dialog and grid/list toggle.
|
||||
import { h, icon, openDialog, storageGet, storageSet } from "./common.js";
|
||||
|
||||
// Each ranked list on the page ships its items as JSON next to the markup.
|
||||
const lists = [...document.querySelectorAll("[data-items]")].map((ol) => {
|
||||
const data = JSON.parse(ol.nextElementSibling?.textContent || "[]");
|
||||
return { ol, items: data, title: ol.dataset.listTitle, square: ol.classList.contains("aspect-square") };
|
||||
});
|
||||
|
||||
function findItem(id) {
|
||||
for (const list of lists) {
|
||||
const idx = list.items.findIndex((it) => it.id === id);
|
||||
if (idx >= 0) return { list, idx };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let current = null;
|
||||
|
||||
function renderDialog(list, idx) {
|
||||
const it = list.items[idx];
|
||||
const art = it.image
|
||||
? h("img", { src: it.image, alt: `Artwork for ${it.title}` })
|
||||
: h("span", { class: "art-fallback" }, icon("image"));
|
||||
const sub = [it.subtitle, it.year].filter(Boolean).join(" · ");
|
||||
const topClass = it.rank <= 3 ? ` top${it.rank}` : "";
|
||||
const meta = it.meta.filter((m) => m.label !== "Production");
|
||||
|
||||
const prev = h("button", { type: "button", class: "icon-btn", "aria-label": "Previous", disabled: idx === 0, onclick: () => show(list, idx - 1) }, icon("chevron-left"));
|
||||
const next = h("button", { type: "button", class: "icon-btn", "aria-label": "Next", disabled: idx === list.items.length - 1, onclick: () => show(list, idx + 1) }, icon("chevron-right"));
|
||||
|
||||
return h("div", { class: "item-dialog-inner" },
|
||||
h("div", { class: "dlg-art" }, art),
|
||||
h("div", { class: "dlg-body" },
|
||||
h("button", { type: "button", class: "icon-btn dlg-close", "aria-label": "Close", onclick: () => current?.close() }, icon("x")),
|
||||
h("p", { class: "dlg-rank" + topClass }, h("strong", {}, `#${it.rank}`), h("span", {}, list.title)),
|
||||
h("h2", { class: "dlg-title" }, it.title),
|
||||
sub && h("p", { class: "dlg-sub" }, sub),
|
||||
it.badge && h("span", { class: "badge" }, it.badge),
|
||||
meta.length > 0 && h("dl", { class: "dlg-meta" }, meta.map((m) => [h("dt", {}, m.label), h("dd", {}, m.value)])),
|
||||
it.note && h("blockquote", { class: "dlg-note" }, it.note),
|
||||
h("div", { class: "dlg-actions" },
|
||||
it.link && h("a", { class: "btn btn--sm", href: it.link, target: "_blank", rel: "noopener noreferrer" },
|
||||
icon("external"), it.source ? `View on ${it.source}` : "Learn more"),
|
||||
h("div", { class: "dlg-nav" }, prev, next),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function show(list, idx) {
|
||||
const content = renderDialog(list, idx);
|
||||
if (current) {
|
||||
current.replaceChildren(content);
|
||||
} else {
|
||||
current = openDialog(content, {
|
||||
className: "item-dialog" + (list.square ? " is-square" : ""),
|
||||
onClose: () => { current = null; },
|
||||
});
|
||||
current.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowLeft") current.querySelector(".dlg-nav button:first-child")?.click();
|
||||
if (e.key === "ArrowRight") current.querySelector(".dlg-nav button:last-child")?.click();
|
||||
});
|
||||
}
|
||||
current.setAttribute("aria-label", `#${list.items[idx].rank} ${list.items[idx].title}`);
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-item]");
|
||||
if (!btn) return;
|
||||
const found = findItem(Number(btn.dataset.item));
|
||||
if (found) show(found.list, found.idx);
|
||||
});
|
||||
|
||||
// Grid / list toggle on list pages, remembered per list in this browser.
|
||||
const toggle = document.querySelector("[data-layout-toggle]");
|
||||
if (toggle) {
|
||||
const key = "topdeck:layout:" + toggle.dataset.key;
|
||||
const apply = (layout) => {
|
||||
for (const { ol } of lists) {
|
||||
ol.classList.toggle("items--grid", layout === "grid");
|
||||
ol.classList.toggle("items--rows", layout === "rows");
|
||||
}
|
||||
for (const b of toggle.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.layout === layout));
|
||||
};
|
||||
apply(storageGet(key) || toggle.dataset.default);
|
||||
toggle.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button[data-layout]");
|
||||
if (!b) return;
|
||||
apply(b.dataset.layout);
|
||||
storageSet(key, b.dataset.layout);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Minimal drag-to-reorder for vertical lists. Works with mouse, touch and pen
|
||||
// via pointer events; the handle also supports ArrowUp/ArrowDown.
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container element whose children are sortable
|
||||
* @param {object} opts
|
||||
* @param {string} opts.handle selector for the drag handle inside a child
|
||||
* @param {string} opts.placeholderClass classes for the gap left while dragging
|
||||
* @param {(el: HTMLElement, from: number, to: number) => void} opts.onMove
|
||||
*/
|
||||
export function sortable(container, { handle = ".drag-handle", placeholderClass = "is-placeholder", onMove }) {
|
||||
const children = () => [...container.children];
|
||||
|
||||
container.addEventListener("pointerdown", (e) => {
|
||||
const hdl = e.target.closest(handle);
|
||||
if (!hdl || !container.contains(hdl) || (e.pointerType === "mouse" && e.button !== 0)) return;
|
||||
const el = [...container.children].find((c) => c.contains(hdl));
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
|
||||
const from = children().indexOf(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const grabOffset = e.clientY - rect.top;
|
||||
let pointerY = e.clientY;
|
||||
|
||||
const ph = el.cloneNode(true);
|
||||
ph.className = el.className + " " + placeholderClass;
|
||||
ph.removeAttribute("id");
|
||||
ph.setAttribute("aria-hidden", "true");
|
||||
ph.style.height = rect.height + "px";
|
||||
el.after(ph);
|
||||
|
||||
Object.assign(el.style, {
|
||||
position: "fixed",
|
||||
left: rect.left + "px",
|
||||
top: rect.top + "px",
|
||||
width: rect.width + "px",
|
||||
zIndex: "50",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
el.classList.add("is-dragging");
|
||||
document.documentElement.classList.add("dragging-active");
|
||||
hdl.setPointerCapture(e.pointerId);
|
||||
|
||||
const place = () => {
|
||||
el.style.top = pointerY - grabOffset + "px";
|
||||
const others = children().filter((c) => c !== el && c !== ph);
|
||||
let before = null;
|
||||
for (const c of others) {
|
||||
const r = c.getBoundingClientRect();
|
||||
if (pointerY < r.top + r.height / 2) { before = c; break; }
|
||||
}
|
||||
if (before) {
|
||||
if (ph.nextElementSibling !== before) container.insertBefore(ph, before);
|
||||
} else if (container.lastElementChild !== ph) {
|
||||
container.append(ph);
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll the page when dragging near the top or bottom edge.
|
||||
let raf = 0;
|
||||
const autoscroll = () => {
|
||||
const edge = 70;
|
||||
let dy = 0;
|
||||
if (pointerY < edge + 60) dy = -Math.ceil((edge + 60 - pointerY) / 6);
|
||||
else if (pointerY > innerHeight - edge) dy = Math.ceil((pointerY - (innerHeight - edge)) / 6);
|
||||
if (dy) { scrollBy(0, dy); place(); }
|
||||
raf = requestAnimationFrame(autoscroll);
|
||||
};
|
||||
raf = requestAnimationFrame(autoscroll);
|
||||
|
||||
const move = (ev) => { pointerY = ev.clientY; place(); };
|
||||
const end = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
hdl.removeEventListener("pointermove", move);
|
||||
hdl.removeEventListener("pointerup", end);
|
||||
hdl.removeEventListener("pointercancel", end);
|
||||
ph.replaceWith(el);
|
||||
el.removeAttribute("style");
|
||||
el.classList.remove("is-dragging");
|
||||
document.documentElement.classList.remove("dragging-active");
|
||||
const to = children().indexOf(el);
|
||||
if (to !== from) onMove(el, from, to);
|
||||
};
|
||||
hdl.addEventListener("pointermove", move);
|
||||
hdl.addEventListener("pointerup", end);
|
||||
hdl.addEventListener("pointercancel", end);
|
||||
});
|
||||
|
||||
container.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
||||
const hdl = e.target.closest(handle);
|
||||
if (!hdl) return;
|
||||
const el = children().find((c) => c.contains(hdl));
|
||||
const from = children().indexOf(el);
|
||||
const to = e.key === "ArrowUp" ? from - 1 : from + 1;
|
||||
if (to < 0 || to >= children().length) return;
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") el.previousElementSibling.before(el);
|
||||
else el.nextElementSibling.after(el);
|
||||
hdl.focus();
|
||||
onMove(el, from, to);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="{{.Site.Theme}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .PageTitle}}{{.PageTitle}} · {{end}}{{.Site.Title}}</title>
|
||||
<meta name="description" content="{{if .Site.Tagline}}{{.Site.Tagline}}{{else}}{{.Site.Title}}: a ranked collection of favorites{{end}}">
|
||||
<meta property="og:title" content="{{if .PageTitle}}{{.PageTitle}} · {{end}}{{.Site.Title}}">
|
||||
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/css/themes.css?v={{.AssetVer}}">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetVer}}">
|
||||
{{- if .Site.CustomCSS}}
|
||||
<link rel="stylesheet" href="/custom.css?v={{.Site.CSSVersion}}">
|
||||
{{- end}}
|
||||
</head>
|
||||
<body class="{{block "bodyclass" .}}{{end}}">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner container">
|
||||
<a class="brand" href="/">
|
||||
{{- if .Site.AvatarURL}}<img class="brand-avatar" src="{{.Site.AvatarURL}}" alt="">{{else}}<span class="brand-mark">{{icon "deck"}}</span>{{end -}}
|
||||
<span class="brand-name">{{.Site.Title}}</span>
|
||||
</a>
|
||||
{{- if .LoggedIn}}
|
||||
<nav class="topnav" aria-label="Admin">
|
||||
<a href="/admin"{{if or (eq .Path "/admin") (hasPrefix .Path "/admin/lists")}} aria-current="page"{{end}}>{{icon "list"}}<span>Lists</span></a>
|
||||
<a href="/admin/settings"{{if eq .Path "/admin/settings"}} aria-current="page"{{end}}>{{icon "settings"}}<span>Settings</span></a>
|
||||
<form method="post" action="/logout" class="topnav-form">
|
||||
<button type="submit" class="topnav-btn" title="Sign out ({{.Username}})">{{icon "logout"}}<span class="sr-only">Sign out</span></button>
|
||||
</form>
|
||||
</nav>
|
||||
{{- end}}
|
||||
</div>
|
||||
</header>
|
||||
<main id="main">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<span>{{if .Site.OwnerName}}Curated by {{.Site.OwnerName}}{{else}}{{.Site.Title}}{{end}}</span>
|
||||
<span class="footer-sep" aria-hidden="true">·</span>
|
||||
<span>Powered by <a href="https://git.iamchrisama.com/cdnutter/topdeck" rel="noopener">TopDeck</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
{{define "bodyclass"}}page-admin{{end}}
|
||||
{{define "content"}}
|
||||
{{$c := category .Data.List.Category}}
|
||||
<div class="container admin">
|
||||
<a class="back-link" href="/admin">{{icon "arrow-left"}}All lists</a>
|
||||
<header class="admin-head">
|
||||
<div class="admin-head-title">
|
||||
<span class="shelf-icon">{{icon $c.Icon}}</span>
|
||||
<div>
|
||||
<h1 data-bind="title">{{.Data.List.Title}}</h1>
|
||||
<p class="muted"><span data-bind="count">{{.Data.List.ItemCount}}</span> of <span data-bind="max">{{.Data.List.MaxItems}}</span> · {{$c.Label}}<span data-bind="hidden">{{if not .Data.List.Visible}} · Hidden{{end}}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-head-actions">
|
||||
<a class="btn btn--ghost" data-bind="public" href="/l/{{.Data.List.Slug}}">{{icon "external"}}View</a>
|
||||
<button class="btn" type="button" data-action="edit-list">{{icon "settings"}}List settings</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="meter" aria-hidden="true"><span data-bind="meter" style="width: {{pct .Data.List.ItemCount .Data.List.MaxItems}}%"></span></div>
|
||||
|
||||
<div class="editor">
|
||||
<section class="panel editor-search" aria-labelledby="add-h">
|
||||
<div class="panel-head">
|
||||
<h2 id="add-h">Add {{$c.Label}}</h2>
|
||||
<span class="muted small" data-bind="provider"></span>
|
||||
</div>
|
||||
<form class="search-bar" data-search role="search">
|
||||
{{icon "search"}}
|
||||
<input type="search" name="q" placeholder="Search for a {{$c.Singular}}…" autocomplete="off" aria-label="Search">
|
||||
<span class="spinner" hidden></span>
|
||||
</form>
|
||||
<div class="search-results" data-results aria-live="polite"></div>
|
||||
<button type="button" class="btn btn--ghost btn--block" data-action="manual">{{icon "pencil"}}Add one manually</button>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-items" aria-labelledby="items-h">
|
||||
<div class="panel-head">
|
||||
<h2 id="items-h">Ranking</h2>
|
||||
<span class="muted small">Drag, use the arrows, or type a new rank.</span>
|
||||
</div>
|
||||
<ol class="rank-list" data-rank-list></ol>
|
||||
<nav class="pager" data-pager aria-label="Pages"></nav>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="boot">{{.Data.Boot}}</script>
|
||||
{{end}}
|
||||
{{define "scripts"}}<script type="module" src="/static/js/admin-list.js?v={{.AssetVer}}"></script>{{end}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{define "bodyclass"}}page-admin{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container admin">
|
||||
<header class="admin-head">
|
||||
<div>
|
||||
<h1>Your lists</h1>
|
||||
<p class="muted">Drag to set the order they appear on your page.</p>
|
||||
</div>
|
||||
<div class="admin-head-actions">
|
||||
<a class="btn btn--ghost" href="/">{{icon "external"}}View site</a>
|
||||
<button class="btn btn--primary" type="button" data-action="new-list">{{icon "plus"}}New list</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="lists" class="admin-lists" aria-live="polite"></div>
|
||||
</div>
|
||||
<script type="application/json" id="boot">{{.Data.Boot}}</script>
|
||||
{{end}}
|
||||
{{define "scripts"}}<script type="module" src="/static/js/admin-lists.js?v={{.AssetVer}}"></script>{{end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "bodyclass"}}page-admin{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container admin">
|
||||
<header class="admin-head">
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<p class="muted">Changes apply to your public page right away.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<a href="#profile">{{icon "user"}}Profile</a>
|
||||
<a href="#appearance">{{icon "palette"}}Appearance</a>
|
||||
<a href="#integrations">{{icon "plug"}}Integrations</a>
|
||||
<a href="#account">{{icon "lock"}}Account</a>
|
||||
</nav>
|
||||
<div class="settings-body" id="settings" aria-busy="true">
|
||||
<p class="muted">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="boot">{{.Data.Boot}}</script>
|
||||
{{end}}
|
||||
{{define "scripts"}}<script type="module" src="/static/js/admin-settings.js?v={{.AssetVer}}"></script>{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "content"}}
|
||||
<section class="empty container">
|
||||
<p class="error-code">{{.Data.Code}}</p>
|
||||
<h1>{{if eq .Data.Code 404}}Not found{{else}}Something broke{{end}}</h1>
|
||||
<p>{{.Data.Message}}</p>
|
||||
<a class="btn" href="/">{{icon "arrow-left"}}Back home</a>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{define "bodyclass"}}page-home{{end}}
|
||||
{{define "content"}}
|
||||
{{$name := or .Site.OwnerName .Site.Title}}
|
||||
<section class="profile container">
|
||||
<div class="avatar avatar--lg">
|
||||
{{- if .Site.AvatarURL}}<img src="{{.Site.AvatarURL}}" alt="">{{else}}<span>{{initial $name}}</span>{{end -}}
|
||||
</div>
|
||||
<div class="profile-text">
|
||||
<h1 class="profile-name">{{$name}}</h1>
|
||||
{{with .Site.Tagline}}<p class="profile-tagline">{{.}}</p>{{end}}
|
||||
{{with .Data}}{{if .Sections}}
|
||||
<p class="profile-stats"><strong>{{num .TotalItems}}</strong> {{plural .TotalItems "favorite" "favorites"}} across <strong>{{.TotalLists}}</strong> {{plural .TotalLists "list" "lists"}}</p>
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Data.Sections}}
|
||||
{{if gt (len .Data.Sections) 1}}
|
||||
<nav class="jump container" aria-label="Lists">
|
||||
{{range .Data.Sections}}<a class="chip" href="#{{.List.Slug}}">{{icon .Category.Icon}}<span>{{.List.Title}}</span></a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{range .Data.Sections}}
|
||||
<section class="shelf container" id="{{.List.Slug}}" aria-labelledby="h-{{.List.Slug}}">
|
||||
<header class="shelf-head">
|
||||
<span class="shelf-icon">{{icon .Category.Icon}}</span>
|
||||
<div class="shelf-titles">
|
||||
<h2 id="h-{{.List.Slug}}"><a href="/l/{{.List.Slug}}">{{.List.Title}}</a></h2>
|
||||
{{with .List.Description}}<p class="shelf-desc">{{.}}</p>{{end}}
|
||||
</div>
|
||||
<a class="see-all" href="/l/{{.List.Slug}}">{{if .More}}All {{num .List.ItemCount}}{{else}}Open{{end}}{{icon "arrow-right"}}</a>
|
||||
</header>
|
||||
{{if .Items}}{{template "items" (dict "Items" .Items "Category" .Category "Layout" .List.Layout "Title" .List.Title)}}{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<section class="empty container">
|
||||
<div class="empty-art">{{icon "deck"}}</div>
|
||||
<h2>Nothing ranked yet</h2>
|
||||
<p>This collection is still being curated. Check back soon.</p>
|
||||
{{if .LoggedIn}}<a class="btn btn--primary" href="/admin">{{icon "plus"}}Start a list</a>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{define "scripts"}}<script type="module" src="/static/js/public.js?v={{.AssetVer}}"></script>{{end}}
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "bodyclass"}}page-list{{end}}
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<section class="list-hero{{if .Hero}} list-hero--art{{end}}">
|
||||
{{if .Hero}}<div class="list-hero-backdrop" aria-hidden="true"><img src="{{.Hero.Image}}" alt=""></div>{{end}}
|
||||
<div class="container list-hero-inner">
|
||||
<a class="back-link" href="/">{{icon "arrow-left"}}All lists</a>
|
||||
<div class="list-hero-row">
|
||||
<div>
|
||||
<p class="eyebrow">{{icon .Category.Icon}}{{.Category.Label}}</p>
|
||||
<h1 class="list-title">{{.List.Title}}</h1>
|
||||
{{with .List.Description}}<p class="list-desc">{{.}}</p>{{end}}
|
||||
<p class="list-meta">
|
||||
{{num .List.ItemCount}} {{plural .List.ItemCount "entry" "entries"}}{{if lt .List.ItemCount .List.MaxItems}} <span class="muted">of {{num .List.MaxItems}}</span>{{end}}
|
||||
{{if not .List.Visible}}<span class="badge badge--warn">{{icon "eye-off"}}Hidden</span>{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="list-actions">
|
||||
{{if $.LoggedIn}}<a class="btn btn--ghost" href="/admin/lists/{{.List.ID}}">{{icon "pencil"}}Edit</a>{{end}}
|
||||
{{if .Items}}
|
||||
<div class="segmented" role="group" aria-label="Layout" data-layout-toggle data-default="{{.List.Layout}}" data-key="{{.List.Slug}}">
|
||||
<button type="button" data-layout="grid" title="Grid">{{icon "grid"}}<span class="sr-only">Grid</span></button>
|
||||
<button type="button" data-layout="rows" title="List">{{icon "rows"}}<span class="sr-only">List</span></button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container list-body">
|
||||
{{if .Items}}
|
||||
{{template "items" (dict "Items" .Items "Category" .Category "Layout" .List.Layout "Title" .List.Title)}}
|
||||
{{if .PageNums}}
|
||||
<nav class="pager" aria-label="Pages">
|
||||
{{if gt .Page 1}}<a class="pager-btn" href="?page={{add .Page -1}}" rel="prev">{{icon "chevron-left"}}<span class="sr-only">Previous</span></a>{{end}}
|
||||
{{range .PageNums}}
|
||||
{{if eq . 0}}<span class="pager-gap">…</span>
|
||||
{{else if eq . $.Data.Page}}<span class="pager-btn is-current" aria-current="page">{{.}}</span>
|
||||
{{else}}<a class="pager-btn" href="?page={{.}}">{{.}}</a>{{end}}
|
||||
{{end}}
|
||||
{{if lt .Page .Pages}}<a class="pager-btn" href="?page={{add .Page 1}}" rel="next">{{icon "chevron-right"}}<span class="sr-only">Next</span></a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
<div class="empty-art">{{icon .Category.Icon}}</div>
|
||||
<h2>Nothing here yet</h2>
|
||||
<p>This list hasn't been filled in.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{define "scripts"}}<script type="module" src="/static/js/public.js?v={{.AssetVer}}"></script>{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "bodyclass"}}page-auth{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-mark">{{icon "lock"}}</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="muted">Manage your lists, art and theme.</p>
|
||||
{{with .Data.Error}}<p class="alert alert--error" role="alert">{{icon "alert"}}{{.}}</p>{{end}}
|
||||
<form method="post" action="/login" class="stack">
|
||||
<input type="hidden" name="next" value="{{.Data.Next}}">
|
||||
<label class="field">
|
||||
<span class="field-label">Username</span>
|
||||
<input name="username" autocomplete="username" required autofocus value="{{.Data.Username}}">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
|
||||
</form>
|
||||
<a class="auth-back" href="/">{{icon "arrow-left"}}Back to {{.Site.Title}}</a>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{define "bodyclass"}}page-auth{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-mark">{{icon "deck"}}</div>
|
||||
<h1>Welcome to TopDeck</h1>
|
||||
<p class="muted">Create the owner account. Only you can sign in; everyone else just sees your lists.</p>
|
||||
{{with .Data.Error}}<p class="alert alert--error" role="alert">{{icon "alert"}}{{.}}</p>{{end}}
|
||||
<form method="post" action="/setup" class="stack">
|
||||
<label class="field">
|
||||
<span class="field-label">Setup code</span>
|
||||
<input name="code" required autocomplete="one-time-code" placeholder="XXXX-XXXX" spellcheck="false" autocapitalize="characters">
|
||||
<span class="field-hint">Printed in the server log when TopDeck starts. Run <code>docker compose logs topdeck</code> to see it.</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Username</span>
|
||||
<input name="username" autocomplete="username" required value="{{.Data.Username}}" maxlength="64">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Password</span>
|
||||
<input name="password" type="password" autocomplete="new-password" required minlength="8">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Confirm password</span>
|
||||
<input name="confirm" type="password" autocomplete="new-password" required minlength="8">
|
||||
</label>
|
||||
<button class="btn btn--primary btn--block" type="submit">Create account</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "items"}}
|
||||
<ol class="items items--{{.Layout}} aspect-{{.Category.Aspect}}" data-items data-list-title="{{.Title}}" start="{{with index .Items 0}}{{.Rank}}{{end}}">
|
||||
{{- range .Items}}
|
||||
<li class="item{{if le .Rank 3}} item--top item--top{{.Rank}}{{end}}">
|
||||
<button type="button" class="item-btn" data-item="{{.ID}}">
|
||||
<span class="item-art">
|
||||
{{- if .Image}}<img src="{{.Image}}" alt="" loading="lazy" decoding="async">{{else}}<span class="art-fallback">{{icon $.Category.Icon}}</span>{{end -}}
|
||||
<span class="rank-badge" aria-hidden="true">{{.Rank}}</span>
|
||||
</span>
|
||||
<span class="item-rank" aria-hidden="true">{{.Rank}}</span>
|
||||
<span class="item-body">
|
||||
<span class="item-title"><span class="sr-only">#{{.Rank}}: </span>{{.Title}}</span>
|
||||
{{- if .Badge}}
|
||||
{{- with .Subtitle}}<span class="item-sub">{{.}}</span>{{end}}
|
||||
<span class="item-tags"><span class="badge">{{.Badge}}</span>{{with .Year}}<span class="item-year">{{.}}</span>{{end}}</span>
|
||||
{{- else if or .Subtitle .Year}}
|
||||
<span class="item-sub">{{.Subtitle}}{{if and .Subtitle .Year}}<span class="dot" aria-hidden="true">·</span>{{end}}{{.Year}}</span>
|
||||
{{- end}}
|
||||
{{- with .Note}}<span class="item-note">{{.}}</span>{{end}}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{{- end}}
|
||||
</ol>
|
||||
<script type="application/json" class="items-data">{{.Items}}</script>
|
||||
{{end}}
|
||||
Reference in new issue
Block a user