1090 lines
37 KiB
JavaScript
1090 lines
37 KiB
JavaScript
/**
|
|
* Grid View Feature - Asset grid display
|
|
*/
|
|
|
|
import { get, hydrateAssetRatingTags } from "../../api/client.js";
|
|
import { buildListURL } from "../../api/endpoints.js";
|
|
import { createAssetCard } from "../../components/Card.js";
|
|
import { createRatingBadge, createTagsBadge, setFileBadgeCollision } from "../../components/Badges.js";
|
|
import { APP_CONFIG } from "../../app/config.js";
|
|
import { getViewerInstance } from "../../components/Viewer.js";
|
|
// Context menus are bound by the panel (GridContextMenu) to avoid duplicate handlers.
|
|
import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js";
|
|
import { safeDispatchCustomEvent } from "../../utils/events.js";
|
|
import { bindAssetDragStart } from "../dnd/DragDrop.js";
|
|
import { loadMajoorSettings } from "../../app/settings.js";
|
|
|
|
const GRID_STATE = new WeakMap();
|
|
|
|
const RT_HYDRATE_CONCURRENCY = APP_CONFIG.RT_HYDRATE_CONCURRENCY ?? 2;
|
|
const RT_HYDRATE_SEEN = new Set();
|
|
const RT_HYDRATE_SEEN_MAX = APP_CONFIG.RT_HYDRATE_SEEN_MAX ?? 20000;
|
|
const RT_HYDRATE_SEEN_TTL_MS = APP_CONFIG.RT_HYDRATE_SEEN_TTL_MS ?? (10 * 60 * 1000);
|
|
let rtHydrateSeenPruneBudget = 0;
|
|
let rtHydrateSeenLastPruneAt = 0;
|
|
const RT_HYDRATE_QUEUE = [];
|
|
let rtHydrateInflight = 0;
|
|
|
|
function pruneRatingTagsHydrateSeen() {
|
|
// Avoid unbounded growth on large libraries; prune occasionally to keep perf predictable.
|
|
const now = Date.now();
|
|
const needsSizePrune = RT_HYDRATE_SEEN.size > RT_HYDRATE_SEEN_MAX;
|
|
const needsTimePrune =
|
|
rtHydrateSeenLastPruneAt > 0
|
|
&& (now - rtHydrateSeenLastPruneAt) > RT_HYDRATE_SEEN_TTL_MS
|
|
&& RT_HYDRATE_SEEN.size > Math.max(1000, Math.floor(RT_HYDRATE_SEEN_MAX / 4));
|
|
|
|
if (!needsSizePrune && !needsTimePrune) return;
|
|
if (rtHydrateSeenPruneBudget > 0) {
|
|
rtHydrateSeenPruneBudget -= 1;
|
|
return;
|
|
}
|
|
rtHydrateSeenPruneBudget = APP_CONFIG.RT_HYDRATE_PRUNE_BUDGET ?? 250;
|
|
rtHydrateSeenLastPruneAt = now;
|
|
|
|
try {
|
|
const keep = new Set();
|
|
// Keep items that are still queued/in-flight first.
|
|
for (const job of RT_HYDRATE_QUEUE) {
|
|
if (job?.id) keep.add(String(job.id));
|
|
}
|
|
// Keep up to half of the max from the existing set (in insertion order).
|
|
const target = Math.max(1000, Math.floor(RT_HYDRATE_SEEN_MAX / 2));
|
|
if (keep.size < target) {
|
|
for (const id of RT_HYDRATE_SEEN) {
|
|
keep.add(id);
|
|
if (keep.size >= target) break;
|
|
}
|
|
}
|
|
RT_HYDRATE_SEEN.clear();
|
|
for (const id of keep) RT_HYDRATE_SEEN.add(id);
|
|
} catch {}
|
|
}
|
|
|
|
function _updateCardRatingTagsBadges(card, rating, tags) {
|
|
if (!card) return;
|
|
const thumb = card.querySelector?.(".mjr-thumb");
|
|
if (!thumb) return;
|
|
|
|
const nextRatingBadge = createRatingBadge(rating || 0);
|
|
const oldRatingBadge = thumb.querySelector?.(".mjr-rating-badge");
|
|
if (oldRatingBadge && nextRatingBadge) {
|
|
oldRatingBadge.replaceWith(nextRatingBadge);
|
|
} else if (oldRatingBadge && !nextRatingBadge) {
|
|
oldRatingBadge.remove();
|
|
} else if (!oldRatingBadge && nextRatingBadge) {
|
|
thumb.appendChild(nextRatingBadge);
|
|
}
|
|
|
|
const oldTagsBadge = thumb.querySelector?.(".mjr-tags-badge");
|
|
if (oldTagsBadge) {
|
|
if (Array.isArray(tags) && tags.length) {
|
|
oldTagsBadge.textContent = tags.join(", ");
|
|
oldTagsBadge.style.display = "";
|
|
} else {
|
|
oldTagsBadge.textContent = "";
|
|
oldTagsBadge.style.display = "none";
|
|
}
|
|
} else {
|
|
const nextTagsBadge = createTagsBadge(Array.isArray(tags) ? tags : []);
|
|
thumb.appendChild(nextTagsBadge);
|
|
}
|
|
}
|
|
|
|
function _pumpRatingTagsHydration() {
|
|
while (rtHydrateInflight < RT_HYDRATE_CONCURRENCY && RT_HYDRATE_QUEUE.length) {
|
|
const job = RT_HYDRATE_QUEUE.shift();
|
|
if (!job) break;
|
|
rtHydrateInflight += 1;
|
|
(async () => {
|
|
const res = await hydrateAssetRatingTags(job.id);
|
|
if (!res || !res.ok || !res.data) return;
|
|
const updated = res.data;
|
|
const rating = Number(updated.rating || 0) || 0;
|
|
const tags = Array.isArray(updated.tags) ? updated.tags : [];
|
|
|
|
try {
|
|
if (job.asset) {
|
|
job.asset.rating = rating;
|
|
job.asset.tags = tags;
|
|
}
|
|
} catch {}
|
|
|
|
_updateCardRatingTagsBadges(job.card, rating, tags);
|
|
|
|
safeDispatchCustomEvent(
|
|
ASSET_RATING_CHANGED_EVENT,
|
|
{ assetId: String(job.id), rating },
|
|
{ warnPrefix: "[GridView]" }
|
|
);
|
|
safeDispatchCustomEvent(
|
|
ASSET_TAGS_CHANGED_EVENT,
|
|
{ assetId: String(job.id), tags },
|
|
{ warnPrefix: "[GridView]" }
|
|
);
|
|
})()
|
|
.catch(() => null)
|
|
.finally(() => {
|
|
rtHydrateInflight -= 1;
|
|
_pumpRatingTagsHydration();
|
|
});
|
|
}
|
|
}
|
|
|
|
function enqueueRatingTagsHydration(card, asset) {
|
|
const id = asset?.id != null ? String(asset.id) : "";
|
|
if (!id) return;
|
|
if (RT_HYDRATE_SEEN.has(id)) return;
|
|
|
|
const rating = Number(asset?.rating || 0) || 0;
|
|
const tags = asset?.tags;
|
|
const tagsEmpty = !(Array.isArray(tags) && tags.length);
|
|
if (rating > 0 && !tagsEmpty) {
|
|
RT_HYDRATE_SEEN.add(id);
|
|
return;
|
|
}
|
|
|
|
// Hydrate only when both are missing in DB (avoid extra IO).
|
|
if (rating > 0 || !tagsEmpty) {
|
|
RT_HYDRATE_SEEN.add(id);
|
|
return;
|
|
}
|
|
|
|
RT_HYDRATE_SEEN.add(id);
|
|
pruneRatingTagsHydrateSeen();
|
|
RT_HYDRATE_QUEUE.push({ id, card, asset });
|
|
_pumpRatingTagsHydration();
|
|
}
|
|
|
|
/**
|
|
* Create grid container
|
|
*/
|
|
export function createGridContainer() {
|
|
const container = document.createElement("div");
|
|
container.id = "mjr-assets-grid";
|
|
container.classList.add("mjr-grid");
|
|
container.style.setProperty("--mjr-grid-min-size", `${APP_CONFIG.GRID_MIN_SIZE}px`);
|
|
container.style.setProperty("--mjr-grid-gap", `${APP_CONFIG.GRID_GAP}px`);
|
|
|
|
// Bind delegated dragstart once (avoid per-card listeners; improves load perf on large grids).
|
|
try {
|
|
bindAssetDragStart(container);
|
|
} catch {}
|
|
|
|
// Bind double-click to open viewer
|
|
container.addEventListener('dblclick', (e) => {
|
|
const card = e.target.closest('.mjr-asset-card');
|
|
if (!card) return;
|
|
|
|
const asset = card._mjrAsset;
|
|
if (!asset) return;
|
|
|
|
// Get all assets in current grid for navigation
|
|
const allCards = Array.from(container.querySelectorAll('.mjr-asset-card'));
|
|
const allAssets = allCards.map(c => c._mjrAsset).filter(Boolean);
|
|
const currentIndex = allAssets.findIndex(a => a.id === asset.id);
|
|
|
|
// Open viewer
|
|
const viewer = getViewerInstance();
|
|
viewer.open(allAssets, currentIndex);
|
|
});
|
|
|
|
return container;
|
|
}
|
|
|
|
const OVERLAY_CLASS = "mjr-grid-loading-overlay";
|
|
const SENTINEL_CLASS = "mjr-grid-sentinel";
|
|
|
|
function showLoadingOverlay(gridContainer, message = "Loading assets...") {
|
|
let overlay = gridContainer.querySelector(`.${OVERLAY_CLASS}`);
|
|
if (!overlay) {
|
|
overlay = document.createElement("div");
|
|
overlay.className = OVERLAY_CLASS;
|
|
}
|
|
overlay.textContent = message;
|
|
if (!gridContainer.contains(overlay)) {
|
|
gridContainer.appendChild(overlay);
|
|
}
|
|
overlay.style.display = "flex";
|
|
// Ensure the overlay is visible even when the grid is empty/collapsed.
|
|
if (!gridContainer.style.minHeight) {
|
|
gridContainer.style.minHeight = "160px";
|
|
}
|
|
}
|
|
|
|
function hideLoadingOverlay(gridContainer) {
|
|
const overlay = gridContainer.querySelector(`.${OVERLAY_CLASS}`);
|
|
if (overlay) {
|
|
overlay.remove();
|
|
}
|
|
try {
|
|
if (gridContainer.style.minHeight === "160px") {
|
|
gridContainer.style.minHeight = "";
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function sanitizeQuery(query) {
|
|
if (!query || query.trim() === "") {
|
|
return query;
|
|
}
|
|
|
|
if (query === "*") {
|
|
return "*";
|
|
}
|
|
|
|
// Avoid expensive Unicode property regexes on large inputs; fallback for older engines.
|
|
let cleaned = query;
|
|
if (query.length > 256) {
|
|
cleaned = query.replace(/[^a-z0-9\s-]/gi, " ");
|
|
} else {
|
|
try {
|
|
cleaned = query.replace(/[^\p{L}\p{N}\s-]/gu, " ");
|
|
} catch {
|
|
cleaned = query.replace(/[^a-z0-9\s-]/gi, " ");
|
|
}
|
|
}
|
|
return cleaned.trim() || query;
|
|
}
|
|
|
|
function getOrCreateState(gridContainer) {
|
|
let state = GRID_STATE.get(gridContainer);
|
|
if (!state) {
|
|
state = {
|
|
query: "*",
|
|
offset: 0,
|
|
total: null,
|
|
loading: false,
|
|
done: false,
|
|
seenKeys: new Set(),
|
|
assets: [],
|
|
filenameCounts: new Map(),
|
|
nonImageStems: new Set(),
|
|
sentinel: null,
|
|
observer: null,
|
|
scrollRoot: null,
|
|
scrollHandler: null,
|
|
ignoreNextScroll: false,
|
|
userScrolled: false,
|
|
allowUntilFilled: true
|
|
};
|
|
GRID_STATE.set(gridContainer, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function assetKey(asset) {
|
|
if (!asset || typeof asset !== "object") return "";
|
|
if (asset.id != null) return `id:${asset.id}`;
|
|
const fp = asset.filepath || "";
|
|
const t = asset.type || "";
|
|
const rid = asset.root_id || asset.rootId || asset.custom_root_id || "";
|
|
return `${t}|${rid}|${fp}|${asset.subfolder || ""}|${asset.filename || ""}`;
|
|
}
|
|
|
|
function getSelectedIdSet(gridContainer) {
|
|
const selected = new Set();
|
|
if (!gridContainer) return selected;
|
|
try {
|
|
const raw = gridContainer.dataset?.mjrSelectedAssetIds;
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
for (const id of parsed) {
|
|
if (id == null) continue;
|
|
selected.add(String(id));
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
} catch {}
|
|
try {
|
|
const id = gridContainer.dataset?.mjrSelectedAssetId;
|
|
if (id) selected.add(String(id));
|
|
} catch {}
|
|
return selected;
|
|
}
|
|
|
|
function _getFilenameKey(filename) {
|
|
try {
|
|
return String(filename || "").trim().toLowerCase();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _getExtUpper(filename) {
|
|
try {
|
|
return (String(filename || "").split(".").pop() || "").toUpperCase();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _getStemLower(filename) {
|
|
try {
|
|
const s = String(filename || "");
|
|
const idx = s.lastIndexOf(".");
|
|
const stem = idx > 0 ? s.slice(0, idx) : s;
|
|
return String(stem || "").trim().toLowerCase();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _detectKind(asset, extUpper) {
|
|
const k = String(asset?.kind || "").toLowerCase();
|
|
if (k) return k;
|
|
const images = new Set(["PNG", "JPG", "JPEG", "WEBP", "GIF", "BMP", "TIF", "TIFF"]);
|
|
const videos = new Set(["MP4", "WEBM", "MOV", "AVI", "MKV"]);
|
|
const audio = new Set(["MP3", "WAV", "OGG", "FLAC"]);
|
|
if (images.has(extUpper)) return "image";
|
|
if (videos.has(extUpper)) return "video";
|
|
if (audio.has(extUpper)) return "audio";
|
|
return "unknown";
|
|
}
|
|
|
|
function _isHidePngSiblingsEnabled() {
|
|
try {
|
|
const s = loadMajoorSettings();
|
|
return !!s?.siblings?.hidePngSiblings;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function appendAssets(gridContainer, assets, state) {
|
|
const hidePngSiblings = _isHidePngSiblingsEnabled();
|
|
const selectedIds = getSelectedIdSet(gridContainer);
|
|
const frag = document.createDocumentFragment();
|
|
const newAssets = [];
|
|
let added = 0;
|
|
|
|
const filenameCounts = state.filenameCounts || new Map();
|
|
state.filenameCounts = filenameCounts;
|
|
const nonImageStems = state.nonImageStems || new Set();
|
|
state.nonImageStems = nonImageStems;
|
|
|
|
for (const asset of assets || []) {
|
|
const filename = String(asset?.filename || "");
|
|
const extUpper = _getExtUpper(filename);
|
|
const stemLower = _getStemLower(filename);
|
|
const kind = _detectKind(asset, extUpper);
|
|
|
|
// Hide PNG siblings for videos: if a non-image exists with the same stem, hide the PNG.
|
|
if (hidePngSiblings && stemLower) {
|
|
if (kind !== "image") {
|
|
try {
|
|
nonImageStems.add(stemLower);
|
|
} catch {}
|
|
|
|
// If the PNG was already rendered, remove it now (best-effort).
|
|
try {
|
|
const stemSel = CSS.escape ? CSS.escape(stemLower) : stemLower;
|
|
const sibCards = Array.from(
|
|
gridContainer.querySelectorAll(`.mjr-asset-card[data-mjr-stem="${stemSel}"][data-mjr-ext="PNG"]`)
|
|
);
|
|
for (const c of sibCards) {
|
|
try {
|
|
const sibAsset = c?._mjrAsset;
|
|
const sibKey = assetKey(sibAsset);
|
|
if (sibKey) {
|
|
try {
|
|
state.assets = (state.assets || []).filter((a) => assetKey(a) !== sibKey);
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
try {
|
|
c.remove?.();
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
} else if (extUpper === "PNG" && nonImageStems.has(stemLower)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Name collisions: same filename appears multiple times (different folders/roots).
|
|
const fnKey = _getFilenameKey(filename);
|
|
if (fnKey) {
|
|
const prev = Number(filenameCounts.get(fnKey) || 0) || 0;
|
|
const next = prev + 1;
|
|
filenameCounts.set(fnKey, next);
|
|
if (next >= 2) {
|
|
asset._mjrNameCollision = true;
|
|
if (prev === 1) {
|
|
// Update already-rendered cards to show "+" too.
|
|
try {
|
|
for (const a of state.assets || []) {
|
|
if (_getFilenameKey(a?.filename) === fnKey) a._mjrNameCollision = true;
|
|
}
|
|
} catch {}
|
|
try {
|
|
const sel = CSS.escape ? CSS.escape(fnKey) : fnKey;
|
|
const badges = Array.from(
|
|
gridContainer.querySelectorAll(`[data-mjr-filename-key="${sel}"] .mjr-file-badge`)
|
|
);
|
|
for (const b of badges) {
|
|
setFileBadgeCollision(b, true);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
const key = assetKey(asset);
|
|
if (!key) continue;
|
|
if (state.seenKeys.has(key)) continue;
|
|
state.seenKeys.add(key);
|
|
newAssets.push(asset);
|
|
const card = createAssetCard(asset);
|
|
try {
|
|
if (asset?._mjrNameCollision) {
|
|
const badge = card.querySelector?.(".mjr-file-badge");
|
|
setFileBadgeCollision(badge, true);
|
|
}
|
|
} catch {}
|
|
try {
|
|
const id = asset?.id != null ? String(asset.id) : "";
|
|
if (id && selectedIds.has(id)) {
|
|
card.classList.add("is-selected");
|
|
card.setAttribute?.("aria-selected", "true");
|
|
}
|
|
} catch {}
|
|
frag.appendChild(card);
|
|
// Best-effort: hydrate existing OS rating/tags (if DB empty) without requiring a full rescan.
|
|
enqueueRatingTagsHydration(card, asset);
|
|
added += 1;
|
|
}
|
|
if (added > 0) {
|
|
try {
|
|
state.assets.push(...newAssets);
|
|
} catch {}
|
|
gridContainer.appendChild(frag);
|
|
// Update context menu asset list (bind is idempotent).
|
|
// Context menus are bound by the panel.
|
|
}
|
|
return added;
|
|
}
|
|
|
|
function ensureSentinel(gridContainer, state) {
|
|
let sentinel = state.sentinel;
|
|
if (sentinel && sentinel.isConnected) return sentinel;
|
|
sentinel = document.createElement("div");
|
|
sentinel.className = SENTINEL_CLASS;
|
|
// Ensure it's always placed at the end of the grid and spans all columns.
|
|
// This stabilizes IntersectionObserver triggering as the panel resizes.
|
|
sentinel.style.cssText = "height: 1px; width: 100%; grid-column: 1 / -1;";
|
|
state.sentinel = sentinel;
|
|
gridContainer.appendChild(sentinel);
|
|
return sentinel;
|
|
}
|
|
|
|
function stopObserver(state) {
|
|
try {
|
|
if (state.observer) state.observer.disconnect();
|
|
} catch {}
|
|
state.observer = null;
|
|
try {
|
|
if (state.sentinel && state.sentinel.isConnected) state.sentinel.remove();
|
|
} catch {}
|
|
state.sentinel = null;
|
|
|
|
try {
|
|
if (state.scrollRoot && state.scrollHandler) {
|
|
state.scrollRoot.removeEventListener("scroll", state.scrollHandler, { passive: true });
|
|
}
|
|
} catch {}
|
|
state.scrollRoot = null;
|
|
state.scrollHandler = null;
|
|
state.ignoreNextScroll = false;
|
|
state.userScrolled = false;
|
|
state.allowUntilFilled = true;
|
|
}
|
|
|
|
function captureScrollMetrics(state) {
|
|
const root = state?.scrollRoot;
|
|
if (!root) return null;
|
|
const clientHeight = Number(root.clientHeight) || 0;
|
|
const scrollHeight = Number(root.scrollHeight) || 0;
|
|
const scrollTop = Number(root.scrollTop) || 0;
|
|
const bottomGap = scrollHeight - (scrollTop + clientHeight);
|
|
return { clientHeight, scrollHeight, scrollTop, bottomGap };
|
|
}
|
|
|
|
function maybeKeepPinnedToBottom(state, before) {
|
|
const root = state?.scrollRoot;
|
|
if (!root || !before) return;
|
|
|
|
// If the user was effectively at the bottom before we appended, keep them pinned
|
|
// to avoid the scrollbar "jumping up" when new content increases scrollHeight.
|
|
if (before.bottomGap <= 2) {
|
|
try {
|
|
state.ignoreNextScroll = true;
|
|
root.scrollTop = root.scrollHeight - root.clientHeight;
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
async function fetchPage(gridContainer, query, limit, offset) {
|
|
const scope = gridContainer?.dataset?.mjrScope || "output";
|
|
const customRootId = gridContainer?.dataset?.mjrCustomRootId || "";
|
|
const subfolder = gridContainer?.dataset?.mjrSubfolder || "";
|
|
const kind = gridContainer?.dataset?.mjrFilterKind || "";
|
|
const workflowOnly = gridContainer?.dataset?.mjrFilterWorkflowOnly === "1";
|
|
const minRating = Number(gridContainer?.dataset?.mjrFilterMinRating || 0) || 0;
|
|
const dateRange = String(gridContainer?.dataset?.mjrFilterDateRange || "").trim().toLowerCase();
|
|
const dateExact = String(gridContainer?.dataset?.mjrFilterDateExact || "").trim();
|
|
const sortKey = gridContainer?.dataset?.mjrSort || "mtime_desc";
|
|
|
|
const requestedQuery = query && query.trim() ? query : "*";
|
|
const safeQuery = sanitizeQuery(requestedQuery) || requestedQuery;
|
|
|
|
try {
|
|
const includeTotal = !(String(scope || "").toLowerCase() === "output" && Number(offset || 0) > 0);
|
|
const url = buildListURL({
|
|
q: safeQuery,
|
|
limit,
|
|
offset,
|
|
scope,
|
|
subfolder,
|
|
customRootId: customRootId || null,
|
|
kind: kind || null,
|
|
hasWorkflow: workflowOnly ? true : null,
|
|
minRating: minRating > 0 ? minRating : null,
|
|
dateRange: dateRange || null,
|
|
dateExact: dateExact || null,
|
|
includeTotal
|
|
});
|
|
const result = await get(url);
|
|
|
|
if (result.ok) {
|
|
let assets = (result.data?.assets) || [];
|
|
const rawTotal = result.data?.total;
|
|
const total = rawTotal == null ? null : (Number(rawTotal || 0) || 0);
|
|
|
|
// Client-side sorting (applies to current page). Default is already mtime desc from the backend.
|
|
if (sortKey !== "mtime_desc") {
|
|
const safeName = (a) => String(a?.filename || "");
|
|
const safeMtime = (a) => Number(a?.mtime || 0) || 0;
|
|
if (sortKey === "name_asc") {
|
|
assets.sort((a, b) => safeName(a).localeCompare(safeName(b)));
|
|
} else if (sortKey === "name_desc") {
|
|
assets.sort((a, b) => safeName(b).localeCompare(safeName(a)));
|
|
} else if (sortKey === "mtime_asc") {
|
|
assets.sort((a, b) => safeMtime(a) - safeMtime(b));
|
|
}
|
|
}
|
|
return { ok: true, assets, total, count: assets.length, sortKey, safeQuery };
|
|
} else {
|
|
return { ok: false, error: result.error };
|
|
}
|
|
} catch (error) {
|
|
return { ok: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
const emitAgendaStatus = (dateExact, hasResults) => {
|
|
if (!dateExact) return;
|
|
try {
|
|
window?.dispatchEvent?.(
|
|
new CustomEvent("MJR:AgendaStatus", {
|
|
detail: { date: dateExact, hasResults: Boolean(hasResults) },
|
|
})
|
|
);
|
|
} catch {}
|
|
};
|
|
|
|
async function loadNextPage(gridContainer, state) {
|
|
if (state.loading || state.done) return;
|
|
const limit = Math.max(1, Math.min(APP_CONFIG.MAX_PAGE_SIZE, APP_CONFIG.DEFAULT_PAGE_SIZE));
|
|
state.loading = true;
|
|
const before = captureScrollMetrics(state);
|
|
try {
|
|
const page = await fetchPage(gridContainer, state.query, limit, state.offset);
|
|
const dateExact = String(gridContainer?.dataset?.mjrFilterDateExact || "").trim();
|
|
emitAgendaStatus(dateExact, page.ok && Array.isArray(page.assets) && page.assets.length > 0);
|
|
if (!page.ok) {
|
|
state.done = true;
|
|
stopObserver(state);
|
|
const p = document.createElement("p");
|
|
p.className = "mjr-muted";
|
|
p.style.color = "var(--error-text, #f44336)";
|
|
p.textContent = `Error: ${page.error}`;
|
|
gridContainer.appendChild(p);
|
|
return;
|
|
}
|
|
|
|
if (page.total != null) {
|
|
state.total = page.total;
|
|
}
|
|
const added = appendAssets(gridContainer, page.assets || [], state);
|
|
state.offset += (page.count || 0);
|
|
|
|
maybeKeepPinnedToBottom(state, before);
|
|
|
|
const hasTotal = state.total != null && Number(state.total) > 0;
|
|
if ((page.count || 0) === 0 || (hasTotal && state.offset >= Number(state.total))) {
|
|
state.done = true;
|
|
stopObserver(state);
|
|
}
|
|
} finally {
|
|
state.loading = false;
|
|
}
|
|
}
|
|
|
|
function startInfiniteScroll(gridContainer, state) {
|
|
if (!APP_CONFIG.INFINITE_SCROLL_ENABLED) return;
|
|
stopObserver(state);
|
|
const sentinel = ensureSentinel(gridContainer, state);
|
|
const rootEl = gridContainer.closest(".mjr-am-browse") || gridContainer.parentElement || null;
|
|
|
|
state.scrollRoot = rootEl;
|
|
state.userScrolled = false;
|
|
state.allowUntilFilled = true;
|
|
state.ignoreNextScroll = false;
|
|
|
|
if (rootEl && !state.scrollHandler) {
|
|
state.scrollHandler = () => {
|
|
if (state.ignoreNextScroll) {
|
|
state.ignoreNextScroll = false;
|
|
return;
|
|
}
|
|
state.userScrolled = true;
|
|
};
|
|
try {
|
|
rootEl.addEventListener("scroll", state.scrollHandler, { passive: true });
|
|
} catch {}
|
|
}
|
|
|
|
state.observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries || []) {
|
|
if (!entry.isIntersecting) continue;
|
|
if (state.loading || state.done) return;
|
|
|
|
// Prevent repeated auto-loads when new content is appended while the user
|
|
// stays pinned at the bottom. Require a user scroll between loads unless
|
|
// the grid doesn't fill the viewport yet (initial fill).
|
|
const metrics = captureScrollMetrics(state);
|
|
const fillsViewport = metrics ? metrics.scrollHeight > metrics.clientHeight + 40 : true;
|
|
if (!state.userScrolled && fillsViewport && !state.allowUntilFilled) {
|
|
return;
|
|
}
|
|
try {
|
|
state.observer?.unobserve?.(sentinel);
|
|
} catch {}
|
|
|
|
state.userScrolled = false;
|
|
if (fillsViewport) state.allowUntilFilled = false;
|
|
|
|
Promise.resolve(loadNextPage(gridContainer, state))
|
|
.catch(() => null)
|
|
.finally(() => {
|
|
if (state.done) return;
|
|
if (!sentinel.isConnected) return;
|
|
try {
|
|
state.observer?.observe?.(sentinel);
|
|
} catch {}
|
|
});
|
|
}
|
|
},
|
|
{
|
|
root: rootEl,
|
|
rootMargin: APP_CONFIG.INFINITE_SCROLL_ROOT_MARGIN || "800px",
|
|
threshold: APP_CONFIG.INFINITE_SCROLL_THRESHOLD ?? 0.01
|
|
}
|
|
);
|
|
state.observer.observe(sentinel);
|
|
}
|
|
|
|
export async function loadAssets(gridContainer, query = "*", options = {}) {
|
|
const { reset = true } = options || {};
|
|
const state = getOrCreateState(gridContainer);
|
|
|
|
const requestedQuery = query && query.trim() ? query : "*";
|
|
state.query = sanitizeQuery(requestedQuery) || requestedQuery;
|
|
|
|
if (reset) {
|
|
stopObserver(state);
|
|
state.offset = 0;
|
|
state.total = null;
|
|
state.done = false;
|
|
state.loading = false;
|
|
state.seenKeys = new Set();
|
|
state.assets = [];
|
|
state.filenameCounts = new Map();
|
|
state.nonImageStems = new Set();
|
|
gridContainer.innerHTML = "";
|
|
showLoadingOverlay(gridContainer, state.query === "*" ? "Loading assets..." : `Searching for "${state.query}"...`);
|
|
}
|
|
|
|
try {
|
|
await loadNextPage(gridContainer, state);
|
|
|
|
if (reset) {
|
|
hideLoadingOverlay(gridContainer);
|
|
const hasCards = gridContainer.querySelector(".mjr-card") != null;
|
|
if (!hasCards && state.offset === 0) {
|
|
gridContainer.innerHTML = "";
|
|
const p = document.createElement("p");
|
|
p.className = "mjr-muted";
|
|
p.textContent = "No assets found";
|
|
gridContainer.appendChild(p);
|
|
} else {
|
|
startInfiniteScroll(gridContainer, state);
|
|
}
|
|
}
|
|
return { ok: true, count: state.offset, total: state.total || 0 };
|
|
} catch (err) {
|
|
stopObserver(state);
|
|
if (reset) hideLoadingOverlay(gridContainer);
|
|
gridContainer.innerHTML = "";
|
|
const p = document.createElement("p");
|
|
p.className = "mjr-muted";
|
|
p.style.color = "var(--error-text, #f44336)";
|
|
p.textContent = `Failed to load assets: ${err?.message || err}`;
|
|
gridContainer.appendChild(p);
|
|
return { ok: false, error: err?.message || String(err) };
|
|
} finally {
|
|
if (reset) hideLoadingOverlay(gridContainer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load a pre-defined list of assets into the grid (no paging/infinite scroll).
|
|
* Used for "Collections" views.
|
|
*/
|
|
export async function loadAssetsFromList(gridContainer, assets, options = {}) {
|
|
const { title = "Collection", reset = true } = options || {};
|
|
const state = getOrCreateState(gridContainer);
|
|
|
|
const list = Array.isArray(assets) ? assets : [];
|
|
|
|
if (reset) {
|
|
stopObserver(state);
|
|
state.offset = 0;
|
|
state.total = list.length;
|
|
state.done = true;
|
|
state.loading = false;
|
|
state.query = String(title || "Collection");
|
|
state.seenKeys = new Set();
|
|
state.assets = [];
|
|
state.filenameCounts = new Map();
|
|
state.nonImageStems = new Set();
|
|
gridContainer.innerHTML = "";
|
|
showLoadingOverlay(gridContainer, list.length ? `Loading ${title}...` : `${title} is empty`);
|
|
}
|
|
|
|
try {
|
|
// Apply the current sort order for consistent UX.
|
|
const sortKey = gridContainer.dataset.mjrSort || "mtime_desc";
|
|
const sorted = list.slice().sort((a, b) => {
|
|
const av = getSortValue(a, sortKey);
|
|
const bv = getSortValue(b, sortKey);
|
|
if (av < bv) return -1;
|
|
if (av > bv) return 1;
|
|
return 0;
|
|
});
|
|
|
|
appendAssets(gridContainer, sorted, state);
|
|
|
|
if (reset) {
|
|
hideLoadingOverlay(gridContainer);
|
|
const hasCards = gridContainer.querySelector(".mjr-card") != null;
|
|
if (!hasCards) {
|
|
gridContainer.innerHTML = "";
|
|
const p = document.createElement("p");
|
|
p.className = "mjr-muted";
|
|
p.textContent = "No assets found";
|
|
gridContainer.appendChild(p);
|
|
}
|
|
}
|
|
|
|
return { ok: true, count: sorted.length, total: sorted.length };
|
|
} catch (err) {
|
|
stopObserver(state);
|
|
if (reset) hideLoadingOverlay(gridContainer);
|
|
gridContainer.innerHTML = "";
|
|
const p = document.createElement("p");
|
|
p.className = "mjr-muted";
|
|
p.style.color = "var(--error-text, #f44336)";
|
|
p.textContent = `Failed to load collection: ${err?.message || err}`;
|
|
gridContainer.appendChild(p);
|
|
return { ok: false, error: err?.message || String(err) };
|
|
} finally {
|
|
if (reset) hideLoadingOverlay(gridContainer);
|
|
}
|
|
}
|
|
|
|
export function disposeGrid(gridContainer) {
|
|
const state = GRID_STATE.get(gridContainer);
|
|
if (!state) return;
|
|
stopObserver(state);
|
|
try {
|
|
state.seenKeys?.clear?.();
|
|
} catch {}
|
|
GRID_STATE.delete(gridContainer);
|
|
}
|
|
|
|
/**
|
|
* Get sort value for an asset based on sort key
|
|
*/
|
|
function getSortValue(asset, sortKey) {
|
|
switch (sortKey) {
|
|
case 'mtime_desc':
|
|
return -(Number(asset?.mtime) || 0); // Negative for descending order
|
|
case 'mtime_asc':
|
|
return Number(asset?.mtime) || 0;
|
|
case 'name_asc':
|
|
return String(asset?.filename || '').toLowerCase();
|
|
case 'name_desc':
|
|
return String(asset?.filename || '').toLowerCase(); // Will be handled specially in comparison
|
|
default:
|
|
return -(Number(asset?.mtime) || 0); // Default to mtime_desc
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if an asset should be inserted before another based on sort order
|
|
*/
|
|
function shouldInsertBefore(assetValue, cardValue, sortKey) {
|
|
if (sortKey === 'name_asc') {
|
|
// For ascending string comparisons
|
|
return String(assetValue) < String(cardValue);
|
|
} else if (sortKey === 'name_desc') {
|
|
// For descending string comparisons
|
|
return String(assetValue) > String(cardValue);
|
|
} else {
|
|
// For numeric comparisons
|
|
return Number(assetValue) < Number(cardValue);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the correct insertion position in an array based on sort order
|
|
*/
|
|
function findInsertPosition(array, asset, sortKey) {
|
|
const assetValue = getSortValue(asset, sortKey);
|
|
for (let i = 0; i < array.length; i++) {
|
|
const arrValue = getSortValue(array[i], sortKey);
|
|
if (shouldInsertBefore(assetValue, arrValue, sortKey)) {
|
|
return i;
|
|
}
|
|
}
|
|
return array.length; // Append to end
|
|
}
|
|
|
|
/**
|
|
* Find asset element by ID
|
|
*/
|
|
function findAssetElement(gridContainer, assetId) {
|
|
return gridContainer.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(assetId)) : String(assetId)}"]`);
|
|
}
|
|
|
|
/**
|
|
* Update or insert a single asset in the grid
|
|
*/
|
|
export function upsertAsset(gridContainer, asset) {
|
|
if (!asset || !asset.id) return false;
|
|
|
|
const state = getOrCreateState(gridContainer);
|
|
const assetId = String(asset.id);
|
|
const key = assetKey(asset);
|
|
|
|
// Check if asset already exists in the grid
|
|
const existingElement = findAssetElement(gridContainer, assetId);
|
|
|
|
if (existingElement) {
|
|
// Update existing asset
|
|
const existingAsset = existingElement._mjrAsset;
|
|
if (existingAsset) {
|
|
// Check if the existing element was selected before replacement
|
|
const wasSelected = existingElement.classList.contains("is-selected");
|
|
const wasAriaSelected = existingElement.getAttribute("aria-selected");
|
|
|
|
// Update the asset data
|
|
Object.assign(existingAsset, asset);
|
|
// Update the card display
|
|
const card = createAssetCard(asset);
|
|
|
|
// Preserve selection state after replacement
|
|
if (wasSelected) {
|
|
card.classList.add("is-selected");
|
|
}
|
|
if (wasAriaSelected) {
|
|
card.setAttribute("aria-selected", wasAriaSelected);
|
|
}
|
|
|
|
existingElement.replaceWith(card);
|
|
return true;
|
|
}
|
|
} else {
|
|
// Check if asset key is already in seen keys
|
|
if (!state.seenKeys.has(key)) {
|
|
// Insert new asset
|
|
const selectedIds = getSelectedIdSet(gridContainer);
|
|
const card = createAssetCard(asset);
|
|
try {
|
|
if (selectedIds.has(assetId)) {
|
|
card.classList.add("is-selected");
|
|
card.setAttribute?.("aria-selected", "true");
|
|
}
|
|
} catch {}
|
|
|
|
// Determine sort order from grid container
|
|
const sortKey = gridContainer.dataset.mjrSort || "mtime_desc";
|
|
|
|
// Insert at the appropriate position based on sort order
|
|
const allCards = Array.from(gridContainer.querySelectorAll('.mjr-asset-card'));
|
|
let insertIndex = -1;
|
|
|
|
// Find the correct position based on sort order
|
|
const assetValue = getSortValue(asset, sortKey);
|
|
for (let i = 0; i < allCards.length; i++) {
|
|
const cardAsset = allCards[i]._mjrAsset;
|
|
const cardValue = getSortValue(cardAsset, sortKey);
|
|
|
|
if (shouldInsertBefore(assetValue, cardValue, sortKey)) {
|
|
insertIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (insertIndex === -1) {
|
|
// Append to end if no earlier asset found
|
|
gridContainer.appendChild(card);
|
|
} else {
|
|
// Insert at the calculated position
|
|
gridContainer.insertBefore(card, allCards[insertIndex]);
|
|
}
|
|
|
|
// Add to seen keys and assets array in correct sort position
|
|
state.seenKeys.add(key);
|
|
const insertPos = findInsertPosition(state.assets, asset, sortKey);
|
|
if (insertPos === -1) {
|
|
state.assets.push(asset);
|
|
} else {
|
|
state.assets.splice(insertPos, 0, asset);
|
|
}
|
|
|
|
// Bind context menu and drag start
|
|
// Context menus are bound by the panel.
|
|
bindAssetDragStart(gridContainer);
|
|
|
|
// Hydrate rating/tags if needed
|
|
enqueueRatingTagsHydration(card, asset);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Capture scroll anchor before making changes
|
|
*/
|
|
export function captureAnchor(gridContainer) {
|
|
const state = getOrCreateState(gridContainer);
|
|
const selectedElement = gridContainer.querySelector('.mjr-asset-card.is-selected');
|
|
const firstVisibleElement = getFirstVisibleAssetElement(gridContainer);
|
|
|
|
const el = selectedElement || firstVisibleElement;
|
|
if (!el) return null;
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
const containerRect = gridContainer.getBoundingClientRect();
|
|
|
|
return {
|
|
id: el.dataset.mjrAssetId,
|
|
top: rect.top - containerRect.top,
|
|
scrollTop: gridContainer.parentElement?.scrollTop || 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get first visible asset element
|
|
*/
|
|
function getFirstVisibleAssetElement(gridContainer) {
|
|
const cards = gridContainer.querySelectorAll('.mjr-asset-card');
|
|
const containerRect = gridContainer.getBoundingClientRect();
|
|
const containerTop = containerRect.top;
|
|
const containerBottom = containerRect.bottom;
|
|
|
|
for (const card of cards) {
|
|
const cardRect = card.getBoundingClientRect();
|
|
if (cardRect.bottom >= containerTop && cardRect.top <= containerBottom) {
|
|
return card;
|
|
}
|
|
}
|
|
|
|
// If no card is visible, return the first one
|
|
return cards[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Restore scroll position based on anchor
|
|
*/
|
|
export async function restoreAnchor(gridContainer, anchor) {
|
|
if (!anchor) return;
|
|
|
|
// Wait for layout to complete
|
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
|
|
const el = findAssetElement(gridContainer, anchor.id);
|
|
if (!el) return;
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
const containerRect = gridContainer.getBoundingClientRect();
|
|
const delta = (rect.top - containerRect.top) - anchor.top;
|
|
|
|
const parent = gridContainer.parentElement;
|
|
if (parent) {
|
|
parent.scrollTop = (parent.scrollTop || 0) + delta;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user is at bottom of grid (within threshold)
|
|
*/
|
|
export function isAtBottom(gridContainer, threshold = 80) {
|
|
const parent = gridContainer.parentElement;
|
|
if (!parent) return false;
|
|
|
|
const atBottom = (parent.scrollHeight - (parent.scrollTop + parent.clientHeight)) < threshold;
|
|
return atBottom;
|
|
}
|
|
|
|
/**
|
|
* Scroll to bottom of grid
|
|
*/
|
|
export function scrollToBottom(gridContainer) {
|
|
const parent = gridContainer.parentElement;
|
|
if (parent) {
|
|
parent.scrollTop = parent.scrollHeight;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear grid placeholder
|
|
*/
|
|
export function clearGrid(gridContainer) {
|
|
try {
|
|
const state = GRID_STATE.get(gridContainer);
|
|
if (state) {
|
|
stopObserver(state);
|
|
state.offset = 0;
|
|
state.total = null;
|
|
state.done = false;
|
|
state.loading = false;
|
|
state.seenKeys = new Set();
|
|
}
|
|
} catch {}
|
|
gridContainer.innerHTML = "";
|
|
const p = document.createElement("p");
|
|
p.className = "mjr-muted";
|
|
p.textContent = "Type to search or wait for the scan to finish";
|
|
gridContainer.appendChild(p);
|
|
}
|