Files
MajoorWaldi-ComfyUI-Majoor-…/ui/stores/usePanelStore.ts
T

442 lines
16 KiB
TypeScript

/**
* Pinia store — Panel UI state
*
* Replaces the Proxy-based panelState.js with a fully reactive Pinia store.
* State is persisted to localStorage (same key as the old system for backwards
* compatibility) with a 750 ms debounce.
*
* Vue components consume this store via `usePanelStore()`.
* Legacy DOM controllers consume this store through `panelStateBridge.js`
* during the incremental migration.
*/
import { defineStore } from "pinia";
import { ref, watch, onScopeDispose } from "vue";
import { get as apiGet } from "../api/client.js";
type PanelScope = "output" | "input" | "all" | "custom" | "workflow";
type ResolutionCompare = "gte" | "lte";
interface PanelState {
scope: PanelScope;
customRootId: string;
customRootLabel: string;
currentFolderRelativePath: string;
collectionId: string;
collectionName: string;
kindFilter: string;
dateRangeFilter: string;
dateExactFilter: string;
workflowOnly: boolean;
minRating: number;
minSizeMB: number;
maxSizeMB: number;
resolutionCompare: ResolutionCompare;
minWidth: number;
minHeight: number;
maxWidth: number;
maxHeight: number;
workflowType: string;
workflowId: string;
workflowModelFilter: string;
workflowRunsOnFilter: string;
sort: string;
searchQuery: string;
scrollTop: number;
activeAssetId: string;
selectedAssetIds: string[];
sidebarOpen: boolean;
lastGridCount?: number;
lastGridTotal?: number;
viewScope?: string;
similarResults?: unknown[];
similarTitle?: string;
similarSourceAssetId?: string;
}
type StoredPanelState = Partial<PanelState>;
interface NavigateToScopeOptions {
folder?: string;
collectionId?: string;
collectionName?: string;
customRootId?: string;
customRootLabel?: string;
}
interface CustomRootRow {
id?: unknown;
label?: unknown;
}
const STORAGE_KEY = "mjr_panel_state";
const ALLOWED_SCOPES = new Set<PanelScope>(["output", "input", "all", "custom", "workflow"]);
// ─── helpers ──────────────────────────────────────────────────────────────────
function toBool(value: any, fallback = false): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const n = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(n)) return true;
if (["0", "false", "no", "off"].includes(n)) return false;
}
return !!fallback;
}
function toNumber(value: any, fallback = 0): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function toStr(value: any, fallback = ""): string {
if (value === null || value === undefined) return fallback;
return String(value);
}
function clampInt(value: any, min = 0, max = Infinity): number {
return Math.max(min, Math.min(max, Math.floor(toNumber(value, min))));
}
function normalizeScope(raw: any): PanelScope {
const s = toStr(raw || "output").toLowerCase();
const mapped = (s === "outputs" ? "output" : s === "inputs" ? "input" : s) as PanelScope;
return ALLOWED_SCOPES.has(mapped) ? mapped : "output";
}
function sanitize(raw: any): StoredPanelState {
if (!raw || typeof raw !== "object") return {};
const row = raw as Record<string, any>;
return {
scope: normalizeScope(row.scope),
customRootId: toStr(row.customRootId),
customRootLabel: toStr(row.customRootLabel),
currentFolderRelativePath: toStr(row.currentFolderRelativePath || row.subfolder),
collectionId: toStr(row.collectionId),
collectionName: toStr(row.collectionName),
kindFilter: toStr(row.kindFilter),
dateRangeFilter: toStr(row.dateRangeFilter),
dateExactFilter: toStr(row.dateExactFilter),
workflowOnly: toBool(row.workflowOnly, false),
minRating: clampInt(row.minRating, 0, 5),
minSizeMB: Math.max(0, toNumber(row.minSizeMB, 0)),
maxSizeMB: Math.max(0, toNumber(row.maxSizeMB, 0)),
resolutionCompare: row.resolutionCompare === "lte" ? "lte" : "gte",
minWidth: clampInt(row.minWidth),
minHeight: clampInt(row.minHeight),
maxWidth: clampInt(row.maxWidth),
maxHeight: clampInt(row.maxHeight),
workflowType: toStr(row.workflowType),
workflowId: toStr(row.workflowId || row.workflow_id),
workflowModelFilter: toStr(row.workflowModelFilter || row.workflowModel || row.workflow_model),
workflowRunsOnFilter: toStr(row.workflowRunsOnFilter || row.workflowRunsOn || row.runs_on),
sort: toStr(row.sort || "mtime_desc"),
searchQuery: toStr(row.searchQuery),
scrollTop: clampInt(row.scrollTop),
activeAssetId: toStr(row.activeAssetId),
selectedAssetIds: Array.isArray(row.selectedAssetIds)
? row.selectedAssetIds
.map((x) => toStr(x).trim())
.filter(Boolean)
.slice(0, 5000)
: [],
sidebarOpen: toBool(row.sidebarOpen, false),
};
}
function loadFromStorage(): StoredPanelState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return sanitize(JSON.parse(raw));
} catch {
/* ignore */
}
return {};
}
function saveToStorage(state: StoredPanelState) {
try {
const toSave = { ...state };
// Transient fields — never persisted
delete toSave.lastGridCount;
delete toSave.lastGridTotal;
delete toSave.viewScope;
delete toSave.similarResults;
delete toSave.similarTitle;
delete toSave.similarSourceAssetId;
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch {
/* ignore */
}
}
// ─── store ────────────────────────────────────────────────────────────────────
export const usePanelStore = defineStore("mjr-panel", () => {
const saved = loadFromStorage();
// ── persistent state ──────────────────────────────────────────────────────
const scope = ref(saved.scope || "output");
const customRootId = ref(saved.customRootId || "");
const customRootLabel = ref(saved.customRootLabel || "");
const currentFolderRelativePath = ref(saved.currentFolderRelativePath || "");
const collectionId = ref(saved.collectionId || "");
const collectionName = ref(saved.collectionName || "");
const kindFilter = ref(saved.kindFilter || "");
const dateRangeFilter = ref(saved.dateRangeFilter || "");
const dateExactFilter = ref(saved.dateExactFilter || "");
const workflowOnly = ref(saved.workflowOnly || false);
const minRating = ref(saved.minRating || 0);
const minSizeMB = ref(saved.minSizeMB || 0);
const maxSizeMB = ref(saved.maxSizeMB || 0);
const resolutionCompare = ref(saved.resolutionCompare || "gte");
const minWidth = ref(saved.minWidth || 0);
const minHeight = ref(saved.minHeight || 0);
const maxWidth = ref(saved.maxWidth || 0);
const maxHeight = ref(saved.maxHeight || 0);
const workflowType = ref(saved.workflowType || "");
const workflowId = ref(saved.workflowId || "");
const workflowModelFilter = ref(saved.workflowModelFilter || "");
const workflowRunsOnFilter = ref(saved.workflowRunsOnFilter || "");
const sort = ref(saved.sort || "mtime_desc");
const searchQuery = ref(saved.searchQuery || "");
const scrollTop = ref(saved.scrollTop || 0);
const activeAssetId = ref(saved.activeAssetId || "");
const selectedAssetIds = ref(saved.selectedAssetIds || []);
const sidebarOpen = ref(saved.sidebarOpen || false);
// ── transient state (never persisted) ────────────────────────────────────
const lastGridCount = ref(0);
const lastGridTotal = ref(0);
const viewScope = ref("");
const similarResults = ref([]);
const similarTitle = ref("");
const similarSourceAssetId = ref("");
// ── persistence watcher ──────────────────────────────────────────────────
let _saveTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleSave = () => {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
_saveTimer = null;
saveToStorage({
scope: scope.value,
customRootId: customRootId.value,
customRootLabel: customRootLabel.value,
currentFolderRelativePath: currentFolderRelativePath.value,
collectionId: collectionId.value,
collectionName: collectionName.value,
kindFilter: kindFilter.value,
dateRangeFilter: dateRangeFilter.value,
dateExactFilter: dateExactFilter.value,
workflowOnly: workflowOnly.value,
minRating: minRating.value,
minSizeMB: minSizeMB.value,
maxSizeMB: maxSizeMB.value,
resolutionCompare: resolutionCompare.value,
minWidth: minWidth.value,
minHeight: minHeight.value,
maxWidth: maxWidth.value,
maxHeight: maxHeight.value,
workflowType: workflowType.value,
workflowId: workflowId.value,
workflowModelFilter: workflowModelFilter.value,
workflowRunsOnFilter: workflowRunsOnFilter.value,
sort: sort.value,
searchQuery: searchQuery.value,
scrollTop: scrollTop.value,
activeAssetId: activeAssetId.value,
selectedAssetIds: selectedAssetIds.value,
sidebarOpen: sidebarOpen.value,
});
}, 750);
};
watch(
[
scope,
customRootId,
customRootLabel,
currentFolderRelativePath,
collectionId,
collectionName,
kindFilter,
dateRangeFilter,
dateExactFilter,
workflowOnly,
minRating,
minSizeMB,
maxSizeMB,
resolutionCompare,
minWidth,
minHeight,
maxWidth,
maxHeight,
workflowType,
workflowId,
workflowModelFilter,
workflowRunsOnFilter,
sort,
searchQuery,
scrollTop,
activeAssetId,
selectedAssetIds,
sidebarOpen,
],
scheduleSave,
{ deep: true },
);
// ── cross-tab sync ───────────────────────────────────────────────────────
const _onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY || !event.newValue) return;
try {
const updated = sanitize(JSON.parse(event.newValue));
if (updated.scope !== undefined) scope.value = updated.scope;
if (updated.customRootLabel !== undefined)
customRootLabel.value = updated.customRootLabel;
if (updated.searchQuery !== undefined) searchQuery.value = updated.searchQuery;
if (updated.sort !== undefined) sort.value = updated.sort;
if (updated.sidebarOpen !== undefined) sidebarOpen.value = updated.sidebarOpen;
} catch {
/* ignore */
}
};
try {
window.addEventListener("storage", _onStorage);
} catch {
/* ignore */
}
onScopeDispose(() => {
try {
window.removeEventListener("storage", _onStorage);
} catch {
/* ignore */
}
});
// ── actions ───────────────────────────────────────────────────────────────
function resetFilters() {
kindFilter.value = "";
dateRangeFilter.value = "";
dateExactFilter.value = "";
workflowOnly.value = false;
minRating.value = 0;
minSizeMB.value = 0;
maxSizeMB.value = 0;
resolutionCompare.value = "gte";
minWidth.value = 0;
minHeight.value = 0;
maxWidth.value = 0;
maxHeight.value = 0;
workflowType.value = "";
workflowId.value = "";
workflowModelFilter.value = "";
workflowRunsOnFilter.value = "";
searchQuery.value = "";
}
function clearSelection() {
selectedAssetIds.value = [];
activeAssetId.value = "";
}
function setSelection(ids: unknown[], activeId = "") {
selectedAssetIds.value = Array.isArray(ids) ? ids.map(String).filter(Boolean) : [];
activeAssetId.value = activeId || selectedAssetIds.value[0] || "";
}
function navigateToScope(newScope: PanelScope, opts: NavigateToScopeOptions = {}) {
scope.value = newScope;
currentFolderRelativePath.value = opts.folder || "";
collectionId.value = opts.collectionId || "";
collectionName.value = opts.collectionName || "";
customRootId.value = opts.customRootId || "";
customRootLabel.value = opts.customRootLabel || "";
viewScope.value = "";
similarResults.value = [];
similarTitle.value = "";
similarSourceAssetId.value = "";
clearSelection();
}
/**
* Verify the persisted `customRootId` still matches a configured custom
* root. Stale ids (e.g. a root that was removed in another tab or in a
* previous session) silently broke the grid by triggering 404 listings;
* clearing them here lets the panel fall back to the default scope.
*/
async function validatePersistedCustomRoot() {
const persistedId = String(customRootId.value || "").trim();
if (!persistedId) return;
try {
const resp = await apiGet("/mjr/am/custom-roots");
const rows: CustomRootRow[] = Array.isArray(resp?.data) ? resp.data : [];
const match = rows.find((r) => String(r?.id || "") === persistedId);
if (!match) {
customRootId.value = "";
customRootLabel.value = "";
if (scope.value === "custom") {
scope.value = "output";
currentFolderRelativePath.value = "";
}
return;
}
// Refresh cached label in case it was renamed elsewhere.
const freshLabel = String(match.label || "").trim();
if (freshLabel && freshLabel !== customRootLabel.value) {
customRootLabel.value = freshLabel;
}
} catch {
// Network / auth failure: don't clobber persisted state — the
// user might be offline; the listing endpoint will surface the
// real error if/when they actually open the custom scope.
}
}
return {
// persistent
scope,
customRootId,
customRootLabel,
currentFolderRelativePath,
collectionId,
collectionName,
kindFilter,
dateRangeFilter,
dateExactFilter,
workflowOnly,
minRating,
minSizeMB,
maxSizeMB,
resolutionCompare,
minWidth,
minHeight,
maxWidth,
maxHeight,
workflowType,
workflowId,
workflowModelFilter,
workflowRunsOnFilter,
sort,
searchQuery,
scrollTop,
activeAssetId,
selectedAssetIds,
sidebarOpen,
// transient
lastGridCount,
lastGridTotal,
viewScope,
similarResults,
similarTitle,
similarSourceAssetId,
// actions
resetFilters,
clearSelection,
setSelection,
navigateToScope,
validatePersistedCustomRoot,
};
});