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>
1864 lines
116 KiB
JavaScript
1864 lines
116 KiB
JavaScript
(function(){
|
||
// Recommended Scenes (Full UI Restored w/ cleanup)
|
||
// Visual parity with the richer version you preferred:
|
||
// • Algorithm & min score controls
|
||
// • Zoom slider + dynamic card width (upstream parity logic)
|
||
// • Native‑style pagination (top + bottom) w/ dropdown
|
||
// • Duration + size aggregate stats
|
||
// • Adaptive GraphQL fetch & schema pruning
|
||
// • Independent persistence (aiRec.* keys) + shareable URL params + cross‑tab sync
|
||
// Cleanup changes:
|
||
// • Extracted helpers & constants
|
||
// • Added light typing & defensive guards
|
||
// • Reduced duplicated pagination calculations
|
||
// • Centralized fetch + prune logic
|
||
// • Wrapped debug logs behind w.AIDebug
|
||
(function () {
|
||
const BUILD_VERSION = 'rec-pagination-v2-' + new Date().toISOString();
|
||
try {
|
||
console.info('[RecommendedScenes] Loaded bundle version', BUILD_VERSION);
|
||
}
|
||
catch (_) { }
|
||
const w = window;
|
||
// Safer initialization - wait for everything to be ready
|
||
function initializeRecommendedScenes() {
|
||
const PluginApi = w.PluginApi;
|
||
if (!PluginApi || !PluginApi.React) {
|
||
console.warn('[RecommendedScenes] 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('[RecommendedScenes] React hooks not available');
|
||
return;
|
||
}
|
||
const React = PluginApi.React;
|
||
const { useState, useMemo, useEffect, useRef, useCallback } = React;
|
||
const getSharedApiKey = () => {
|
||
try {
|
||
const helper = w.AISharedApiKeyHelper;
|
||
if (helper && typeof helper.get === 'function') {
|
||
const value = helper.get();
|
||
if (typeof value === 'string')
|
||
return value.trim();
|
||
}
|
||
}
|
||
catch { }
|
||
const raw = w.AI_SHARED_API_KEY;
|
||
return typeof raw === 'string' ? raw.trim() : '';
|
||
};
|
||
const withSharedKeyHeaders = (init) => {
|
||
const helper = w.AISharedApiKeyHelper;
|
||
if (helper && typeof helper.withHeaders === 'function') {
|
||
return helper.withHeaders(init || {});
|
||
}
|
||
const key = getSharedApiKey();
|
||
if (!key)
|
||
return init || {};
|
||
const headers = { ...(init && init.headers ? init.headers : {}) };
|
||
headers['x-ai-api-key'] = key;
|
||
return { ...(init || {}), headers };
|
||
};
|
||
// Using only the new backend hydrated recommendations API.
|
||
// const GQL = {} as any; // (legacy GraphQL client removed)
|
||
// 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) => {
|
||
// ResizeObserver passes an array of entries
|
||
if (entries && entries.length > 0) {
|
||
callback(entries[0]);
|
||
}
|
||
});
|
||
ro.observe(target.current);
|
||
return () => ro.disconnect();
|
||
}, [target, callback]);
|
||
}
|
||
function calculateCardWidth(containerWidth, preferredWidth) {
|
||
// Use CSS variables for layout values
|
||
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;
|
||
calculateCardWidth._last = { maxElementsOnRow, preferredWidth, width, containerWidth };
|
||
return width;
|
||
}
|
||
function useContainerDimensions(sensitivityThreshold = 20) {
|
||
const target = useRef(null);
|
||
const [dimension, setDimension] = useState({ width: 0, height: 0 });
|
||
const debouncedSetDimension = useDebounce((entry) => {
|
||
// SafeGuard against undefined contentBoxSize
|
||
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);
|
||
// Initialize with current size if available
|
||
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(() => {
|
||
// Check for mobile - upstream returns undefined for mobile devices
|
||
const isMobile = window.innerWidth <= 768; // Simple mobile check
|
||
if (isMobile)
|
||
return undefined;
|
||
// Provide a reasonable fallback if container width is not yet measured
|
||
// Upstream measures a parent whose visual width includes the row's negative margins expanding into outer padding.
|
||
// Our ref is on the .row itself (content box not enlarged by negative margins). Add 30px (15px each side) so
|
||
// the effective width fed to the algorithm matches native measurement and prevents an extra trailing gap.
|
||
const effectiveWidth = (containerWidth ? containerWidth : 1200); // use raw row width; padding provided by outer wrapper
|
||
if (zoomIndex === undefined || zoomIndex < 0 || zoomIndex >= zoomWidths.length) {
|
||
return undefined; // Return undefined instead of empty return
|
||
}
|
||
const preferredCardWidth = zoomWidths[zoomIndex];
|
||
return calculateCardWidth(effectiveWidth, preferredCardWidth);
|
||
}, [containerWidth, zoomIndex, zoomWidths]);
|
||
}
|
||
const { NavLink } = PluginApi.libraries.ReactRouterDOM || {};
|
||
const Bootstrap = PluginApi.libraries.Bootstrap || {};
|
||
const Button = Bootstrap.Button || ((p) => React.createElement('button', p, p.children));
|
||
const ROUTE = '/plugins/recommended-scenes';
|
||
const RECOMMENDATION_CONTEXT = 'global_feed';
|
||
const LS_PER_PAGE_KEY = 'aiRec.perPage';
|
||
const LS_ZOOM_KEY = 'aiRec.zoom';
|
||
const LS_PAGE_KEY = 'aiRec.page';
|
||
// All scenes arrive hydrated from backend recommender query.
|
||
function log(...args) { if (w.AIDebug)
|
||
console.log('[RecommendedScenes]', ...args); }
|
||
function warn(...args) { if (w.AIDebug)
|
||
console.warn('[RecommendedScenes]', ...args); }
|
||
function normalizeScene(sc) {
|
||
if (!sc || typeof sc !== 'object')
|
||
return undefined;
|
||
const arrayFields = ['performers', 'tags', 'markers', 'scene_markers', 'galleries', 'images', 'files', 'groups'];
|
||
arrayFields.forEach(f => { if (sc[f] == null)
|
||
sc[f] = [];
|
||
else if (!Array.isArray(sc[f]))
|
||
sc[f] = [sc[f]].filter(Boolean); });
|
||
if (!sc.studio)
|
||
sc.studio = null;
|
||
if (sc.rating100 == null && typeof sc.rating === 'number')
|
||
sc.rating100 = sc.rating * 20;
|
||
if (sc.rating == null && typeof sc.rating100 === 'number')
|
||
sc.rating = Math.round(sc.rating100 / 20);
|
||
return sc;
|
||
}
|
||
//
|
||
// Interaction Tracking (Usage Example Only - non-invasive):
|
||
// After rendering a scene detail or when user navigates to one, you can call:
|
||
// (window as any).stashAIInteractionTracker?.trackSceneView(String(sceneId), { title: scene.title });
|
||
// If you have access to the HTMLVideoElement of the scene playback you can instrument it once:
|
||
// const videoEl = document.querySelector('video');
|
||
// (window as any).stashAIInteractionTracker?.instrumentSceneVideo(String(sceneId), videoEl as HTMLVideoElement);
|
||
// This file does not automatically track to avoid assumptions about when a scene detail view is active.
|
||
// Integrations should be placed where the actual scene detail/player component mounts.
|
||
//
|
||
const RecommendedScenesPage = () => {
|
||
var _a, _b, _c, _d;
|
||
function readInitial(key, urlParam, fallback) {
|
||
try {
|
||
const usp = new URLSearchParams(location.search);
|
||
const v = usp.get(urlParam);
|
||
if (v != null) {
|
||
const n = parseInt(v, 10);
|
||
if (!isNaN(n))
|
||
return n;
|
||
}
|
||
}
|
||
catch (_) { }
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (raw != null) {
|
||
const n = parseInt(raw, 10);
|
||
if (!isNaN(n))
|
||
return n;
|
||
}
|
||
}
|
||
catch (_) { }
|
||
return fallback;
|
||
}
|
||
//
|
||
const [recommenders, setRecommenders] = useState(null);
|
||
const [recommenderId, setRecommenderId] = useState(null);
|
||
const [zoomIndex, setZoomIndex] = useState(() => readInitial(LS_ZOOM_KEY, 'z', 1));
|
||
const [itemsPerPage, setItemsPerPage] = useState(() => readInitial(LS_PER_PAGE_KEY, 'perPage', 40));
|
||
const [page, setPage] = useState(() => readInitial(LS_PAGE_KEY, 'p', 1));
|
||
// Scenes for current page only (server paginated)
|
||
const [scenes, setScenes] = useState([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [hasMore, setHasMore] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const zoomWidths = [280, 340, 480, 640];
|
||
const [componentRef, { width: containerWidth }] = useContainerDimensions();
|
||
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
|
||
// fetch IDs (mock until new backend recommender query flow integrated)
|
||
//
|
||
const [discoveryAttempted, setDiscoveryAttempted] = useState(false);
|
||
const pageAPI = w.AIPageContext; // for contextual recommendation requests
|
||
// ---------------- Config State (per recommender) -----------------
|
||
const [configValues, setConfigValues] = useState({});
|
||
const configCacheRef = useRef({});
|
||
const configValuesRef = useRef({});
|
||
const LS_SHOW_CONFIG_KEY = 'aiRec.showConfig';
|
||
function readShowConfigRec() { try {
|
||
const raw = localStorage.getItem(LS_SHOW_CONFIG_KEY);
|
||
if (raw == null)
|
||
return true;
|
||
return raw === '1' || raw === 'true';
|
||
}
|
||
catch (_) {
|
||
return true;
|
||
} }
|
||
const [showConfig, setShowConfig] = useState(() => readShowConfigRec());
|
||
useEffect(() => {
|
||
function onStorage(e) { try {
|
||
if (e.key === LS_SHOW_CONFIG_KEY) {
|
||
const v = e.newValue;
|
||
const next = v === '1' || v === 'true';
|
||
setShowConfig(next);
|
||
}
|
||
}
|
||
catch (_) { } }
|
||
function onCustom(ev) { try {
|
||
if (ev && ev.detail !== undefined)
|
||
setShowConfig(Boolean(ev.detail));
|
||
}
|
||
catch (_) { } }
|
||
window.addEventListener('storage', onStorage);
|
||
window.addEventListener('aiRec.showConfig', onCustom);
|
||
return () => { window.removeEventListener('storage', onStorage); window.removeEventListener('aiRec.showConfig', onCustom); };
|
||
}, []);
|
||
function toggleShowConfigRec() { const next = !showConfig; try {
|
||
localStorage.setItem(LS_SHOW_CONFIG_KEY, next ? '1' : '0');
|
||
}
|
||
catch (_) { } try {
|
||
window.dispatchEvent(new CustomEvent('aiRec.showConfig', { detail: next }));
|
||
}
|
||
catch (_) { } setShowConfig(next); }
|
||
// Generic tick to force config panel rerender (for tag mode changes)
|
||
const [configRerenderTick, setConfigRerenderTick] = useState(0);
|
||
function forceConfigRerender() { setConfigRerenderTick((t) => t + 1); }
|
||
const textDebounceTimersRef = useRef({});
|
||
const compositeRawRef = useRef({}); // raw text for tags/performers inputs
|
||
useEffect(() => { configValuesRef.current = configValues; }, [configValues]);
|
||
const currentRecommender = React.useMemo(() => { var _a; return (_a = (recommenders || [])) === null || _a === void 0 ? void 0 : _a.find((r) => r.id === recommenderId); }, [recommenders, recommenderId]);
|
||
const preferenceSaveTimerRef = useRef(null);
|
||
const lastPersistedSnapshotRef = useRef(null);
|
||
const serverSeedConfigRef = useRef({});
|
||
const backendBaseRef = useRef('');
|
||
useEffect(() => {
|
||
return () => {
|
||
if (preferenceSaveTimerRef.current) {
|
||
clearTimeout(preferenceSaveTimerRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
function sanitizeConfigPayload(config) {
|
||
const out = {};
|
||
if (config && typeof config === 'object') {
|
||
Object.keys(config).forEach((key) => {
|
||
const value = config[key];
|
||
if (value !== undefined) {
|
||
out[key] = value;
|
||
}
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
function shouldPersistField(field) {
|
||
if (!field)
|
||
return true;
|
||
if (typeof field.persist === 'undefined')
|
||
return true;
|
||
return Boolean(field.persist);
|
||
}
|
||
function isFieldPersistable(definition, fieldName) {
|
||
if (!definition || !Array.isArray(definition.config))
|
||
return false;
|
||
const field = definition.config.find((f) => f.name === fieldName);
|
||
if (!field)
|
||
return false;
|
||
return shouldPersistField(field);
|
||
}
|
||
function buildPersistableConfig(definition, values) {
|
||
if (!definition || !Array.isArray(definition.config))
|
||
return {};
|
||
const out = {};
|
||
definition.config.forEach((field) => {
|
||
if (!shouldPersistField(field))
|
||
return;
|
||
if (values && Object.prototype.hasOwnProperty.call(values, field.name)) {
|
||
const value = values[field.name];
|
||
if (value !== undefined) {
|
||
out[field.name] = value;
|
||
}
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
const persistPreference = React.useCallback(async () => {
|
||
const base = backendBaseRef.current;
|
||
if (!base || !recommenderId || !currentRecommender)
|
||
return;
|
||
const persistable = sanitizeConfigPayload(buildPersistableConfig(currentRecommender, configValuesRef.current || {}));
|
||
const payload = {
|
||
context: RECOMMENDATION_CONTEXT,
|
||
recommenderId,
|
||
config: persistable,
|
||
};
|
||
const signature = JSON.stringify(payload);
|
||
if (lastPersistedSnapshotRef.current === signature) {
|
||
return;
|
||
}
|
||
try {
|
||
const url = `${base}/api/v1/recommendations/preferences`;
|
||
await fetch(url, withSharedKeyHeaders({
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
}));
|
||
lastPersistedSnapshotRef.current = signature;
|
||
}
|
||
catch (err) {
|
||
warn('Failed to persist recommender preference', err);
|
||
}
|
||
}, [recommenderId, currentRecommender, withSharedKeyHeaders]);
|
||
const schedulePreferencePersist = React.useCallback((reason, opts) => {
|
||
if (!backendBaseRef.current || !recommenderId || !currentRecommender)
|
||
return;
|
||
const delay = reason === 'recommender' ? 25 : ((opts === null || opts === void 0 ? void 0 : opts.debounce) ? 800 : 220);
|
||
if (preferenceSaveTimerRef.current) {
|
||
clearTimeout(preferenceSaveTimerRef.current);
|
||
}
|
||
preferenceSaveTimerRef.current = setTimeout(() => {
|
||
preferenceSaveTimerRef.current = null;
|
||
persistPreference();
|
||
}, delay);
|
||
}, [recommenderId, currentRecommender, persistPreference]);
|
||
// ---------------- Tag Include/Exclude Selector (Unified) -----------------
|
||
// Sole implementation: single bar with inline mode toggle (+ include / - exclude) and chips inline.
|
||
// Enhanced Constraint Editor Component with auto-save and advanced co-occurrence support
|
||
function ConstraintEditor({ tagId, constraint, tagName, value, fieldName, onSave, onCancel, allowedConstraintTypes, entity: popupEntity }) {
|
||
const [localConstraint, setLocalConstraint] = React.useState(constraint);
|
||
// Local name lookup helper (ConstraintEditor is defined before the outer lookupName),
|
||
// uses the same compositeRawRef maps keyed by fieldName
|
||
function lookupLocalName(id, forEntity) {
|
||
const ent = forEntity || popupEntity || 'tag';
|
||
const key = fieldName + '__' + (ent === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||
const map = compositeRawRef.current[key] || {};
|
||
return map[id] || (ent === 'performer' ? `Performer ${id}` : `Tag ${id}`);
|
||
}
|
||
// Reset local state when constraint prop changes (e.g., when switching constraint types)
|
||
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' }
|
||
];
|
||
// If the backend supplied allowedConstraintTypes, filter available types accordingly
|
||
const constraintTypes = Array.isArray(allowedConstraintTypes) && allowedConstraintTypes.length > 0
|
||
? allConstraintTypes.filter(ct => allowedConstraintTypes.includes(ct.value))
|
||
: allConstraintTypes;
|
||
// Memoize expensive tag computations for overlap constraint
|
||
const overlapTagData = React.useMemo(() => {
|
||
if (localConstraint.type !== 'overlap')
|
||
return { allCoOccurrencePrimaries: new Set(), availableTags: [] };
|
||
// Get all currently selected tags (include + exclude) for co-occurrence selection
|
||
// Exclude primary tags from other co-occurrence groups
|
||
const allCoOccurrencePrimaries = 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 constraint = ((value === null || value === void 0 ? void 0 : value.constraints) || {})[id] || { type: 'presence' };
|
||
if (constraint.type === 'overlap' && ((_b = (_a = constraint.overlap) === null || _a === void 0 ? void 0 : _a.coTags) === null || _b === void 0 ? void 0 : _b.length) > 0 && id !== tagId) {
|
||
allCoOccurrencePrimaries.add(id);
|
||
}
|
||
});
|
||
const entity = popupEntity || localConstraint._entity || 'tag';
|
||
const availableTags = [...((value === null || value === void 0 ? void 0 : value.include) || []), ...((value === null || value === void 0 ? void 0 : value.exclude) || [])]
|
||
.filter(id => id !== tagId && !allCoOccurrencePrimaries.has(id));
|
||
return { allCoOccurrencePrimaries, 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 newConstraint = { type: newType };
|
||
// Initialize default values for each constraint type
|
||
switch (newType) {
|
||
case 'presence':
|
||
newConstraint.presence = 'include';
|
||
break;
|
||
case 'duration':
|
||
newConstraint.duration = { min: 10, max: 60, unit: 'percent' };
|
||
break;
|
||
case 'overlap':
|
||
newConstraint.overlap = { minDuration: 5, maxDuration: 30, unit: 'percent' };
|
||
break;
|
||
case 'importance':
|
||
newConstraint.importance = 0.5;
|
||
break;
|
||
}
|
||
setLocalConstraint(newConstraint);
|
||
}
|
||
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: 'label' }, 'Mode: '),
|
||
React.createElement('select', {
|
||
key: 'select',
|
||
value: localConstraint.presence || 'include',
|
||
onChange: (e) => setLocalConstraint((prev) => ({ ...prev, 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: 'label' }, '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((prev) => ({
|
||
...prev,
|
||
duration: { ...prev.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((prev) => ({
|
||
...prev,
|
||
duration: { ...prev.duration, max: e.target.value ? Number(e.target.value) : undefined }
|
||
}))
|
||
})
|
||
]),
|
||
React.createElement('div', { key: 'unit' }, [
|
||
React.createElement('label', { key: 'label' }, 'Unit: '),
|
||
React.createElement('select', {
|
||
key: 'select',
|
||
value: ((_c = localConstraint.duration) === null || _c === void 0 ? void 0 : _c.unit) || 'percent',
|
||
onChange: (e) => setLocalConstraint((prev) => ({
|
||
...prev,
|
||
duration: { ...prev.duration, unit: e.target.value }
|
||
}))
|
||
}, [
|
||
React.createElement('option', { key: 'pct', value: 'percent' }, '% of video'),
|
||
React.createElement('option', { key: 'sec', value: 'seconds' }, 'Seconds')
|
||
])
|
||
])
|
||
]);
|
||
case 'overlap':
|
||
// Use memoized tag data to avoid expensive recomputation on every render
|
||
const { availableTags } = overlapTagData;
|
||
const selectedCoTags = ((_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: 'tags-section' }, [
|
||
React.createElement('label', { key: 'label' }, 'Selected for co-occurrence: '),
|
||
React.createElement('div', { key: 'selected-tags', className: 'constraint-selected-tags' }, selectedCoTags.length > 0 ? selectedCoTags.map((coTagId) => {
|
||
const coTagName = lookupLocalName(coTagId, entity);
|
||
return React.createElement('span', {
|
||
key: coTagId,
|
||
className: 'constraint-cochip-tag'
|
||
}, [
|
||
coTagName,
|
||
React.createElement('button', {
|
||
key: 'remove',
|
||
onClick: () => {
|
||
const newCoTags = selectedCoTags.filter((id) => id !== coTagId);
|
||
setLocalConstraint((prev) => ({
|
||
...prev,
|
||
overlap: { ...prev.overlap, coTags: newCoTags }
|
||
}));
|
||
},
|
||
className: 'constraint-cochip-remove'
|
||
}, '×')
|
||
]);
|
||
}) : React.createElement('span', { className: 'constraint-selected-empty' }, 'No tags selected for co-occurrence')),
|
||
availableTags.length > 0 ? React.createElement('div', { key: 'available-tags', className: 'constraint-available-tags' }, availableTags.map((coTagId) => {
|
||
const coTagName = lookupLocalName(coTagId, entity);
|
||
const isSelected = selectedCoTags.includes(coTagId);
|
||
if (isSelected)
|
||
return null; // Don't show already selected tags
|
||
return React.createElement('button', {
|
||
key: coTagId,
|
||
onClick: () => {
|
||
const newCoTags = [...selectedCoTags, coTagId];
|
||
setLocalConstraint((prev) => ({
|
||
...prev,
|
||
overlap: { ...prev.overlap, coTags: newCoTags }
|
||
}));
|
||
},
|
||
className: 'constraint-tag-button'
|
||
}, coTagName);
|
||
})) : null
|
||
]),
|
||
React.createElement('div', { key: 'range' }, [
|
||
React.createElement('label', { key: 'label' }, '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((prev) => ({
|
||
...prev,
|
||
overlap: { ...prev.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((prev) => ({
|
||
...prev,
|
||
overlap: { ...prev.overlap, maxDuration: e.target.value ? Number(e.target.value) : undefined }
|
||
}))
|
||
})
|
||
]),
|
||
React.createElement('div', { key: 'unit' }, [
|
||
React.createElement('label', { key: 'label' }, 'Unit: '),
|
||
React.createElement('select', {
|
||
key: 'select',
|
||
value: ((_g = localConstraint.overlap) === null || _g === void 0 ? void 0 : _g.unit) || 'percent',
|
||
onChange: (e) => setLocalConstraint((prev) => ({
|
||
...prev,
|
||
overlap: { ...prev.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: 'label' }, 'Weight (0.0 - 1.0): '),
|
||
React.createElement('input', {
|
||
key: 'input', type: 'number', step: '0.1', min: '0', max: '1',
|
||
value: localConstraint.importance || 0.5,
|
||
onChange: (e) => setLocalConstraint((prev) => ({ ...prev, importance: Number(e.target.value) }))
|
||
})
|
||
]);
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
// Auto-save on unmount (click-out) without saving on every change
|
||
const localConstraintRef = React.useRef(localConstraint);
|
||
const canceledRef = React.useRef(false);
|
||
React.useEffect(() => { localConstraintRef.current = localConstraint; }, [localConstraint]);
|
||
React.useEffect(() => {
|
||
// save once on unmount unless canceled via Escape
|
||
return () => {
|
||
try {
|
||
if (!canceledRef.current)
|
||
onSave(localConstraintRef.current);
|
||
}
|
||
catch (e) { }
|
||
};
|
||
}, [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' }, [
|
||
React.createElement('div', { key: 'title', className: 'constraint-title' }, `Configure: ${tagName}`),
|
||
React.createElement('div', { key: 'type', className: 'constraint-type' }, [
|
||
React.createElement('label', { key: 'label' }, 'Type: '),
|
||
React.createElement('select', {
|
||
key: 'select',
|
||
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);
|
||
},
|
||
title: 'Save changes'
|
||
}, 'Save')
|
||
])
|
||
]);
|
||
}
|
||
const TagIncludeExclude = ({ value, onChange, fieldName, initialTagCombination, allowedConstraintTypes, allowedCombinationModes, entity = 'tag' }) => {
|
||
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 : [];
|
||
// Enhanced value structure for constraints
|
||
const constraints = v.constraints || {};
|
||
// Use React state instead of ref-based state to avoid focus issues
|
||
// Determine allowed combination modes: default to ['and','or'] unless field restricts.
|
||
// Normalize and resolve allowed modes: prefer explicit allowedCombinationModes; else use initialTagCombination; else default ['and','or'].
|
||
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']));
|
||
// Determine initial mode from provided value or defaults; treat null/undefined/invalid as first allowed.
|
||
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
|
||
});
|
||
// Debug: log initial props and resolved modes to help diagnose behavior
|
||
// (Debug log removed)
|
||
// Instance id for coordinating dropdowns between multiple tag selectors on the page
|
||
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);
|
||
}
|
||
}
|
||
// When any instance opens, other instances should close their dropdowns
|
||
React.useEffect(() => {
|
||
function onOtherOpen(ev) {
|
||
try {
|
||
const otherId = ev && ev.detail && ev.detail.id;
|
||
const myId = instanceIdRef.current;
|
||
if (w.AIDebug) {
|
||
console.log('[TagFallback] Received open event. Other ID:', otherId, 'My ID:', myId);
|
||
}
|
||
if (otherId && otherId !== myId) {
|
||
if (w.AIDebug) {
|
||
console.log('[TagFallback] Closing dropdown for instance', myId);
|
||
}
|
||
setSearchState((prev) => ({ ...prev, showDropdown: false }));
|
||
}
|
||
}
|
||
catch (e) {
|
||
if (w.AIDebug) {
|
||
console.warn('[TagFallback] Error handling open event:', e);
|
||
}
|
||
}
|
||
}
|
||
document.addEventListener('ai-tag-fallback-open', onOtherOpen);
|
||
return () => document.removeEventListener('ai-tag-fallback-open', onOtherOpen);
|
||
}, []);
|
||
// Sync combinationMode from external value changes (persisted value may arrive asynchronously)
|
||
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 }));
|
||
if (w.AIDebug) {
|
||
console.log('[TagFallback] synced combinationMode from value:', externalMode);
|
||
}
|
||
}
|
||
}, [v && v.tag_combination]);
|
||
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];
|
||
// Helper to look up a name for an id for a given entity
|
||
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);
|
||
function removeTag(id, list) {
|
||
const nextInclude = list === 'include' ? include.filter((i) => i !== id) : include;
|
||
const nextExclude = list === 'exclude' ? exclude.filter((i) => i !== id) : exclude;
|
||
// Also remove constraints for this tag
|
||
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];
|
||
});
|
||
}
|
||
if (w.AIDebug) {
|
||
console.log('New constraints object:', { include: nextInclude, exclude: nextExclude, constraints: nextConstraints });
|
||
}
|
||
// 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) {
|
||
const constraint = constraints[tagId] || { type: 'presence', presence: include.includes(tagId) ? 'include' : 'exclude' };
|
||
if (w.AIDebug) {
|
||
console.log('Getting constraint for tag', tagId, ':', constraint);
|
||
}
|
||
return constraint;
|
||
}
|
||
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();
|
||
}
|
||
// Use a ref for the tag input for focus and positioning
|
||
const tagInputRef = React.useRef(null);
|
||
function addTag(id, name) {
|
||
const supportsPresence = !Array.isArray(allowedConstraintTypes) || allowedConstraintTypes.length === 0 || allowedConstraintTypes.includes('presence');
|
||
if (!supportsPresence) {
|
||
const preferredType = (Array.isArray(allowedConstraintTypes) && allowedConstraintTypes.length > 0) ? allowedConstraintTypes[0] : 'overlap';
|
||
const init = { type: preferredType };
|
||
if (preferredType === 'presence')
|
||
init.presence = 'include';
|
||
if (preferredType === 'duration')
|
||
init.duration = { min: 10, max: 60, unit: 'percent' };
|
||
if (preferredType === 'overlap')
|
||
init.overlap = { minDuration: 5, maxDuration: 30, unit: 'percent', coTags: [] };
|
||
if (preferredType === 'importance')
|
||
init.importance = 0.5;
|
||
let position = { x: window.innerWidth / 2 - 100, y: window.innerHeight / 2 - 80 };
|
||
if (tagInputRef.current) {
|
||
const rect = tagInputRef.current.getBoundingClientRect();
|
||
position = { x: rect.left, y: rect.bottom + 5 };
|
||
}
|
||
setConstraintPopup({
|
||
tagId: id,
|
||
entity: entity,
|
||
position,
|
||
initialConstraint: init
|
||
});
|
||
if (name)
|
||
tagNameMap[id] = name;
|
||
return;
|
||
}
|
||
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) || []);
|
||
// populate name map so other UI (co-occurrence chips, popups) shows proper names
|
||
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('');
|
||
}
|
||
}
|
||
}
|
||
// Close dropdown when clicking outside
|
||
React.useEffect(() => {
|
||
function handleClickOutside(event) {
|
||
const target = event.target;
|
||
if (!target.closest('.ai-tag-fallback.unified')) {
|
||
setSearchState((prev) => ({ ...prev, showDropdown: false }));
|
||
}
|
||
// Close constraint popup when clicking outside
|
||
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]);
|
||
const chips = [];
|
||
const processedOverlapGroups = new Set();
|
||
// Helper function to create co-occurrence group chip
|
||
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.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.forEach(id => {
|
||
const constraint = getTagConstraint(id);
|
||
// Skip if this tag is part of a co-occurrence group already processed, or if it's ANY overlap constraint
|
||
if (constraint.type === 'overlap' && constraint.overlap) {
|
||
const coTags = constraint.overlap.coTags || [];
|
||
const groupKey = [id, ...coTags].sort().join('-');
|
||
if (processedOverlapGroups.has(groupKey)) {
|
||
return; // Skip, already rendered as part of the group
|
||
}
|
||
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}`;
|
||
// Add constraint indicator text
|
||
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) {
|
||
constraintText = ` [×${constraint.importance.toFixed(1)}]`;
|
||
}
|
||
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.forEach(id => {
|
||
const constraint = getTagConstraint(id);
|
||
// Skip if this tag is part of a co-occurrence group already processed, or if it's ANY overlap constraint
|
||
if (constraint.type === 'overlap' && constraint.overlap) {
|
||
const coTags = constraint.overlap.coTags || [];
|
||
const groupKey = [id, ...coTags].sort().join('-');
|
||
if (processedOverlapGroups.has(groupKey)) {
|
||
return; // Skip, already rendered as part of the group
|
||
}
|
||
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}`;
|
||
// Add constraint indicator text
|
||
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) {
|
||
constraintText = ` [×${constraint.importance.toFixed(1)}]`;
|
||
}
|
||
// Use consistent spacing - all constraints get the same padding
|
||
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;
|
||
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) {
|
||
// Remove the last tag from either include or exclude (prefer include first)
|
||
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: [] }));
|
||
}
|
||
}
|
||
}
|
||
// Determine if combination toggle should be shown (show unless 'not-applicable')
|
||
const showCombinationToggle = resolvedAllowedModes.length > 0 && resolvedAllowedModes.every(m => m !== 'not-applicable');
|
||
const toggleClickable = resolvedAllowedModes.length > 1;
|
||
// Debug: log combination toggle visibility
|
||
// (Debug log removed)
|
||
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 }));
|
||
// Immediately persist the mode change
|
||
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;
|
||
// Constraint popup component
|
||
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,
|
||
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
|
||
]);
|
||
};
|
||
// Initialize defaults when recommender changes
|
||
useEffect(() => {
|
||
if (!currentRecommender)
|
||
return;
|
||
const defs = currentRecommender.config || [];
|
||
const base = {};
|
||
for (const field of defs) {
|
||
base[field.name] = field.default;
|
||
if (field.type === 'tags' || field.type === 'performers') {
|
||
compositeRawRef.current[field.name] = '';
|
||
}
|
||
}
|
||
const existing = configCacheRef.current[currentRecommender.id];
|
||
let merged = existing ? { ...base, ...existing } : { ...base };
|
||
const seeded = serverSeedConfigRef.current[currentRecommender.id];
|
||
if (seeded) {
|
||
merged = { ...merged, ...seeded };
|
||
delete serverSeedConfigRef.current[currentRecommender.id];
|
||
}
|
||
configCacheRef.current[currentRecommender.id] = merged;
|
||
setConfigValues({ ...merged });
|
||
configValuesRef.current = merged;
|
||
schedulePreferencePersist('recommender');
|
||
}, [currentRecommender, schedulePreferencePersist]);
|
||
function scheduleFetchAfterConfigChange(previousPage) {
|
||
// If page changed to 1 because of config change, we rely on page effect; otherwise manual fetch
|
||
if (previousPage === 1) {
|
||
// manual fetch to reflect immediate change
|
||
queueMicrotask(() => fetchRecommendations());
|
||
}
|
||
}
|
||
function applyConfigImmediate(update) {
|
||
setConfigValues((v) => { const next = { ...v, ...update }; configValuesRef.current = next; if (recommenderId) {
|
||
configCacheRef.current[recommenderId] = next;
|
||
} return next; });
|
||
}
|
||
function updateConfigField(name, value, opts) {
|
||
const field = opts === null || opts === void 0 ? void 0 : opts.field;
|
||
const prevPage = page;
|
||
// Debounced text fields: update local state immediately but delay fetch
|
||
if (opts === null || opts === void 0 ? void 0 : opts.debounce) {
|
||
applyConfigImmediate({ [name]: value });
|
||
if (textDebounceTimersRef.current[name])
|
||
clearTimeout(textDebounceTimersRef.current[name]);
|
||
textDebounceTimersRef.current[name] = setTimeout(() => {
|
||
// ensure still active recommender
|
||
scheduleFetchAfterConfigChange(prevPage);
|
||
}, 400);
|
||
}
|
||
else {
|
||
applyConfigImmediate({ [name]: value });
|
||
scheduleFetchAfterConfigChange(prevPage);
|
||
}
|
||
if (currentRecommender && isFieldPersistable(currentRecommender, name)) {
|
||
schedulePreferencePersist('config', { debounce: !!(opts === null || opts === void 0 ? void 0 : opts.debounce) });
|
||
}
|
||
}
|
||
function parseIdList(raw) {
|
||
return raw.split(',').map(s => s.trim()).filter(s => s.length > 0).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 0);
|
||
}
|
||
function renderConfigPanel() {
|
||
if (!currentRecommender || !Array.isArray(currentRecommender.config) || !currentRecommender.config.length)
|
||
return null;
|
||
const defs = currentRecommender.config;
|
||
const rows = 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': {
|
||
// Ensure the checkbox has an accessible description (help) and the visible label is rendered above via labelNode.
|
||
// Keep the internal switch label empty so the above label (labelNode) is the visible caption
|
||
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': {
|
||
// Always use custom fallback - no native TagSelect/TagIDSelect components
|
||
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 || {};
|
||
}
|
||
// Custom searchable include/exclude selector with chips.
|
||
control = React.createElement('div', { className: 'w-tags' }, React.createElement(TagIncludeExclude, {
|
||
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
|
||
}));
|
||
break;
|
||
}
|
||
case 'performers': {
|
||
// Use the unified selector for performers (reuses tag selector UI/behavior)
|
||
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' }, React.createElement(TagIncludeExclude, {
|
||
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'
|
||
}));
|
||
break;
|
||
}
|
||
default:
|
||
control = React.createElement('div', { className: 'text-muted small' }, 'Unsupported: ' + field.type);
|
||
}
|
||
// Render labels above every control (including boolean switches) so layout is consistent
|
||
const showLabelAbove = true;
|
||
// Make labels inline-block and only as wide as the control beneath to prevent blocking layout
|
||
const capWidth = (field.type === 'tags' || field.type === 'performers') ? 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;
|
||
// Use auto-width columns for compact fields; let large/complex fields take normal grid width
|
||
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,
|
||
// Wrap control so it can be constrained to the same width as the label and centered
|
||
React.createElement('div', { key: 'ctrlwrap', style: labelStyle, className: 'control-wrap' }, control)
|
||
])
|
||
]);
|
||
});
|
||
return React.createElement('div', { className: 'ai-rec-config mb-1' }, [
|
||
React.createElement('div', { key: 'hdr', className: 'd-flex justify-content-between align-items-center mb-1' }, [
|
||
React.createElement('strong', { key: 't', className: 'small' }),
|
||
React.createElement('div', { key: 'actions', className: 'd-flex align-items-center gap-2' }, [
|
||
React.createElement('button', { key: 'tgl', className: 'btn btn-secondary btn-sm', onClick: () => toggleShowConfigRec() }, showConfig ? 'Hide' : 'Show')
|
||
])
|
||
]),
|
||
showConfig ? React.createElement('div', { key: 'body', className: 'config-row row' }, rows) : null
|
||
]);
|
||
}
|
||
// filtered scenes (placeholder for future filters/search)
|
||
const filteredScenes = useMemo(() => scenes, [scenes]);
|
||
// totalPages is heuristic if hasMore: allow navigating one page past current computed value repeatedly
|
||
const totalPages = useMemo(() => {
|
||
const base = Math.max(1, Math.ceil(total / itemsPerPage));
|
||
if (hasMore) {
|
||
// Allow navigating to the next page when backend indicates more data.
|
||
return Math.max(base, page + 1);
|
||
}
|
||
return base;
|
||
}, [total, itemsPerPage, hasMore, page]);
|
||
// Sync & persist
|
||
useEffect(() => { try {
|
||
const usp = new URLSearchParams(location.search);
|
||
usp.set('perPage', String(itemsPerPage));
|
||
usp.set('z', String(zoomIndex));
|
||
if (page > 1)
|
||
usp.set('p', String(page));
|
||
else
|
||
usp.delete('p');
|
||
const qs = usp.toString();
|
||
const desired = location.pathname + (qs ? ('?' + qs) : '');
|
||
if (desired !== location.pathname + location.search)
|
||
history.replaceState(null, '', desired);
|
||
localStorage.setItem(LS_PER_PAGE_KEY, String(itemsPerPage));
|
||
localStorage.setItem(LS_ZOOM_KEY, String(zoomIndex));
|
||
localStorage.setItem(LS_PAGE_KEY, String(page));
|
||
}
|
||
catch (_) { } }, [itemsPerPage, zoomIndex, page]);
|
||
useEffect(() => { function onStorage(e) { if (!e.key)
|
||
return; if (e.key === LS_PER_PAGE_KEY) {
|
||
const n = parseInt(String(e.newValue || ''), 10);
|
||
if (!isNaN(n))
|
||
setItemsPerPage(n);
|
||
} if (e.key === LS_ZOOM_KEY) {
|
||
const n = parseInt(String(e.newValue || ''), 10);
|
||
if (!isNaN(n))
|
||
setZoomIndex(n);
|
||
} if (e.key === LS_PAGE_KEY) {
|
||
const n = parseInt(String(e.newValue || ''), 10);
|
||
if (!isNaN(n))
|
||
setPage(n);
|
||
} } window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage); }, []);
|
||
const sanitizeBase = useCallback((value) => {
|
||
const origin = (() => {
|
||
try {
|
||
return typeof location !== 'undefined' && location.origin ? location.origin.replace(/\/$/, '') : '';
|
||
}
|
||
catch {
|
||
return '';
|
||
}
|
||
})();
|
||
if (typeof value !== 'string')
|
||
return '';
|
||
const trimmed = value.trim();
|
||
if (!trimmed)
|
||
return '';
|
||
const cleaned = trimmed.replace(/\/$/, '');
|
||
return origin && cleaned === origin ? '' : cleaned;
|
||
}, []);
|
||
const resolveBackendBase = useCallback(() => {
|
||
const fn = w.AIDefaultBackendBase;
|
||
if (typeof fn !== 'function')
|
||
return '';
|
||
try {
|
||
const value = fn();
|
||
return sanitizeBase(typeof value === 'string' ? value : '');
|
||
}
|
||
catch {
|
||
return '';
|
||
}
|
||
}, [sanitizeBase]);
|
||
const [backendBase, setBackendBase] = useState(() => resolveBackendBase());
|
||
useEffect(() => {
|
||
backendBaseRef.current = backendBase || '';
|
||
}, [backendBase]);
|
||
const applyBackendBase = useCallback((nextRaw) => {
|
||
const next = sanitizeBase(nextRaw || '');
|
||
let changed = false;
|
||
setBackendBase((prev) => {
|
||
if (prev === next)
|
||
return prev;
|
||
changed = true;
|
||
return next;
|
||
});
|
||
if (changed) {
|
||
setDiscoveryAttempted(false);
|
||
}
|
||
}, [sanitizeBase, setDiscoveryAttempted]);
|
||
useEffect(() => {
|
||
const handler = (event) => {
|
||
const next = typeof (event === null || event === void 0 ? void 0 : event.detail) === 'string' ? event.detail : resolveBackendBase();
|
||
applyBackendBase(next || '');
|
||
};
|
||
try {
|
||
window.addEventListener('AIBackendBaseUpdated', handler);
|
||
}
|
||
catch (_) { }
|
||
const timer = !backendBase ? setTimeout(() => applyBackendBase(resolveBackendBase() || ''), 0) : null;
|
||
return () => {
|
||
try {
|
||
window.removeEventListener('AIBackendBaseUpdated', handler);
|
||
}
|
||
catch (_) { }
|
||
if (timer)
|
||
clearTimeout(timer);
|
||
};
|
||
}, [backendBase, resolveBackendBase, applyBackendBase]);
|
||
const backendHealthApi = w.AIBackendHealth;
|
||
const backendHealthEvent = (backendHealthApi === null || backendHealthApi === void 0 ? void 0 : backendHealthApi.EVENT_NAME) || 'AIBackendHealthChange';
|
||
const [backendHealthTick, setBackendHealthTick] = useState(0);
|
||
useEffect(() => {
|
||
if (!backendHealthApi || !backendHealthEvent)
|
||
return;
|
||
const handler = () => setBackendHealthTick((t) => t + 1);
|
||
try {
|
||
window.addEventListener(backendHealthEvent, handler);
|
||
}
|
||
catch (_) { }
|
||
return () => { try {
|
||
window.removeEventListener(backendHealthEvent, handler);
|
||
}
|
||
catch (_) { } ; };
|
||
}, [backendHealthApi, backendHealthEvent]);
|
||
const backendHealthState = useMemo(() => {
|
||
if (backendHealthApi && typeof backendHealthApi.getState === 'function') {
|
||
return backendHealthApi.getState();
|
||
}
|
||
return null;
|
||
}, [backendHealthApi, backendHealthTick]);
|
||
const discoverRecommenders = React.useCallback(async () => {
|
||
var _a, _b;
|
||
if (!backendBase) {
|
||
return { success: false, recommenderId: null };
|
||
}
|
||
try {
|
||
if (backendHealthApi && typeof backendHealthApi.reportChecking === 'function') {
|
||
try {
|
||
backendHealthApi.reportChecking(backendBase);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
const ctxPage = (_b = (_a = pageAPI === null || pageAPI === void 0 ? void 0 : pageAPI.get) === null || _a === void 0 ? void 0 : _a.call(pageAPI)) === null || _b === void 0 ? void 0 : _b.page;
|
||
const recContext = RECOMMENDATION_CONTEXT;
|
||
const url = `${backendBase}/api/v1/recommendations/recommenders?context=${encodeURIComponent(recContext)}`;
|
||
const res = await fetch(url, withSharedKeyHeaders());
|
||
if (!res.ok)
|
||
throw new Error('status ' + res.status);
|
||
const j = await res.json();
|
||
if (j && Array.isArray(j.recommenders)) {
|
||
const defs = j.recommenders;
|
||
setRecommenders(defs);
|
||
setDiscoveryAttempted(true);
|
||
if (defs.length === 0) {
|
||
if (backendHealthApi && typeof backendHealthApi.reportOk === 'function') {
|
||
try {
|
||
backendHealthApi.reportOk(backendBase);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
setRecommenderId(null);
|
||
setScenes([]);
|
||
setTotal(0);
|
||
setHasMore(false);
|
||
setError(null);
|
||
return { success: true, recommenderId: null };
|
||
}
|
||
let savedId = typeof j.savedRecommenderId === 'string' ? j.savedRecommenderId : null;
|
||
let savedDef = savedId ? defs.find(r => r.id === savedId) : null;
|
||
if (savedDef) {
|
||
const seedConfig = sanitizeConfigPayload(j.savedConfig || {});
|
||
serverSeedConfigRef.current[savedDef.id] = seedConfig;
|
||
lastPersistedSnapshotRef.current = JSON.stringify({
|
||
context: recContext,
|
||
recommenderId: savedDef.id,
|
||
config: seedConfig,
|
||
});
|
||
}
|
||
else {
|
||
savedId = null;
|
||
}
|
||
const existingMatch = recommenderId && defs.find(r => r.id === recommenderId);
|
||
const defaultDef = (j.defaultRecommenderId && defs.find(r => r.id === j.defaultRecommenderId)) || defs[0];
|
||
let nextId = null;
|
||
if (savedDef) {
|
||
nextId = savedDef.id;
|
||
}
|
||
else if (existingMatch) {
|
||
nextId = existingMatch.id;
|
||
}
|
||
else if (defaultDef) {
|
||
if (w.AIDebug)
|
||
console.log('[RecommendedScenes] default recommender', defaultDef.id);
|
||
nextId = defaultDef.id;
|
||
}
|
||
if (nextId) {
|
||
setRecommenderId((prev) => prev === nextId ? prev : nextId);
|
||
}
|
||
if (backendHealthApi && typeof backendHealthApi.reportOk === 'function') {
|
||
try {
|
||
backendHealthApi.reportOk(backendBase);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
return { success: true, recommenderId: nextId || (defaultDef ? defaultDef.id : null) };
|
||
}
|
||
throw new Error('Unexpected discovery response');
|
||
}
|
||
catch (e) {
|
||
const message = e && e.message ? e.message : 'failed to discover recommenders';
|
||
if (backendHealthApi && typeof backendHealthApi.reportError === 'function') {
|
||
try {
|
||
backendHealthApi.reportError(backendBase, message, e);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
setRecommenders([]);
|
||
setDiscoveryAttempted(true);
|
||
return { success: false, recommenderId: null };
|
||
}
|
||
}, [backendBase, pageAPI, backendHealthApi, recommenderId]);
|
||
// Attempt new recommender discovery first; fallback to legacy algorithms if unavailable
|
||
useEffect(() => {
|
||
if (discoveryAttempted || !backendBase)
|
||
return;
|
||
discoverRecommenders();
|
||
}, [discoveryAttempted, discoverRecommenders, backendBase]);
|
||
// (legacy algorithm effects removed)
|
||
// Unified function to request recommendations (first page)
|
||
const latestRequestIdRef = React.useRef(0);
|
||
const fetchRecommendations = React.useCallback(async (overrideId) => {
|
||
if (!backendBase)
|
||
return;
|
||
const myId = ++latestRequestIdRef.current;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const activeRecommenderId = overrideId !== null && overrideId !== void 0 ? overrideId : recommenderId;
|
||
if (!activeRecommenderId) {
|
||
return;
|
||
}
|
||
if (backendHealthApi && typeof backendHealthApi.reportChecking === 'function') {
|
||
try {
|
||
backendHealthApi.reportChecking(backendBase);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
const ctx = (pageAPI === null || pageAPI === void 0 ? void 0 : pageAPI.get) ? pageAPI.get() : null; // reserved for future context mapping
|
||
const offset = (page - 1) * itemsPerPage;
|
||
const body = { context: RECOMMENDATION_CONTEXT, recommenderId: activeRecommenderId, limit: itemsPerPage, offset, config: configValuesRef.current || {} };
|
||
if (ctx) {
|
||
body.context = RECOMMENDATION_CONTEXT;
|
||
}
|
||
const url = `${backendBase}/api/v1/recommendations/query`;
|
||
if (w.AIDebug)
|
||
console.log('[RecommendedScenes] query', body);
|
||
const res = await fetch(url, withSharedKeyHeaders({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }));
|
||
if (!res.ok) {
|
||
if (myId === latestRequestIdRef.current) {
|
||
if (backendHealthApi && typeof backendHealthApi.reportError === 'function' && (res.status >= 500 || res.status === 0)) {
|
||
try {
|
||
backendHealthApi.reportError(backendBase, `HTTP ${res.status}`);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
else if (backendHealthApi && typeof backendHealthApi.reportOk === 'function') {
|
||
try {
|
||
backendHealthApi.reportOk(backendBase);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
}
|
||
throw new Error('HTTP ' + res.status);
|
||
}
|
||
const j = await res.json();
|
||
if (myId !== latestRequestIdRef.current) {
|
||
if (w.AIDebug)
|
||
console.log('[RecommendedScenes] stale response ignored', { myId, current: latestRequestIdRef.current });
|
||
return;
|
||
}
|
||
if (!Array.isArray(j.scenes)) {
|
||
throw new Error('Unexpected response format');
|
||
}
|
||
const norm = j.scenes.map((s) => normalizeScene(s)).filter(Boolean);
|
||
setScenes(norm);
|
||
const serverTotal = (j.meta && typeof j.meta.total === 'number') ? j.meta.total : norm.length;
|
||
const floorTotal = offset + norm.length;
|
||
const metaTotal = serverTotal < floorTotal ? floorTotal : serverTotal;
|
||
setTotal(metaTotal);
|
||
const nextOffset = j.meta && typeof j.meta.nextOffset === 'number' ? j.meta.nextOffset : null;
|
||
const hm = j.meta && typeof j.meta.hasMore === 'boolean' ? Boolean(j.meta.hasMore) : (nextOffset != null ? nextOffset > offset : offset + norm.length < metaTotal);
|
||
setHasMore(hm);
|
||
if (w.AIDebug)
|
||
console.log('[RecommendedScenes] meta', j.meta, { page, itemsPerPage, computedPages: Math.ceil(metaTotal / itemsPerPage), hasMore: hm });
|
||
if (myId === latestRequestIdRef.current && backendHealthApi && typeof backendHealthApi.reportOk === 'function') {
|
||
try {
|
||
backendHealthApi.reportOk(backendBase);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
}
|
||
catch (e) {
|
||
if (myId !== latestRequestIdRef.current) {
|
||
return;
|
||
}
|
||
const message = e && e.message ? e.message : 'unknown error';
|
||
if (backendHealthApi && typeof backendHealthApi.reportError === 'function') {
|
||
try {
|
||
backendHealthApi.reportError(backendBase, message, e);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
setError('Failed to load scenes: ' + message);
|
||
}
|
||
finally {
|
||
if (myId === latestRequestIdRef.current) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, [recommenderId, backendBase, pageAPI, page, itemsPerPage, backendHealthApi]);
|
||
// Fetch whenever recommender changes
|
||
// When recommender changes, reset page then fetch (single sequence without double calling prior fetch)
|
||
const prevRecommenderRef = React.useRef(null);
|
||
useEffect(() => {
|
||
if (!discoveryAttempted)
|
||
return;
|
||
if (!recommenderId)
|
||
return;
|
||
if (prevRecommenderRef.current !== recommenderId) {
|
||
prevRecommenderRef.current = recommenderId;
|
||
setPage(1);
|
||
// fetch after synchronous state update using microtask
|
||
queueMicrotask(() => fetchRecommendations());
|
||
return;
|
||
}
|
||
}, [recommenderId, discoveryAttempted, fetchRecommendations]);
|
||
// Expose manual refresh
|
||
const manualRefresh = React.useCallback(() => {
|
||
if (w.AIDebug)
|
||
console.log('[RecommendedScenes] manual refresh');
|
||
(async () => {
|
||
const needDiscovery = !discoveryAttempted || !recommenderId || (Array.isArray(recommenders) && recommenders.length === 0);
|
||
if (needDiscovery) {
|
||
const result = await discoverRecommenders();
|
||
if (!result.success) {
|
||
return;
|
||
}
|
||
const idToUse = result.recommenderId || recommenderId;
|
||
if (idToUse) {
|
||
await fetchRecommendations(idToUse);
|
||
}
|
||
return;
|
||
}
|
||
await fetchRecommendations();
|
||
})().catch((err) => { if (w.AIDebug)
|
||
console.warn('[RecommendedScenes] refresh failed', err); });
|
||
}, [discoverRecommenders, fetchRecommendations, discoveryAttempted, recommenderId, recommenders]);
|
||
const backendNotice = backendHealthApi && typeof backendHealthApi.buildNotice === 'function'
|
||
? backendHealthApi.buildNotice(backendHealthState, { onRetry: manualRefresh, dense: true })
|
||
: null;
|
||
// Clamp page when per-page changes
|
||
useEffect(() => {
|
||
if (loading)
|
||
return; // avoid clamp while fetch pending
|
||
if (!hasMore) {
|
||
const maxPages = Math.max(1, Math.ceil(total / itemsPerPage));
|
||
if (page > maxPages) {
|
||
if (w.AIDebug)
|
||
console.log('[RecommendedScenes] clamp page', { page, maxPages });
|
||
setPage(maxPages);
|
||
}
|
||
}
|
||
}, [itemsPerPage, total, page, hasMore, loading]);
|
||
useEffect(() => { if (w.AIDebug)
|
||
console.log('[RecommendedScenes] page change', { page, itemsPerPage, total, hasMore }); }, [page, itemsPerPage, total, hasMore]);
|
||
// Fetch when page or itemsPerPage change (offset-based pagination)
|
||
useEffect(() => { if (!discoveryAttempted)
|
||
return; if (!recommenderId)
|
||
return; fetchRecommendations(); }, [page, itemsPerPage, discoveryAttempted, recommenderId, fetchRecommendations]);
|
||
const paginatedScenes = filteredScenes; // server already paginated
|
||
const startIndex = useMemo(() => (total ? (page - 1) * itemsPerPage + 1 : 0), [total, page, itemsPerPage]);
|
||
const endIndex = useMemo(() => Math.min(total, page * itemsPerPage), [total, page, itemsPerPage]);
|
||
const { totalDuration, totalSizeBytes } = useMemo(() => { let duration = 0, size = 0; for (const sc of filteredScenes) {
|
||
const files = sc.files || [];
|
||
let longest = 0;
|
||
for (const f of files) {
|
||
if (typeof (f === null || f === void 0 ? void 0 : f.duration) === 'number')
|
||
longest = Math.max(longest, f.duration);
|
||
if (typeof (f === null || f === void 0 ? void 0 : f.size) === 'number')
|
||
size += f.size;
|
||
}
|
||
duration += longest;
|
||
} return { totalDuration: duration, totalSizeBytes: size }; }, [filteredScenes]);
|
||
function formatDuration(seconds) { if (!seconds)
|
||
return '0s'; const MIN = 60, H = 3600, D = 86400, M = 30 * D; let rem = seconds; const months = Math.floor(rem / M); rem %= M; const days = Math.floor(rem / D); rem %= D; const hours = Math.floor(rem / H); rem %= H; const mins = Math.floor(rem / MIN); const parts = []; if (months)
|
||
parts.push(months + 'M'); if (days)
|
||
parts.push(days + 'D'); if (hours)
|
||
parts.push(hours + 'h'); if (mins)
|
||
parts.push(mins + 'm'); return parts.length ? parts.join(' ') : seconds + 's'; }
|
||
function formatSize(bytes) { if (!bytes)
|
||
return '0 B'; const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; let i = 0, val = bytes; while (val > 1024 && i < units.length - 1) {
|
||
val /= 1024;
|
||
i++;
|
||
} return (i >= 3 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i]; }
|
||
const componentsToLoad = [
|
||
(_a = PluginApi.loadableComponents) === null || _a === void 0 ? void 0 : _a.SceneCard,
|
||
// Attempt to also pre-load Tag selectors if they are loadable (some builds expose these)
|
||
((_b = PluginApi.loadableComponents) === null || _b === void 0 ? void 0 : _b.TagIDSelect) || ((_c = PluginApi.loadableComponents) === null || _c === void 0 ? void 0 : _c.TagSelect)
|
||
].filter(Boolean);
|
||
const componentsLoading = ((_d = PluginApi.hooks) === null || _d === void 0 ? void 0 : _d.useLoadComponents) ? PluginApi.hooks.useLoadComponents(componentsToLoad) : false;
|
||
const { SceneCard, TagIDSelect, TagSelect } = PluginApi.components || {};
|
||
// Attempt alternate resolution if not found (some builds may expose under different keys or on window)
|
||
const _w = window;
|
||
const ResolvedTagIDSelect = TagIDSelect || _w.TagIDSelect || _w.TagSelectID || null;
|
||
const ResolvedTagSelect = TagSelect || _w.TagSelect || null;
|
||
if (w.AIDebug && !ResolvedTagIDSelect && !ResolvedTagSelect) {
|
||
console.debug('[RecommendedScenes] Tag selector components not found; falling back to text input');
|
||
}
|
||
const grid = useMemo(() => {
|
||
if (loading || componentsLoading)
|
||
return React.createElement('div', { className: 'scene-grid-loading' }, 'Loading scenes...');
|
||
if (error)
|
||
return React.createElement('div', { className: 'scene-grid-error' }, error);
|
||
if (!paginatedScenes.length)
|
||
return React.createElement('div', { className: 'scene-grid-empty' }, 'No scenes');
|
||
if (cardWidth === undefined)
|
||
return React.createElement('div', { className: 'scene-grid-calculating' }, 'Calculating layout...');
|
||
const children = paginatedScenes.map((s, i) => React.createElement('div', { key: s.id + '_' + i, style: { display: 'contents' } }, SceneCard ? React.createElement(SceneCard, { scene: s, zoomIndex, queue: undefined, index: i }) : null));
|
||
return React.createElement('div', { className: 'row ai-rec-grid d-flex flex-wrap justify-content-center', ref: componentRef, style: { ['--ai-card-width']: cardWidth + 'px' } }, children);
|
||
}, [loading, componentsLoading, error, paginatedScenes, SceneCard, cardWidth, zoomIndex]);
|
||
useEffect(() => { if (w.AIDebug && cardWidth)
|
||
log('layout', { containerWidth, zoomIndex, preferredWidth: zoomWidths[zoomIndex], cardWidth }); }, [containerWidth, zoomIndex, cardWidth, paginatedScenes]);
|
||
function PaginationControl({ position }) {
|
||
const disabledFirst = page <= 1;
|
||
const disabledLast = page >= totalPages && !hasMore;
|
||
const controls = React.createElement('div', { key: 'pc', role: 'group', className: 'pagination btn-group' }, [
|
||
React.createElement('button', { key: 'first', disabled: disabledFirst, className: 'btn btn-secondary', onClick: () => setPage(1) }, '«'),
|
||
React.createElement('button', { key: 'prev', disabled: disabledFirst, className: 'btn btn-secondary', onClick: () => setPage((p) => Math.max(1, p - 1)) }, '<'),
|
||
React.createElement('div', { key: 'cnt', className: 'page-count-container' }, [
|
||
React.createElement('div', { key: 'grp', role: 'group', className: 'btn-group' }, [
|
||
React.createElement('button', { key: 'lbl', type: 'button', className: 'page-count btn btn-secondary' }, `${page} of ${totalPages}`)
|
||
])
|
||
]),
|
||
React.createElement('button', { key: 'next', disabled: disabledLast, className: 'btn btn-secondary', onClick: () => setPage((p) => Math.min(totalPages, p + 1)) }, '>'),
|
||
React.createElement('button', { key: 'last', disabled: disabledLast, className: 'btn btn-secondary', onClick: () => setPage(totalPages) }, '»')
|
||
]);
|
||
const statsFragment = totalDuration > 0 ? ` (${formatDuration(totalDuration)} - ${formatSize(totalSizeBytes)})` : '';
|
||
const info = React.createElement('span', { key: 'info', className: 'filter-container text-muted paginationIndex center-text w-100 text-center mt-1' }, `${startIndex}-${endIndex} of ${total}${statsFragment}`);
|
||
return React.createElement('div', { className: 'd-flex flex-column align-items-center w-100 pagination-footer mt-2' }, position === 'top' ? [controls, info] : [info, controls]);
|
||
}
|
||
const recSelect = React.createElement('select', { key: 'rec', className: 'btn-secondary form-control form-control-sm', value: recommenderId || '', onChange: (e) => { setRecommenderId(e.target.value); } }, (recommenders || []).map((r) => React.createElement('option', { key: r.id, value: r.id }, r.label || r.id)));
|
||
const toolbar = React.createElement('div', { key: 'toolbar', role: 'toolbar', className: 'filtered-list-toolbar btn-toolbar flex-wrap w-100 mb-1 justify-content-center' }, [
|
||
React.createElement('div', { key: 'cluster', className: 'd-flex flex-wrap justify-content-center align-items-center gap-2' }, [
|
||
React.createElement('div', { key: 'recGroup', role: 'group', className: 'mr-2 mb-2 btn-group' }, [recSelect]),
|
||
React.createElement('div', { key: 'ps', className: 'page-size-selector mr-2 mb-2' }, React.createElement('select', { className: 'btn-secondary form-control form-control-sm', value: itemsPerPage, onChange: (e) => { setItemsPerPage(Number(e.target.value)); setPage(1); } }, [20, 40, 80, 100].map(n => React.createElement('option', { key: n, value: n }, n)))),
|
||
React.createElement('div', { key: 'zoomWrap', className: 'mx-2 mb-2 d-inline-flex align-items-center' }, [
|
||
React.createElement('input', { key: 'zr', min: 0, max: 3, type: 'range', className: 'zoom-slider ml-1 form-control-range', value: zoomIndex, onChange: (e) => setZoomIndex(Number(e.target.value)) })
|
||
]),
|
||
React.createElement('div', { key: 'act', role: 'group', className: 'mb-2 btn-group' }, [
|
||
React.createElement(Button, { key: 'refresh', className: 'btn btn-secondary minimal', disabled: loading, title: 'Refresh', onClick: manualRefresh }, '↻')
|
||
])
|
||
])
|
||
]);
|
||
// While recommender discovery hasn't finished, suppress legacy UI to avoid flash
|
||
if (!discoveryAttempted) {
|
||
return React.createElement('div', { className: 'text-center mt-4' }, 'Loading recommendation engine…');
|
||
}
|
||
if (discoveryAttempted && !backendNotice && Array.isArray(recommenders) && recommenders.length === 0) {
|
||
return React.createElement('div', { className: 'ai-rec-empty-state text-center mt-4' }, [
|
||
React.createElement('div', { key: 'no-recommenders', className: 'alert alert-info d-inline-block text-left', style: { maxWidth: 520, margin: '12px auto' } }, [
|
||
React.createElement('div', { key: 'title', style: { fontWeight: 600, marginBottom: 6 } }, 'No recommendation plugins installed'),
|
||
React.createElement('div', { key: 'body' }, 'Install a recommender plugin under Settings → Tools → AI Overhaul Settings.')
|
||
])
|
||
]);
|
||
}
|
||
return React.createElement(React.Fragment, null, [
|
||
backendNotice,
|
||
toolbar,
|
||
renderConfigPanel(),
|
||
React.createElement(PaginationControl, { key: 'pgt', position: 'top' }),
|
||
grid,
|
||
React.createElement(PaginationControl, { key: 'pgb', position: 'bottom' })
|
||
]);
|
||
};
|
||
try {
|
||
PluginApi.register.route(ROUTE, RecommendedScenesPage);
|
||
}
|
||
catch { }
|
||
// Single canonical patch key based on provided MainNavbar source
|
||
try {
|
||
PluginApi.patch.before('MainNavBar.MenuItems', function (props) {
|
||
// Duplicate guard
|
||
try {
|
||
const arr = React.Children.toArray(props.children);
|
||
if (arr.some((c) => { var _a, _b, _c, _d; return ((_c = (_b = (_a = c === null || c === void 0 ? void 0 : c.props) === null || _a === void 0 ? void 0 : _a.children) === null || _b === void 0 ? void 0 : _b.props) === null || _c === void 0 ? void 0 : _c.to) === ROUTE || ((_d = c === null || c === void 0 ? void 0 : c.props) === null || _d === void 0 ? void 0 : _d.to) === ROUTE; }))
|
||
return props;
|
||
}
|
||
catch { }
|
||
const label = 'Recommended Scenes';
|
||
let qs = '';
|
||
try {
|
||
const pp = localStorage.getItem(LS_PER_PAGE_KEY);
|
||
const z = localStorage.getItem(LS_ZOOM_KEY);
|
||
const p = localStorage.getItem(LS_PAGE_KEY);
|
||
const params = new URLSearchParams();
|
||
if (pp)
|
||
params.set('perPage', pp);
|
||
if (z)
|
||
params.set('z', z);
|
||
if (p && p !== '1')
|
||
params.set('p', p);
|
||
const s = params.toString();
|
||
if (s)
|
||
qs = '?' + s;
|
||
}
|
||
catch (_) { }
|
||
const node = React.createElement('div', { key: 'recommended-scenes-link', className: 'col-4 col-sm-3 col-md-2 col-lg-auto' }, NavLink ? (React.createElement(NavLink, {
|
||
exact: true,
|
||
to: ROUTE + qs,
|
||
activeClassName: 'active',
|
||
className: 'btn minimal p-4 p-xl-2 d-flex d-xl-inline-block flex-column justify-content-between align-items-center'
|
||
}, label)) : (React.createElement('a', { href: '#' + ROUTE, className: 'btn minimal p-4 p-xl-2 d-flex d-xl-inline-block flex-column justify-content-between align-items-center' }, label)));
|
||
return [{ children: (React.createElement(React.Fragment, null,
|
||
props.children,
|
||
node)) }];
|
||
});
|
||
}
|
||
catch { }
|
||
w.RecommendedScenesPage = RecommendedScenesPage;
|
||
} // End initializeRecommendedScenes
|
||
// Wait for dependencies and initialize
|
||
function waitAndInitialize() {
|
||
if (w.PluginApi && w.PluginApi.React) {
|
||
console.log('[RecommendedScenes] Dependencies ready, initializing...');
|
||
initializeRecommendedScenes();
|
||
}
|
||
else {
|
||
console.log('[RecommendedScenes] Waiting for PluginApi and React...');
|
||
setTimeout(waitAndInitialize, 100);
|
||
}
|
||
}
|
||
waitAndInitialize();
|
||
})();
|
||
})();
|
||
|