mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Add Restore Defaults, Import and Export buttons to Advanced Section Editor
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<div class="listItemBodyText" style="margin-bottom: 1em;">
|
||||
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.'}
|
||||
</div>
|
||||
<textarea id="hsae-export-section-textarea" class="fld emby-textarea" rows="16"
|
||||
readonly
|
||||
style="width: 100%; font-family: monospace; font-size: 0.9em; line-height: 1.5;"></textarea>
|
||||
`,
|
||||
footer: `
|
||||
${canCopy ? `
|
||||
<button type="button" class="emby-button raised button-submit" id="hsae-export-copy-btn"
|
||||
style="padding: 0.75em 2em; font-size: 1em; font-weight: 500; margin-right: 1em;">
|
||||
<span>Copy</span>
|
||||
</button>` : ''}
|
||||
<button type="button" class="emby-button raised" id="hsae-export-close-btn"
|
||||
style="padding: 0.75em 2em; font-size: 1em;">
|
||||
<span>Close</span>
|
||||
</button>
|
||||
`,
|
||||
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: `
|
||||
<div class="listItemBodyText" style="margin-bottom: 1em;">
|
||||
Paste a single section JSON below, then Import.
|
||||
</div>
|
||||
<textarea id="hsae-import-section-textarea" class="fld emby-textarea" rows="14"
|
||||
placeholder="{ "name": "My Section", ... }"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.9em; line-height: 1.5;"></textarea>
|
||||
`,
|
||||
footer: `
|
||||
<button type="button" class="emby-button raised" id="hsae-import-cancel-btn"
|
||||
style="padding: 0.75em 2em; font-size: 1em; margin-right: 1em;">
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="emby-button raised button-submit" id="hsae-import-confirm-btn"
|
||||
style="padding: 0.75em 2em; font-size: 1em; font-weight: 500;">
|
||||
<span>Import</span>
|
||||
</button>
|
||||
`,
|
||||
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
|
||||
? `<div class="listItemBodyText" style="margin-top: 0.75em;">Already matching defaults</div>`
|
||||
: `
|
||||
<div class="hsae-restore-diff-table" style="margin-top: 0.85em; max-height: 16rem; overflow: auto;">
|
||||
<div class="hsae-restore-diff-row hsae-restore-diff-head">
|
||||
<span>Field</span><span>Current</span><span>Default</span>
|
||||
</div>
|
||||
${diffs.map((d) => `
|
||||
<div class="hsae-restore-diff-row">
|
||||
<span title="${escapeHtml(d.key)}">${escapeHtml(d.key)}</span>
|
||||
<span title="${escapeHtml(d.current)}">${escapeHtml(d.current)}</span>
|
||||
<span title="${escapeHtml(d.default)}">${escapeHtml(d.default)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
const content = document.createElement('div');
|
||||
content.innerHTML = `
|
||||
<div class="listItemBodyText" style="line-height: 1.5;">
|
||||
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.
|
||||
</div>
|
||||
${rowsHtml}
|
||||
`;
|
||||
const footer = document.createElement('div');
|
||||
footer.style.display = 'flex';
|
||||
footer.style.gap = '0.5em';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.innerHTML = `
|
||||
<button type="button" class="emby-button raised" id="hsae-restore-cancel">Cancel</button>
|
||||
<button type="button" class="emby-button raised block button-submit" id="hsae-restore-proceed"
|
||||
${matching ? 'disabled' : ''}>Restore Defaults</button>
|
||||
`;
|
||||
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 = `<span class="material-icons">${escapeHtml(icon)}</span>`;
|
||||
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 = {}) {
|
||||
|
||||
@@ -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 ? `
|
||||
<div class="hsse-section-block hsse-custom-items-intro">
|
||||
<div class="listItemBodyText hsse-section-label">Custom Items</div>
|
||||
<p class="listItemBodyText secondary hsse-field-hint">Name and poster required. Link and other images optional.</p>
|
||||
<p class="listItemBodyText secondary hsse-field-hint">Name required; poster or link recommended. Other images optional.</p>
|
||||
</div>` : ''}
|
||||
<div class="hsse-custom-items-grid">
|
||||
${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove, opts, items.length)).join('')}
|
||||
|
||||
Reference in New Issue
Block a user