View switching feature
This commit is contained in:
+13
-1
@@ -8,7 +8,17 @@
|
|||||||
<div class="content">{{.SpecialContent}}</div>
|
<div class="content">{{.SpecialContent}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Entries}}
|
{{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">
|
<table class="data-table panel">
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Entries}}
|
{{range .Entries}}
|
||||||
@@ -20,6 +30,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{end}}
|
||||||
|
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
|
||||||
{{else if not .Content}}
|
{{else if not .Content}}
|
||||||
{{if not .SpecialContent}}
|
{{if not .SpecialContent}}
|
||||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
<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); }
|
.btn-fab:hover { background: var(--bg-panel-hover); color: var(--primary-hover); }
|
||||||
.danger { color: var(--danger); }
|
.danger { color: var(--danger); }
|
||||||
.danger:hover { color: var(--danger-hover); }
|
.danger:hover { color: var(--danger-hover); }
|
||||||
|
/* Selected segmented-toggle button (view-settings modal). */
|
||||||
|
.btn.is-active { color: var(--primary-hover); }
|
||||||
|
|
||||||
/* === Form controls ===
|
/* === Form controls ===
|
||||||
.input baseline is shared by search-input, modal inputs, and the editor
|
.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;
|
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; }
|
.empty { padding: var(--space-4); text-align: center; }
|
||||||
|
|
||||||
/* === Scrollbars === */
|
/* === Scrollbars === */
|
||||||
|
|||||||
@@ -256,9 +256,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
rendered = renderMarkdown(rawMD)
|
rendered = renderMarkdown(rawMD)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
||||||
var entries []entry
|
var entries []entry
|
||||||
if !editMode && (special == nil || !special.SuppressListing) {
|
if !editMode && (special == nil || !special.SuppressListing) {
|
||||||
entries = listEntries(fsPath, urlPath)
|
entries = listEntries(fsPath, urlPath, sortKey, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
title := pageTitle(urlPath)
|
title := pageTitle(urlPath)
|
||||||
@@ -312,6 +313,9 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
RawContent: rawContent,
|
RawContent: rawContent,
|
||||||
Content: rendered,
|
Content: rendered,
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
|
View: view,
|
||||||
|
Sort: sortKey,
|
||||||
|
Order: order,
|
||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
SidebarWidget: sidebarWidget,
|
SidebarWidget: sidebarWidget,
|
||||||
SuppressTOC: suppressTOC,
|
SuppressTOC: suppressTOC,
|
||||||
@@ -354,6 +358,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
h.handleAddTask(w, r, urlPath, fsPath)
|
h.handleAddTask(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if query.Has("settings") {
|
||||||
|
h.handleSettings(w, r, urlPath, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
@@ -447,7 +455,8 @@ func readPageSettings(dir string) *pageSettings {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 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") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
@@ -457,9 +466,16 @@ func readPageSettings(dir string) *pageSettings {
|
|||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
switch strings.TrimSpace(parts[0]) {
|
switch strings.TrimSpace(parts[0]) {
|
||||||
case "type":
|
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
|
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"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
@@ -31,6 +33,13 @@ func initMarkdown(root string) {
|
|||||||
type entry struct {
|
type entry struct {
|
||||||
Icon template.HTML
|
Icon template.HTML
|
||||||
Name, URL, Meta string
|
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 {
|
type pageData struct {
|
||||||
@@ -45,15 +54,68 @@ type pageData struct {
|
|||||||
RawContent string
|
RawContent string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
Entries []entry
|
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
|
SpecialContent template.HTML
|
||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
SuppressTOC bool
|
SuppressTOC bool
|
||||||
RenderMS int64
|
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 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 (
|
var (
|
||||||
@@ -117,7 +179,7 @@ func parentURL(urlPath string) string {
|
|||||||
return parent + "/"
|
return parent + "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
func listEntries(fsPath, urlPath string) []entry {
|
func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
||||||
entries, err := os.ReadDir(fsPath)
|
entries, err := os.ReadDir(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -140,26 +202,32 @@ func listEntries(fsPath, urlPath string) []entry {
|
|||||||
Name: name,
|
Name: name,
|
||||||
URL: entryURL + "/",
|
URL: entryURL + "/",
|
||||||
Meta: info.ModTime().Format("2006-01-02"),
|
Meta: info.ModTime().Format("2006-01-02"),
|
||||||
|
modTime: info.ModTime(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if name == "index.md" {
|
if name == "index.md" {
|
||||||
continue // rendered above, don't list it
|
continue // rendered above, don't list it
|
||||||
}
|
}
|
||||||
files = append(files, entry{
|
f := entry{
|
||||||
Icon: fileIcon(name),
|
Icon: fileIcon(name),
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: entryURL,
|
URL: entryURL,
|
||||||
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
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 {
|
// Folders always sort by name regardless of the chosen key (they have no
|
||||||
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
|
// meaningful byte size); files honor the chosen key. The chosen order
|
||||||
})
|
// applies to both groups.
|
||||||
sort.Slice(files, func(i, j int) bool {
|
sortEntries(folders, sortName, order)
|
||||||
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
sortEntries(files, sortKey, order)
|
||||||
})
|
|
||||||
|
|
||||||
// `..` row mirrors the header Up button so the listing itself is
|
// `..` row mirrors the header Up button so the listing itself is
|
||||||
// navigable without reaching for the header on mobile. Prepended after
|
// navigable without reaching for the header on mobile. Prepended after
|
||||||
@@ -177,6 +245,42 @@ func listEntries(fsPath, urlPath string) []entry {
|
|||||||
return out
|
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 {
|
func readIcon(name string) template.HTML {
|
||||||
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
||||||
return template.HTML(strings.TrimSpace(string(b)))
|
return template.HTML(strings.TrimSpace(string(b)))
|
||||||
|
|||||||
Reference in New Issue
Block a user