- Added resetBrowserHistory functionality to contextController for better state management. - Improved handling of custom root states in customRootsController, including resetting labels and paths. - Removed unused refreshCustomRoots from scopeController. - Updated sidebarController to provide more context in asset status updates. - Introduced new folder details section in the sidebar to display folder metadata. - Created pinned folders popover view for better folder management. - Implemented custom status dot themes for improved visual feedback in asset status. - Added folder info endpoint in the backend to retrieve folder statistics and metadata.
332 lines
12 KiB
JavaScript
332 lines
12 KiB
JavaScript
/**
|
|
* Inline Asset Sidebar - Integrates into the Assets Manager panel
|
|
*
|
|
* This module keeps the public API stable (`createSidebar`, `showAssetInSidebar`, `closeSidebar`)
|
|
* while delegating UI rendering/parsing to `js/components/sidebar/sections/*` and `parsers/*`.
|
|
*/
|
|
|
|
import { getAssetMetadata, getFileMetadataScoped, getFolderInfo } from "../../api/client.js";
|
|
import { createSidebarHeader } from "./sections/HeaderSection.js";
|
|
import { createPreviewSection } from "./sections/PreviewSection.js";
|
|
import { createRatingTagsSection } from "./sections/RatingTagsSection.js";
|
|
import { createFileInfoSection } from "./sections/FileInfoSection.js";
|
|
import { createGenerationSection } from "./sections/GenerationSection.js";
|
|
import { createWorkflowMinimapSection } from "./sections/WorkflowMinimapSection.js";
|
|
import { createFolderDetailsSection } from "./sections/FolderDetailsSection.js";
|
|
import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js";
|
|
import { loadMajoorSettings } from "../../app/settings.js";
|
|
|
|
/**
|
|
* Create inline sidebar (for panel integration)
|
|
* @param {string} position - "left" or "right"
|
|
*/
|
|
export function createSidebar(position = "right") {
|
|
const sidebar = document.createElement("div");
|
|
sidebar.className = "mjr-inline-sidebar";
|
|
|
|
sidebar.dataset.position = position;
|
|
sidebar._requestSeq = 0;
|
|
sidebar._closeTimer = null;
|
|
|
|
const placeholder = document.createElement("div");
|
|
placeholder.className = "mjr-sidebar-placeholder";
|
|
placeholder.textContent = "Select an asset to view details";
|
|
|
|
sidebar.appendChild(placeholder);
|
|
sidebar._placeholder = placeholder;
|
|
sidebar._currentAsset = null;
|
|
sidebar._currentFullAsset = null;
|
|
sidebar._ratingTagsSection = null;
|
|
|
|
const unsubs = [];
|
|
const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
sidebar._mjrAbortController = ac;
|
|
const disposeSidebar = () => {
|
|
try {
|
|
ac?.abort?.();
|
|
} catch {}
|
|
try {
|
|
for (const u of unsubs) u?.();
|
|
} catch {}
|
|
try {
|
|
unsubs.length = 0;
|
|
} catch {}
|
|
};
|
|
sidebar.dispose = disposeSidebar;
|
|
sidebar._dispose = disposeSidebar;
|
|
|
|
const matchesCurrent = (assetId) => {
|
|
const id = sidebar._currentAsset?.id ?? sidebar._currentFullAsset?.id ?? null;
|
|
if (id == null || assetId == null) return false;
|
|
return String(id) === String(assetId);
|
|
};
|
|
|
|
const onRatingChanged = (ev) => {
|
|
const detail = ev?.detail || {};
|
|
const assetId = detail.assetId ?? detail.id ?? null;
|
|
const rating = Number(detail.rating);
|
|
if (!matchesCurrent(assetId)) return;
|
|
if (!Number.isFinite(rating)) return;
|
|
try {
|
|
if (sidebar._currentAsset) sidebar._currentAsset.rating = rating;
|
|
if (sidebar._currentFullAsset) sidebar._currentFullAsset.rating = rating;
|
|
sidebar._ratingTagsSection?._mjrSetRating?.(rating);
|
|
} catch {}
|
|
};
|
|
|
|
const onTagsChanged = (ev) => {
|
|
const detail = ev?.detail || {};
|
|
const assetId = detail.assetId ?? detail.id ?? null;
|
|
const tags = Array.isArray(detail.tags) ? detail.tags : null;
|
|
if (!matchesCurrent(assetId)) return;
|
|
if (!tags) return;
|
|
try {
|
|
if (sidebar._currentAsset) sidebar._currentAsset.tags = tags;
|
|
if (sidebar._currentFullAsset) sidebar._currentFullAsset.tags = tags;
|
|
sidebar._ratingTagsSection?._mjrSetTags?.(tags);
|
|
} catch {}
|
|
};
|
|
|
|
try {
|
|
window.addEventListener(ASSET_RATING_CHANGED_EVENT, onRatingChanged, ac ? { signal: ac.signal } : undefined);
|
|
window.addEventListener(ASSET_TAGS_CHANGED_EVENT, onTagsChanged, ac ? { signal: ac.signal } : undefined);
|
|
} catch {
|
|
try {
|
|
window.addEventListener(ASSET_RATING_CHANGED_EVENT, onRatingChanged);
|
|
window.addEventListener(ASSET_TAGS_CHANGED_EVENT, onTagsChanged);
|
|
unsubs.push(() => {
|
|
try {
|
|
window.removeEventListener(ASSET_RATING_CHANGED_EVENT, onRatingChanged);
|
|
} catch {}
|
|
try {
|
|
window.removeEventListener(ASSET_TAGS_CHANGED_EVENT, onTagsChanged);
|
|
} catch {}
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
return sidebar;
|
|
}
|
|
|
|
const cleanupMinimapSections = (root) => {
|
|
try {
|
|
if (!root) return;
|
|
const sections = root.querySelectorAll(".mjr-sidebar-section");
|
|
sections.forEach((section) => {
|
|
try {
|
|
section._mjrMinimapCleanup?.();
|
|
} catch {}
|
|
});
|
|
} catch {}
|
|
};
|
|
|
|
/**
|
|
* Show asset in inline sidebar
|
|
*/
|
|
export async function showAssetInSidebar(sidebar, asset, onUpdate) {
|
|
if (!sidebar || !asset) return;
|
|
const isFolderAsset = String(asset?.kind || "").toLowerCase() === "folder";
|
|
|
|
const hasMeaningfulMetadataRaw = (value) => {
|
|
if (value == null) return false;
|
|
let obj = value;
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed === "{}" || trimmed === "null") return false;
|
|
try {
|
|
obj = JSON.parse(trimmed);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if (typeof obj === "object") {
|
|
try {
|
|
// Require generation-specific keys (not just any metadata payload).
|
|
if (obj.geninfo || obj.prompt || obj.workflow) return true;
|
|
if (obj.metadata_raw && typeof obj.metadata_raw === "object") {
|
|
return Boolean(obj.metadata_raw.geninfo || obj.metadata_raw.prompt || obj.metadata_raw.workflow);
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const hasGenerationLikeData = (obj) => {
|
|
if (!obj) return false;
|
|
return !!(obj.geninfo || obj.prompt || hasMeaningfulMetadataRaw(obj.metadata_raw));
|
|
};
|
|
|
|
try {
|
|
if (sidebar._closeTimer) {
|
|
clearTimeout(sidebar._closeTimer);
|
|
sidebar._closeTimer = null;
|
|
}
|
|
} catch {}
|
|
|
|
const requestSeq = (sidebar._requestSeq = (sidebar._requestSeq || 0) + 1);
|
|
sidebar._currentFetchAbortController?.abort?.();
|
|
sidebar._currentFetchAbortController = null;
|
|
|
|
sidebar.classList.add("is-open");
|
|
|
|
if (sidebar._placeholder && sidebar._placeholder.parentNode) {
|
|
sidebar._placeholder.remove();
|
|
}
|
|
|
|
sidebar.innerHTML = "";
|
|
sidebar._currentAsset = asset;
|
|
sidebar._currentFullAsset = asset;
|
|
sidebar._ratingTagsSection = null;
|
|
|
|
const header = createSidebarHeader(asset, () => closeSidebar(sidebar));
|
|
const content = document.createElement("div");
|
|
content.className = "mjr-sidebar-content";
|
|
content.style.cssText = `
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 10px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
`;
|
|
|
|
sidebar.appendChild(header);
|
|
sidebar.appendChild(content);
|
|
|
|
let fullAsset = asset;
|
|
const renderContent = (data) => {
|
|
if (sidebar._requestSeq !== requestSeq || sidebar._currentAsset !== asset) return;
|
|
cleanupMinimapSections(content);
|
|
content.innerHTML = "";
|
|
const settings = loadMajoorSettings();
|
|
const showPreviewThumb = !!(settings?.sidebar?.showPreviewThumb ?? true);
|
|
if (isFolderAsset) {
|
|
content.appendChild(createFolderDetailsSection(asset, data?.folder_info || data?.folderInfo || null));
|
|
} else {
|
|
content.appendChild(createPreviewSection(data, { showPreviewThumb }));
|
|
const ratingTagsSection = createRatingTagsSection(data, onUpdate);
|
|
sidebar._ratingTagsSection = ratingTagsSection;
|
|
content.appendChild(ratingTagsSection);
|
|
const fileInfoSection = createFileInfoSection(data);
|
|
if (fileInfoSection) content.appendChild(fileInfoSection);
|
|
const genMetadata = createGenerationSection(data);
|
|
if (genMetadata) content.appendChild(genMetadata);
|
|
const workflow = createWorkflowMinimapSection(data);
|
|
if (workflow) content.appendChild(workflow);
|
|
}
|
|
sidebar._currentFullAsset = data;
|
|
};
|
|
|
|
renderContent(fullAsset);
|
|
|
|
const tryUpdateWith = (extra = {}) => {
|
|
const updated = { ...fullAsset, ...extra };
|
|
fullAsset = updated;
|
|
renderContent(fullAsset);
|
|
try {
|
|
if (typeof onUpdate === "function") onUpdate(fullAsset);
|
|
} catch {}
|
|
};
|
|
|
|
const buildFetchOptions = () => {
|
|
sidebar._currentFetchAbortController?.abort?.();
|
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
sidebar._currentFetchAbortController = controller;
|
|
return controller?.signal ? { signal: controller.signal } : {};
|
|
};
|
|
|
|
const loadMetadataAsync = async () => {
|
|
if (sidebar._requestSeq !== requestSeq || sidebar._currentAsset !== asset) return;
|
|
const opts = buildFetchOptions();
|
|
const signal = opts.signal;
|
|
if (isFolderAsset) {
|
|
try {
|
|
const filepath = String(fullAsset?.filepath || fullAsset?.subfolder || "").trim();
|
|
const root_id = String(fullAsset?.root_id || fullAsset?.rootId || "").trim();
|
|
const subfolder = root_id ? String(fullAsset?.subfolder || "").trim() : "";
|
|
const res = await getFolderInfo({ filepath, root_id, subfolder }, opts);
|
|
if (signal?.aborted) return;
|
|
if (res?.ok && res.data) {
|
|
tryUpdateWith({ folder_info: res.data });
|
|
}
|
|
} catch (err) {
|
|
if (!(signal?.aborted)) console.warn("Failed to load folder details:", err);
|
|
} finally {
|
|
if (!signal?.aborted) {
|
|
sidebar._currentFetchAbortController = null;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
if (asset.id && (!hasGenerationLikeData(fullAsset) && !fullAsset.exif)) {
|
|
const result = await getAssetMetadata(asset.id, opts);
|
|
if (signal?.aborted) return;
|
|
if (result?.ok && result.data) {
|
|
tryUpdateWith(result.data);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!(signal?.aborted)) console.warn("Failed to load full asset metadata:", err);
|
|
}
|
|
if (signal?.aborted) return;
|
|
|
|
if (!hasGenerationLikeData(fullAsset)) {
|
|
const filename = String(fullAsset?.filename || "").trim();
|
|
const type = String(fullAsset?.type || "output").trim().toLowerCase();
|
|
const subfolder = String(fullAsset?.subfolder || "").trim();
|
|
const root_id = String(fullAsset?.root_id || fullAsset?.rootId || "").trim();
|
|
if (filename) {
|
|
try {
|
|
const result = await getFileMetadataScoped({ type, filename, subfolder, root_id }, opts);
|
|
if (signal?.aborted) return;
|
|
if (result?.ok && result.data) {
|
|
const md = result.data;
|
|
const updates = {
|
|
prompt: fullAsset.prompt ?? md.prompt,
|
|
workflow: fullAsset.workflow ?? md.workflow,
|
|
geninfo: fullAsset.geninfo ?? md.geninfo,
|
|
exif: fullAsset.exif ?? md.exif,
|
|
ffprobe: fullAsset.ffprobe ?? md.ffprobe,
|
|
metadata_raw: fullAsset.metadata_raw ?? md,
|
|
};
|
|
tryUpdateWith(updates);
|
|
}
|
|
} catch (err) {
|
|
if (!(signal?.aborted)) console.warn("Failed to load scoped metadata:", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!signal?.aborted) {
|
|
sidebar._currentFetchAbortController = null;
|
|
}
|
|
};
|
|
|
|
void loadMetadataAsync();
|
|
}
|
|
|
|
/**
|
|
* Close inline sidebar
|
|
*/
|
|
export function closeSidebar(sidebar) {
|
|
if (!sidebar) return;
|
|
|
|
sidebar._requestSeq = (sidebar._requestSeq || 0) + 1;
|
|
sidebar.classList.remove("is-open");
|
|
sidebar._currentAsset = null;
|
|
sidebar._currentFetchAbortController?.abort?.();
|
|
sidebar._currentFetchAbortController = null;
|
|
|
|
// Dispatch event so controllers can update their state
|
|
try {
|
|
sidebar.dispatchEvent?.(new CustomEvent("mjr:sidebar-closed", { bubbles: true }));
|
|
} catch {}
|
|
|
|
}
|
|
|
|
// Raw metadata is now available as a toggle inside WorkflowMinimapSection.
|