mirror of
https://github.com/stashapp/CommunityScripts.git
synced 2026-02-05 04:45:09 -06:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
711 lines
53 KiB
JavaScript
711 lines
53 KiB
JavaScript
(function(){
|
||
// Shared utilities for recommendation components
|
||
// Extracted from RecommendedScenes.tsx for reuse in SimilarScenes.tsx
|
||
(function () {
|
||
const w = window;
|
||
// Safer initialization - wait for everything to be ready
|
||
function initializeRecommendationUtils() {
|
||
const PluginApi = w.PluginApi;
|
||
if (!PluginApi || !PluginApi.React) {
|
||
console.warn('[RecommendationUtils] PluginApi or React not available');
|
||
return;
|
||
}
|
||
// Validate React hooks are available
|
||
if (!PluginApi.React.useState || !PluginApi.React.useMemo || !PluginApi.React.useEffect || !PluginApi.React.useRef) {
|
||
console.warn('[RecommendationUtils] React hooks not available');
|
||
return;
|
||
}
|
||
const React = PluginApi.React;
|
||
const { useState, useMemo, useEffect, useRef } = React;
|
||
// Upstream grid hooks copied from GridCard.tsx for exact parity
|
||
function useDebounce(fn, delay) {
|
||
const timeoutRef = useRef(null);
|
||
return useMemo(() => (...args) => {
|
||
clearTimeout(timeoutRef.current);
|
||
timeoutRef.current = setTimeout(() => fn(...args), delay);
|
||
}, [fn, delay]);
|
||
}
|
||
function useResizeObserver(target, callback) {
|
||
useEffect(() => {
|
||
if (!target.current || typeof ResizeObserver === 'undefined')
|
||
return;
|
||
const ro = new ResizeObserver((entries) => {
|
||
if (entries && entries.length > 0) {
|
||
callback(entries[0]);
|
||
}
|
||
});
|
||
ro.observe(target.current);
|
||
return () => ro.disconnect();
|
||
}, [target, callback]);
|
||
}
|
||
function calculateCardWidth(containerWidth, preferredWidth) {
|
||
const root = typeof window !== 'undefined' ? window.getComputedStyle(document.documentElement) : null;
|
||
const containerPadding = root ? parseFloat(root.getPropertyValue('--ai-rec-container-padding')) : 30;
|
||
const cardMargin = root ? parseFloat(root.getPropertyValue('--ai-rec-card-margin')) : 10;
|
||
const maxUsableWidth = containerWidth - containerPadding;
|
||
const maxElementsOnRow = Math.ceil(maxUsableWidth / preferredWidth);
|
||
const width = maxUsableWidth / maxElementsOnRow - cardMargin;
|
||
return width;
|
||
}
|
||
function useContainerDimensions(sensitivityThreshold = 20) {
|
||
const target = useRef(null);
|
||
const [dimension, setDimension] = useState({ width: 0, height: 0 });
|
||
const debouncedSetDimension = useDebounce((entry) => {
|
||
if (!entry.contentBoxSize || !entry.contentBoxSize.length)
|
||
return;
|
||
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];
|
||
let difference = Math.abs(dimension.width - width);
|
||
if (difference > sensitivityThreshold) {
|
||
setDimension({ width, height });
|
||
}
|
||
}, 50);
|
||
useResizeObserver(target, debouncedSetDimension);
|
||
useEffect(() => {
|
||
if (target.current && dimension.width === 0) {
|
||
const rect = target.current.getBoundingClientRect();
|
||
if (rect.width > 0) {
|
||
setDimension({ width: rect.width, height: rect.height });
|
||
}
|
||
}
|
||
}, []);
|
||
return [target, dimension];
|
||
}
|
||
function useCardWidth(containerWidth, zoomIndex, zoomWidths) {
|
||
return useMemo(() => {
|
||
const isMobile = window.innerWidth <= 768;
|
||
if (isMobile)
|
||
return undefined;
|
||
const effectiveWidth = (containerWidth ? containerWidth : 1200);
|
||
if (zoomIndex === undefined || zoomIndex < 0 || zoomIndex >= zoomWidths.length) {
|
||
return undefined;
|
||
}
|
||
const preferredCardWidth = zoomWidths[zoomIndex];
|
||
return calculateCardWidth(effectiveWidth, preferredCardWidth);
|
||
}, [containerWidth, zoomIndex, zoomWidths]);
|
||
}
|
||
// Constraint Editor Component
|
||
function ConstraintEditor({ tagId, constraint, tagName, value, fieldName, onSave, onCancel, allowedConstraintTypes, entity: popupEntity, compositeRawRef, popupPosition }) {
|
||
const [localConstraint, setLocalConstraint] = React.useState(constraint);
|
||
const localConstraintRef = React.useRef(localConstraint);
|
||
React.useEffect(() => { localConstraintRef.current = localConstraint; }, [localConstraint]);
|
||
const canceledRef = React.useRef(false);
|
||
function lookupLocalName(id, forEntity) {
|
||
try {
|
||
const ent = forEntity || popupEntity || 'tag';
|
||
const key = fieldName + '__' + (ent === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||
const map = compositeRawRef && compositeRawRef.current ? (compositeRawRef.current[key] || {}) : {};
|
||
return map[id] || (ent === 'performer' ? `Performer ${id}` : `Tag ${id}`);
|
||
}
|
||
catch (_) {
|
||
return forEntity === 'performer' ? `Performer ${id}` : `Tag ${id}`;
|
||
}
|
||
}
|
||
React.useEffect(() => { setLocalConstraint(constraint); }, [constraint]);
|
||
const allConstraintTypes = [
|
||
{ value: 'presence', label: 'Include/Exclude' },
|
||
{ value: 'duration', label: 'Duration Filter' },
|
||
{ value: 'overlap', label: 'Co-occurrence' },
|
||
{ value: 'importance', label: 'Importance Weight' }
|
||
];
|
||
const constraintTypes = Array.isArray(allowedConstraintTypes) && allowedConstraintTypes.length > 0
|
||
? allConstraintTypes.filter(ct => allowedConstraintTypes.includes(ct.value))
|
||
: allConstraintTypes;
|
||
const overlapTagData = React.useMemo(() => {
|
||
if (localConstraint.type !== 'overlap')
|
||
return { availableTags: [] };
|
||
const allCoPrimaries = new Set();
|
||
[...((value === null || value === void 0 ? void 0 : value.include) || []), ...((value === null || value === void 0 ? void 0 : value.exclude) || [])].forEach(id => {
|
||
var _a, _b;
|
||
const c = ((value === null || value === void 0 ? void 0 : value.constraints) || {})[id] || { type: 'presence' };
|
||
if (c.type === 'overlap' && ((_b = (_a = c.overlap) === null || _a === void 0 ? void 0 : _a.coTags) === null || _b === void 0 ? void 0 : _b.length) > 0 && id !== tagId) {
|
||
allCoPrimaries.add(id);
|
||
}
|
||
});
|
||
const availableTags = [...((value === null || value === void 0 ? void 0 : value.include) || []), ...((value === null || value === void 0 ? void 0 : value.exclude) || [])]
|
||
.filter(id => id !== tagId && !allCoPrimaries.has(id));
|
||
return { availableTags };
|
||
}, [localConstraint.type, value === null || value === void 0 ? void 0 : value.include, value === null || value === void 0 ? void 0 : value.exclude, value === null || value === void 0 ? void 0 : value.constraints, tagId]);
|
||
function handleTypeChange(newType) {
|
||
let nc = { type: newType };
|
||
switch (newType) {
|
||
case 'presence':
|
||
nc.presence = 'include';
|
||
break;
|
||
case 'duration':
|
||
nc.duration = { min: 10, max: 60, unit: 'percent' };
|
||
break;
|
||
case 'overlap':
|
||
nc.overlap = { minDuration: 5, maxDuration: 30, unit: 'percent' };
|
||
break;
|
||
case 'importance':
|
||
nc.importance = 0.5;
|
||
break;
|
||
}
|
||
setLocalConstraint(nc);
|
||
}
|
||
function renderOptions() {
|
||
var _a, _b, _c, _d, _e, _f, _g;
|
||
switch (localConstraint.type) {
|
||
case 'presence':
|
||
return React.createElement('div', { className: 'constraint-options' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Mode: '),
|
||
React.createElement('select', { key: 'sel', value: localConstraint.presence || 'include', onChange: (e) => setLocalConstraint((p) => ({ ...p, presence: e.target.value })) }, [
|
||
React.createElement('option', { key: 'inc', value: 'include' }, 'Include'),
|
||
React.createElement('option', { key: 'exc', value: 'exclude' }, 'Exclude')
|
||
])
|
||
]);
|
||
case 'duration':
|
||
return React.createElement('div', { className: 'constraint-options' }, [
|
||
React.createElement('div', { key: 'range' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Duration: '),
|
||
React.createElement('input', { key: 'min', type: 'number', placeholder: 'Min', value: ((_a = localConstraint.duration) === null || _a === void 0 ? void 0 : _a.min) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, duration: { ...p.duration, min: e.target.value ? Number(e.target.value) : undefined } })) }),
|
||
React.createElement('span', { key: 'dash' }, ' - '),
|
||
React.createElement('input', { key: 'max', type: 'number', placeholder: 'Max', value: ((_b = localConstraint.duration) === null || _b === void 0 ? void 0 : _b.max) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, duration: { ...p.duration, max: e.target.value ? Number(e.target.value) : undefined } })) })
|
||
]),
|
||
React.createElement('div', { key: 'unit' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Unit: '),
|
||
React.createElement('select', { key: 'sel', value: ((_c = localConstraint.duration) === null || _c === void 0 ? void 0 : _c.unit) || 'percent', onChange: (e) => setLocalConstraint((p) => ({ ...p, duration: { ...p.duration, unit: e.target.value } })) }, [
|
||
React.createElement('option', { key: 'pct', value: 'percent' }, '% of video'),
|
||
React.createElement('option', { key: 'sec', value: 'seconds' }, 'Seconds')
|
||
])
|
||
])
|
||
]);
|
||
case 'overlap': {
|
||
const available = overlapTagData.availableTags;
|
||
const selected = ((_d = localConstraint.overlap) === null || _d === void 0 ? void 0 : _d.coTags) || [];
|
||
const entity = popupEntity || localConstraint._entity || 'tag';
|
||
return React.createElement('div', { className: 'constraint-options' }, [
|
||
React.createElement('div', { key: 'info' }, `Co-occurrence with other selected ${entity === 'performer' ? 'performers' : 'tags'}`),
|
||
React.createElement('div', { key: 'selwrap' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Selected for co-occurrence: '),
|
||
React.createElement('div', { key: 'selected', className: 'constraint-selected-tags' }, selected.length ? selected.map((cid) => {
|
||
const nm = lookupLocalName(cid, entity);
|
||
return React.createElement('span', { key: cid, className: 'constraint-cochip-tag' }, [
|
||
nm,
|
||
React.createElement('button', { key: 'rm', onClick: () => { const n = selected.filter((i) => i !== cid); setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, coTags: n } })); }, className: 'constraint-cochip-remove' }, '×')
|
||
]);
|
||
}) : React.createElement('span', { className: 'constraint-selected-empty' }, 'No tags selected for co-occurrence')),
|
||
available.length ? React.createElement('div', { key: 'avail', className: 'constraint-available-tags' }, available.map((cid) => { if (selected.includes(cid))
|
||
return null; const nm = lookupLocalName(cid, entity); return React.createElement('button', { key: cid, className: 'constraint-tag-button', onClick: () => { const n = [...selected, cid]; setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, coTags: n } })); } }, nm); })) : null
|
||
]),
|
||
React.createElement('div', { key: 'range' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Overlap duration: '),
|
||
React.createElement('input', { key: 'min', type: 'number', placeholder: 'Min', value: ((_e = localConstraint.overlap) === null || _e === void 0 ? void 0 : _e.minDuration) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, minDuration: e.target.value ? Number(e.target.value) : undefined } })) }),
|
||
React.createElement('span', { key: 'dash' }, ' - '),
|
||
React.createElement('input', { key: 'max', type: 'number', placeholder: 'Max', value: ((_f = localConstraint.overlap) === null || _f === void 0 ? void 0 : _f.maxDuration) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, maxDuration: e.target.value ? Number(e.target.value) : undefined } })) })
|
||
]),
|
||
React.createElement('div', { key: 'unit' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Unit: '),
|
||
React.createElement('select', { key: 'sel', value: ((_g = localConstraint.overlap) === null || _g === void 0 ? void 0 : _g.unit) || 'percent', onChange: (e) => setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, unit: e.target.value } })) }, [
|
||
React.createElement('option', { key: 'pct', value: 'percent' }, '% of video'),
|
||
React.createElement('option', { key: 'sec', value: 'seconds' }, 'Seconds')
|
||
])
|
||
])
|
||
]);
|
||
}
|
||
case 'importance':
|
||
return React.createElement('div', { className: 'constraint-options' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Weight (0.0 - 1.0): '),
|
||
React.createElement('input', { key: 'in', type: 'number', step: '0.1', min: '0', max: '1', value: localConstraint.importance || 0.5, onChange: (e) => setLocalConstraint((p) => ({ ...p, importance: Number(e.target.value) })) })
|
||
]);
|
||
default: return null;
|
||
}
|
||
}
|
||
React.useEffect(() => { return () => { try {
|
||
if (!canceledRef.current)
|
||
onSave(localConstraintRef.current);
|
||
}
|
||
catch (_) { } }; }, [onSave]);
|
||
React.useEffect(() => { function onKey(e) { if (e.key === 'Escape') {
|
||
canceledRef.current = true;
|
||
onCancel();
|
||
} } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onCancel]);
|
||
return React.createElement('div', { className: 'constraint-popup-overlay' }, [
|
||
React.createElement('div', { key: 'popup', className: 'constraint-popup', style: popupPosition ? { position: 'absolute', left: popupPosition.x, top: popupPosition.y, zIndex: 9999 } : {} }, [
|
||
React.createElement('div', { key: 'title', className: 'constraint-title' }, `Configure: ${tagName || lookupLocalName(tagId)}`),
|
||
React.createElement('div', { key: 'type', className: 'constraint-type' }, [
|
||
React.createElement('label', { key: 'lbl' }, 'Type: '),
|
||
React.createElement('select', { key: 'sel', value: localConstraint.type, onChange: (e) => handleTypeChange(e.target.value) }, constraintTypes.map(ct => React.createElement('option', { key: ct.value, value: ct.value }, ct.label)))
|
||
]),
|
||
renderOptions(),
|
||
React.createElement('div', { key: 'actions', className: 'constraint-actions' }, [
|
||
React.createElement('button', { key: 'save', className: 'btn-constraint btn-save', onClick: (e) => { e.stopPropagation(); onSave(localConstraint); } }, 'Save')
|
||
])
|
||
])
|
||
]);
|
||
}
|
||
// Tag Selector Component
|
||
function createTagSelector(options) {
|
||
const { value: v, onChange, entity = 'tag', fieldName, label = entity === 'performer' ? 'Performers' : 'Tags', allowedConstraintTypes, allowedCombinationModes, initialTagCombination, compositeRawRef } = options;
|
||
const include = Array.isArray(v) ? v : Array.isArray(v === null || v === void 0 ? void 0 : v.include) ? v.include : [];
|
||
const exclude = Array.isArray(v) ? [] : Array.isArray(v === null || v === void 0 ? void 0 : v.exclude) ? v.exclude : [];
|
||
const constraints = (v === null || v === void 0 ? void 0 : v.constraints) || {};
|
||
// Combination mode logic
|
||
const normalizeMode = (m) => (m == null ? undefined : String(m).toLowerCase());
|
||
const allowedNorm = Array.isArray(allowedCombinationModes) && allowedCombinationModes.length > 0
|
||
? allowedCombinationModes.map(normalizeMode).filter(Boolean)
|
||
: [];
|
||
const initLC = typeof initialTagCombination === 'string' ? normalizeMode(initialTagCombination) : undefined;
|
||
const resolvedAllowedModes = (allowedNorm.length > 0 ? allowedNorm : (typeof initLC !== 'undefined' ? [initLC] : ['and', 'or']));
|
||
const rawValueMode = (v && Object.prototype.hasOwnProperty.call(v, 'tag_combination')) ? v.tag_combination : undefined;
|
||
const valueMode = normalizeMode(rawValueMode);
|
||
const isValidMode = (m) => m === 'and' || m === 'or' || m === 'not-applicable';
|
||
const initialMode = (isValidMode(valueMode) ? valueMode : (isValidMode(initLC) ? initLC : resolvedAllowedModes[0]));
|
||
const [searchState, setSearchState] = React.useState({
|
||
search: '',
|
||
suggestions: [],
|
||
loading: false,
|
||
error: null,
|
||
showDropdown: false,
|
||
combinationMode: initialMode
|
||
});
|
||
const instanceIdRef = React.useRef(null);
|
||
if (!instanceIdRef.current) {
|
||
try {
|
||
w.__aiTagFallbackCounter = (w.__aiTagFallbackCounter || 0) + 1;
|
||
instanceIdRef.current = w.__aiTagFallbackCounter;
|
||
}
|
||
catch (e) {
|
||
instanceIdRef.current = Math.floor(Math.random() * 1000000);
|
||
}
|
||
}
|
||
const [constraintPopup, setConstraintPopup] = React.useState(null);
|
||
const nameMapKey = fieldName + '__' + (entity === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||
if (!compositeRawRef.current[nameMapKey]) {
|
||
compositeRawRef.current[nameMapKey] = {};
|
||
}
|
||
const tagNameMap = compositeRawRef.current[nameMapKey];
|
||
function lookupName(id, forEntity) {
|
||
const ent = forEntity || entity || 'tag';
|
||
const key = fieldName + '__' + (ent === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||
const map = compositeRawRef.current[key] || {};
|
||
return map[id] || (ent === 'performer' ? `Performer ${id}` : `Tag ${id}`);
|
||
}
|
||
const debounceTimerRef = React.useRef(null);
|
||
const tagInputRef = React.useRef(null);
|
||
// Return the complete tag selector component
|
||
return {
|
||
lookupName,
|
||
searchState,
|
||
setSearchState,
|
||
constraintPopup,
|
||
setConstraintPopup,
|
||
tagInputRef,
|
||
instanceIdRef,
|
||
resolvedAllowedModes,
|
||
include,
|
||
exclude,
|
||
constraints
|
||
};
|
||
}
|
||
// Advanced Tag Include/Exclude Selector with constraints (extracted from RecommendedScenes)
|
||
function TagIncludeExclude({ value, onChange, fieldName, initialTagCombination, allowedConstraintTypes, allowedCombinationModes, entity = 'tag', compositeRawRef: extCompositeRef }) {
|
||
const React = PluginApi.React;
|
||
const compositeRef = extCompositeRef || React.useRef({});
|
||
const v = value || {};
|
||
const include = Array.isArray(v) ? v : Array.isArray(v.include) ? v.include : [];
|
||
const exclude = Array.isArray(v) ? [] : Array.isArray(v.exclude) ? v.exclude : [];
|
||
const constraints = v.constraints || {};
|
||
const normalizeMode = (m) => (m == null ? undefined : String(m).toLowerCase());
|
||
const allowedNorm = Array.isArray(allowedCombinationModes) && allowedCombinationModes.length > 0
|
||
? allowedCombinationModes.map(normalizeMode).filter(Boolean)
|
||
: [];
|
||
const initLC = typeof initialTagCombination === 'string' ? normalizeMode(initialTagCombination) : undefined;
|
||
const resolvedAllowedModes = (allowedNorm.length > 0 ? allowedNorm : (typeof initLC !== 'undefined' ? [initLC] : ['and', 'or']));
|
||
const rawValueMode = (v && Object.prototype.hasOwnProperty.call(v, 'tag_combination')) ? v.tag_combination : undefined;
|
||
const valueMode = normalizeMode(rawValueMode);
|
||
const isValidMode = (m) => m === 'and' || m === 'or' || m === 'not-applicable';
|
||
const initialMode = (isValidMode(valueMode) ? valueMode : (isValidMode(initLC) ? initLC : resolvedAllowedModes[0]));
|
||
const [searchState, setSearchState] = React.useState({ search: '', suggestions: [], loading: false, error: null, showDropdown: false, combinationMode: initialMode });
|
||
const instanceIdRef = React.useRef(null);
|
||
if (!instanceIdRef.current) {
|
||
try {
|
||
w.__aiTagFallbackCounter = (w.__aiTagFallbackCounter || 0) + 1;
|
||
instanceIdRef.current = w.__aiTagFallbackCounter;
|
||
}
|
||
catch (e) {
|
||
instanceIdRef.current = Math.floor(Math.random() * 1000000);
|
||
}
|
||
}
|
||
React.useEffect(() => { function onOtherOpen(ev) { try {
|
||
const otherId = ev && ev.detail && ev.detail.id;
|
||
if (otherId && otherId !== instanceIdRef.current) {
|
||
setSearchState((prev) => ({ ...prev, showDropdown: false }));
|
||
}
|
||
}
|
||
catch (_) { } } document.addEventListener('ai-tag-fallback-open', onOtherOpen); return () => document.removeEventListener('ai-tag-fallback-open', onOtherOpen); }, []);
|
||
React.useEffect(() => { const externalModeRaw = v && Object.prototype.hasOwnProperty.call(v, 'tag_combination') ? v.tag_combination : undefined; const externalMode = normalizeMode(externalModeRaw); if (externalMode && externalMode !== searchState.combinationMode && (externalMode === 'and' || externalMode === 'or' || externalMode === 'not-applicable')) {
|
||
setSearchState((prev) => ({ ...prev, combinationMode: externalMode }));
|
||
} }, [v && v.tag_combination]);
|
||
const [constraintPopup, setConstraintPopup] = React.useState(null);
|
||
const nameMapKey = fieldName + '__' + (entity === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||
if (!compositeRef.current[nameMapKey]) {
|
||
compositeRef.current[nameMapKey] = {};
|
||
}
|
||
const tagNameMap = compositeRef.current[nameMapKey];
|
||
function lookupName(id, forEntity) { const ent = forEntity || entity || 'tag'; const key = fieldName + '__' + (ent === 'performer' ? 'performerNameMap' : 'tagNameMap'); const map = compositeRef.current[key] || {}; return map[id] || (ent === 'performer' ? `Performer ${id}` : `Tag ${id}`); }
|
||
const debounceTimerRef = React.useRef(null);
|
||
function removeTag(id, list) { const nextInclude = list === 'include' ? include.filter(i => i !== id) : include; const nextExclude = list === 'exclude' ? exclude.filter(i => i !== id) : exclude; const nextConstraints = { ...constraints }; delete nextConstraints[id]; onChange({ include: nextInclude, exclude: nextExclude, constraints: nextConstraints, tag_combination: searchState.combinationMode }); }
|
||
function updateTagConstraint(tagId, constraint) {
|
||
const nextConstraints = { ...constraints };
|
||
let nextInclude = [...include];
|
||
let nextExclude = [...exclude];
|
||
nextConstraints[tagId] = constraint;
|
||
// If this is overlap with coTags, make sure those co-occurrence tags are included so they get hydrated
|
||
if (constraint.type === 'overlap' && constraint.overlap && constraint.overlap.coTags) {
|
||
constraint.overlap.coTags.forEach((coTagId) => {
|
||
if (!nextInclude.includes(coTagId) && !nextExclude.includes(coTagId)) {
|
||
nextInclude.push(coTagId);
|
||
}
|
||
});
|
||
}
|
||
// If this is a presence constraint, ensure tag is placed in the right set and removed from the other
|
||
if (constraint.type === 'presence') {
|
||
// remove from both then add to the selected list
|
||
nextInclude = nextInclude.filter(id => id !== tagId);
|
||
nextExclude = nextExclude.filter(id => id !== tagId);
|
||
if (constraint.presence === 'exclude') {
|
||
nextExclude.push(tagId);
|
||
}
|
||
else {
|
||
nextInclude.push(tagId);
|
||
}
|
||
// store constraint and persist
|
||
nextConstraints[tagId] = constraint;
|
||
onChange({ include: nextInclude, exclude: nextExclude, constraints: nextConstraints, tag_combination: searchState.combinationMode });
|
||
return;
|
||
}
|
||
// If this is an overlap constraint with coTags, remove those coTags from include/exclude lists
|
||
if (constraint.type === 'overlap' && constraint.overlap && constraint.overlap.coTags) {
|
||
const coTags = constraint.overlap.coTags;
|
||
nextInclude = nextInclude.filter(id => !coTags.includes(id));
|
||
nextExclude = nextExclude.filter(id => !coTags.includes(id));
|
||
// Also remove constraints for the co-occurrence tags since they're now part of this tag's constraint
|
||
coTags.forEach((coTagId) => {
|
||
delete nextConstraints[coTagId];
|
||
});
|
||
}
|
||
// Ensure primary tag is present in include list for non-presence constraints
|
||
if (!nextInclude.includes(tagId) && !nextExclude.includes(tagId)) {
|
||
nextInclude.push(tagId);
|
||
}
|
||
onChange({ include: nextInclude, exclude: nextExclude, constraints: nextConstraints, tag_combination: searchState.combinationMode });
|
||
}
|
||
function getTagConstraint(tagId) { return constraints[tagId] || { type: 'presence', presence: include.includes(tagId) ? 'include' : 'exclude' }; }
|
||
function showConstraintPopup(tagId, event, popupEntity) { const rect = event.target.getBoundingClientRect(); setConstraintPopup({ tagId, entity: popupEntity || entity, position: { x: rect.left, y: rect.bottom + 5 } }); event.stopPropagation(); }
|
||
const tagInputRef = React.useRef(null);
|
||
function addTag(id, name) { if (!include.includes(id) && !exclude.includes(id)) {
|
||
onChange({ include: [...include, id], exclude, constraints, tag_combination: searchState.combinationMode });
|
||
} if (name)
|
||
tagNameMap[id] = name; if (debounceTimerRef.current)
|
||
clearTimeout(debounceTimerRef.current); setSearchState((prev) => ({ ...prev, search: '', suggestions: [], showDropdown: false })); }
|
||
function search(term) { if (debounceTimerRef.current)
|
||
clearTimeout(debounceTimerRef.current); setSearchState((prev) => ({ ...prev, search: term })); const q = term.trim(); const immediate = q === ''; const run = async () => { var _a, _b, _c, _d; setSearchState((prev) => ({ ...prev, loading: true, error: null })); try {
|
||
let gql;
|
||
if (entity === 'performer') {
|
||
gql = q ? `query PerformerSuggest($term: String!) { findPerformers(filter: { per_page: 20 }, performer_filter: { name: { value: $term, modifier: INCLUDES } }) { performers { id name } } }` : `query PerformerSuggest { findPerformers(filter: { per_page: 20 }) { performers { id name } } }`;
|
||
}
|
||
else {
|
||
gql = q ? `query TagSuggest($term: String!) { findTags(filter: { per_page: 20 }, tag_filter: { name: { value: $term, modifier: INCLUDES } }) { tags { id name } } }` : `query TagSuggest { findTags(filter: { per_page: 20 }) { tags { id name } } }`;
|
||
}
|
||
const variables = q ? { term: q } : {};
|
||
const res = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: gql, variables }) });
|
||
if (!res.ok)
|
||
throw new Error('HTTP ' + res.status);
|
||
const json = await res.json();
|
||
if (json.errors)
|
||
throw new Error(json.errors.map((e) => e.message).join('; '));
|
||
const suggestions = entity === 'performer' ? (((_b = (_a = json === null || json === void 0 ? void 0 : json.data) === null || _a === void 0 ? void 0 : _a.findPerformers) === null || _b === void 0 ? void 0 : _b.performers) || []) : (((_d = (_c = json === null || json === void 0 ? void 0 : json.data) === null || _c === void 0 ? void 0 : _c.findTags) === null || _d === void 0 ? void 0 : _d.tags) || []);
|
||
try {
|
||
suggestions.forEach((s) => { const sid = parseInt(s.id, 10); if (!isNaN(sid))
|
||
tagNameMap[sid] = s.name; });
|
||
}
|
||
catch (e) { }
|
||
setSearchState((prev) => ({ ...prev, suggestions, loading: false, error: suggestions.length ? null : null }));
|
||
}
|
||
catch (e) {
|
||
setSearchState((prev) => ({ ...prev, error: 'Search failed', loading: false }));
|
||
} }; if (immediate)
|
||
run();
|
||
else {
|
||
debounceTimerRef.current = setTimeout(run, 200);
|
||
} }
|
||
function onInputFocus() { if (!searchState.showDropdown) {
|
||
try {
|
||
document.dispatchEvent(new CustomEvent('ai-tag-fallback-open', { detail: { id: instanceIdRef.current } }));
|
||
}
|
||
catch (e) { }
|
||
setSearchState((prev) => ({ ...prev, showDropdown: true }));
|
||
if (!searchState.suggestions.length && !searchState.loading) {
|
||
search('');
|
||
}
|
||
} }
|
||
React.useEffect(() => { function handleClickOutside(event) { const target = event.target; if (!target.closest('.ai-tag-fallback.unified')) {
|
||
setSearchState((prev) => ({ ...prev, showDropdown: false }));
|
||
} if (!target.closest('.constraint-popup') && !target.closest('.constraint-btn')) {
|
||
setConstraintPopup(null);
|
||
} } if (searchState.showDropdown || constraintPopup) {
|
||
document.addEventListener('click', handleClickOutside);
|
||
return () => document.removeEventListener('click', handleClickOutside);
|
||
} }, [searchState.showDropdown, constraintPopup]);
|
||
function onKeyDown(e) { if (e.key === 'Enter') {
|
||
if (searchState.suggestions.length) {
|
||
const firstTag = searchState.suggestions[0];
|
||
addTag(parseInt(firstTag.id, 10), firstTag.name);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
const raw = searchState.search.trim();
|
||
if (/^[0-9]+$/.test(raw)) {
|
||
addTag(parseInt(raw, 10));
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
else if (e.key === 'Backspace' && !searchState.search) {
|
||
e.preventDefault();
|
||
if (include.length) {
|
||
removeTag(include[include.length - 1], 'include');
|
||
}
|
||
else if (exclude.length) {
|
||
removeTag(exclude[exclude.length - 1], 'exclude');
|
||
}
|
||
}
|
||
else if (e.key === 'Escape') {
|
||
if (constraintPopup) {
|
||
setConstraintPopup(null);
|
||
}
|
||
else {
|
||
setSearchState((prev) => ({ ...prev, showDropdown: false, search: '', suggestions: [] }));
|
||
}
|
||
} }
|
||
const showCombinationToggle = resolvedAllowedModes.length > 0 && resolvedAllowedModes.every(m => m !== 'not-applicable');
|
||
const toggleClickable = resolvedAllowedModes.length > 1;
|
||
const combinationToggle = showCombinationToggle ? React.createElement('button', { key: 'combo-toggle', type: 'button', className: `combination-toggle ${searchState.combinationMode}${toggleClickable ? '' : ' disabled'}`, disabled: !toggleClickable, onClick: toggleClickable ? (e) => { e.stopPropagation(); const currentIdx = resolvedAllowedModes.indexOf(searchState.combinationMode); const nextIdx = (currentIdx + 1) % resolvedAllowedModes.length; const nextMode = resolvedAllowedModes[nextIdx]; setSearchState((prev) => ({ ...prev, combinationMode: nextMode })); onChange({ include, exclude, constraints, tag_combination: nextMode }); } : undefined, title: toggleClickable ? `Toggle combination mode (current: ${searchState.combinationMode})` : `Combination mode: ${searchState.combinationMode} (fixed)` }, (searchState.combinationMode ? String(searchState.combinationMode).toUpperCase() : '')) : null;
|
||
// Enhanced chip rendering with co-occurrence grouping + constraint indicators
|
||
const chips = [];
|
||
const processedOverlapGroups = new Set();
|
||
function createCoOccurrenceChip(primaryId, group, setType, chipEntity = 'tag') {
|
||
const primaryName = lookupName(primaryId, chipEntity);
|
||
const coTags = group.coTags || [];
|
||
const allTagIds = [primaryId, ...coTags];
|
||
const allTagNames = allTagIds.map((id) => lookupName(id, chipEntity));
|
||
const min = group.minDuration || 0;
|
||
const max = group.maxDuration || '∞';
|
||
const unit = group.unit === 'percent' ? '%' : 's';
|
||
const chipClass = `tag-chip overlap ${setType}`;
|
||
const groupKey = allTagIds.slice().sort().join('-');
|
||
return React.createElement('span', { key: `co-${setType}-${groupKey}`, className: `${chipClass} co-chip` }, [
|
||
React.createElement('span', { key: 'constraint-prefix', className: 'co-constraint-info' }, `[${min}-${max}${unit}]`),
|
||
React.createElement('span', { key: 'tags', className: 'co-tags' }, allTagNames.map((name, idx) => React.createElement('span', { key: allTagIds[idx], className: 'co-tag-item' }, [
|
||
React.createElement('span', { key: 'n', className: 'co-tag-name', title: name }, name),
|
||
React.createElement('button', { key: 'x', onClick: (e) => { e.stopPropagation(); const tagIdToRemove = allTagIds[idx]; if (tagIdToRemove === primaryId) {
|
||
removeTag(primaryId, setType);
|
||
}
|
||
else {
|
||
const updatedCoTags = coTags.filter((id) => id !== tagIdToRemove);
|
||
updateTagConstraint(primaryId, { type: 'overlap', overlap: { ...group, coTags: updatedCoTags } });
|
||
} }, className: 'co-tag-remove', title: `Remove ${name} from group` }, '×')
|
||
]))),
|
||
React.createElement('span', { key: 'actions', className: 'co-actions' }, [
|
||
React.createElement('button', { key: 'gear', className: 'constraint-btn', onClick: (e) => showConstraintPopup(primaryId, e, entity), title: 'Configure group constraint' }, '⚙'),
|
||
React.createElement('button', { key: 'remove-group', onClick: (e) => { e.stopPropagation(); removeTag(primaryId, setType); }, className: 'co-chip-remove', title: 'Remove entire group' }, '×')
|
||
])
|
||
]);
|
||
}
|
||
// Include chips
|
||
include.forEach(id => {
|
||
const constraint = getTagConstraint(id);
|
||
if (constraint.type === 'overlap' && constraint.overlap) {
|
||
const coTags = constraint.overlap.coTags || [];
|
||
const groupKey = [id, ...coTags].slice().sort().join('-');
|
||
if (processedOverlapGroups.has(groupKey))
|
||
return; // already rendered
|
||
processedOverlapGroups.add(groupKey);
|
||
chips.push(createCoOccurrenceChip(id, constraint.overlap, 'include', entity));
|
||
return;
|
||
}
|
||
const tagName = lookupName(id, entity);
|
||
const chipClass = `tag-chip ${constraint.type === 'presence' ? 'include' : constraint.type}`;
|
||
let constraintText = '';
|
||
if (constraint.type === 'duration' && constraint.duration) {
|
||
const min = constraint.duration.min || 0;
|
||
const max = constraint.duration.max || '∞';
|
||
const unit = constraint.duration.unit === 'percent' ? '%' : 's';
|
||
constraintText = ` [${min}-${max}${unit}]`;
|
||
}
|
||
else if (constraint.type === 'importance' && constraint.importance !== undefined) {
|
||
try {
|
||
constraintText = ` [×${Number(constraint.importance).toFixed(1)}]`;
|
||
}
|
||
catch (_) {
|
||
constraintText = ` [×${constraint.importance}]`;
|
||
}
|
||
}
|
||
chips.push(React.createElement('span', { key: 'i' + id, className: `${chipClass} tag-chip-flex` }, [
|
||
React.createElement('span', { key: 'text', className: 'tag-chip-text' }, tagName),
|
||
constraintText ? React.createElement('span', { key: 'constraint', className: 'tag-chip-constraint' }, constraintText) : null,
|
||
React.createElement('div', { key: 'actions', className: 'tag-chip-actions' }, [
|
||
React.createElement('button', { key: 'gear', className: 'constraint-btn', onClick: (e) => showConstraintPopup(id, e, entity), title: 'Configure constraint' }, '⚙'),
|
||
React.createElement('button', { key: 'x', onClick: (e) => { e.stopPropagation(); removeTag(id, 'include'); }, title: 'Remove', className: 'tag-chip-remove' }, '×')
|
||
])
|
||
].filter(Boolean)));
|
||
});
|
||
// Exclude chips
|
||
exclude.forEach(id => {
|
||
const constraint = getTagConstraint(id);
|
||
if (constraint.type === 'overlap' && constraint.overlap) {
|
||
const coTags = constraint.overlap.coTags || [];
|
||
const groupKey = [id, ...coTags].slice().sort().join('-');
|
||
if (processedOverlapGroups.has(groupKey))
|
||
return;
|
||
processedOverlapGroups.add(groupKey);
|
||
chips.push(createCoOccurrenceChip(id, constraint.overlap, 'exclude', entity));
|
||
return;
|
||
}
|
||
const tagName = lookupName(id, entity);
|
||
const chipClass = `tag-chip ${constraint.type === 'presence' ? 'exclude' : constraint.type}`;
|
||
let constraintText = '';
|
||
if (constraint.type === 'duration' && constraint.duration) {
|
||
const min = constraint.duration.min || 0;
|
||
const max = constraint.duration.max || '∞';
|
||
const unit = constraint.duration.unit === 'percent' ? '%' : 's';
|
||
constraintText = ` [${min}-${max}${unit}]`;
|
||
}
|
||
else if (constraint.type === 'importance' && constraint.importance !== undefined) {
|
||
try {
|
||
constraintText = ` [×${Number(constraint.importance).toFixed(1)}]`;
|
||
}
|
||
catch (_) {
|
||
constraintText = ` [×${constraint.importance}]`;
|
||
}
|
||
}
|
||
chips.push(React.createElement('span', { key: 'e' + id, className: `${chipClass} tag-chip-flex` }, [
|
||
React.createElement('span', { key: 'text', className: 'tag-chip-text' }, tagName),
|
||
constraintText ? React.createElement('span', { key: 'constraint', className: 'tag-chip-constraint' }, constraintText) : null,
|
||
React.createElement('div', { key: 'actions', className: 'tag-chip-actions' }, [
|
||
React.createElement('button', { key: 'gear', className: 'constraint-btn', onClick: (e) => showConstraintPopup(id, e, entity), title: 'Configure constraint' }, '⚙'),
|
||
React.createElement('button', { key: 'x', onClick: (e) => { e.stopPropagation(); removeTag(id, 'exclude'); }, title: 'Remove', className: 'tag-chip-remove' }, '×')
|
||
])
|
||
].filter(Boolean)));
|
||
});
|
||
const suggestionsList = (searchState.showDropdown || searchState.search) && (searchState.suggestions.length || searchState.loading || searchState.error) ? React.createElement('div', { className: 'suggestions-list', key: 'list' }, searchState.loading ? React.createElement('div', { className: 'empty-suggest' }, 'Searching…') : searchState.error ? React.createElement('div', { className: 'empty-suggest' }, searchState.error) : searchState.suggestions.length ? searchState.suggestions.map((tg) => React.createElement('div', { key: tg.id, onClick: (e) => { e.stopPropagation(); addTag(parseInt(tg.id, 10), tg.name); } }, tg.name + ' (#' + tg.id + ')')) : React.createElement('div', { className: 'empty-suggest' }, 'No matches')) : null;
|
||
const constraintPopupEl = constraintPopup ? React.createElement('div', { className: 'constraint-popup', style: { left: constraintPopup.position.x + 'px', top: constraintPopup.position.y + 'px' }, onClick: (e) => e.stopPropagation() }, [React.createElement(ConstraintEditor, { key: 'editor', tagId: constraintPopup.tagId, constraint: constraintPopup.initialConstraint || getTagConstraint(constraintPopup.tagId), tagName: lookupName(constraintPopup.tagId, constraintPopup && constraintPopup.entity), value: v, fieldName: fieldName, entity: constraintPopup.entity, allowedConstraintTypes, compositeRawRef: compositeRef, onSave: (constraint) => { updateTagConstraint(constraintPopup.tagId, constraint); setConstraintPopup(null); }, onCancel: () => setConstraintPopup(null), onClose: () => setConstraintPopup(null) })]) : null;
|
||
return React.createElement('div', { className: 'ai-tag-fallback unified w-100', onClick: () => { if (tagInputRef.current)
|
||
tagInputRef.current.focus(); } }, [combinationToggle, chips.length ? chips : React.createElement('span', { key: 'ph', className: 'text-muted small' }, 'No tags'), React.createElement('input', { key: 'inp', type: 'text', className: 'tag-input', value: searchState.search, placeholder: 'Search tags…', onChange: (e) => search(e.target.value), onKeyDown, onFocus: onInputFocus, onClick: (e) => e.stopPropagation(), ref: tagInputRef }), suggestionsList, constraintPopupEl]);
|
||
}
|
||
// Build standardized config control rows (shared between RecommendedScenes & SimilarScenes)
|
||
function buildConfigRows(params) {
|
||
const { React, defs, configValues, updateConfigField, TagIncludeExclude, compositeRawRef, narrowTagWidth } = params;
|
||
return defs.map(field => {
|
||
var _a, _b, _c;
|
||
const val = configValues[field.name];
|
||
const id = 'cfg_' + field.name;
|
||
let control = null;
|
||
switch (field.type) {
|
||
case 'number':
|
||
control = React.createElement('input', { id, type: 'number', className: 'text-input form-control form-control-sm w-num', value: val !== null && val !== void 0 ? val : '', min: field.min, max: field.max, step: field.step || 1, onChange: (e) => updateConfigField(field.name, e.target.value === '' ? null : Number(e.target.value)) });
|
||
break;
|
||
case 'slider':
|
||
control = React.createElement('div', { className: 'range-wrapper' }, [
|
||
React.createElement('input', { key: 'rng', id, type: 'range', className: 'zoom-slider', value: (_a = val !== null && val !== void 0 ? val : field.default) !== null && _a !== void 0 ? _a : 0, min: field.min, max: field.max, step: field.step || 1, onChange: (e) => updateConfigField(field.name, Number(e.target.value)) }),
|
||
React.createElement('div', { key: 'val', className: 'range-value' }, String((_b = val !== null && val !== void 0 ? val : field.default) !== null && _b !== void 0 ? _b : 0))
|
||
]);
|
||
break;
|
||
case 'select':
|
||
case 'enum':
|
||
control = React.createElement('select', { id, className: 'input-control form-control form-control-sm w-select w-180', value: (_c = val !== null && val !== void 0 ? val : field.default) !== null && _c !== void 0 ? _c : '', onChange: (e) => updateConfigField(field.name, e.target.value) }, (field.options || []).map((o) => React.createElement('option', { key: o.value, value: o.value }, o.label || o.value)));
|
||
break;
|
||
case 'boolean':
|
||
control = React.createElement('div', { className: 'custom-control custom-switch' }, [
|
||
React.createElement('input', { key: 'chk', id, type: 'checkbox', className: 'custom-control-input', checked: !!val, onChange: (e) => updateConfigField(field.name, e.target.checked) }),
|
||
React.createElement('label', { key: 'lb', htmlFor: id, className: 'custom-control-label' }, '')
|
||
]);
|
||
break;
|
||
case 'text':
|
||
control = React.createElement('input', { id, type: 'text', className: 'text-input form-control form-control-sm w-text w-180', value: val !== null && val !== void 0 ? val : '', placeholder: field.help || '', onChange: (e) => updateConfigField(field.name, e.target.value, { debounce: true, field }) });
|
||
break;
|
||
case 'search':
|
||
control = React.createElement('div', { className: 'clearable-input-group search-term-input w-180' }, [
|
||
React.createElement('input', { key: 'in', id, type: 'text', className: 'clearable-text-field form-control form-control-sm w-180', value: val !== null && val !== void 0 ? val : '', placeholder: field.help || 'Search…', onChange: (e) => updateConfigField(field.name, e.target.value, { debounce: true, field }) })
|
||
]);
|
||
break;
|
||
case 'tags': {
|
||
let includeIds = [];
|
||
let excludeIds = [];
|
||
let constraints = {};
|
||
if (Array.isArray(val)) {
|
||
includeIds = val;
|
||
}
|
||
else if (val && typeof val === 'object') {
|
||
includeIds = Array.isArray(val.include) ? val.include : [];
|
||
excludeIds = Array.isArray(val.exclude) ? val.exclude : [];
|
||
constraints = val.constraints || {};
|
||
}
|
||
control = React.createElement('div', { className: 'w-tags' }, TagIncludeExclude ? React.createElement(TagIncludeExclude, { compositeRawRef, fieldName: field.name, value: { include: includeIds, exclude: excludeIds, constraints, tag_combination: val === null || val === void 0 ? void 0 : val.tag_combination }, onChange: (next) => updateConfigField(field.name, next), initialTagCombination: field.tag_combination, allowedConstraintTypes: field.constraint_types, allowedCombinationModes: field.allowed_combination_modes }) : React.createElement('div', { className: 'text-muted small' }, 'Tag selector unavailable'));
|
||
break;
|
||
}
|
||
case 'performers': {
|
||
let includeIds = [];
|
||
let excludeIds = [];
|
||
let constraints = {};
|
||
if (Array.isArray(val)) {
|
||
includeIds = val;
|
||
}
|
||
else if (val && typeof val === 'object') {
|
||
includeIds = Array.isArray(val.include) ? val.include : [];
|
||
excludeIds = Array.isArray(val.exclude) ? val.exclude : [];
|
||
constraints = val.constraints || {};
|
||
}
|
||
control = React.createElement('div', { className: 'w-tags' }, TagIncludeExclude ? React.createElement(TagIncludeExclude, { compositeRawRef, fieldName: field.name, value: { include: includeIds, exclude: excludeIds, constraints, tag_combination: val === null || val === void 0 ? void 0 : val.tag_combination }, onChange: (next) => updateConfigField(field.name, next), initialTagCombination: field.tag_combination, allowedConstraintTypes: field.constraint_types, allowedCombinationModes: field.allowed_combination_modes, entity: 'performer' }) : React.createElement('div', { className: 'text-muted small' }, 'Performer selector unavailable'));
|
||
break;
|
||
}
|
||
default:
|
||
control = React.createElement('div', { className: 'text-muted small' }, 'Unsupported: ' + field.type);
|
||
}
|
||
const showLabelAbove = true;
|
||
const capWidth = (field.type === 'tags' || field.type === 'performers') ? (narrowTagWidth !== null && narrowTagWidth !== void 0 ? narrowTagWidth : 400) : (field.type === 'slider' ? 92 : (['text', 'search', 'select', 'enum'].includes(field.type) ? 180 : undefined));
|
||
const labelStyle = capWidth ? { display: 'inline-block', width: capWidth + 'px', maxWidth: capWidth + 'px' } : undefined;
|
||
const labelProps = { htmlFor: id, className: 'form-label d-flex justify-content-between mb-0', style: labelStyle };
|
||
if (field.help)
|
||
labelProps.title = field.help;
|
||
const labelNode = showLabelAbove ? React.createElement('label', labelProps, [React.createElement('span', { key: 't', className: 'label-text' }, field.label || field.name)]) : null;
|
||
const compactTypes = ['number', 'select', 'enum', 'boolean', 'slider', 'text', 'search', 'tags', 'performers'];
|
||
const colClass = compactTypes.includes(field.type) ? 'col-auto mb-1' : 'col-lg-4 col-md-6 col-12 mb-1';
|
||
return React.createElement('div', { key: field.name, className: colClass }, [
|
||
React.createElement('div', { className: 'form-group mb-0' }, [
|
||
labelNode,
|
||
React.createElement('div', { key: 'ctrlwrap', style: labelStyle, className: 'control-wrap' }, control)
|
||
])
|
||
]);
|
||
});
|
||
}
|
||
// Export utilities to global namespace
|
||
w.AIRecommendationUtils = {
|
||
useDebounce,
|
||
useResizeObserver,
|
||
calculateCardWidth,
|
||
useContainerDimensions,
|
||
useCardWidth,
|
||
ConstraintEditor,
|
||
createTagSelector,
|
||
TagIncludeExclude,
|
||
buildConfigRows
|
||
};
|
||
} // End initializeRecommendationUtils
|
||
// Wait for dependencies and initialize
|
||
function waitAndInitialize() {
|
||
if (w.PluginApi && w.PluginApi.React) {
|
||
console.log('[RecommendationUtils] Dependencies ready, initializing...');
|
||
initializeRecommendationUtils();
|
||
}
|
||
else {
|
||
console.log('[RecommendationUtils] Waiting for PluginApi and React...');
|
||
setTimeout(waitAndInitialize, 100);
|
||
}
|
||
}
|
||
waitAndInitialize();
|
||
})();
|
||
})();
|
||
|