diff --git a/assets/page/main.html b/assets/page/main.html index 7d1ecb5..a93fc3e 100644 --- a/assets/page/main.html +++ b/assets/page/main.html @@ -8,7 +8,17 @@
{{.SpecialContent}}
{{end}} {{if .Entries}} -

Files

+

Files {{if .CanEdit}} {{end}}

+{{if eq .View "thumbnail"}} +
+ {{range .Entries}} + + {{if .ThumbURL}}{{else}}{{.Icon}}{{end}} + {{.Name}} + + {{end}} +
+{{else}} {{range .Entries}} @@ -20,6 +30,8 @@ {{end}}
+{{end}} +{{if .CanEdit}}{{end}} {{else if not .Content}} {{if not .SpecialContent}}

Empty folder — [CREATE]

diff --git a/assets/page/view-settings.js b/assets/page/view-settings.js new file mode 100644 index 0000000..d3e3cfc --- /dev/null +++ b/assets/page/view-settings.js @@ -0,0 +1,85 @@ +// View-settings modal: lets the user pick the folder listing's view style, +// sort key, and order, then persists them by POSTing to the folder with +// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js. +function openViewSettings() { + var btn = document.getElementById('view-settings-btn'); + var state = { + view: (btn && btn.dataset.view) || 'list', + sort: (btn && btn.dataset.sort) || 'name', + order: (btn && btn.dataset.order) || 'asc' + }; + + // segmented builds a row of mutually-exclusive .btn toggles bound to a + // single state key, marking the current choice with .is-active. + function segmented(key, options) { + var wrap = document.createElement('div'); + wrap.className = 'row gap-1'; + options.forEach(function (opt) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'btn btn-small'; + b.textContent = opt.label; + if (state[key] === opt.value) b.classList.add('is-active'); + b.addEventListener('click', function () { + state[key] = opt.value; + wrap.querySelectorAll('button').forEach(function (x) { + x.classList.remove('is-active'); + }); + b.classList.add('is-active'); + }); + wrap.appendChild(b); + }); + return wrap; + } + + function field(labelText, control) { + var row = document.createElement('div'); + row.className = 'col gap-1'; + var label = document.createElement('span'); + label.className = 'caption'; + label.textContent = labelText; + row.appendChild(label); + row.appendChild(control); + return row; + } + + var sortSelect = document.createElement('select'); + sortSelect.className = 'input'; + [['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) { + var opt = document.createElement('option'); + opt.value = o[0]; + opt.textContent = o[1]; + if (state.sort === o[0]) opt.selected = true; + sortSelect.appendChild(opt); + }); + sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; }); + + var body = document.createElement('div'); + body.className = 'col'; + body.appendChild(field('View style', segmented('view', [ + { value: 'list', label: 'List' }, + { value: 'thumbnail', label: 'Thumbnail' } + ]))); + body.appendChild(field('Sort by', sortSelect)); + body.appendChild(field('Order', segmented('order', [ + { value: 'asc', label: 'Asc' }, + { value: 'desc', label: 'Desc' } + ]))); + + openModal({ + title: 'View settings', + body: body, + confirm: { + label: 'SAVE', + onConfirm: function () { + var action = window.location.pathname + '?settings'; + var formBody = 'view=' + encodeURIComponent(state.view) + + '&sort=' + encodeURIComponent(state.sort) + + '&order=' + encodeURIComponent(state.order); + var target = window.location.pathname; + closeModal(); + postReplace(action, formBody, target); + } + } + }); +} diff --git a/assets/style.css b/assets/style.css index ac3234a..0634960 100644 --- a/assets/style.css +++ b/assets/style.css @@ -192,6 +192,8 @@ footer { .btn-fab:hover { background: var(--bg-panel-hover); color: var(--primary-hover); } .danger { color: var(--danger); } .danger:hover { color: var(--danger-hover); } +/* Selected segmented-toggle button (view-settings modal). */ +.btn.is-active { color: var(--primary-hover); } /* === Form controls === .input baseline is shared by search-input, modal inputs, and the editor @@ -435,6 +437,43 @@ button.fab { display: none; } background: var(--bg-panel) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat; } +/* === Thumbnail listing grid === + File-listing variant of .photo-grid: responsive tiles that pair a thumbnail + (or a file/folder icon for non-thumbnailable entries) with a truncated + name label beneath. */ +.thumb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--space-3); + margin-top: var(--space-3); +} +.thumb-tile { + display: flex; + flex-direction: column; + gap: var(--space-1); + color: var(--text); + border: var(--border); + background: var(--bg-panel); + padding: var(--space-2); +} +.thumb-tile:hover { background: var(--bg-panel-hover); color: var(--primary-hover); } +.thumb-img { + width: 100%; + height: 150px; + object-fit: cover; + display: block; + background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat; +} +.thumb-icon { + height: 150px; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + color: var(--secondary); +} +.thumb-label { font-size: var(--font-sm); } + .empty { padding: var(--space-4); text-align: center; } /* === Scrollbars === */ diff --git a/main.go b/main.go index 1abe002..f7b534b 100644 --- a/main.go +++ b/main.go @@ -256,9 +256,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa rendered = renderMarkdown(rawMD) } + view, sortKey, order := readPageSettings(fsPath).viewSettings() var entries []entry if !editMode && (special == nil || !special.SuppressListing) { - entries = listEntries(fsPath, urlPath) + entries = listEntries(fsPath, urlPath, sortKey, order) } title := pageTitle(urlPath) @@ -312,6 +313,9 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa RawContent: rawContent, Content: rendered, Entries: entries, + View: view, + Sort: sortKey, + Order: order, SpecialContent: specialContent, SidebarWidget: sidebarWidget, SuppressTOC: suppressTOC, @@ -354,6 +358,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs h.handleAddTask(w, r, urlPath, fsPath) return } + if query.Has("settings") { + h.handleSettings(w, r, urlPath, fsPath) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -447,7 +455,8 @@ func readPageSettings(dir string) *pageSettings { if err != nil { return nil } - s := &pageSettings{} + // Defaults; overridden only by valid values present in the file. + s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc} for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { @@ -457,9 +466,16 @@ func readPageSettings(dir string) *pageSettings { if len(parts) != 2 { continue } + value := strings.TrimSpace(parts[1]) switch strings.TrimSpace(parts[0]) { case "type": - s.Type = strings.TrimSpace(parts[1]) + s.Type = value + case "view": + s.View = validateView(value) + case "sort": + s.Sort = validateSort(value) + case "order": + s.Order = validateOrder(value) } } return s diff --git a/pagesettings.go b/pagesettings.go new file mode 100644 index 0000000..7b874ec --- /dev/null +++ b/pagesettings.go @@ -0,0 +1,89 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// handleSettings persists the listing view/sort/order to the folder's +// .page-settings file. Values are validated against the allowed sets (unknown +// values fall back to defaults). Triggered by POST /{path}?settings. +func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + view := validateView(r.FormValue("view")) + sortKey := validateSort(r.FormValue("sort")) + order := validateOrder(r.FormValue("order")) + + if err := writePageSettings(fsPath, view, sortKey, order); err != nil { + http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, urlPath, http.StatusSeeOther) +} + +// writePageSettings performs a read-modify-write of /.page-settings, +// updating the view/sort/order lines while preserving every other line +// (other keys, comments, blank lines, ordering) verbatim. Missing keys are +// appended. The write is atomic (temp file + rename). +func writePageSettings(dir, view, sortKey, order string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + p := filepath.Join(dir, ".page-settings") + existing, err := os.ReadFile(p) + if err != nil && !os.IsNotExist(err) { + return err + } + updated := updateSettingsLines(existing, view, sortKey, order) + return writeFileAtomic(p, updated, 0644) +} + +// updateSettingsLines rewrites the view/sort/order lines in existing while +// leaving all other lines untouched. Every occurrence of a known key is +// updated (so the reader's last-wins parse stays consistent); keys absent from +// the file are appended in a stable order. The result always ends in a newline. +func updateSettingsLines(existing []byte, view, sortKey, order string) []byte { + targets := map[string]string{"view": view, "sort": sortKey, "order": order} + appendOrder := []string{"view", "sort", "order"} + seen := map[string]bool{} + + var lines []string + if len(existing) > 0 { + s := string(existing) + s = strings.TrimSuffix(s, "\n") + lines = strings.Split(s, "\n") + } + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + if val, ok := targets[key]; ok { + lines[i] = key + " = " + val + seen[key] = true + } + } + + for _, k := range appendOrder { + if !seen[k] { + lines = append(lines, k+" = "+targets[k]) + } + } + + out := strings.Join(lines, "\n") + if out != "" { + out += "\n" + } + return []byte(out) +} diff --git a/render.go b/render.go index df08015..92b54a3 100644 --- a/render.go +++ b/render.go @@ -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 + // 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)))