Rename project from TopDeck to Canon

- Module github.com/cdnutter/canon, binary/command cmd/canon, Docker
  image and compose project "canon"
- Environment variables now use the CANON_ prefix
- Default site title is "<Your name>'s Canon" (or "Canon" with no name);
  a Site title set in Settings still overrides it
- Existing topdeck.db (and WAL files) is renamed to canon.db on start
- README: upgrade notes, including moving the old compose volume

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 17:22:17 -07:00
1 parent 58a29b7339
commit 3923444b3b
26 files changed
+202 -107

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
.git
data
.env
topdeck
canon
+12 -12
View File
@@ -1,21 +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
# Create the owner account on first start. If unset, Canon 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=
CANON_ADMIN_USERNAME=
CANON_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=
CANON_TMDB_API_KEY=
CANON_TVDB_API_KEY=
CANON_TVDB_PIN=
CANON_IGDB_CLIENT_ID=
CANON_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
# CANON_TRUST_PROXY=true # honor X-Forwarded-For / -Proto / -Host
# CANON_SECURE_COOKIES=true # always mark the session cookie Secure
# TOPDECK_PORT=4242
# TOPDECK_LOG_LEVEL=debug
# CANON_PORT=4242
# CANON_LOG_LEVEL=debug
+1 -1
View File
@@ -1,3 +1,3 @@
/data/
/topdeck
/canon
.env
+9 -9
View File
@@ -6,18 +6,18 @@ 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
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/canon ./cmd/canon
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
&& adduser -D -u 1000 canon \
&& mkdir -p /data && chown canon:canon /data
COPY --from=build /out/canon /usr/local/bin/canon
USER canon
ENV CANON_DATA_DIR=/data \
CANON_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"]
CMD wget -qO- http://127.0.0.1:${CANON_PORT}/healthz >/dev/null || exit 1
ENTRYPOINT ["canon"]
+39 -23
View File
@@ -1,7 +1,9 @@
# TopDeck
# Canon
A self-hosted page for **your** ranked favorites: movies, TV shows, albums, songs, video games, theater, and anything else you care to rank in a custom list. 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.
Your page is titled **"*Your name*'s Canon"** by default. Set your name under Settings → Profile, or give the site any title you like there.
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).
@@ -18,24 +20,24 @@ Visitors just see your lists. There's no sign-up, no feed and no login button. Y
## Quick start (Docker Compose)
```sh
git clone https://git.iamchrisama.com/cdnutter/topdeck && cd topdeck
git clone https://git.iamchrisama.com/cdnutter/canon && cd canon
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
docker compose logs canon # 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.
To skip the setup page, set `CANON_ADMIN_USERNAME` and `CANON_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
docker build -t canon .
docker run -d --name canon -p 4242:4242 -v canon-data:/data canon
```
Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`.
Or from source (Go 1.26+): `go run ./cmd/canon` stores data in `./data`.
## Data sources and API keys
@@ -50,7 +52,7 @@ Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`.
**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.
- **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. Canon 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.
@@ -58,23 +60,23 @@ Keys entered in the UI are stored in the SQLite database on your server. Keys se
| 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 |
| `CANON_PORT` | `4242` | Listen port |
| `CANON_DATA_DIR` | `/data` (Docker), `./data` | Database + cached images |
| `CANON_ADMIN_USERNAME` / `_PASSWORD` | | Create the owner account on first start |
| `CANON_TMDB_API_KEY` | | |
| `CANON_TVDB_API_KEY` / `CANON_TVDB_PIN` | | The PIN is only for user-supported keys |
| `CANON_IGDB_CLIENT_ID` / `_SECRET` | | Twitch app credentials |
| `CANON_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` headers (set behind a reverse proxy) |
| `CANON_SECURE_COOKIES` | `false` | Force `Secure` cookies (set if served over HTTPS) |
| `CANON_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:
Canon serves plain HTTP. Put it behind Caddy, Traefik or nginx for HTTPS, and set `CANON_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
reverse_proxy canon:4242
}
```
@@ -107,25 +109,39 @@ Every list can be downloaded as its titles only, in rank order, one per line. Th
The Export menu appears in the list editor and, when you're signed in, on the list's public page. The links follow the list's visibility: anyone can download a public list, and only you can download a hidden one.
## Upgrading from TopDeck
Canon was called TopDeck, and upgrading keeps all your data:
- The database is renamed from `topdeck.db` to `canon.db` automatically on first start.
- Environment variables changed prefix from `TOPDECK_` to `CANON_`. Rename them in your `.env`.
- You'll be signed out once (the session cookie was renamed).
- Docker Compose now always uses the project name `canon`. If you ran the old compose file, your data is in the `topdeck_topdeck-data` volume. Copy it into the new one before starting:
```sh
docker volume create canon_canon-data
docker run --rm -v topdeck_topdeck-data:/from -v canon_canon-data:/to alpine cp -a /from/. /to/
```
## Backups
Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running:
Everything lives in the data volume: `canon.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
docker compose exec canon sh -c 'cd /data && tar c canon.db* images' > canon-backup.tar
```
## Development
```sh
go run ./cmd/topdeck # http://localhost:4242
go run ./cmd/canon # 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
cmd/canon 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
+10 -10
View File
@@ -1,4 +1,4 @@
// Command topdeck serves a personal, self-hosted collection of ranked
// Command canon serves a personal, self-hosted collection of ranked
// favorites.
package main
@@ -16,12 +16,12 @@ import (
"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"
"github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/canon/internal/config"
"github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/server"
"github.com/cdnutter/canon/internal/store"
)
// version is set at build time with -ldflags "-X main.version=…".
@@ -29,7 +29,7 @@ var version = "dev"
func main() {
level := slog.LevelInfo
if strings.EqualFold(os.Getenv("TOPDECK_LOG_LEVEL"), "debug") {
if strings.EqualFold(os.Getenv("CANON_LOG_LEVEL"), "debug") {
level = slog.LevelDebug
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
@@ -92,7 +92,7 @@ func run(log *slog.Logger) error {
}
errc := make(chan error, 1)
go func() {
log.Info("TopDeck is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version)
log.Info("Canon is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version)
errc <- httpSrv.ListenAndServe()
}()
select {
@@ -119,7 +119,7 @@ func bootstrapAdmin(ctx context.Context, cfg config.Config, st *store.Store, srv
}
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)
return fmt.Errorf("CANON_ADMIN_PASSWORD must be at least %d characters", auth.MinPasswordLength)
}
hash, err := auth.HashPassword(cfg.AdminPassword)
if err != nil {
+7 -5
View File
@@ -1,16 +1,18 @@
name: canon
services:
topdeck:
canon:
build: .
image: topdeck:latest
container_name: topdeck
image: canon:latest
container_name: canon
restart: unless-stopped
ports:
- "4242:4242"
volumes:
- topdeck-data:/data
- canon-data:/data
env_file:
- path: .env
required: false
volumes:
topdeck-data:
canon-data:
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/cdnutter/topdeck
module github.com/cdnutter/canon
go 1.26.0
+12 -12
View File
@@ -28,19 +28,19 @@ type Config struct {
}
func Load() Config {
port := env("TOPDECK_PORT", "4242")
port := env("CANON_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", ""),
ListenAddr: env("CANON_LISTEN", ":"+port),
DataDir: env("CANON_DATA_DIR", "./data"),
AdminUsername: env("CANON_ADMIN_USERNAME", ""),
AdminPassword: env("CANON_ADMIN_PASSWORD", ""),
SecureCookies: envBool("CANON_SECURE_COOKIES", false),
TrustProxy: envBool("CANON_TRUST_PROXY", false),
TMDBKey: env("CANON_TMDB_API_KEY", ""),
TVDBKey: env("CANON_TVDB_API_KEY", ""),
TVDBPin: env("CANON_TVDB_PIN", ""),
IGDBClientID: env("CANON_IGDB_CLIENT_ID", ""),
IGDBClientSecret: env("CANON_IGDB_CLIENT_SECRET", ""),
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"time"
)
const UserAgent = "TopDeck/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/topdeck)"
const UserAgent = "Canon/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/canon)"
// Result is one search hit, already shaped like a list item.
type Result struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"html/template"
"net/http"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/canon/internal/store"
)
// adminPage carries a JSON blob the page script boots from.
+5 -4
View File
@@ -10,10 +10,10 @@ import (
"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"
"github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/store"
)
// ---- lists ----
@@ -467,6 +467,7 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
writeJSON(w, http.StatusOK, map[string]any{
"siteTitle": set[store.SettingSiteTitle],
"siteTitleShown": site.Title,
"ownerName": site.OwnerName,
"tagline": site.Tagline,
"avatar": site.AvatarURL,
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/cdnutter/topdeck/internal/auth"
"github.com/cdnutter/canon/internal/auth"
)
type loginPage struct {
@@ -143,7 +143,7 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
}
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).")
fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs canon).")
return
}
if page.Username == "" || len(page.Username) > 64 {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strconv"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/canon/internal/store"
)
// ItemView is an item shaped for public display.
+12 -3
View File
@@ -11,8 +11,8 @@ import (
"strconv"
"strings"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/topdeck/web"
"github.com/cdnutter/canon/internal/store"
"github.com/cdnutter/canon/web"
)
// Site is the owner-configurable identity shown on every page.
@@ -53,7 +53,7 @@ func (s *Server) site(ctx context.Context) Site {
ShowNotes: set[store.SettingShowNotes] != "false",
}
if site.Title == "" {
site.Title = "TopDeck"
site.Title = DefaultTitle(site.OwnerName)
}
if !validTheme(site.Theme) {
site.Theme = DefaultTheme
@@ -71,6 +71,15 @@ func (s *Server) site(ctx context.Context) Site {
return site
}
// DefaultTitle is the site title when the owner hasn't set one:
// "Chris's Canon", or just "Canon" before they've entered a name.
func DefaultTitle(ownerName string) string {
if ownerName = strings.TrimSpace(ownerName); ownerName == "" {
return "Canon"
}
return ownerName + "'s Canon"
}
func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) {
set, err := s.store.Settings(r.Context())
if err != nil {
+11
View File
@@ -0,0 +1,11 @@
package server
import "testing"
func TestDefaultTitle(t *testing.T) {
for in, want := range map[string]string{"": "Canon", " ": "Canon", "Chris": "Chris's Canon", " Ana ": "Ana's Canon"} {
if got := DefaultTitle(in); got != want {
t.Errorf("DefaultTitle(%q) = %q, want %q", in, got, want)
}
}
}
+8 -8
View File
@@ -18,16 +18,16 @@ import (
"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"
"github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/canon/internal/config"
"github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/store"
"github.com/cdnutter/canon/web"
)
const (
sessionCookie = "topdeck_session"
sessionCookie = "canon_session"
sessionTTL = 30 * 24 * time.Hour
)
@@ -240,7 +240,7 @@ func (s *Server) requireUser(h http.HandlerFunc) http.HandlerFunc {
// 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) {
if r.Header.Get("X-Canon") != "1" || !s.sameOrigin(r) {
writeError(w, http.StatusForbidden, "Missing request header")
return
}
+24 -1
View File
@@ -6,6 +6,7 @@ import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
@@ -18,7 +19,10 @@ type Store struct {
}
func Open(dataDir string) (*Store, error) {
path := filepath.Join(dataDir, "topdeck.db")
path := filepath.Join(dataDir, "canon.db")
if err := adoptLegacyDB(dataDir, path); err != nil {
return nil, err
}
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 {
@@ -35,6 +39,25 @@ func Open(dataDir string) (*Store, error) {
func (s *Store) Close() error { return s.db.Close() }
// adoptLegacyDB renames a database from before the project was called
// Canon (topdeck.db, plus its WAL files) so upgrades keep their data.
func adoptLegacyDB(dataDir, path string) error {
legacy := filepath.Join(dataDir, "topdeck.db")
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
return nil
}
if _, err := os.Stat(legacy); err != nil {
return nil
}
for _, suffix := range []string{"-wal", "-shm", ""} {
err := os.Rename(legacy+suffix, path+suffix)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("renaming %s to %s: %w", legacy+suffix, path+suffix, err)
}
}
return nil
}
// migrations are applied in order; the index into this slice (plus one) is
// the schema version stored in PRAGMA user_version.
var migrations = []string{
+30
View File
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -177,3 +179,31 @@ func TestCustomList(t *testing.T) {
t.Errorf("movie aspect = %q", m.Aspect())
}
}
func TestAdoptLegacyDB(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
if _, err := s.CreateList(context.Background(), List{Title: "Keep me", Category: "movie", MaxItems: 5}); err != nil {
t.Fatal(err)
}
s.Close()
// Pretend this data dir predates the rename.
for _, suffix := range []string{"", "-wal", "-shm"} {
os.Rename(filepath.Join(dir, "canon.db"+suffix), filepath.Join(dir, "topdeck.db"+suffix))
}
s, err = Open(dir)
if err != nil {
t.Fatal(err)
}
defer s.Close()
lists, err := s.Lists(context.Background(), true)
if err != nil || len(lists) != 1 || lists[0].Title != "Keep me" {
t.Fatalf("legacy data not adopted: %v %v", lists, err)
}
if _, err := os.Stat(filepath.Join(dir, "topdeck.db")); !os.IsNotExist(err) {
t.Errorf("topdeck.db should have been renamed")
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/* ==========================================================================
TopDeck. Colors and fonts come from themes.css.
Canon. Colors and fonts come from themes.css.
========================================================================== */
*, *::before, *::after { box-sizing: border-box; }
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* TopDeck theme presets.
* Canon 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.
+7 -4
View File
@@ -70,7 +70,10 @@ function profileSection() {
});
const ownerName = h("input", { maxlength: 100, value: settings.ownerName, placeholder: "Your name" });
const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: "TopDeck" });
// Mirrors DefaultTitle in render.go.
const defaultTitle = (name) => (name.trim() ? `${name.trim()}'s Canon` : "Canon");
const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: defaultTitle(settings.ownerName) });
ownerName.addEventListener("input", () => { siteTitle.placeholder = defaultTitle(ownerName.value); });
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 });
@@ -78,7 +81,7 @@ function profileSection() {
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.")),
h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab. Leave blank to use “Your name's Canon”.")),
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"),
@@ -93,7 +96,7 @@ function profileSection() {
if (ok) {
renderAvatar();
const brand = document.querySelector(".brand-name");
if (brand) brand.textContent = settings.siteTitle || "TopDeck";
if (brand) brand.textContent = settings.siteTitleShown;
}
});
renderAvatar();
@@ -181,7 +184,7 @@ function secretInput(key, label, hintHTML) {
disabled: st.locked,
});
const hint = h("span", { class: "field-hint" });
if (st.locked) hint.textContent = `Set by the ${"TOPDECK_" + key.toUpperCase()} environment variable.`;
if (st.locked) hint.textContent = `Set by the ${"CANON_" + key.toUpperCase()} environment variable.`;
else if (hintHTML) hint.append(...hintHTML);
const wrap = field(label, input);
wrap.append(hint);
+2 -2
View File
@@ -1,4 +1,4 @@
// Shared helpers for TopDeck's pages. No build step, no dependencies.
// Shared helpers for Canon's pages. No build step, no dependencies.
/** Create an element: h("button", { class: "btn", onclick }, "Save") */
export function h(tag, attrs = {}, ...children) {
@@ -53,7 +53,7 @@ export class APIError extends Error {
/** 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" };
const opts = { method, headers: { "X-Canon": "1" }, credentials: "same-origin" };
if (body instanceof FormData) {
opts.body = body;
} else if (body !== undefined) {
+1 -1
View File
@@ -75,7 +75,7 @@ document.addEventListener("click", (e) => {
// 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 key = "canon:layout:" + toggle.dataset.key;
const apply = (layout) => {
for (const { ol } of lists) {
ol.classList.toggle("items--grid", layout === "grid");
+1 -1
View File
@@ -39,7 +39,7 @@
<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>
<span>Powered by <a href="https://git.iamchrisama.com/cdnutter/canon" rel="noopener">Canon</a></span>
</div>
</footer>
{{block "scripts" .}}{{end}}
+2 -2
View File
@@ -2,14 +2,14 @@
{{define "content"}}
<section class="auth-card">
<div class="auth-mark">{{icon "deck"}}</div>
<h1>Welcome to TopDeck</h1>
<h1>Welcome to Canon</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>
<span class="field-hint">Printed in the server log when Canon starts. Run <code>docker compose logs canon</code> to see it.</span>
</label>
<label class="field">
<span class="field-label">Username</span>