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:
@@ -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))");
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user