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 @@
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