From 4faaae17b89d039b8f6497ec06add682be2e6a4c Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Sat, 17 Oct 2026 18:35:26 -0400 Subject: [PATCH] Add Restore Defaults, Import and Export buttons to Advanced Section Editor --- configuration/homeScreenAdvancedEditor.css | 61 ++ configuration/homeScreenAdvancedEditor.js | 671 +++++++++++++++++++ configuration/homeScreenCustomItemsEditor.js | 19 +- 3 files changed, 745 insertions(+), 6 deletions(-) diff --git a/configuration/homeScreenAdvancedEditor.css b/configuration/homeScreenAdvancedEditor.css index 9280128..ac21481 100644 --- a/configuration/homeScreenAdvancedEditor.css +++ b/configuration/homeScreenAdvancedEditor.css @@ -81,6 +81,67 @@ text-overflow: ellipsis; } +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-actions { + display: inline-flex; + align-items: center; + gap: 0.1em; + flex-shrink: 0; + margin-left: 0.15em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn { + flex-shrink: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn[hidden] { + display: none !important; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn + .material-icons { + font-size: 1.35rem; +} + +.hsae-restore-diff-table { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + font-size: 0.85rem; +} + +.hsae-restore-diff-row { + display: grid; + grid-template-columns: minmax(5.5rem, 0.9fr) 1.1fr 1.1fr; + gap: 0.5em; + padding: 0.45em 0.65em; + border-top: 1px solid rgba(255, 255, 255, 0.08); + align-items: start; +} + +.hsae-restore-diff-row:first-child { + border-top: none; +} + +.hsae-restore-diff-head { + font-weight: 600; + opacity: 0.85; + background: rgba(255, 255, 255, 0.04); +} + +.hsae-restore-diff-row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group { margin-bottom: 1.25em; } diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js index e6fea7c..b5b4304 100644 --- a/configuration/homeScreenAdvancedEditor.js +++ b/configuration/homeScreenAdvancedEditor.js @@ -3640,6 +3640,653 @@ } } + const HSAE_KEFIN_DEFAULT_GROUP_KEYS = [ + 'HOME_SECTION_GROUPS', + 'SEASONAL_SECTION_GROUPS', + 'DISCOVERY_SECTION_GROUPS' + ]; + const HSAE_SECTION_EXPORT_MODAL_ID = 'hsae-section-export'; + const HSAE_SECTION_IMPORT_MODAL_ID = 'hsae-section-import-paste'; + const HSAE_RESTORE_DEFAULTS_MODAL_ID = 'hsae-section-restore-defaults'; + const HSAE_SECTION_DIFF_KEYS = [ + 'name', 'caption', 'description', 'enabled', 'order', 'cardFormat', 'renderMode', 'ttl', + 'queries', 'items', 'externalListUrls', 'spotlightConfig', 'hideName', 'userConfigurable', + 'useRandomQuery', 'useMultiQueryPicker', 'multiQueryPickerLabel', 'useQueryNamesForSection', + 'sortBy', 'sortOrder', 'sortOrderDirection', 'itemLimit', 'minimumItems', 'flattenSeries', + 'limitBeforeSort', 'sectionCssClass', 'borderStyle', 'borderColor', 'cardTitleColor', + 'itemsLayout', 'useGaplessCards', 'cardTitleVisibility', 'useParentCard', 'cardTitlePosition', + 'cardTitleCapitalization', 'cardTitleFontFamily', 'cardTitleFontSize', 'startDate', 'endDate', + 'hidden', 'discoveryEnabled', 'type', 'category', 'livePreviewUpdates' + ]; + + function showHsaeToast(message, duration = '3') { + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(message, duration); + } + } + + function deepCloneJson(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); + } + + /** collectData writes these even when canonical omits them (runtime defaults). */ + const HSAE_IMPLICIT_SECTION_DEFAULTS = { + renderMode: 'Normal', + useGaplessCards: false, + useParentCard: false, + discoveryEnabled: false, + livePreviewUpdates: false, + hideName: false, + useRandomQuery: false, + useMultiQueryPicker: false, + useQueryNamesForSection: false, + flattenSeries: false, + limitBeforeSort: false, + hideCardTitles: false, + category: 'none' + }; + + const HSAE_GROUP_KEY_TO_TYPE = { + HOME_SECTION_GROUPS: 'home', + SEASONAL_SECTION_GROUPS: 'seasonal', + DISCOVERY_SECTION_GROUPS: 'discovery' + }; + + function getDefaultTtlMs() { + const preset = getCachePresets().find((p) => p.key === 'DEFAULT'); + return preset?.ms ?? 3600000; + } + + /** Empty / missing editor values are equivalent for restore diffs ("" vs omitted). */ + function normalizeForDiff(value) { + if (value === undefined || value === null) return null; + if (typeof value === 'string' && value.trim() === '') return null; + if (Array.isArray(value) && value.length === 0) return null; + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) { + return null; + } + return value; + } + + function omitEmptyDeep(value) { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) { + return value.map(omitEmptyDeep).filter((v) => v !== undefined); + } + if (typeof value === 'object') { + const out = {}; + Object.keys(value).sort().forEach((key) => { + const nested = omitEmptyDeep(value[key]); + if (nested === undefined) return; + if (typeof nested === 'object' && !Array.isArray(nested) && Object.keys(nested).length === 0) { + return; + } + out[key] = nested; + }); + return out; + } + return value; + } + + function stableStringify(value) { + if (value === undefined) return 'null'; + if (value === null || typeof value !== 'object') { + try { + return JSON.stringify(value); + } catch (_) { + return String(value); + } + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`; + } + + function coerceQueryOptionValue(val) { + if (typeof val === 'string' && val.trim() !== '' && /^-?\d+(\.\d+)?$/.test(val.trim())) { + const n = Number(val); + if (Number.isFinite(n)) return n; + } + return val; + } + + function normalizeQueryForCompare(query) { + if (!query || typeof query !== 'object') return null; + const clone = deepCloneJson(query) || {}; + Object.keys(clone).forEach((key) => { + if (key.startsWith('_')) delete clone[key]; + }); + if (clone.queryOptions && typeof clone.queryOptions === 'object') { + Object.keys(clone.queryOptions).forEach((key) => { + if (key.startsWith('_')) { + delete clone.queryOptions[key]; + return; + } + clone.queryOptions[key] = coerceQueryOptionValue(clone.queryOptions[key]); + }); + } + return omitEmptyDeep(clone) || {}; + } + + function normalizeQueriesForCompare(queries) { + if (!Array.isArray(queries)) return null; + return queries.map(normalizeQueryForCompare); + } + + function normalizeItemsForCompare(items) { + if (!Array.isArray(items)) return null; + return items.map((item) => { + if (!item || typeof item !== 'object') return item; + const clone = deepCloneJson(item) || {}; + const name = clone.Name || clone.name; + if (name != null) { + clone.Name = name; + delete clone.name; + } + ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl', 'imageUrl', 'cardFooter', 'cardUrl'] + .forEach((key) => { + if (typeof clone[key] === 'string' && !clone[key].trim()) delete clone[key]; + }); + return omitEmptyDeep(clone) || {}; + }); + } + + function normalizeDiffFieldValue(key, value, section) { + if (key === 'queries') { + const normalized = normalizeQueriesForCompare(Array.isArray(value) ? value : null); + if (!normalized || normalized.length === 0) return null; + return normalized; + } + if (key === 'items') { + const normalized = normalizeItemsForCompare(Array.isArray(value) ? value : null); + if (!normalized || normalized.length === 0) return null; + return normalized; + } + + let normalized = normalizeForDiff(value); + if (Object.prototype.hasOwnProperty.call(HSAE_IMPLICIT_SECTION_DEFAULTS, key)) { + const implicit = HSAE_IMPLICIT_SECTION_DEFAULTS[key]; + if (normalized === null || valuesRawEqual(normalized, implicit)) { + return null; + } + } + if (key === 'ttl') { + const defaultTtl = getDefaultTtlMs(); + if (normalized === null || valuesRawEqual(Number(normalized), defaultTtl)) { + return null; + } + } + if (key === 'type') { + const derived = deriveSectionSurfaceType(section); + if (normalized === null || valuesRawEqual(normalized, derived)) { + return derived; + } + } + if (key === 'hideName' && (normalized === false || normalized === null)) return null; + return normalized; + } + + function valuesRawEqual(a, b) { + try { + return stableStringify(a) === stableStringify(b); + } catch (_) { + return a === b; + } + } + + function deriveSectionSurfaceType(section) { + if (!section) return 'home'; + const existing = String(section.type || '').toLowerCase(); + if (existing === 'home' || existing === 'seasonal' || existing === 'discovery') return existing; + if (section.discoveryEnabled === true || section.discoveryType) return 'discovery'; + if (section.startDate?.trim?.() && section.endDate?.trim?.()) return 'seasonal'; + if (section.startDate && section.endDate) return 'seasonal'; + return 'home'; + } + + function valuesDeepEqual(a, b, key, sectionA, sectionB) { + try { + const left = key != null + ? normalizeDiffFieldValue(key, a, sectionA) + : normalizeForDiff(a); + const right = key != null + ? normalizeDiffFieldValue(key, b, sectionB) + : normalizeForDiff(b); + return stableStringify(left) === stableStringify(right); + } catch (_) { + return normalizeForDiff(a) === normalizeForDiff(b); + } + } + + function getCanonicalDefaultSection(sectionId) { + if (!sectionId) return null; + const defaults = window.KefinHomeConfig2; + if (!defaults) return null; + for (const groupKey of HSAE_KEFIN_DEFAULT_GROUP_KEYS) { + const groups = defaults[groupKey]; + if (!Array.isArray(groups)) continue; + for (const group of groups) { + const sections = group?.sections; + if (!Array.isArray(sections)) continue; + const found = sections.find((s) => s?.id === sectionId); + if (found) { + const clone = deepCloneJson(found); + const surfaceType = HSAE_GROUP_KEY_TO_TYPE[groupKey]; + if (clone && surfaceType && !clone.type) clone.type = surfaceType; + return clone; + } + } + } + return null; + } + + function isKefinDefaultSection(section, context) { + if (context?.isCustomSection === true) return false; + const id = section?.id; + if (!id) return false; + return getCanonicalDefaultSection(id) != null; + } + + function parseSectionImportPayload(text) { + if (text == null || String(text).trim() === '') return null; + let parsed; + try { + parsed = JSON.parse(String(text)); + } catch (_) { + return null; + } + if (Array.isArray(parsed)) { + if (parsed.length !== 1) return null; + parsed = parsed[0]; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const hasShape = ['name', 'queries', 'items', 'renderMode', 'id', 'cardFormat'] + .some((key) => Object.prototype.hasOwnProperty.call(parsed, key)); + if (!hasShape) return null; + return parsed; + } + + function exportCurrentSectionJson(modalInstance) { + const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent; + const state = modalInstance?._hsaeState; + if (!dialog || !state) return null; + const original = state.context?.originalSection || state.section || {}; + const section = collectData(dialog, original); + if (!section) return null; + if (state.section?.id) section.id = state.section.id; + return JSON.stringify(section, null, 2); + } + + function applyImportedSection(modalInstance, importedSection) { + const state = modalInstance?._hsaeState; + if (!state || !importedSection) return; + const current = state.section || {}; + const context = state.context || {}; + const merged = deepCloneJson(importedSection) || {}; + merged.id = current.id || merged.id; + if (current.deleted === true) merged.deleted = true; + const newState = buildEditorStateFromSection(merged, context); + modalInstance._hsaeState = newState; + const stateHost = modalInstance.dialogContainer || modalInstance.dialogContent; + if (stateHost) stateHost._hsaeState = newState; + activeEditorState = newState; + refreshEditorBody(modalInstance, { skipCollect: true }); + updateSectionNameBadge(modalInstance); + } + + function abbreviateDiffValue(value, { markChanged = false } = {}) { + if (value === undefined) return '(none)'; + if (value === null) return '(none)'; + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const base = `array (${value.length})`; + return markChanged ? `${base} · changed` : base; + } + if (typeof value === 'object') { + const keys = Object.keys(value); + return keys.length ? `object (${keys.length} keys)` : '{}'; + } + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + const s = String(value); + if (!s) return '""'; + return s.length > 48 ? `${s.slice(0, 45)}…` : s; + } + + function diffSectionAgainstDefault(current, canonical) { + const diffs = []; + if (!current || !canonical) return diffs; + for (const key of HSAE_SECTION_DIFF_KEYS) { + const hasCurrent = Object.prototype.hasOwnProperty.call(current, key); + const hasCanonical = Object.prototype.hasOwnProperty.call(canonical, key); + if (!hasCurrent && !hasCanonical) continue; + const curVal = hasCurrent ? current[key] : undefined; + const defVal = hasCanonical ? canonical[key] : undefined; + if (valuesDeepEqual(curVal, defVal, key, current, canonical)) continue; + + const normCur = normalizeDiffFieldValue(key, curVal, current); + const normDef = normalizeDiffFieldValue(key, defVal, canonical); + const bothArraysSameLen = Array.isArray(normCur) && Array.isArray(normDef) + && normCur.length === normDef.length + && normCur.length > 0; + diffs.push({ + key, + current: abbreviateDiffValue(normCur ?? curVal, { markChanged: bothArraysSameLen }), + default: abbreviateDiffValue(normDef ?? defVal, { markChanged: bothArraysSameLen }) + }); + } + return diffs; + } + + function isHttpsContextForClipboard() { + try { + if (typeof ApiClient !== 'undefined' && ApiClient._serverAddress?.startsWith?.('https://')) return true; + } catch (_) { /* ignore */ } + return typeof location !== 'undefined' && location.protocol === 'https:'; + } + + function canUseClipboardWrite() { + return !!(isHttpsContextForClipboard() + && navigator.clipboard + && typeof navigator.clipboard.writeText === 'function'); + } + + function canUseClipboardRead() { + return !!(isHttpsContextForClipboard() + && navigator.clipboard + && typeof navigator.clipboard.readText === 'function'); + } + + function showSectionExportModal(json) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open export modal'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_SECTION_EXPORT_MODAL_ID)) { + window.ModalSystem.close(HSAE_SECTION_EXPORT_MODAL_ID); + } + const canCopy = canUseClipboardWrite(); + window.ModalSystem.create({ + id: HSAE_SECTION_EXPORT_MODAL_ID, + title: 'Export Section', + content: ` +
+ Section JSON is shown below. Select and copy this text to back up or share the section. + ${canCopy ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'} +
+ + `, + footer: ` + ${canCopy ? ` + ` : ''} + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modal) => { + const textarea = modal.dialogContent?.querySelector('#hsae-export-section-textarea'); + if (textarea) { + textarea.value = json; + setTimeout(() => { + textarea.focus(); + textarea.select(); + }, 100); + } + modal.dialogFooter?.querySelector('#hsae-export-close-btn')?.addEventListener('click', () => { + window.ModalSystem.close(HSAE_SECTION_EXPORT_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsae-export-copy-btn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(json); + showHsaeToast('Section copied to clipboard'); + } catch (_) { + showHsaeToast('Error copying to clipboard', '5'); + } + }); + } + }); + } + + function showSectionImportPasteModal(onImport) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open import modal'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_SECTION_IMPORT_MODAL_ID)) { + window.ModalSystem.close(HSAE_SECTION_IMPORT_MODAL_ID); + } + window.ModalSystem.create({ + id: HSAE_SECTION_IMPORT_MODAL_ID, + title: 'Import Section', + content: ` +
+ Paste a single section JSON below, then Import. +
+ + `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modal) => { + const textarea = modal.dialogContent?.querySelector('#hsae-import-section-textarea'); + setTimeout(() => textarea?.focus(), 100); + modal.dialogFooter?.querySelector('#hsae-import-cancel-btn')?.addEventListener('click', () => { + window.ModalSystem.close(HSAE_SECTION_IMPORT_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsae-import-confirm-btn')?.addEventListener('click', () => { + const text = textarea?.value || ''; + let parsed = null; + try { + const raw = JSON.parse(String(text)); + if (Array.isArray(raw) && raw.length !== 1) { + showHsaeToast('Not a single section'); + return; + } + parsed = parseSectionImportPayload(text); + } catch (_) { + parsed = null; + } + if (!parsed) { + showHsaeToast('Invalid section JSON'); + return; + } + window.ModalSystem.close(HSAE_SECTION_IMPORT_MODAL_ID); + if (typeof onImport === 'function') onImport(parsed); + }); + } + }); + } + + function showRestoreDefaultsConfirmModal(modalInstance, diffs, onProceed) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open restore dialog'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_RESTORE_DEFAULTS_MODAL_ID)) { + window.ModalSystem.close(HSAE_RESTORE_DEFAULTS_MODAL_ID); + } + const matching = !diffs?.length; + const rowsHtml = matching + ? `
Already matching defaults
` + : ` +
+
+ FieldCurrentDefault +
+ ${diffs.map((d) => ` +
+ ${escapeHtml(d.key)} + ${escapeHtml(d.current)} + ${escapeHtml(d.default)} +
+ `).join('')} +
+ `; + const content = document.createElement('div'); + content.innerHTML = ` +
+ Restoring defaults will override the current editor values for this section with the canonical Kefin defaults. + Enabled status and order are kept. You still need to Save to persist. +
+ ${rowsHtml} + `; + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + window.ModalSystem.create({ + id: HSAE_RESTORE_DEFAULTS_MODAL_ID, + title: 'Restore Defaults', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + fixedSize: false, + dialogStyle: { + width: 'min(36rem, calc(100vw - 2rem))', + maxWidth: '36rem' + }, + onOpen: (modal) => { + modal.dialogFooter?.querySelector('#hsae-restore-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(HSAE_RESTORE_DEFAULTS_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsae-restore-proceed')?.addEventListener('click', () => { + if (matching) return; + window.ModalSystem.close(HSAE_RESTORE_DEFAULTS_MODAL_ID); + if (typeof onProceed === 'function') onProceed(); + }); + } + }); + } + + async function handleHeaderExport(modalInstance) { + const json = exportCurrentSectionJson(modalInstance); + if (!json) { + showHsaeToast('Unable to export section'); + return; + } + try { + if (canUseClipboardWrite()) { + await navigator.clipboard.writeText(json); + showHsaeToast('Section copied to clipboard'); + return; + } + } catch (_) { /* fall through to modal */ } + showSectionExportModal(json); + } + + async function handleHeaderImport(modalInstance) { + const apply = (section) => { + applyImportedSection(modalInstance, section); + showHsaeToast('Section imported'); + }; + if (canUseClipboardRead()) { + try { + const text = await navigator.clipboard.readText(); + let parsed = null; + try { + const raw = JSON.parse(String(text)); + if (Array.isArray(raw) && raw.length !== 1) { + showHsaeToast('Not a single section'); + return; + } + parsed = parseSectionImportPayload(text); + } catch (_) { + parsed = null; + } + if (parsed) { + apply(parsed); + return; + } + } catch (_) { /* paste modal */ } + } + showSectionImportPasteModal(apply); + } + + function handleHeaderRestoreDefaults(modalInstance) { + const state = modalInstance?._hsaeState; + const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent; + if (!state || !dialog) return; + const sectionId = state.section?.id; + const canonical = getCanonicalDefaultSection(sectionId); + if (!canonical) { + showHsaeToast('No default found for this section'); + syncHeaderActions(modalInstance); + return; + } + const original = state.context?.originalSection || state.section || {}; + const current = collectData(dialog, original) || deepCloneJson(state.section) || {}; + if (state.section?.id) current.id = state.section.id; + const diffs = diffSectionAgainstDefault(current, canonical); + showRestoreDefaultsConfirmModal(modalInstance, diffs, () => { + const merged = deepCloneJson(canonical) || {}; + merged.id = current.id || sectionId; + if (Object.prototype.hasOwnProperty.call(current, 'enabled')) merged.enabled = current.enabled; + if (Object.prototype.hasOwnProperty.call(current, 'order')) merged.order = current.order; + if (current.deleted === true) merged.deleted = true; + applyImportedSection(modalInstance, merged); + showHsaeToast('Defaults restored'); + }); + } + + function syncHeaderActions(modalInstance) { + const actions = modalInstance?.dialogHeader?.querySelector('.hsae-header-actions'); + if (!actions) return; + const state = modalInstance._hsaeState; + const section = state?.section || {}; + const context = state?.context || {}; + const isDefault = isKefinDefaultSection(section, context); + const importBtn = actions.querySelector('[data-hsae-header-action="import"]'); + const exportBtn = actions.querySelector('[data-hsae-header-action="export"]'); + const restoreBtn = actions.querySelector('[data-hsae-header-action="restore"]'); + if (importBtn) importBtn.hidden = isDefault; + if (exportBtn) exportBtn.hidden = isDefault; + if (restoreBtn) { + restoreBtn.hidden = !isDefault; + if (isDefault && !getCanonicalDefaultSection(section.id)) { + restoreBtn.hidden = true; + } + } + } + + function createHeaderActionButton(action, title, icon) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.setAttribute('is', 'paper-icon-button-light'); + btn.className = 'paper-icon-button-light autoSize hsae-header-action-btn'; + btn.title = title; + btn.setAttribute('aria-label', title); + btn.dataset.hsaeHeaderAction = action; + btn.innerHTML = `${escapeHtml(icon)}`; + return btn; + } + function setupModalHeader(modalInstance, options = {}) { if (!modalInstance?.dialogHeader) return; if (modalInstance.dialogHeader.dataset.hsaeHeaderSetup === 'true') { @@ -3648,6 +4295,7 @@ titleEl.textContent = options.title; } updateSectionNameBadge(modalInstance); + syncHeaderActions(modalInstance); return; } modalInstance.dialogHeader.dataset.hsaeHeaderSetup = 'true'; @@ -3674,11 +4322,34 @@ headerMain.appendChild(titleWrap); + const actions = document.createElement('div'); + actions.className = 'hsae-header-actions'; + const importBtn = createHeaderActionButton('import', 'Import', 'content_paste'); + const exportBtn = createHeaderActionButton('export', 'Export', 'content_copy'); + const restoreBtn = createHeaderActionButton('restore', 'Restore Defaults', 'settings_backup_restore'); + actions.appendChild(importBtn); + actions.appendChild(exportBtn); + actions.appendChild(restoreBtn); + headerMain.appendChild(actions); + + actions.addEventListener('click', (e) => { + const btn = e.target.closest('[data-hsae-header-action]'); + if (!btn || !actions.contains(btn) || btn.hidden) return; + e.preventDefault(); + e.stopPropagation(); + const action = btn.dataset.hsaeHeaderAction; + if (action === 'export') handleHeaderExport(modalInstance); + else if (action === 'import') handleHeaderImport(modalInstance); + else if (action === 'restore') handleHeaderRestoreDefaults(modalInstance); + }); + if (closeBtn) { modalInstance.dialogHeader.insertBefore(headerMain, closeBtn); } else { modalInstance.dialogHeader.prepend(headerMain); } + + syncHeaderActions(modalInstance); } function attachListeners(modalInstance, context, callbacks = {}) { diff --git a/configuration/homeScreenCustomItemsEditor.js b/configuration/homeScreenCustomItemsEditor.js index 68ab8c3..9f5f539 100644 --- a/configuration/homeScreenCustomItemsEditor.js +++ b/configuration/homeScreenCustomItemsEditor.js @@ -151,7 +151,11 @@ } function hasValidCustomItems(items) { - return (items || []).some(item => item.name?.trim() && item.posterUrl?.trim()); + return (items || []).some((item) => { + const name = item.name?.trim(); + if (!name) return false; + return !!(item.posterUrl?.trim() || item.cardUrl?.trim()); + }); } function hasCustomItemContent(item) { @@ -165,7 +169,9 @@ function buildStaticItemFromDraft(draft) { const name = draft.name?.trim(); const posterUrl = draft.posterUrl?.trim(); - if (!name || !posterUrl) return null; + const cardUrl = draft.cardUrl?.trim(); + // Name + poster, or Name + link (e.g. liveTv-browse button tiles) + if (!name || (!posterUrl && !cardUrl)) return null; const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl']; const provided = {}; @@ -174,9 +180,10 @@ if (value) provided[key] = value; }); - const item = { Name: name, Type: draft.type || 'Folder' }; + const item = { Name: name }; + if (draft.type) item.Type = draft.type; + else if (posterUrl) item.Type = 'Folder'; if (draft.id) item.Id = draft.id; - const cardUrl = draft.cardUrl?.trim(); if (cardUrl) item.cardUrl = cardUrl; const cardFooter = draft.cardFooter?.trim(); if (cardFooter) item.cardFooter = cardFooter; @@ -185,7 +192,7 @@ const keys = Object.keys(provided); if (keys.length === 1 && provided.posterUrl) { item.imageUrl = provided.posterUrl; - } else { + } else if (keys.length) { Object.assign(item, provided); } return item; @@ -397,7 +404,7 @@ ${opts.intro ? `
-

Name and poster required. Link and other images optional.

+

Name required; poster or link recommended. Other images optional.

` : ''}
${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove, opts, items.length)).join('')}