fix(gallery): display settings apply at load, columns work, media info toggles

Default View Mode, Items per Page and Grid Columns were only applied when
Save was pressed, never when the page loaded; the column setting swapped a
Tailwind class that was never compiled (only 3, 6 and 8 exist) and a wider
breakpoint class overrode it anyway; Show Media Info was saved and never
read.

- gallery-settings.js validates what localStorage holds (view mode, limit
  option, column range, booleans) and falls back to defaults per value
- settings are applied in the constructor before the first load; changing
  the page size reloads, toggling media info re-renders
- the column count sets an explicit grid template through a CSS variable
  that wins on desktop widths
- media info lines honour the toggle in grid and list views
- image load failures are handled by one delegated listener that tries the
  original before showing the failed-to-load card
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:35 -07:00
parent 85d1031179
commit 672eb6a18c
4 changed files with 199 additions and 48 deletions
+57
View File
@@ -0,0 +1,57 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_SETTINGS,
normalizeGallerySettings,
gridColumnsStyle,
LIMIT_OPTIONS,
} = require("../../web/js/gallery-settings.js");
test("defaults are a complete, sane settings object", () => {
const s = normalizeGallerySettings(null);
assert.deepEqual(s, DEFAULT_SETTINGS);
assert.equal(s.defaultViewMode, "grid");
assert.equal(s.defaultLimit, 100);
assert.equal(s.gridColumns, 8);
assert.equal(s.showImageInfo, true);
assert.equal(s.sidebarCollapsedByDefault, false);
});
test("stored values override defaults and unknown keys are kept", () => {
const s = normalizeGallerySettings({ defaultViewMode: "list", defaultLimit: 500, gridColumns: 10, showImageInfo: false, videoLoop: false, extra: 1 });
assert.equal(s.defaultViewMode, "list");
assert.equal(s.defaultLimit, 500);
assert.equal(s.gridColumns, 10);
assert.equal(s.showImageInfo, false);
assert.equal(s.videoLoop, false);
assert.equal(s.extra, 1);
});
test("invalid stored values fall back to defaults instead of breaking the page", () => {
const s = normalizeGallerySettings({ defaultViewMode: "mosaic", defaultLimit: 7, gridColumns: "lots", showImageInfo: "no" });
assert.equal(s.defaultViewMode, "grid");
assert.equal(s.defaultLimit, 100);
assert.equal(s.gridColumns, 8);
assert.equal(s.showImageInfo, true);
});
test("numeric strings from <select> values are accepted and columns are clamped", () => {
assert.equal(normalizeGallerySettings({ defaultLimit: "200" }).defaultLimit, 200);
assert.equal(normalizeGallerySettings({ gridColumns: "10" }).gridColumns, 10);
assert.equal(normalizeGallerySettings({ gridColumns: 40 }).gridColumns, 12);
assert.equal(normalizeGallerySettings({ gridColumns: 1 }).gridColumns, 2);
assert.deepEqual(LIMIT_OPTIONS, [50, 100, 200, 500]);
});
test("normalize never mutates its input", () => {
const raw = { gridColumns: 4 };
normalizeGallerySettings(raw);
assert.deepEqual(raw, { gridColumns: 4 });
});
test("gridColumnsStyle produces an explicit grid template", () => {
assert.equal(gridColumnsStyle(10), "repeat(10, minmax(0, 1fr))");
assert.equal(gridColumnsStyle("6"), "repeat(6, minmax(0, 1fr))");
assert.equal(gridColumnsStyle("x"), "repeat(8, minmax(0, 1fr))");
});
+10
View File
@@ -7,6 +7,7 @@
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/image-helpers.js"></script>
<script src="/prompt_manager/js/gallery-settings.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
@@ -16,6 +17,15 @@
<link rel="stylesheet" href="lib/tailwind/styles.css">
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
<script src="lib/viewerjs/viewer.min.js"></script>
<style>
/* Column count from Display Settings. An id selector inside the desktop
media query outranks the Tailwind breakpoint classes on #galleryGrid. */
@media (min-width: 1280px) {
#galleryGrid.gallery-columns-custom {
grid-template-columns: var(--gallery-columns, repeat(8, minmax(0, 1fr)));
}
}
</style>
</head>
<body class="bg-pm-primary text-pm min-h-screen">
<div class="min-h-screen flex flex-col">
+85
View File
@@ -0,0 +1,85 @@
/**
* Gallery display settings: defaults, validation of what localStorage holds,
* and the grid template for a chosen column count.
*
* Settings are per browser (localStorage key "gallerySettings"). Anything
* stored can be stale or hand-edited, so every value is checked before use.
*
* Loadable in the browser (window.GallerySettings) and in Node for tests.
*/
(function (root) {
"use strict";
const VIEW_MODES = ["grid", "list"];
const LIMIT_OPTIONS = [50, 100, 200, 500];
const MIN_COLUMNS = 2;
const MAX_COLUMNS = 12;
const DEFAULT_SETTINGS = Object.freeze({
lazyLoading: true,
imageQuality: "medium",
defaultViewMode: "grid",
defaultLimit: 100,
gridColumns: 8,
showImageInfo: true,
autoLoadMetadata: true,
cacheMetadata: true,
showFilePaths: true,
sidebarCollapsedByDefault: false,
debugMode: false,
apiTimeout: 30,
thumbnailsGenerated: false,
checkThumbnailsAtStartup: true,
videoAutoplay: false,
videoMute: true,
videoLoop: true,
});
function toInt(value) {
const n = typeof value === "string" ? Number(value.trim()) : value;
return typeof n === "number" && Number.isInteger(n) ? n : null;
}
function bool(value, fallback) {
return typeof value === "boolean" ? value : fallback;
}
/** Merge stored settings over the defaults, replacing anything invalid. */
function normalizeGallerySettings(raw) {
const stored = raw && typeof raw === "object" ? raw : {};
const merged = { ...DEFAULT_SETTINGS, ...stored };
const limit = toInt(merged.defaultLimit);
const columns = toInt(merged.gridColumns);
const timeout = toInt(merged.apiTimeout);
return {
...merged,
defaultViewMode: VIEW_MODES.includes(merged.defaultViewMode) ? merged.defaultViewMode : DEFAULT_SETTINGS.defaultViewMode,
defaultLimit: limit !== null && LIMIT_OPTIONS.includes(limit) ? limit : DEFAULT_SETTINGS.defaultLimit,
gridColumns: columns === null ? DEFAULT_SETTINGS.gridColumns : Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, columns)),
apiTimeout: timeout !== null && timeout > 0 ? timeout : DEFAULT_SETTINGS.apiTimeout,
imageQuality: ["low", "medium", "high"].includes(merged.imageQuality) ? merged.imageQuality : DEFAULT_SETTINGS.imageQuality,
lazyLoading: bool(merged.lazyLoading, DEFAULT_SETTINGS.lazyLoading),
showImageInfo: bool(merged.showImageInfo, DEFAULT_SETTINGS.showImageInfo),
autoLoadMetadata: bool(merged.autoLoadMetadata, DEFAULT_SETTINGS.autoLoadMetadata),
cacheMetadata: bool(merged.cacheMetadata, DEFAULT_SETTINGS.cacheMetadata),
showFilePaths: bool(merged.showFilePaths, DEFAULT_SETTINGS.showFilePaths),
sidebarCollapsedByDefault: bool(merged.sidebarCollapsedByDefault, DEFAULT_SETTINGS.sidebarCollapsedByDefault),
debugMode: bool(merged.debugMode, DEFAULT_SETTINGS.debugMode),
thumbnailsGenerated: bool(merged.thumbnailsGenerated, DEFAULT_SETTINGS.thumbnailsGenerated),
checkThumbnailsAtStartup: bool(merged.checkThumbnailsAtStartup, DEFAULT_SETTINGS.checkThumbnailsAtStartup),
videoAutoplay: bool(merged.videoAutoplay, DEFAULT_SETTINGS.videoAutoplay),
videoMute: bool(merged.videoMute, DEFAULT_SETTINGS.videoMute),
videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop),
};
}
/** CSS grid-template-columns for the desktop grid. */
function gridColumnsStyle(columns) {
const n = normalizeGallerySettings({ gridColumns: columns }).gridColumns;
return `repeat(${n}, minmax(0, 1fr))`;
}
const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, normalizeGallerySettings, gridColumnsStyle };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.GallerySettings = api;
})(typeof window !== "undefined" ? window : globalThis);
+47 -48
View File
@@ -25,6 +25,7 @@
};
this.initializeEventListeners();
this.applySettings(this.getSettings());
this.loadFolderTree();
this.loadImages();
@@ -44,8 +45,26 @@
});
}
handleMediaError(img) {
if (!img || img.tagName !== 'IMG' || !img.dataset || img.dataset.original === undefined) return;
const next = ImageHelpers.fallbackImageSource(img.getAttribute('src'), img.dataset);
if (next) {
// The thumbnail failed (stale cache, half-written file): show the original instead
img.dataset.fellBack = '1';
img.src = next;
return;
}
img.style.display = 'none';
const fallback = img.nextElementSibling;
if (fallback && fallback.dataset && fallback.dataset.mediaFallback !== undefined) {
fallback.style.display = 'flex';
}
}
initializeEventListeners() {
document.addEventListener('click', (e) => this.handleActionClick(e));
// Image load failures do not bubble; capture them once for every card
document.addEventListener('error', (e) => this.handleMediaError(e.target), true);
document.getElementById('refreshBtn').addEventListener('click', () => this.loadImages());
document.getElementById('limitSelector').addEventListener('change', (e) => this.changeLimit(parseInt(e.target.value)));
document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid'));
@@ -408,6 +427,7 @@
renderGridView() {
const grid = document.getElementById('galleryGrid');
const list = document.getElementById('galleryList');
const showInfo = this.getSettings().showImageInfo;
list.classList.add('hidden');
grid.classList.remove('hidden');
@@ -431,9 +451,9 @@
data-caption="${escapeHtml(this.formatImageCaption(image))}"
data-media-type="${escapeHtml(mediaType)}"
data-is-video="${isVideo}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">
>
<!-- Fallback for failed thumbnails -->
<div class="hidden absolute inset-0 bg-pm-surface text-pm-secondary flex items-center justify-center">
<div data-media-fallback class="hidden absolute inset-0 bg-pm-surface text-pm-secondary flex items-center justify-center">
<div class="text-center">
<div class="text-lg mb-1">${isVideo ? '🎬' : '🖼️'}</div>
<div class="text-xs">Failed to load</div>
@@ -457,7 +477,7 @@
</div>
<div class="p-2">
<div class="text-xs text-pm-secondary truncate" title="${escapeHtml(image.filename)}">${escapeHtml(image.filename)}</div>
<div class="text-xs text-pm-muted">${this.formatFileSize(image.size)}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>
${showInfo ? `<div class="text-xs text-pm-muted">${this.formatFileSize(image.size)}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>` : ''}
</div>
</div>
`;
@@ -467,6 +487,7 @@
renderListView() {
const grid = document.getElementById('galleryGrid');
const list = document.getElementById('galleryList');
const showInfo = this.getSettings().showImageInfo;
grid.classList.add('hidden');
list.classList.remove('hidden');
@@ -489,14 +510,13 @@
data-thumbnail="${escapeHtml(image.thumbnail_url || '')}"
data-caption="${escapeHtml(this.formatImageCaption(image))}"
data-media-type="${escapeHtml(mediaType)}"
data-is-video="${isVideo}"
onerror="this.style.display='none'">
data-is-video="${isVideo}">
${hasThumb ? '<div class="absolute top-1 right-1 w-2 h-2 bg-pm-success rounded-full" title="Thumbnail available"></div>' : ''}
${isVideo ? '<div class="absolute top-1 left-1 w-4 h-3 bg-pm-error text-pm text-xs flex items-center justify-center rounded" title="Video">▶</div>' : ''}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-pm truncate">${escapeHtml(image.filename)}${isVideo ? ' 🎬' : ''}</div>
<div class="text-xs text-pm-secondary">${this.formatFileSize(image.size)} • ${new Date(image.modified_time * 1000).toLocaleDateString()}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>
${showInfo ? `<div class="text-xs text-pm-secondary">${this.formatFileSize(image.size)} • ${new Date(image.modified_time * 1000).toLocaleDateString()}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>` : ''}
<div class="text-xs text-pm-muted truncate">${escapeHtml(image.relative_path)}</div>
</div>
<div class="flex-shrink-0 ml-4">
@@ -1657,37 +1677,14 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
getSettings() {
const defaultSettings = {
lazyLoading: true,
imageQuality: 'medium',
defaultViewMode: 'grid',
defaultLimit: 100,
gridColumns: 8,
showImageInfo: true,
autoLoadMetadata: true,
cacheMetadata: true,
showFilePaths: true,
sidebarCollapsedByDefault: false,
debugMode: false,
apiTimeout: 30,
thumbnailsGenerated: false,
checkThumbnailsAtStartup: true,
// Video settings
videoAutoplay: false,
videoMute: true,
videoLoop: true
};
let stored = null;
try {
const stored = localStorage.getItem('gallerySettings');
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) };
}
const raw = localStorage.getItem('gallerySettings');
if (raw) stored = JSON.parse(raw);
} catch (e) {
console.warn('Failed to load settings from localStorage:', e);
}
return defaultSettings;
return GallerySettings.normalizeGallerySettings(stored);
}
saveSettings() {
@@ -1734,29 +1731,31 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
applySettings(settings) {
// Apply grid columns
// Runs at startup and after Save, so every display setting takes effect
// without a reload. Settings that change what is rendered re-render.
const loaded = this.images.length > 0;
this.updateGridColumns(settings.gridColumns);
// Apply default view mode if different
const limitChanged = this.limit !== settings.defaultLimit;
this.limit = settings.defaultLimit;
const limitSelector = document.getElementById('limitSelector');
if (limitSelector) limitSelector.value = String(settings.defaultLimit);
if (this.viewMode !== settings.defaultViewMode) {
this.setViewMode(settings.defaultViewMode);
} else if (loaded && !limitChanged) {
this.renderGallery(); // e.g. "Show Media Info" toggled
}
// Apply default limit if different
if (this.limit !== settings.defaultLimit) {
this.limit = settings.defaultLimit;
document.getElementById('limitSelector').value = settings.defaultLimit;
}
if (loaded && limitChanged) this.loadImages(1);
}
updateGridColumns(columns) {
const gridContainer = document.getElementById('galleryGrid');
if (gridContainer) {
// Remove existing column classes
gridContainer.className = gridContainer.className.replace(/xl:grid-cols-\d+/g, '');
// Add new column class
gridContainer.classList.add(`xl:grid-cols-${columns}`);
}
if (!gridContainer) return;
// The Tailwind column classes are compiled for a fixed set of values and a
// wider breakpoint overrides them; an explicit template wins on desktop widths.
gridContainer.style.setProperty('--gallery-columns', GallerySettings.gridColumnsStyle(columns));
gridContainer.classList.add('gallery-columns-custom');
}
async generateThumbnails() {