Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
6.1 KiB
Go
242 lines
6.1 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"html/template"
|
||
"io/fs"
|
||
"net/http"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/cdnutter/canon/internal/store"
|
||
"github.com/cdnutter/canon/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
|
||
ShowPreviews bool
|
||
}
|
||
|
||
type pageData struct {
|
||
Site Site
|
||
PageTitle string
|
||
Page string // template name
|
||
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",
|
||
ShowPreviews: set[store.SettingShowPreviews] != "false",
|
||
}
|
||
if site.Title == "" {
|
||
site.Title = DefaultTitle(site.OwnerName)
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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 {
|
||
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,
|
||
// first and after split a ranked list: the podium/spotlight entries and
|
||
// everything below them.
|
||
"first": func(n int, items []ItemView) []ItemView { return items[:min(n, len(items))] },
|
||
"after": func(n int, items []ItemView) []ItemView { return items[min(n, len(items)):] },
|
||
// podium returns places 1–3 for the podium, nil for an empty one, up to
|
||
// the last of them that's taken.
|
||
"podium": func(items []ItemView) []*ItemView {
|
||
spots := make([]*ItemView, 0, 3)
|
||
for i := range items {
|
||
if items[i].Rank > 3 {
|
||
break
|
||
}
|
||
for len(spots) < items[i].Rank-1 {
|
||
spots = append(spots, nil)
|
||
}
|
||
spots = append(spots, &items[i])
|
||
}
|
||
return spots
|
||
},
|
||
// belowPlace returns the entries placed after #n.
|
||
"belowPlace": func(n int, items []ItemView) []ItemView {
|
||
for i, it := range items {
|
||
if it.Rank > n {
|
||
return items[i:]
|
||
}
|
||
}
|
||
return nil
|
||
},
|
||
// flatLayout is how a style shows entries that aren't featured.
|
||
"flatLayout": func(style string) string {
|
||
if style == "grid" {
|
||
return "grid"
|
||
}
|
||
return "rows"
|
||
},
|
||
// 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,
|
||
Page: page,
|
||
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)
|
||
}
|