// Shared Custom Items editor (wizard + advanced section editor) (function() { 'use strict'; const CUSTOM_IMAGE_FIELDS = [ { key: 'posterUrl', label: 'Poster URL', required: true }, { key: 'thumbUrl', label: 'Thumb URL' }, { key: 'backdropUrl', label: 'Backdrop URL' }, { key: 'squareUrl', label: 'Square URL' }, { key: 'bannerUrl', label: 'Banner URL' }, { key: 'logoUrl', label: 'Logo URL' } ]; const CUSTOM_POSTER_FIELD = CUSTOM_IMAGE_FIELDS[0]; const CUSTOM_ADDITIONAL_IMAGE_FIELDS = CUSTOM_IMAGE_FIELDS.slice(1); function escapeHtml(str) { if (str == null) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderMaterialIcon(name, className = '') { return ``; } function getOptions(options = {}) { return { idPrefix: options.idPrefix || 'hsse-custom', actionAttr: options.actionAttr || 'data-hsse-action', actions: { openEditor: 'wizard-open-custom-item-editor', closeEditor: 'wizard-close-custom-item-editor', addItem: 'wizard-add-custom-item', removeItem: 'wizard-remove-custom-item', toggleImages: 'wizard-toggle-custom-images', toggleHide: 'wizard-toggle-custom-item-hide', moveLeft: 'wizard-move-custom-item-left', moveRight: 'wizard-move-custom-item-right', ...(options.actions || {}) }, intro: options.intro !== false, // Default Kefin sections pass false — hide only, never delete allowRemove: options.allowRemove !== false }; } function actionAttr(options, action) { return `${options.actionAttr}="${escapeHtml(options.actions[action])}"`; } function fieldId(options, index, key) { return `${options.idPrefix}-${index}-${key}`; } function createBlankCustomItem() { return { name: '', cardFooter: '', cardUrl: '', posterUrl: '', thumbUrl: '', backdropUrl: '', squareUrl: '', bannerUrl: '', logoUrl: '', hidden: false }; } function staticItemToDraft(item) { if (!item) return createBlankCustomItem(); const draft = createBlankCustomItem(); draft.name = item.Name || item.name || ''; draft.cardFooter = item.cardFooter || ''; draft.cardUrl = item.cardUrl || ''; draft.posterUrl = item.posterUrl || item.imageUrl || ''; draft.thumbUrl = item.thumbUrl || ''; draft.backdropUrl = item.backdropUrl || ''; draft.squareUrl = item.squareUrl || ''; draft.bannerUrl = item.bannerUrl || ''; draft.logoUrl = item.logoUrl || ''; draft.hidden = item.hidden === true; if (item.Id || item.id) draft.id = item.Id || item.id; if (item.Type) draft.type = item.Type; return draft; } function staticItemsToDrafts(items) { const list = (items || []).map(staticItemToDraft).filter(Boolean); return list.length ? list : [createBlankCustomItem()]; } function ensureCustomItemsInitialized(state) { if (!state.customItems?.length) { state.customItems = [createBlankCustomItem()]; } } function hasAdditionalCustomImages(item) { return CUSTOM_ADDITIONAL_IMAGE_FIELDS.some(field => item[field.key]?.trim()); } function isCustomItemAdditionalExpanded(state, index, item) { const explicit = state.customItemAdditionalExpanded?.[index]; if (explicit === true) return true; if (explicit === false) return false; return hasAdditionalCustomImages(item); } function reindexCustomItemAdditionalExpanded(state, removedIndex) { if (!state.customItemAdditionalExpanded) return; const next = {}; Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => { const i = parseInt(key, 10); if (Number.isNaN(i) || value === undefined) return; if (i < removedIndex) next[i] = value; else if (i > removedIndex) next[i - 1] = value; }); state.customItemAdditionalExpanded = next; } function swapCustomItemExpanded(state, fromIndex, toIndex) { if (!state.customItemAdditionalExpanded) return; const a = state.customItemAdditionalExpanded[fromIndex]; const b = state.customItemAdditionalExpanded[toIndex]; if (a === undefined && b === undefined) return; if (b === undefined) delete state.customItemAdditionalExpanded[fromIndex]; else state.customItemAdditionalExpanded[fromIndex] = b; if (a === undefined) delete state.customItemAdditionalExpanded[toIndex]; else state.customItemAdditionalExpanded[toIndex] = a; } function moveCustomItemExpanded(state, fromIndex, toIndex) { if (!state.customItemAdditionalExpanded || fromIndex === toIndex) return; const next = {}; const moved = state.customItemAdditionalExpanded[fromIndex]; Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => { const i = parseInt(key, 10); if (Number.isNaN(i) || i === fromIndex) return; let dest = i; if (fromIndex < toIndex && i > fromIndex && i <= toIndex) dest = i - 1; else if (fromIndex > toIndex && i >= toIndex && i < fromIndex) dest = i + 1; next[dest] = value; }); if (moved !== undefined) next[toIndex] = moved; state.customItemAdditionalExpanded = next; } function hasValidCustomItems(items) { return (items || []).some(item => item.name?.trim() && item.posterUrl?.trim()); } function hasCustomItemContent(item) { if (!item) return false; return [ item.name, item.cardFooter, item.cardUrl, item.posterUrl, item.thumbUrl, item.backdropUrl, item.squareUrl, item.bannerUrl, item.logoUrl ].some(value => value?.trim()); } function buildStaticItemFromDraft(draft) { const name = draft.name?.trim(); const posterUrl = draft.posterUrl?.trim(); if (!name || !posterUrl) return null; const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl']; const provided = {}; imageKeys.forEach(key => { const value = draft[key]?.trim(); if (value) provided[key] = value; }); const item = { Name: name, Type: draft.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; if (draft.hidden === true) item.hidden = true; const keys = Object.keys(provided); if (keys.length === 1 && provided.posterUrl) { item.imageUrl = provided.posterUrl; } else { Object.assign(item, provided); } return item; } function collectCustomItemFromForm(root, state, index, options) { if (!root || !state.customItems?.[index]) return; const opts = getOptions(options); const item = state.customItems[index]; const nameEl = root.querySelector(`#${fieldId(opts, index, 'name')}`); const cardFooterEl = root.querySelector(`#${fieldId(opts, index, 'cardFooter')}`); const cardUrlEl = root.querySelector(`#${fieldId(opts, index, 'cardUrl')}`); if (nameEl) item.name = nameEl.value; if (cardFooterEl) item.cardFooter = cardFooterEl.value; if (cardUrlEl) item.cardUrl = cardUrlEl.value; CUSTOM_IMAGE_FIELDS.forEach(field => { const el = root.querySelector(`#${fieldId(opts, index, field.key)}`); if (el) item[field.key] = el.value; }); } function collectCustomItemsFromForm(root, state, options) { if (!root || !state.customItems) return; const editorIndex = state.customItemEditorIndex; if (editorIndex != null && !Number.isNaN(editorIndex)) { collectCustomItemFromForm(root, state, editorIndex, options); } } function resolvePreviewUrl(url) { const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; const serverId = (typeof ApiClient !== 'undefined' && typeof ApiClient.serverId === 'function') ? ApiClient.serverId() : ''; return String(url || '') .replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot) .replace(/\$\{serverId\}/g, serverId) .trim(); } function updateCustomImagePreview(imgEl, url) { if (!imgEl) return; const trimmed = resolvePreviewUrl(url); if (!trimmed) { imgEl.removeAttribute('src'); imgEl.style.display = 'none'; imgEl.dataset.previewState = 'empty'; return; } imgEl.style.display = ''; imgEl.dataset.previewState = 'loading'; imgEl.onerror = () => { imgEl.style.display = 'none'; imgEl.dataset.previewState = 'error'; }; imgEl.onload = () => { imgEl.style.display = ''; imgEl.dataset.previewState = 'loaded'; }; imgEl.src = trimmed; } function refreshPreviews(root, state, options) { if (!root || !state.customItems) return; state.customItems.forEach((item, index) => { const tileImg = root.querySelector(`img[data-tile-index="${index}"]`); updateCustomImagePreview(tileImg, item.posterUrl); }); root.querySelectorAll('[data-hsse-custom-preview], [data-hsae-custom-preview]').forEach(input => { const img = root.querySelector(`img[data-preview-for="${input.id}"]`); updateCustomImagePreview(img, input.value); }); } function buildCustomImageFieldHTML(index, field, value, options, fieldOptions = {}) { const opts = getOptions(options); const { isPoster = false } = fieldOptions; const requiredMark = field.required ? ' *' : ''; const inputId = fieldId(opts, index, field.key); const previewAttr = isPoster ? ' data-hsse-custom-preview="true" data-hsae-custom-preview="true"' : ''; const posterHint = isPoster ? `

