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,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