View switching feature
This commit is contained in:
+13
-1
@@ -8,7 +8,17 @@
|
||||
<div class="content">{{.SpecialContent}}</div>
|
||||
{{end}}
|
||||
{{if .Entries}}
|
||||
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button></h2>
|
||||
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
||||
{{if eq .View "thumbnail"}}
|
||||
<div class="thumb-grid">
|
||||
{{range .Entries}}
|
||||
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
|
||||
{{if .ThumbURL}}<img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
|
||||
<span class="thumb-label truncate">{{.Name}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<table class="data-table panel">
|
||||
<tbody>
|
||||
{{range .Entries}}
|
||||
@@ -20,6 +30,8 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
|
||||
{{else if not .Content}}
|
||||
{{if not .SpecialContent}}
|
||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 === */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <dir>/.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)
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
@@ -140,26 +202,32 @@ func listEntries(fsPath, urlPath string) []entry {
|
||||
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{
|
||||
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)))
|
||||
|
||||
Reference in New Issue
Block a user