You can place images in your "Jellyfin\\Server\\jellyfin-web" folder and use the "/web/image_name.png" path to access them.

` : ''; return `
${posterHint}
`; } function renderCustomItemEditorHTML(index, item, state, options) { const opts = getOptions(options); const additionalExpanded = isCustomItemAdditionalExpanded(state, index, item); const posterInputId = fieldId(opts, index, 'posterUrl'); return `
${buildCustomImageFieldHTML(index, CUSTOM_POSTER_FIELD, item.posterUrl || '', opts, { isPoster: true })}
${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '', opts)).join('')}
`; } function renderCustomItemTileHTML(index, item, canRemove, options, itemCount) { const opts = getOptions(options); const label = item.name?.trim() || `Item ${index + 1}`; const isHidden = item.hidden === true; const canMoveLeft = index > 0; const canMoveRight = index < itemCount - 1; const hideIcon = isHidden ? 'visibility_off' : 'visibility'; const hideLabel = isHidden ? `Show ${label}` : `Hide ${label}`; const removeTitle = canRemove ? `Remove ${label}` : (opts.allowRemove === false ? 'Default section items cannot be deleted — hide instead' : `Remove ${label}`); return `
${escapeHtml(label)}
`; } function renderCustomItemEditorPanelHTML(state, options) { const opts = getOptions(options); const editorIndex = state.customItemEditorIndex; if (editorIndex == null || Number.isNaN(editorIndex)) return ''; const item = state.customItems?.[editorIndex]; if (!item) return ''; const title = item.name?.trim() ? `Edit: ${item.name.trim()}` : `Edit Item ${editorIndex + 1}`; return `
`; } function renderCustomItemsHTML(state, options) { const opts = getOptions(options); ensureCustomItemsInitialized(state); const items = state.customItems; const canRemove = opts.allowRemove !== false && items.length > 1; return `
${opts.intro ? `

Name and poster required. Link and other images optional.

` : ''}
${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove, opts, items.length)).join('')}
${renderCustomItemEditorPanelHTML(state, opts)}
`; } function reindexTileDom(tile, index, itemCount) { if (!tile) return; tile.dataset.customItemIndex = String(index); tile.querySelectorAll('[data-item-index]').forEach((el) => { el.dataset.itemIndex = String(index); }); const tileImg = tile.querySelector('img[data-tile-index]'); if (tileImg) tileImg.dataset.tileIndex = String(index); // Action order: left | hide | delete | right const actions = tile.querySelectorAll('.hsse-custom-item-actions .hsse-custom-item-action'); if (actions.length >= 4) { actions[0].disabled = index <= 0; actions[3].disabled = index >= itemCount - 1; } } function swapAdjacentCustomItemTiles(root, fromIndex, toIndex) { if (!root || fromIndex === toIndex) return false; const grid = root.querySelector('.hsse-custom-items-grid'); if (!grid) return false; const a = grid.querySelector(`[data-custom-item-index="${fromIndex}"]`); const b = grid.querySelector(`[data-custom-item-index="${toIndex}"]`); if (!a || !b) return false; const aNext = a.nextSibling; const bNext = b.nextSibling; if (aNext === b) { grid.insertBefore(b, a); } else if (bNext === a) { grid.insertBefore(a, b); } else { grid.insertBefore(a, bNext); grid.insertBefore(b, aNext); } const itemCount = grid.querySelectorAll('[data-custom-item-index]').length; reindexTileDom(a, toIndex, itemCount); reindexTileDom(b, fromIndex, itemCount); return true; } function handleAction(action, el, root, state, options, onRefresh) { const opts = getOptions(options); const actions = opts.actions; switch (action) { case actions.openEditor: { const openIndex = parseInt(el.dataset.itemIndex, 10); if (Number.isNaN(openIndex)) break; if (state.customItemEditorIndex != null && state.customItemEditorIndex !== openIndex) { collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts); } state.customItemEditorIndex = openIndex; if (typeof onRefresh === 'function') onRefresh(root); break; } case actions.closeEditor: { const discard = el.dataset.discard === 'true'; if (!discard && state.customItemEditorIndex != null) { collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts); } state.customItemEditorIndex = null; if (typeof onRefresh === 'function') onRefresh(root); break; } case actions.addItem: { collectCustomItemsFromForm(root, state, opts); state.customItems.push(createBlankCustomItem()); state.customItemEditorIndex = state.customItems.length - 1; if (typeof onRefresh === 'function') onRefresh(root); break; } case actions.removeItem: { if (opts.allowRemove === false) break; const removeIndex = parseInt(el.dataset.itemIndex, 10); if (state.customItems.length <= 1 || Number.isNaN(removeIndex)) break; if (state.customItemEditorIndex === removeIndex) { collectCustomItemFromForm(root, state, removeIndex, opts); } else { collectCustomItemsFromForm(root, state, opts); } const itemToRemove = state.customItems[removeIndex]; if (hasCustomItemContent(itemToRemove) && !confirm('Are you sure you want to remove this item?')) { break; } state.customItems.splice(removeIndex, 1); reindexCustomItemAdditionalExpanded(state, removeIndex); if (state.customItemEditorIndex === removeIndex) { state.customItemEditorIndex = null; } else if (state.customItemEditorIndex > removeIndex) { state.customItemEditorIndex -= 1; } if (typeof onRefresh === 'function') onRefresh(root); break; } case actions.toggleImages: { const toggleIndex = parseInt(el.dataset.itemIndex, 10); if (Number.isNaN(toggleIndex)) break; collectCustomItemFromForm(root, state, toggleIndex, opts); if (!state.customItemAdditionalExpanded) state.customItemAdditionalExpanded = {}; const item = state.customItems[toggleIndex]; const currentlyExpanded = isCustomItemAdditionalExpanded(state, toggleIndex, item); state.customItemAdditionalExpanded[toggleIndex] = !currentlyExpanded; if (typeof onRefresh === 'function') onRefresh(root); break; } case actions.toggleHide: { const hideIndex = parseInt(el.dataset.itemIndex, 10); if (Number.isNaN(hideIndex) || !state.customItems?.[hideIndex]) break; collectCustomItemsFromForm(root, state, opts); const item = state.customItems[hideIndex]; item.hidden = !item.hidden; // Remove, then reinsert at the target slot among remaining items state.customItems.splice(hideIndex, 1); let insertAt; if (item.hidden === true) { // Hidden items stay at the end insertAt = state.customItems.length; } else { // Unhide: last active item, immediately before the first still-hidden item insertAt = state.customItems.findIndex((it) => it.hidden === true); if (insertAt < 0) insertAt = state.customItems.length; } state.customItems.splice(insertAt, 0, item); if (insertAt !== hideIndex) { moveCustomItemExpanded(state, hideIndex, insertAt); if (state.customItemEditorIndex === hideIndex) { state.customItemEditorIndex = insertAt; } else if (state.customItemEditorIndex != null) { if (hideIndex < state.customItemEditorIndex && insertAt >= state.customItemEditorIndex) { state.customItemEditorIndex -= 1; } else if (hideIndex > state.customItemEditorIndex && insertAt <= state.customItemEditorIndex) { state.customItemEditorIndex += 1; } } } if (typeof onRefresh === 'function') onRefresh(root); break; } case actions.moveLeft: { const leftIndex = parseInt(el.dataset.itemIndex, 10); if (Number.isNaN(leftIndex) || leftIndex <= 0) break; collectCustomItemsFromForm(root, state, opts); const swapWith = leftIndex - 1; const tmp = state.customItems[leftIndex]; state.customItems[leftIndex] = state.customItems[swapWith]; state.customItems[swapWith] = tmp; swapCustomItemExpanded(state, leftIndex, swapWith); if (state.customItemEditorIndex === leftIndex) state.customItemEditorIndex = swapWith; else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = leftIndex; if (!swapAdjacentCustomItemTiles(root, leftIndex, swapWith) && typeof onRefresh === 'function') { onRefresh(root); } break; } case actions.moveRight: { const rightIndex = parseInt(el.dataset.itemIndex, 10); if (Number.isNaN(rightIndex) || rightIndex >= state.customItems.length - 1) break; collectCustomItemsFromForm(root, state, opts); const swapWith = rightIndex + 1; const tmp = state.customItems[rightIndex]; state.customItems[rightIndex] = state.customItems[swapWith]; state.customItems[swapWith] = tmp; swapCustomItemExpanded(state, rightIndex, swapWith); if (state.customItemEditorIndex === rightIndex) state.customItemEditorIndex = swapWith; else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = rightIndex; if (!swapAdjacentCustomItemTiles(root, rightIndex, swapWith) && typeof onRefresh === 'function') { onRefresh(root); } break; } default: break; } } window.KefinHomeScreenCustomItemsEditor = { CUSTOM_IMAGE_FIELDS, createBlankCustomItem, staticItemToDraft, staticItemsToDrafts, ensureCustomItemsInitialized, hasValidCustomItems, hasCustomItemContent, buildStaticItemFromDraft, collectCustomItemsFromForm, collectCustomItemFromForm, renderCustomItemsHTML, refreshPreviews, handleAction, reindexCustomItemAdditionalExpanded, getCustomItemsOptions: (overrides = {}) => getOptions({ idPrefix: 'hsae-custom', actionAttr: 'data-hsae-action', actions: { openEditor: 'hsae-open-custom-item-editor', closeEditor: 'hsae-close-custom-item-editor', addItem: 'hsae-add-custom-item', removeItem: 'hsae-remove-custom-item', toggleImages: 'hsae-toggle-custom-images', toggleHide: 'hsae-toggle-custom-item-hide', moveLeft: 'hsae-move-custom-item-left', moveRight: 'hsae-move-custom-item-right' }, ...overrides }) }; })();