View switching feature

This commit is contained in:
2026-05-29 09:21:19 +02:00
parent 5844a870ce
commit f85c29ba42
6 changed files with 368 additions and 23 deletions
+123 -19
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"fmt"
"html/template"
"net/url"
"os"
"path"
"sort"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
@@ -31,6 +33,13 @@ func initMarkdown(root string) {
type entry struct {
Icon template.HTML
Name, URL, Meta string
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
// <img> when it is non-empty and falls back to Icon otherwise.
ThumbURL string
// modTime/size carry the raw sort keys; the template only reads the
// formatted Meta string.
modTime time.Time
size int64
}
type pageData struct {
@@ -45,15 +54,68 @@ type pageData struct {
RawContent string
Content template.HTML
Entries []entry
View string // listing view style: "list" or "thumbnail"
Sort string // listing sort key: "name" / "modified" / "size"
Order string // listing sort order: "asc" / "desc"
SpecialContent template.HTML
SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64
}
// pageSettings holds the parsed contents of a .page-settings file.
// Allowed values for the listing view settings. Unknown values in the file or
// a POST body fall back to the first (default) value of each set.
const (
viewList = "list"
viewThumbnail = "thumbnail"
sortName = "name"
sortModified = "modified"
sortSize = "size"
orderAsc = "asc"
orderDesc = "desc"
)
// pageSettings holds the parsed contents of a .page-settings file. View, Sort,
// and Order are always valid once parsed (defaults applied on read).
type pageSettings struct {
Type string
Type string
View string
Sort string
Order string
}
// viewSettings returns the listing view/sort/order, applying defaults when the
// receiver is nil (no .page-settings file).
func (s *pageSettings) viewSettings() (view, sortKey, order string) {
if s == nil {
return viewList, sortName, orderAsc
}
return s.View, s.Sort, s.Order
}
func validateView(v string) string {
if v == viewThumbnail {
return viewThumbnail
}
return viewList
}
func validateSort(v string) string {
switch v {
case sortModified, sortSize:
return v
default:
return sortName
}
}
func validateOrder(v string) string {
if v == orderDesc {
return orderDesc
}
return orderAsc
}
var (
@@ -117,7 +179,7 @@ func parentURL(urlPath string) string {
return parent + "/"
}
func listEntries(fsPath, urlPath string) []entry {
func listEntries(fsPath, urlPath, sortKey, order string) []entry {
entries, err := os.ReadDir(fsPath)
if err != nil {
return nil
@@ -136,30 +198,36 @@ func listEntries(fsPath, urlPath string) []entry {
entryURL := path.Join(urlPath, name)
if e.IsDir() {
folders = append(folders, entry{
Icon: iconFolder,
Name: name,
URL: entryURL + "/",
Meta: info.ModTime().Format("2006-01-02"),
Icon: iconFolder,
Name: name,
URL: entryURL + "/",
Meta: info.ModTime().Format("2006-01-02"),
modTime: info.ModTime(),
})
} else {
if name == "index.md" {
continue // rendered above, don't list it
}
files = append(files, entry{
Icon: fileIcon(name),
Name: name,
URL: entryURL,
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
})
f := entry{
Icon: fileIcon(name),
Name: name,
URL: entryURL,
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
modTime: info.ModTime(),
size: info.Size(),
}
if hasThumbnail(name) {
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
}
files = append(files, f)
}
}
sort.Slice(folders, func(i, j int) bool {
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
})
sort.Slice(files, func(i, j int) bool {
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
// Folders always sort by name regardless of the chosen key (they have no
// meaningful byte size); files honor the chosen key. The chosen order
// applies to both groups.
sortEntries(folders, sortName, order)
sortEntries(files, sortKey, order)
// `..` row mirrors the header Up button so the listing itself is
// navigable without reaching for the header on mobile. Prepended after
@@ -177,6 +245,42 @@ func listEntries(fsPath, urlPath string) []entry {
return out
}
// sortEntries sorts a single group (folders or files) in place by the given
// key, breaking ties on case-insensitive name, then reverses for descending
// order. The stable sort keeps the name tiebreak meaningful.
func sortEntries(group []entry, sortKey, order string) {
sort.SliceStable(group, func(i, j int) bool {
a, b := group[i], group[j]
cmp := 0
switch sortKey {
case sortModified:
if a.modTime.Before(b.modTime) {
cmp = -1
} else if a.modTime.After(b.modTime) {
cmp = 1
}
case sortSize:
if a.size < b.size {
cmp = -1
} else if a.size > b.size {
cmp = 1
}
}
if cmp == 0 {
an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
if an < bn {
cmp = -1
} else if an > bn {
cmp = 1
}
}
if order == orderDesc {
return cmp > 0
}
return cmp < 0
})
}
func readIcon(name string) template.HTML {
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
return template.HTML(strings.TrimSpace(string(b)))