From 1433ec1495556dfd03da236d29ff42462b4f882b Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 28 May 2026 18:19:13 -0700 Subject: [PATCH] various ui improvements, including a new sequence nav component and a new kabbalah detail view --- app.js | 76 ++- app/card-images.js | 35 +- app/data-service.js | 33 +- app/styles.css | 162 +++++- app/ui-calendar.js | 71 +++ app/ui-holidays.js | 68 +++ app/ui-kabbalah-detail.js | 64 ++- app/ui-kabbalah.js | 1024 +++++++++++++++++++++++++++++++++++-- app/ui-navigation.js | 52 +- app/ui-planets.js | 77 ++- app/ui-section-state.js | 17 +- app/ui-sequence-nav.js | 189 +++++++ app/ui-settings.js | 33 +- app/ui-tarot-detail.js | 71 +++ app/ui-tarot-lightbox.js | 32 +- app/ui-tarot.js | 239 ++++++++- index.html | 151 +++++- 17 files changed, 2274 insertions(+), 120 deletions(-) create mode 100644 app/ui-sequence-nav.js diff --git a/app.js b/app.js index b468527..dc199f2 100644 --- a/app.js +++ b/app.js @@ -50,6 +50,9 @@ const cyclesSectionEl = document.getElementById("cycles-section"); const elementsSectionEl = document.getElementById("elements-section"); const ichingSectionEl = document.getElementById("iching-section"); const kabbalahSectionEl = document.getElementById("kabbalah-section"); +const kabbalahWorldsSectionEl = document.getElementById("kabbalah-worlds-section"); +const kabbalahPathsSectionEl = document.getElementById("kabbalah-paths-section"); +const kabbalahCrossSectionEl = document.getElementById("kabbalah-cross-section"); const kabbalahTreeSectionEl = document.getElementById("kabbalah-tree-section"); const cubeSectionEl = document.getElementById("cube-section"); const alphabetSectionEl = document.getElementById("alphabet-section"); @@ -78,6 +81,10 @@ const openCyclesEl = document.getElementById("open-cycles"); const openElementsEl = document.getElementById("open-elements"); const openIChingEl = document.getElementById("open-iching"); const openKabbalahEl = document.getElementById("open-kabbalah"); +const openKabbalahSephirotEl = document.getElementById("open-kabbalah-sephirot"); +const openKabbalahWorldsEl = document.getElementById("open-kabbalah-worlds"); +const openKabbalahPathsEl = document.getElementById("open-kabbalah-paths"); +const openKabbalahCrossEl = document.getElementById("open-kabbalah-cross"); const openKabbalahTreeEl = document.getElementById("open-kabbalah-tree"); const openKabbalahCubeEl = document.getElementById("open-kabbalah-cube"); const openAlphabetEl = document.getElementById("open-alphabet"); @@ -327,15 +334,22 @@ function getConnectionSettings() { }; } -function syncConnectionGateInputs() { - const connectionSettings = getConnectionSettings(); +function normalizeConnectionSettingsInput(connectionSettings = null) { + return { + apiBaseUrl: String(connectionSettings?.apiBaseUrl || "").trim().replace(/\/+$/, ""), + apiKey: String(connectionSettings?.apiKey || "").trim() + }; +} + +function syncConnectionGateInputs(connectionSettings = getConnectionSettings()) { + const normalizedConnectionSettings = normalizeConnectionSettingsInput(connectionSettings); if (connectionGateBaseUrlEl) { - connectionGateBaseUrlEl.value = String(connectionSettings.apiBaseUrl || ""); + connectionGateBaseUrlEl.value = normalizedConnectionSettings.apiBaseUrl; } if (connectionGateApiKeyEl) { - connectionGateApiKeyEl.value = String(connectionSettings.apiKey || ""); + connectionGateApiKeyEl.value = normalizedConnectionSettings.apiKey; } } @@ -352,8 +366,8 @@ function setConnectionGateStatus(text, tone = "default") { } } -function showConnectionGate(message, tone = "default") { - syncConnectionGateInputs(); +function showConnectionGate(message, tone = "default", connectionSettings = null) { + syncConnectionGateInputs(connectionSettings || getConnectionSettings()); if (connectionGateEl) { connectionGateEl.hidden = false; } @@ -369,33 +383,49 @@ function hideConnectionGate() { } function getConnectionSettingsFromGate() { - return { + return normalizeConnectionSettingsInput({ apiBaseUrl: String(connectionGateBaseUrlEl?.value || "").trim(), apiKey: String(connectionGateApiKeyEl?.value || "").trim() - }; + }); +} + +function warmAllDeckImagesInBackground() { + const activeDeckId = String(window.TarotCardImages?.getActiveDeck?.() || "").trim(); + + window.TarotCardImages?.scheduleAllDeckImagePreload?.({ + startDeckId: activeDeckId, + background: true, + includeThumbnails: true + }); } async function ensureConnectedApp(nextConnectionSettings = null) { - if (nextConnectionSettings) { - window.TarotAppConfig?.updateConnectionSettings?.(nextConnectionSettings); + const configuredConnection = nextConnectionSettings + ? normalizeConnectionSettingsInput(nextConnectionSettings) + : getConnectionSettings(); + + if (!nextConnectionSettings) { + syncConnectionGateInputs(configuredConnection); } - syncConnectionGateInputs(); - - const configuredConnection = getConnectionSettings(); if (!configuredConnection.apiBaseUrl) { - showConnectionGate("Enter an API Base URL to load TaroTime.", "error"); + showConnectionGate("Enter an API Base URL to load TaroTime.", "error", configuredConnection); return false; } - showConnectionGate("Connecting to the API...", "pending"); + showConnectionGate("Connecting to the API...", "pending", configuredConnection); - const probeResult = await window.TarotDataService?.probeConnection?.(); + const probeResult = await window.TarotDataService?.probeConnection?.(configuredConnection); if (!probeResult?.ok) { - showConnectionGate(probeResult?.message || "Unable to reach the API.", "error"); + showConnectionGate(probeResult?.message || "Unable to reach the API.", "error", configuredConnection); return false; } + if (nextConnectionSettings) { + window.TarotAppConfig?.updateConnectionSettings?.(configuredConnection); + syncConnectionGateInputs(configuredConnection); + } + hideConnectionGate(); if (!hasRenderedConnectedShell) { sectionStateUi.setActiveSection?.("home"); @@ -405,6 +435,7 @@ async function ensureConnectedApp(nextConnectionSettings = null) { setConnectionGateStatus("Connected.", "success"); setStatus(`Connected to ${configuredConnection.apiBaseUrl}.`); await appRuntime.renderWeek?.(); + warmAllDeckImagesInBackground(); return true; } @@ -491,6 +522,9 @@ sectionStateUi.init?.({ elementsSectionEl, ichingSectionEl, kabbalahSectionEl, + kabbalahWorldsSectionEl, + kabbalahPathsSectionEl, + kabbalahCrossSectionEl, kabbalahTreeSectionEl, cubeSectionEl, alphabetSectionEl, @@ -519,6 +553,10 @@ sectionStateUi.init?.({ openElementsEl, openIChingEl, openKabbalahEl, + openKabbalahSephirotEl, + openKabbalahWorldsEl, + openKabbalahPathsEl, + openKabbalahCrossEl, openKabbalahTreeEl, openKabbalahCubeEl, openAlphabetEl, @@ -626,6 +664,10 @@ navigationUi.init?.({ openElementsEl, openIChingEl, openKabbalahEl, + openKabbalahSephirotEl, + openKabbalahWorldsEl, + openKabbalahPathsEl, + openKabbalahCrossEl, openKabbalahTreeEl, openKabbalahCubeEl, openAlphabetEl, diff --git a/app/card-images.js b/app/card-images.js index 3ce6c6e..1ac23c8 100644 --- a/app/card-images.js +++ b/app/card-images.js @@ -137,7 +137,7 @@ const standardMinorSuits = ["Wands", "Cups", "Swords", "Disks"]; const standardDeckCardNames = buildStandardDeckCardNames(); - let deckManifestSources = buildDeckManifestSources(); + let deckManifestSources = null; const manifestCache = new Map(); const cardBackCache = new Map(); @@ -161,6 +161,25 @@ .replace(/\/+$/, ""); } + function buildManifestRequestHeaders(path) { + const normalizedPath = String(path || "").trim(); + const apiBaseUrl = getApiBaseUrl(); + const apiKey = String( + window.TarotDataService?.getApiKey?.() + || window.TarotAppConfig?.getApiKey?.() + || window.TarotAppConfig?.apiKey + || "" + ).trim(); + + if (!normalizedPath || !apiBaseUrl || !apiKey || !normalizedPath.startsWith(apiBaseUrl)) { + return {}; + } + + return { + "x-api-key": apiKey + }; + } + function rewriteBasePathForApi(basePath) { const normalizedBasePath = String(basePath || "").trim(); if (!normalizedBasePath) { @@ -530,6 +549,9 @@ try { const request = new XMLHttpRequest(); request.open("GET", encodeURI(path), false); + Object.entries(buildManifestRequestHeaders(path)).forEach(([headerName, headerValue]) => { + request.setRequestHeader(headerName, headerValue); + }); request.send(null); const okStatus = (request.status >= 200 && request.status < 300) || request.status === 0; @@ -1164,6 +1186,9 @@ }) .then((result) => { markDeckAsWarmed(normalizedDeckId); + if (options.background) { + emitDeckPreloadStatus(); + } if (!options.background) { setDeckPreloadStatus({ activeDeckId: normalizedDeckId, @@ -1211,6 +1236,13 @@ })); } + function scheduleAllDeckImagePreload(options = {}) { + return deferPreload(() => preloadAllDeckImages({ + ...defaultDeckWarmupOptions, + ...options + })); + } + function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) { const manifest = getDeckManifest(deckId); const fallbackName = String(cardName || "").trim(); @@ -1333,6 +1365,7 @@ resolveTarotCardBackThumbnail, preloadDeckImages, preloadAllDeckImages, + scheduleAllDeckImagePreload, ensureImageLoaded, isImageLoaded, getDeckPreloadStatus: () => emitDeckPreloadStatus(), diff --git a/app/data-service.js b/app/data-service.js index e00877b..6459b42 100644 --- a/app/data-service.js +++ b/app/data-service.js @@ -102,8 +102,22 @@ pluto: "Pluto" }; - function buildRequestHeaders() { - const apiKey = getApiKey(); + function resolveConnectionSettings(connectionSettings = null) { + if (!connectionSettings || typeof connectionSettings !== "object") { + return { + apiBaseUrl: getApiBaseUrl(), + apiKey: getApiKey() + }; + } + + return { + apiBaseUrl: normalizeApiBaseUrl(connectionSettings.apiBaseUrl), + apiKey: String(connectionSettings.apiKey || "").trim() + }; + } + + function buildRequestHeaders(connectionSettings = null) { + const { apiKey } = resolveConnectionSettings(connectionSettings); return apiKey ? { "x-api-key": apiKey @@ -172,8 +186,8 @@ .join("/"); } - function buildApiUrl(path, query = {}) { - const apiBaseUrl = getApiBaseUrl(); + function buildApiUrl(path, query = {}, connectionSettings = null) { + const { apiBaseUrl } = resolveConnectionSettings(connectionSettings); if (!apiBaseUrl) { return ""; } @@ -569,8 +583,9 @@ })); } - async function probeConnection() { - const apiBaseUrl = getApiBaseUrl(); + async function probeConnection(connectionSettings = null) { + const resolvedConnection = resolveConnectionSettings(connectionSettings); + const apiBaseUrl = resolvedConnection.apiBaseUrl; if (!apiBaseUrl) { return { ok: false, @@ -580,11 +595,11 @@ } const requestOptions = { - headers: buildRequestHeaders() + headers: buildRequestHeaders(resolvedConnection) }; try { - const healthResponse = await fetch(buildApiUrl("/api/v1/health"), requestOptions); + const healthResponse = await fetch(buildApiUrl("/api/v1/health", {}, resolvedConnection), requestOptions); if (!healthResponse.ok) { return { ok: false, @@ -594,7 +609,7 @@ } const health = await healthResponse.json().catch(() => null); - const protectedResponse = await fetch(buildApiUrl("/api/v1/decks/options"), requestOptions); + const protectedResponse = await fetch(buildApiUrl("/api/v1/decks/options", {}, resolvedConnection), requestOptions); if (protectedResponse.status === 401 || protectedResponse.status === 403) { return { diff --git a/app/styles.css b/app/styles.css index 3e6436e..8420310 100644 --- a/app/styles.css +++ b/app/styles.css @@ -792,7 +792,7 @@ } .tarot-detail-top { display: grid; - grid-template-columns: 150px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); gap: 16px; align-items: start; } @@ -866,6 +866,93 @@ text-transform: uppercase; letter-spacing: 0.04em; } + .tarot-deck-gallery-card { + grid-column: 1 / -1; + } + .tarot-deck-gallery { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + } + .tarot-deck-variant { + display: grid; + gap: 8px; + align-content: start; + width: 100%; + padding: 8px; + border: 1px solid #3f3f46; + border-radius: 10px; + background: #18181b; + color: #e4e4e7; + cursor: pointer; + text-align: left; + transition: background 120ms, border-color 120ms, transform 120ms; + } + .tarot-deck-variant:hover { + background: #27272a; + border-color: #52525b; + transform: translateY(-1px); + } + .tarot-deck-variant.is-active { + border-color: #a5b4fc; + box-shadow: inset 0 0 0 1px rgba(165, 180, 252, 0.28); + } + .tarot-deck-variant-image { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: contain; + object-position: center; + display: block; + padding: 4px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid #3f3f46; + background: #09090b; + } + .tarot-deck-variant-label { + display: grid; + gap: 2px; + font-size: 12px; + line-height: 1.3; + } + .tarot-deck-variant-deck { + font-weight: 600; + color: #f4f4f5; + } + .tarot-deck-variant-name { + color: #a1a1aa; + } + .tarot-deck-variant-active { + color: #a5b4fc; + font-size: 11px; + letter-spacing: 0.03em; + text-transform: uppercase; + } + @media (max-width: 720px) { + .tarot-deck-gallery { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .tarot-deck-variant { + padding: 6px; + gap: 6px; + } + + .tarot-deck-variant-label { + font-size: 11px; + } + + .tarot-deck-variant-active { + font-size: 10px; + } + } + + @media (max-width: 420px) { + .tarot-deck-gallery { + grid-template-columns: minmax(0, 1fr); + } + } .tarot-keywords { display: flex; flex-wrap: wrap; @@ -3190,6 +3277,43 @@ line-height: 1.45; color: #e4e4e7; } + .detail-sequence-nav { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + } + .detail-sequence-btn { + min-height: 34px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid #3f3f46; + background: #111118; + color: #f4f4f5; + cursor: pointer; + font-size: 12px; + line-height: 1; + transition: background 120ms, border-color 120ms, color 120ms; + } + .detail-sequence-btn:hover { + background: #27272a; + border-color: #52525b; + } + .detail-sequence-btn:disabled { + opacity: 0.45; + cursor: default; + } + .detail-sequence-btn:disabled:hover { + background: #111118; + border-color: #3f3f46; + } + .detail-sequence-position { + min-width: 78px; + color: #a1a1aa; + font-size: 12px; + line-height: 1.2; + } .planet-meta-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); @@ -3520,6 +3644,34 @@ } #kabbalah-section[hidden] { display: none; } + #kabbalah-worlds-section { + height: calc(100vh - 61px); + background: #18181b; + box-sizing: border-box; + overflow: hidden; + } + #kabbalah-worlds-section[hidden] { display: none; } + + #kabbalah-paths-section { + height: calc(100vh - 61px); + background: #18181b; + box-sizing: border-box; + overflow: hidden; + } + #kabbalah-paths-section[hidden] { display: none; } + + #kabbalah-cross-section { + height: calc(100vh - 61px); + background: #18181b; + box-sizing: border-box; + overflow: hidden; + } + #kabbalah-cross-section[hidden] { display: none; } + + .kab-browser-intro { + padding: 0 12px 8px; + } + #kabbalah-tree-section { height: calc(100vh - 61px); background: #18181b; @@ -5692,6 +5844,14 @@ gap: 10px; } + .detail-sequence-nav { + width: 100%; + } + + .detail-sequence-position { + min-width: 0; + } + .alpha-text-controls--heading { grid-template-columns: 1fr; } diff --git a/app/ui-calendar.js b/app/ui-calendar.js index 34bf133..71743ee 100644 --- a/app/ui-calendar.js +++ b/app/ui-calendar.js @@ -63,6 +63,7 @@ hebrewById: new Map(), dayLinksCache: new Map() }; + let detailNavigator = null; const TAROT_TRUMP_NUMBER_BY_NAME = { "the fool": 0, @@ -200,6 +201,7 @@ function getElements() { return { + sectionEl: document.getElementById("calendar-section"), monthListEl: document.getElementById("calendar-month-list"), monthCountEl: document.getElementById("calendar-month-count"), listTitleEl: document.getElementById("calendar-list-title"), @@ -210,6 +212,9 @@ searchClearEl: document.getElementById("calendar-search-clear"), detailNameEl: document.getElementById("calendar-detail-name"), detailSubEl: document.getElementById("calendar-detail-sub"), + detailPrevEl: document.getElementById("calendar-detail-prev"), + detailPositionEl: document.getElementById("calendar-detail-position"), + detailNextEl: document.getElementById("calendar-detail-next"), detailBodyEl: document.getElementById("calendar-detail-body") }; } @@ -523,6 +528,66 @@ function renderDetail(elements) { calendarDetailUi.renderDetail?.(elements); + syncDetailNavigation(elements); + } + + function getMonthSequenceState() { + const total = state.filteredMonths.length; + const currentIndex = state.filteredMonths.findIndex((month) => month.id === state.selectedMonthId); + + return { + total, + currentIndex, + previousId: currentIndex > 0 ? state.filteredMonths[currentIndex - 1].id : "", + nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredMonths[currentIndex + 1].id : "" + }; + } + + function getDetailNavigator() { + if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return detailNavigator; + } + + detailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.sectionEl && elements.sectionEl.hidden === false), + getSequenceState: getMonthSequenceState, + getPrevButton: (elements) => elements?.detailPrevEl, + getNextButton: (elements) => elements?.detailNextEl, + getPositionEl: (elements) => elements?.detailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + const suffix = state.searchQuery ? " shown" : ""; + return `${currentIndex + 1} of ${total}${suffix}`; + } + + return total > 0 ? `${total} months` : "No months"; + }, + selectTarget: (targetId, elements) => selectByMonthId(targetId, elements) !== false, + afterSelect: (targetId, elements) => { + scrollMonthIntoView(targetId, elements); + } + }); + + return detailNavigator; + } + + function syncDetailNavigation(elements = getElements()) { + getDetailNavigator()?.sync(elements); + } + + function scrollMonthIntoView(monthId, elements = getElements()) { + elements?.monthListEl + ?.querySelector(`[data-month-id="${monthId}"]`) + ?.scrollIntoView({ block: "nearest" }); + } + + function selectAdjacentMonth(offset, elements = getElements()) { + return getDetailNavigator()?.step(offset, elements) === true; + } + + function bindKeyboardNavigation(elements) { + getDetailNavigator()?.bind(elements); } function applySearchFilter(elements) { @@ -613,6 +678,10 @@ } } + function bindDetailNavigation(elements) { + getDetailNavigator()?.bind(elements); + } + function loadCalendarType(calendarId, elements) { const months = state.calendarData[calendarId]; if (!Array.isArray(months)) { @@ -745,6 +814,8 @@ bindYearInput(elements); bindSearchInput(elements); bindCalendarTypeSelect(elements); + bindDetailNavigation(elements); + bindKeyboardNavigation(elements); } applySearchFilter(elements); diff --git a/app/ui-holidays.js b/app/ui-holidays.js index ac7173a..3fb88d1 100644 --- a/app/ui-holidays.js +++ b/app/ui-holidays.js @@ -42,6 +42,7 @@ hebrewById: new Map(), calendarData: {} }; + let detailNavigator = null; const TAROT_TRUMP_NUMBER_BY_NAME = { "the fool": 0, @@ -93,6 +94,7 @@ function getElements() { return { + sectionEl: document.getElementById("holiday-section"), sourceSelectEl: document.getElementById("holiday-source-select"), yearInputEl: document.getElementById("holiday-year-input"), searchInputEl: document.getElementById("holiday-search-input"), @@ -101,6 +103,9 @@ listEl: document.getElementById("holiday-list"), detailNameEl: document.getElementById("holiday-detail-name"), detailSubEl: document.getElementById("holiday-detail-sub"), + detailPrevEl: document.getElementById("holiday-detail-prev"), + detailPositionEl: document.getElementById("holiday-detail-position"), + detailNextEl: document.getElementById("holiday-detail-next"), detailBodyEl: document.getElementById("holiday-detail-body") }; } @@ -225,6 +230,7 @@ detailNameEl.textContent = "--"; detailSubEl.textContent = "Select a holiday to explore"; detailBodyEl.innerHTML = ""; + syncDetailNavigation(elements); return; } @@ -232,6 +238,66 @@ detailSubEl.textContent = `${holidayDataUi.calendarLabel(holiday?.calendarId)} - ${holidayDataUi.monthLabelForCalendar(state.calendarData, holiday?.calendarId, holiday?.monthId)}`; detailBodyEl.innerHTML = renderHolidayDetail(holiday); attachNavHandlers(detailBodyEl); + syncDetailNavigation(elements); + } + + function getHolidaySequenceState() { + const total = state.filteredHolidays.length; + const currentIndex = state.filteredHolidays.findIndex((holiday) => holiday.id === state.selectedHolidayId); + + return { + total, + currentIndex, + previousId: currentIndex > 0 ? state.filteredHolidays[currentIndex - 1].id : "", + nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredHolidays[currentIndex + 1].id : "" + }; + } + + function getDetailNavigator() { + if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return detailNavigator; + } + + detailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.sectionEl && elements.sectionEl.hidden === false), + getSequenceState: getHolidaySequenceState, + getPrevButton: (elements) => elements?.detailPrevEl, + getNextButton: (elements) => elements?.detailNextEl, + getPositionEl: (elements) => elements?.detailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + const suffix = state.searchQuery ? " shown" : ""; + return `${currentIndex + 1} of ${total}${suffix}`; + } + + return total > 0 ? `${total} holidays` : "No holidays"; + }, + selectTarget: (targetId, elements) => selectByHolidayId(targetId, elements) !== false, + afterSelect: (targetId, elements) => { + scrollHolidayIntoView(targetId, elements); + } + }); + + return detailNavigator; + } + + function syncDetailNavigation(elements = getElements()) { + getDetailNavigator()?.sync(elements); + } + + function scrollHolidayIntoView(holidayId, elements = getElements()) { + elements?.listEl + ?.querySelector(`[data-holiday-id="${holidayId}"]`) + ?.scrollIntoView({ block: "nearest" }); + } + + function selectAdjacentHoliday(offset, elements = getElements()) { + return getDetailNavigator()?.step(offset, elements) === true; + } + + function bindKeyboardNavigation(elements) { + getDetailNavigator()?.bind(elements); } function applyFilters(elements) { @@ -307,6 +373,8 @@ elements.searchInputEl.focus(); }); } + + bindKeyboardNavigation(elements); } function attachNavHandlers(detailBodyEl) { diff --git a/app/ui-kabbalah-detail.js b/app/ui-kabbalah-detail.js index 6a62cdb..5a5b2d9 100644 --- a/app/ui-kabbalah-detail.js +++ b/app/ui-kabbalah-detail.js @@ -309,6 +309,62 @@ return card; } + function renderWorldLayerDetail(context) { + const { worldLayer, tree, elements } = context; + if (!worldLayer || !elements?.detailBodyEl) { + return; + } + + elements.detailNameEl.textContent = String(worldLayer.world || "Qabalistic World"); + elements.detailSubEl.textContent = [ + worldLayer.slot ? `${worldLayer.slot}: ${worldLayer.letterChar || ""}`.trim() : "", + worldLayer.soulLayer + ].filter(Boolean).join(" · "); + + elements.detailBodyEl.innerHTML = ""; + elements.detailBodyEl.appendChild(metaCard( + "World Layer", + `${worldLayer.worldLayer || "—"}${worldLayer.worldDescription ? ` · ${worldLayer.worldDescription}` : ""}`, + true + )); + elements.detailBodyEl.appendChild(metaCard( + "Soul Layer", + `${worldLayer.soulLayer || "—"}${worldLayer.soulTitle ? ` — ${worldLayer.soulTitle}` : ""}${worldLayer.soulDescription ? `: ${worldLayer.soulDescription}` : ""}`, + true + )); + + const linkedParts = []; + const hebrewLetterId = context.resolveHebrewLetterId(worldLayer.hebrewToken); + if (hebrewLetterId) { + linkedParts.push(createInlineEventLink( + `${worldLayer.letterChar || ""} ${worldLayer.hebrewToken || ""}`.replace(/\s+/g, " ").trim(), + "nav:alphabet", + { + alphabet: "hebrew", + hebrewLetterId + } + )); + } + + const linkedPath = context.findPathByHebrewToken(tree, worldLayer.hebrewToken); + if (linkedPath?.pathNumber != null) { + if (linkedParts.length) { + linkedParts.push(" · "); + } + linkedParts.push(createInlineEventLink( + `Path ${linkedPath.pathNumber}`, + "nav:kabbalah-path", + { pathNo: Number(linkedPath.pathNumber) } + )); + } + + if (linkedParts.length) { + elements.detailBodyEl.appendChild(metaCard("Linked Attributions", inlineValue(linkedParts))); + } + + elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, worldLayer.hebrewToken, context)); + } + function splitCorrespondenceNames(value) { return String(value || "") .split(/,|;|·|\/|\bor\b|\band\b|\+/i) @@ -419,12 +475,14 @@ function renderSephiraDetail(context) { const { seph, tree, elements } = context; - elements.detailNameEl.textContent = `${seph.number} · ${seph.name}`; + const displayNumber = String(seph.displayNumber || seph.number || "").trim(); + elements.detailNameEl.textContent = displayNumber + ? `${displayNumber} · ${seph.name}` + : `${seph.name}`; elements.detailSubEl.textContent = [seph.nameHebrew, seph.translation, seph.planet].filter(Boolean).join(" · "); elements.detailBodyEl.innerHTML = ""; - elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, "", context)); elements.detailBodyEl.appendChild(buildPlanetLuminaryCard(seph.planet, context)); elements.detailBodyEl.appendChild(metaCard("Intelligence", seph.intelligence)); elements.detailBodyEl.appendChild(buildTarotAttributionCard(seph.tarot)); @@ -484,7 +542,6 @@ elements.detailSubEl.textContent = [path.tarot?.card, astro].filter(Boolean).join(" · "); elements.detailBodyEl.innerHTML = ""; - elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, context.activeHebrewToken, context)); elements.detailBodyEl.appendChild(buildConnectsCard(path, fromName, toName)); elements.detailBodyEl.appendChild(buildHebrewLetterCard(letter, context)); elements.detailBodyEl.appendChild(buildAstrologyCard(path.astrology, context)); @@ -543,6 +600,7 @@ } window.KabbalahDetailUi = { + renderWorldLayerDetail, renderSephiraDetail, renderPathDetail, renderRoseLandingIntro diff --git a/app/ui-kabbalah.js b/app/ui-kabbalah.js index f1d0edb..b2d96d5 100644 --- a/app/ui-kabbalah.js +++ b/app/ui-kabbalah.js @@ -57,11 +57,18 @@ showPathLetters: true, showPathNumbers: true, showPathTarotCards: false, + selectedWorldLayerIndex: 0, selectedSephiraNumber: null, selectedPathNumber: null, + activeNodeKey: "", exportInProgress: false, exportFormat: "" }; + let detailNavigator = null; + let browserDetailNavigator = null; + let worldsDetailNavigator = null; + let pathsDetailNavigator = null; + let roseDetailNavigator = null; const TREE_EXPORT_FORMATS = { webp: { mimeType: "image/webp", @@ -70,6 +77,18 @@ } }; const TREE_EXPORT_BACKGROUND = "#02030a"; + const DAATH_SEPHIRA = Object.freeze({ + number: 0, + displayNumber: "Daath", + sephiraId: "daath", + name: "Daath", + nameHebrew: "דעת", + translation: "Knowledge", + planet: "Abyss / Hidden Sephirah", + intelligence: "Invisible Sephirah of Knowledge", + tarot: "No fixed trump attribution", + description: "Daath is the hidden or invisible sephirah placed beneath Kether and between Chokmah and Binah. In Hermetic Qabalah it often marks the threshold of the Abyss rather than a stable emanation like the ten manifest sephiroth." + }); const kabbalahDetailUi = window.KabbalahDetailUi || {}; const kabbalahViewsUi = window.KabbalahViewsUi || {}; @@ -263,10 +282,42 @@ // ─── element references ───────────────────────────────────────────────────── function getElements() { return { + browserSectionEl: document.getElementById("kabbalah-section"), + browserListEl: document.getElementById("kab-browser-list"), + browserCountEl: document.getElementById("kab-browser-count"), + browserDetailNameEl: document.getElementById("kab-browser-detail-name"), + browserDetailSubEl: document.getElementById("kab-browser-detail-sub"), + browserDetailBodyEl: document.getElementById("kab-browser-detail-body"), + browserDetailPrevEl: document.getElementById("kab-browser-detail-prev"), + browserDetailPositionEl: document.getElementById("kab-browser-detail-position"), + browserDetailNextEl: document.getElementById("kab-browser-detail-next"), + worldsSectionEl: document.getElementById("kabbalah-worlds-section"), + worldsListEl: document.getElementById("kab-worlds-list"), + worldsCountEl: document.getElementById("kab-worlds-count"), + worldsDetailNameEl: document.getElementById("kab-worlds-detail-name"), + worldsDetailSubEl: document.getElementById("kab-worlds-detail-sub"), + worldsDetailBodyEl: document.getElementById("kab-worlds-detail-body"), + worldsDetailPrevEl: document.getElementById("kab-worlds-detail-prev"), + worldsDetailPositionEl: document.getElementById("kab-worlds-detail-position"), + worldsDetailNextEl: document.getElementById("kab-worlds-detail-next"), + pathsSectionEl: document.getElementById("kabbalah-paths-section"), + pathsListEl: document.getElementById("kab-paths-list"), + pathsCountEl: document.getElementById("kab-paths-count"), + pathsDetailNameEl: document.getElementById("kab-paths-detail-name"), + pathsDetailSubEl: document.getElementById("kab-paths-detail-sub"), + pathsDetailBodyEl: document.getElementById("kab-paths-detail-body"), + pathsDetailPrevEl: document.getElementById("kab-paths-detail-prev"), + pathsDetailPositionEl: document.getElementById("kab-paths-detail-position"), + pathsDetailNextEl: document.getElementById("kab-paths-detail-next"), + crossSectionEl: document.getElementById("kabbalah-cross-section"), + sectionEl: document.getElementById("kabbalah-tree-section"), treeContainerEl: document.getElementById("kab-tree-container"), detailNameEl: document.getElementById("kab-detail-name"), detailSubEl: document.getElementById("kab-detail-sub"), detailBodyEl: document.getElementById("kab-detail-body"), + detailPrevEl: document.getElementById("kab-detail-prev"), + detailPositionEl: document.getElementById("kab-detail-position"), + detailNextEl: document.getElementById("kab-detail-next"), pathLetterToggleEl: document.getElementById("kab-path-letter-toggle"), pathNumberToggleEl: document.getElementById("kab-path-number-toggle"), pathTarotToggleEl: document.getElementById("kab-path-tarot-toggle"), @@ -275,6 +326,21 @@ roseDetailNameEl: document.getElementById("kab-rose-detail-name"), roseDetailSubEl: document.getElementById("kab-rose-detail-sub"), roseDetailBodyEl: document.getElementById("kab-rose-detail-body"), + roseDetailPrevEl: document.getElementById("kab-rose-detail-prev"), + roseDetailPositionEl: document.getElementById("kab-rose-detail-position"), + roseDetailNextEl: document.getElementById("kab-rose-detail-next"), + }; + } + + function getTreeDetailElements(elements) { + if (!elements) { + return null; + } + + return { + detailNameEl: elements.detailNameEl, + detailSubEl: elements.detailSubEl, + detailBodyEl: elements.detailBodyEl }; } @@ -290,6 +356,696 @@ }; } + function getPathDetailElements(elements) { + if (!elements) { + return null; + } + + return { + detailNameEl: elements.pathsDetailNameEl, + detailSubEl: elements.pathsDetailSubEl, + detailBodyEl: elements.pathsDetailBodyEl + }; + } + + function getBrowserDetailElements(elements) { + if (!elements) { + return null; + } + + return { + detailNameEl: elements.browserDetailNameEl, + detailSubEl: elements.browserDetailSubEl, + detailBodyEl: elements.browserDetailBodyEl + }; + } + + function getWorldDetailElements(elements) { + if (!elements) { + return null; + } + + return { + detailNameEl: elements.worldsDetailNameEl, + detailSubEl: elements.worldsDetailSubEl, + detailBodyEl: elements.worldsDetailBodyEl + }; + } + + function normalizeDetailElements(elements) { + if (!elements) { + return null; + } + + return { + detailNameEl: elements.detailNameEl || null, + detailSubEl: elements.detailSubEl || null, + detailBodyEl: elements.detailBodyEl || null + }; + } + + function getDetailRenderTargets(primaryElements) { + const elements = getElements(); + const candidates = [ + normalizeDetailElements(primaryElements), + getTreeDetailElements(elements), + getBrowserDetailElements(elements) + ]; + const seen = new Set(); + + return candidates.filter((target) => { + const bodyEl = target?.detailBodyEl; + if (!(bodyEl instanceof Element) || seen.has(bodyEl)) { + return false; + } + + seen.add(bodyEl); + return true; + }); + } + + function hasFiniteSelectionNumber(value) { + if (value === null || value === undefined || value === "") { + return false; + } + + return Number.isFinite(Number(value)); + } + + function isDaathToken(value) { + return String(value || "").trim().toLowerCase() === "daath"; + } + + function buildSephiraKey(value) { + if (isDaathToken(value) || Number(value) === 0) { + return "sephira:daath"; + } + + return hasFiniteSelectionNumber(value) ? `sephira:${Number(value)}` : ""; + } + + function buildPathKey(value) { + return hasFiniteSelectionNumber(value) ? `path:${Number(value)}` : ""; + } + + function buildWorldKey(value) { + return hasFiniteSelectionNumber(value) ? `world:${Number(value)}` : ""; + } + + function getSelectedSephiraKey() { + return buildSephiraKey(state.selectedSephiraNumber); + } + + function getSelectedPathKey() { + return buildPathKey(state.selectedPathNumber); + } + + function getSelectedWorldKey() { + return buildWorldKey(state.selectedWorldLayerIndex); + } + + function getSephiraByNumber(number) { + if (isDaathToken(number) || Number(number) === 0) { + return DAATH_SEPHIRA; + } + + if (!state.tree) { + return null; + } + + return state.tree.sephiroth.find((entry) => Number(entry?.number) === Number(number)) || null; + } + + function getPathByNumber(number) { + if (!state.tree) { + return null; + } + + return state.tree.paths.find((entry) => Number(entry?.pathNumber) === Number(number)) || null; + } + + function getWorldLayerByIndex(index) { + if (!Array.isArray(state.fourWorldLayers)) { + return null; + } + + const targetIndex = Number(index); + return Number.isInteger(targetIndex) && targetIndex >= 0 && targetIndex < state.fourWorldLayers.length + ? state.fourWorldLayers[targetIndex] + : null; + } + + function getSephirotSequenceEntries() { + if (!state.tree) { + return []; + } + + const entries = Array.isArray(state.tree.sephiroth) + ? [...state.tree.sephiroth] + .sort((left, right) => Number(left?.number || 0) - Number(right?.number || 0)) + .map((entry) => ({ + key: buildSephiraKey(entry?.number), + type: "sephira", + number: Number(entry?.number) + })) + : []; + + const daathEntry = { + key: buildSephiraKey(0), + type: "sephira", + number: 0 + }; + const insertIndex = entries.findIndex((entry) => entry.number === 3); + if (insertIndex >= 0) { + entries.splice(insertIndex + 1, 0, daathEntry); + } else { + entries.push(daathEntry); + } + + return entries; + } + + function getPathSequenceEntries() { + if (!state.tree) { + return []; + } + + return Array.isArray(state.tree.paths) + ? [...state.tree.paths] + .sort((left, right) => Number(left?.pathNumber || 0) - Number(right?.pathNumber || 0)) + .map((entry) => ({ + key: buildPathKey(entry?.pathNumber), + type: "path", + number: Number(entry?.pathNumber) + })) + : []; + } + + function getWorldSequenceEntries() { + return Array.isArray(state.fourWorldLayers) + ? state.fourWorldLayers.map((layer, index) => ({ + key: buildWorldKey(index), + type: "world", + index, + world: String(layer?.world || "") + })) + : []; + } + + function getNodeSequenceEntries() { + if (!state.tree) { + return []; + } + + const sephiroth = Array.isArray(state.tree.sephiroth) + ? [...state.tree.sephiroth] + .sort((left, right) => Number(left?.number || 0) - Number(right?.number || 0)) + .map((entry) => ({ + key: buildSephiraKey(entry?.number), + type: "sephira", + number: Number(entry?.number) + })) + : []; + + const paths = getPathSequenceEntries(); + + return [...sephiroth, ...paths]; + } + + function getSelectedNodeKey() { + return String(state.activeNodeKey || "").trim() || getSelectedPathKey() || getSelectedSephiraKey(); + } + + function buildSequenceState(entries, currentKey) { + const currentIndex = entries.findIndex((entry) => entry.key === currentKey); + + return { + total: entries.length, + currentIndex, + previousKey: currentIndex > 0 ? entries[currentIndex - 1].key : "", + nextKey: currentIndex >= 0 && currentIndex < entries.length - 1 ? entries[currentIndex + 1].key : "" + }; + } + + function getNodeSequenceState() { + return buildSequenceState(getNodeSequenceEntries(), getSelectedNodeKey()); + } + + function getSephirotSequenceState() { + return buildSequenceState(getSephirotSequenceEntries(), getSelectedSephiraKey()); + } + + function getPathSequenceState() { + return buildSequenceState(getPathSequenceEntries(), getSelectedPathKey()); + } + + function getWorldSequenceState() { + return buildSequenceState(getWorldSequenceEntries(), getSelectedWorldKey()); + } + + function getBrowserListItemMeta(entry) { + const seph = getSephiraByNumber(entry.number); + const displayNumber = String(seph?.displayNumber || entry.number || "").trim(); + + return { + title: displayNumber ? `${displayNumber} · ${seph?.name || "Sephirah"}` : `${seph?.name || "Sephirah"}`, + meta: [seph?.nameHebrew, seph?.translation, seph?.planet].filter(Boolean).join(" · ") || "Sephirah" + }; + } + + function getWorldListItemMeta(entry) { + const layer = getWorldLayerByIndex(entry.index); + + return { + title: String(layer?.world || `World ${entry.index + 1}`), + meta: [ + layer?.slot ? `${layer.slot}: ${layer.letterChar || ""}`.trim() : "", + layer?.soulLayer + ].filter(Boolean).join(" · ") || "Qabalistic World" + }; + } + + function getPathListItemMeta(entry) { + const path = getPathByNumber(entry.number); + const fromName = getSephiraByNumber(path?.connects?.from)?.name || `Node ${path?.connects?.from || "?"}`; + const toName = getSephiraByNumber(path?.connects?.to)?.name || `Node ${path?.connects?.to || "?"}`; + const letterLabel = [path?.hebrewLetter?.char, path?.hebrewLetter?.transliteration].filter(Boolean).join(" ").trim(); + + return { + title: `Path ${entry.number}${letterLabel ? ` · ${letterLabel}` : ""}`, + meta: [ + `${fromName} -> ${toName}`, + String(path?.tarot?.card || "").trim() + ].filter(Boolean).join(" · ") || "Path" + }; + } + + function syncBrowserListSelection(elements = getElements()) { + if (!elements?.browserListEl) { + return; + } + + const selectedKey = getSelectedSephiraKey(); + elements.browserListEl.querySelectorAll(".planet-list-item[data-node-key]").forEach((button) => { + const isSelected = button.dataset.nodeKey === selectedKey; + button.classList.toggle("is-selected", isSelected); + button.setAttribute("aria-selected", isSelected ? "true" : "false"); + }); + } + + function renderBrowserList(elements = getElements()) { + if (!elements?.browserListEl) { + return; + } + + const entries = getSephirotSequenceEntries(); + elements.browserListEl.innerHTML = ""; + + entries.forEach((entry) => { + const button = document.createElement("button"); + const { title, meta } = getBrowserListItemMeta(entry); + button.type = "button"; + button.className = "planet-list-item"; + button.setAttribute("role", "option"); + button.dataset.nodeKey = entry.key; + button.innerHTML = ` +
${title}
+
${meta}
+ `; + elements.browserListEl.appendChild(button); + }); + + if (elements.browserCountEl) { + elements.browserCountEl.textContent = `${entries.length} sephiroth`; + } + + syncBrowserListSelection(elements); + } + + function syncWorldListSelection(elements = getElements()) { + if (!elements?.worldsListEl) { + return; + } + + const selectedKey = getSelectedWorldKey(); + elements.worldsListEl.querySelectorAll(".planet-list-item[data-world-key]").forEach((button) => { + const isSelected = button.dataset.worldKey === selectedKey; + button.classList.toggle("is-selected", isSelected); + button.setAttribute("aria-selected", isSelected ? "true" : "false"); + }); + } + + function syncPathsListSelection(elements = getElements()) { + if (!elements?.pathsListEl) { + return; + } + + const selectedKey = getSelectedPathKey(); + elements.pathsListEl.querySelectorAll(".planet-list-item[data-path-key]").forEach((button) => { + const isSelected = button.dataset.pathKey === selectedKey; + button.classList.toggle("is-selected", isSelected); + button.setAttribute("aria-selected", isSelected ? "true" : "false"); + }); + } + + function renderWorldsList(elements = getElements()) { + if (!elements?.worldsListEl) { + return; + } + + const entries = getWorldSequenceEntries(); + elements.worldsListEl.innerHTML = ""; + + entries.forEach((entry) => { + const button = document.createElement("button"); + const { title, meta } = getWorldListItemMeta(entry); + button.type = "button"; + button.className = "planet-list-item"; + button.setAttribute("role", "option"); + button.dataset.worldKey = entry.key; + button.innerHTML = ` +
${title}
+
${meta}
+ `; + elements.worldsListEl.appendChild(button); + }); + + if (elements.worldsCountEl) { + elements.worldsCountEl.textContent = `${entries.length} worlds`; + } + + syncWorldListSelection(elements); + } + + function renderPathsList(elements = getElements()) { + if (!elements?.pathsListEl) { + return; + } + + const entries = getPathSequenceEntries(); + elements.pathsListEl.innerHTML = ""; + + entries.forEach((entry) => { + const button = document.createElement("button"); + const { title, meta } = getPathListItemMeta(entry); + button.type = "button"; + button.className = "planet-list-item"; + button.setAttribute("role", "option"); + button.dataset.pathKey = entry.key; + button.innerHTML = ` +
${title}
+
${meta}
+ `; + elements.pathsListEl.appendChild(button); + }); + + if (elements.pathsCountEl) { + elements.pathsCountEl.textContent = `${entries.length} paths`; + } + + syncPathsListSelection(elements); + } + + function bindBrowserList(elements = getElements()) { + if (!elements?.browserListEl || elements.browserListEl.dataset.bound) { + return; + } + + elements.browserListEl.addEventListener("click", (event) => { + const target = event.target instanceof Element + ? event.target.closest(".planet-list-item[data-node-key]") + : null; + + if (!(target instanceof HTMLButtonElement)) { + return; + } + + const targetKey = String(target.dataset.nodeKey || "").trim(); + if (!targetKey) { + return; + } + + selectNodeBySequenceKey(targetKey, getBrowserDetailElements(getElements())); + }); + + elements.browserListEl.dataset.bound = "true"; + } + + function bindWorldList(elements = getElements()) { + if (!elements?.worldsListEl || elements.worldsListEl.dataset.bound) { + return; + } + + elements.worldsListEl.addEventListener("click", (event) => { + const target = event.target instanceof Element + ? event.target.closest(".planet-list-item[data-world-key]") + : null; + + if (!(target instanceof HTMLButtonElement)) { + return; + } + + const targetKey = String(target.dataset.worldKey || "").trim(); + if (!targetKey) { + return; + } + + selectNodeBySequenceKey(targetKey, getWorldDetailElements(getElements())); + }); + + elements.worldsListEl.dataset.bound = "true"; + } + + function bindPathsList(elements = getElements()) { + if (!elements?.pathsListEl || elements.pathsListEl.dataset.bound) { + return; + } + + elements.pathsListEl.addEventListener("click", (event) => { + const target = event.target instanceof Element + ? event.target.closest(".planet-list-item[data-path-key]") + : null; + + if (!(target instanceof HTMLButtonElement)) { + return; + } + + const targetKey = String(target.dataset.pathKey || "").trim(); + if (!targetKey) { + return; + } + + selectNodeBySequenceKey(targetKey, getPathDetailElements(getElements())); + }); + + elements.pathsListEl.dataset.bound = "true"; + } + + function selectNodeBySequenceKey(targetKey, elements = getElements()) { + if (!state.tree) { + return false; + } + + const [type, rawToken] = String(targetKey || "").split(":"); + + if (type === "sephira") { + const seph = getSephiraByNumber(isDaathToken(rawToken) ? 0 : Number(rawToken)); + if (!seph) { + return false; + } + + renderSephiraDetail(seph, state.tree, elements); + return true; + } + + if (type === "path") { + const path = getPathByNumber(Number(rawToken)); + if (!path) { + return false; + } + + renderPathDetail(path, state.tree, elements); + return true; + } + + if (type === "world") { + const worldLayer = getWorldLayerByIndex(Number(rawToken)); + if (!worldLayer) { + return false; + } + + renderWorldLayerDetail(worldLayer, Number(rawToken), state.tree, elements); + return true; + } + + return false; + } + + function getDetailNavigator() { + if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return detailNavigator; + } + + detailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.sectionEl && elements.sectionEl.hidden === false), + getSequenceState: getNodeSequenceState, + getPrevButton: (elements) => elements?.detailPrevEl, + getNextButton: (elements) => elements?.detailNextEl, + getPositionEl: (elements) => elements?.detailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + return `${currentIndex + 1} of ${total} nodes`; + } + + return total > 0 ? `${total} nodes` : "No nodes"; + }, + selectTarget: (targetKey, elements) => selectNodeBySequenceKey(targetKey, elements) + }); + + return detailNavigator; + } + + function getBrowserDetailNavigator() { + if (browserDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return browserDetailNavigator; + } + + browserDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.browserSectionEl && elements.browserSectionEl.hidden === false), + getSequenceState: getSephirotSequenceState, + getPrevButton: (elements) => elements?.browserDetailPrevEl, + getNextButton: (elements) => elements?.browserDetailNextEl, + getPositionEl: (elements) => elements?.browserDetailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + return `${currentIndex + 1} of ${total} sephiroth`; + } + + return total > 0 ? `${total} sephiroth` : "No sephiroth"; + }, + selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getBrowserDetailElements(getElements())) + }); + + return browserDetailNavigator; + } + + function getPathsDetailNavigator() { + if (pathsDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return pathsDetailNavigator; + } + + pathsDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.pathsSectionEl && elements.pathsSectionEl.hidden === false), + getSequenceState: getPathSequenceState, + getPrevButton: (elements) => elements?.pathsDetailPrevEl, + getNextButton: (elements) => elements?.pathsDetailNextEl, + getPositionEl: (elements) => elements?.pathsDetailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + return `${currentIndex + 1} of ${total} paths`; + } + + return total > 0 ? `${total} paths` : "No paths"; + }, + selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getPathDetailElements(getElements())) + }); + + return pathsDetailNavigator; + } + + function getRoseDetailNavigator() { + if (roseDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return roseDetailNavigator; + } + + roseDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.crossSectionEl && elements.crossSectionEl.hidden === false), + getSequenceState: getPathSequenceState, + getPrevButton: (elements) => elements?.roseDetailPrevEl, + getNextButton: (elements) => elements?.roseDetailNextEl, + getPositionEl: (elements) => elements?.roseDetailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + return `${currentIndex + 1} of ${total} paths`; + } + + return total > 0 ? `${total} paths` : "No paths"; + }, + selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getRoseDetailElements(getElements())) + }); + + return roseDetailNavigator; + } + + function getWorldDetailNavigator() { + if (worldsDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return worldsDetailNavigator; + } + + worldsDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.worldsSectionEl && elements.worldsSectionEl.hidden === false), + getSequenceState: getWorldSequenceState, + getPrevButton: (elements) => elements?.worldsDetailPrevEl, + getNextButton: (elements) => elements?.worldsDetailNextEl, + getPositionEl: (elements) => elements?.worldsDetailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + return `${currentIndex + 1} of ${total} worlds`; + } + + return total > 0 ? `${total} worlds` : "No worlds"; + }, + selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getWorldDetailElements(getElements())) + }); + + return worldsDetailNavigator; + } + + function syncDetailNavigation(elements = getElements()) { + getDetailNavigator()?.sync(elements); + } + + function syncBrowserDetailNavigation(elements = getElements()) { + getBrowserDetailNavigator()?.sync(elements); + } + + function syncPathsDetailNavigation(elements = getElements()) { + getPathsDetailNavigator()?.sync(elements); + } + + function syncRoseDetailNavigation(elements = getElements()) { + getRoseDetailNavigator()?.sync(elements); + } + + function syncWorldDetailNavigation(elements = getElements()) { + getWorldDetailNavigator()?.sync(elements); + } + + function bindDetailNavigation(elements = getElements()) { + getDetailNavigator()?.bind(elements); + } + + function bindBrowserDetailNavigation(elements = getElements()) { + getBrowserDetailNavigator()?.bind(elements); + } + + function bindPathsDetailNavigation(elements = getElements()) { + getPathsDetailNavigator()?.bind(elements); + } + + function bindRoseDetailNavigation(elements = getElements()) { + getRoseDetailNavigator()?.bind(elements); + } + + function bindWorldDetailNavigation(elements = getElements()) { + getWorldDetailNavigator()?.bind(elements); + } + function normalizeText(value) { return String(value || "").trim().toLowerCase(); } @@ -395,30 +1151,18 @@ .forEach(el => el.classList.remove("kab-path-active")); } - function renderSephiraDetail(seph, tree, elements) { - state.selectedSephiraNumber = Number(seph?.number); - state.selectedPathNumber = null; - - clearHighlights(); - document.querySelectorAll(`.kab-node[data-sephira="${seph.number}"], .kab-node-glow[data-sephira="${seph.number}"]`) - .forEach(el => el.classList.add("kab-node-active")); - + function renderSephiraDetailIntoElements(seph, tree, elements, options = {}) { if (typeof kabbalahDetailUi.renderSephiraDetail === "function") { kabbalahDetailUi.renderSephiraDetail(getDetailRenderContext(tree, elements, { seph, - onPathSelect: (path) => renderPathDetail(path, tree, elements) + onPathSelect: typeof options.onPathSelect === "function" + ? options.onPathSelect + : null })); } } - function renderPathDetail(path, tree, elements) { - state.selectedPathNumber = Number(path?.pathNumber); - state.selectedSephiraNumber = null; - - clearHighlights(); - document.querySelectorAll(`[data-path="${path.pathNumber}"]`) - .forEach(el => el.classList.add("kab-path-active")); - + function renderPathDetailIntoElements(path, tree, elements) { if (typeof kabbalahDetailUi.renderPathDetail === "function") { kabbalahDetailUi.renderPathDetail(getDetailRenderContext(tree, elements, { path, @@ -427,6 +1171,94 @@ } } + function renderWorldLayerDetailIntoElements(worldLayer, tree, elements, worldIndex) { + if (typeof kabbalahDetailUi.renderWorldLayerDetail === "function") { + kabbalahDetailUi.renderWorldLayerDetail(getDetailRenderContext(tree, elements, { + worldLayer, + worldIndex + })); + } + } + + function renderSephiraDetail(seph, tree, elements) { + state.selectedSephiraNumber = Number(seph?.number); + if (buildSephiraKey(seph?.number) !== "sephira:daath") { + state.activeNodeKey = buildSephiraKey(seph?.number); + } + + clearHighlights(); + document.querySelectorAll(`.kab-node[data-sephira="${seph.number}"], .kab-node-glow[data-sephira="${seph.number}"]`) + .forEach(el => el.classList.add("kab-node-active")); + + const allElements = getElements(); + const treeElements = getTreeDetailElements(allElements); + const browserElements = getBrowserDetailElements(allElements); + + if (treeElements?.detailBodyEl) { + renderSephiraDetailIntoElements(seph, tree, treeElements, { + onPathSelect: (path) => renderPathDetail(path, tree, treeElements) + }); + } + + if (browserElements?.detailBodyEl) { + renderSephiraDetailIntoElements(seph, tree, browserElements, { + onPathSelect: (path) => { + document.dispatchEvent(new CustomEvent("nav:kabbalah-path", { + detail: { pathNo: Number(path?.pathNumber) } + })); + } + }); + } + + syncDetailNavigation(); + syncBrowserDetailNavigation(); + syncBrowserListSelection(); + } + + function renderPathDetail(path, tree, elements) { + state.selectedPathNumber = Number(path?.pathNumber); + state.activeNodeKey = buildPathKey(path?.pathNumber); + + clearHighlights(); + document.querySelectorAll(`[data-path="${path.pathNumber}"]`) + .forEach(el => el.classList.add("kab-path-active")); + + const allElements = getElements(); + const treeElements = getTreeDetailElements(allElements); + const pathElements = getPathDetailElements(allElements); + const roseElements = getRoseDetailElements(allElements); + + if (treeElements?.detailBodyEl) { + renderPathDetailIntoElements(path, tree, treeElements); + } + + if (pathElements?.detailBodyEl) { + renderPathDetailIntoElements(path, tree, pathElements); + } + + if (roseElements?.detailBodyEl) { + renderPathDetailIntoElements(path, tree, roseElements); + } + + syncDetailNavigation(); + syncPathsDetailNavigation(); + syncRoseDetailNavigation(); + syncPathsListSelection(); + syncBrowserListSelection(); + } + + function renderWorldLayerDetail(worldLayer, worldIndex, tree, elements) { + state.selectedWorldLayerIndex = Number(worldIndex); + + const worldElements = getWorldDetailElements(getElements()); + if (worldElements?.detailBodyEl) { + renderWorldLayerDetailIntoElements(worldLayer, tree, worldElements, worldIndex); + } + + syncWorldDetailNavigation(); + syncWorldListSelection(); + } + function renderRoseLandingIntro(roseElements) { if (typeof kabbalahDetailUi.renderRoseLandingIntro === "function") { @@ -434,6 +1266,70 @@ } } + function renderBrowserCurrentSelection(elements) { + if (!state.tree) { + return; + } + + const browserElements = getBrowserDetailElements(elements); + if (!browserElements?.detailBodyEl) { + return; + } + + const selectedSephira = getSephiraByNumber(state.selectedSephiraNumber); + if (selectedSephira) { + renderSephiraDetail(selectedSephira, state.tree, browserElements); + return; + } + + const fallbackSephira = getSephiraByNumber(getSephirotSequenceEntries()[0]?.number); + if (fallbackSephira) { + renderSephiraDetail(fallbackSephira, state.tree, browserElements); + } + } + + function renderPathsCurrentSelection(elements) { + if (!state.tree) { + return; + } + + const pathElements = getPathDetailElements(elements); + if (!pathElements?.detailBodyEl) { + return; + } + + if (hasFiniteSelectionNumber(state.selectedPathNumber)) { + const selectedPath = getPathByNumber(state.selectedPathNumber); + if (selectedPath) { + renderPathDetail(selectedPath, state.tree, pathElements); + return; + } + } + + const fallbackPath = getPathByNumber(getPathSequenceEntries()[0]?.number); + if (fallbackPath) { + renderPathDetail(fallbackPath, state.tree, pathElements); + } + } + + function renderWorldCurrentSelection(elements) { + const worldElements = getWorldDetailElements(elements); + if (!worldElements?.detailBodyEl) { + return; + } + + const selectedWorld = getWorldLayerByIndex(state.selectedWorldLayerIndex); + if (selectedWorld) { + renderWorldLayerDetail(selectedWorld, state.selectedWorldLayerIndex, state.tree, worldElements); + return; + } + + const fallbackWorld = getWorldLayerByIndex(0); + if (fallbackWorld) { + renderWorldLayerDetail(fallbackWorld, 0, state.tree, worldElements); + } + } + function getViewRenderContext(elements) { return { state, @@ -469,8 +1365,8 @@ return; } - if (Number.isFinite(Number(state.selectedPathNumber))) { - const selectedPath = state.tree.paths.find((entry) => entry.pathNumber === Number(state.selectedPathNumber)); + if (hasFiniteSelectionNumber(state.selectedPathNumber)) { + const selectedPath = getPathByNumber(state.selectedPathNumber); if (selectedPath) { renderPathDetail(selectedPath, state.tree, roseElements); return; @@ -478,6 +1374,7 @@ } renderRoseLandingIntro(roseElements); + syncRoseDetailNavigation(elements); } function renderRoseCross(elements) { @@ -720,16 +1617,17 @@ return; } - if (Number.isFinite(Number(state.selectedPathNumber))) { - const selectedPath = state.tree.paths.find((entry) => entry.pathNumber === Number(state.selectedPathNumber)); + const activeNodeKey = String(state.activeNodeKey || "").trim(); + if (activeNodeKey.startsWith("path:")) { + const selectedPath = getPathByNumber(Number(activeNodeKey.split(":")[1])); if (selectedPath) { renderPathDetail(selectedPath, state.tree, elements); return; } } - if (Number.isFinite(Number(state.selectedSephiraNumber))) { - const selectedSephira = state.tree.sephiroth.find((entry) => entry.number === Number(state.selectedSephiraNumber)); + if (activeNodeKey.startsWith("sephira:") && !activeNodeKey.endsWith(":daath")) { + const selectedSephira = getSephiraByNumber(Number(activeNodeKey.split(":")[1])); if (selectedSephira) { renderSephiraDetail(selectedSephira, state.tree, elements); return; @@ -739,6 +1637,47 @@ renderSephiraDetail(state.tree.sephiroth[0], state.tree, elements); } + function renderVisibleKabbalahViews(elements = getElements()) { + renderBrowserList(elements); + renderWorldsList(elements); + renderPathsList(elements); + + if (elements.browserSectionEl && elements.browserSectionEl.hidden === false) { + renderBrowserCurrentSelection(elements); + } else { + syncBrowserListSelection(elements); + syncBrowserDetailNavigation(elements); + } + + if (elements.worldsSectionEl && elements.worldsSectionEl.hidden === false) { + renderWorldCurrentSelection(elements); + } else { + syncWorldListSelection(elements); + syncWorldDetailNavigation(elements); + } + + if (elements.pathsSectionEl && elements.pathsSectionEl.hidden === false) { + renderPathsCurrentSelection(elements); + } else { + syncPathsListSelection(elements); + syncPathsDetailNavigation(elements); + } + + if (elements.crossSectionEl && elements.crossSectionEl.hidden === false) { + renderRoseCross(elements); + renderRoseCurrentSelection(elements); + } else { + syncRoseDetailNavigation(elements); + } + + if (elements.sectionEl && elements.sectionEl.hidden === false) { + renderTree(elements); + renderCurrentSelection(elements); + } else { + syncDetailNavigation(elements); + } + } + // ─── initialise section ────────────────────────────────────────────────────── function init(magickDataset, elements) { const tree = magickDataset?.grouped?.kabbalah?.["kabbalah-tree"]; @@ -753,6 +1692,17 @@ state.godsData = magickDataset?.grouped?.["gods"]?.byPath || {}; state.hebrewLetterIdByToken = buildHebrewLetterLookup(magickDataset); state.fourWorldLayers = buildFourWorldLayersFromDataset(magickDataset); + if (!hasFiniteSelectionNumber(state.selectedWorldLayerIndex) + || Number(state.selectedWorldLayerIndex) < 0 + || Number(state.selectedWorldLayerIndex) >= state.fourWorldLayers.length) { + state.selectedWorldLayerIndex = 0; + } + if (!hasFiniteSelectionNumber(state.selectedSephiraNumber)) { + state.selectedSephiraNumber = Number(state.tree.sephiroth[0]?.number || 1); + } + if (!String(state.activeNodeKey || "").trim()) { + state.activeNodeKey = buildSephiraKey(state.selectedSephiraNumber); + } const bindPathDisplayToggle = (toggleEl, stateKey) => { if (!toggleEl) { @@ -776,6 +1726,14 @@ bindPathDisplayToggle(elements.pathLetterToggleEl, "showPathLetters"); bindPathDisplayToggle(elements.pathNumberToggleEl, "showPathNumbers"); bindPathDisplayToggle(elements.pathTarotToggleEl, "showPathTarotCards"); + bindDetailNavigation(elements); + bindBrowserDetailNavigation(elements); + bindPathsDetailNavigation(elements); + bindWorldDetailNavigation(elements); + bindRoseDetailNavigation(elements); + bindBrowserList(elements); + bindWorldList(elements); + bindPathsList(elements); syncExportControls(elements); if (elements.treeExportWebpEl && !elements.treeExportWebpEl.dataset.bound) { @@ -785,37 +1743,39 @@ elements.treeExportWebpEl.dataset.bound = "true"; } - renderTree(elements); - renderCurrentSelection(elements); - renderRoseCross(elements); - renderRoseCurrentSelection(elements); + renderVisibleKabbalahViews(elements); } function selectPathByNumber(pathNumber) { if (!state.initialized || !state.tree) return; const el = getElements(); - const path = state.tree.paths.find(p => p.pathNumber === pathNumber); + const path = getPathByNumber(pathNumber); if (path) renderPathDetail(path, state.tree, el); } function selectSephiraByNumber(n) { if (!state.initialized || !state.tree) return; const el = getElements(); - const seph = state.tree.sephiroth.find(s => s.number === n); + const seph = getSephiraByNumber(n); if (seph) renderSephiraDetail(seph, state.tree, el); } // select sephirah (1-10) or path (11+) by a single number function selectNode(n) { - if (n >= 1 && n <= 10) selectSephiraByNumber(n); + if (isDaathToken(n) || Number(n) === 0) selectSephiraByNumber(0); + else if (n >= 1 && n <= 10) selectSephiraByNumber(n); else selectPathByNumber(n); } // ─── public API ──────────────────────────────────────────────────────── function ensureKabbalahSection(magickDataset) { - if (state.initialized) return; - state.initialized = true; const elements = getElements(); + if (state.initialized) { + renderVisibleKabbalahViews(elements); + return; + } + + state.initialized = true; init(magickDataset, elements); } diff --git a/app/ui-navigation.js b/app/ui-navigation.js index 049cfe1..1a3ca2d 100644 --- a/app/ui-navigation.js +++ b/app/ui-navigation.js @@ -22,6 +22,20 @@ return config.getMagickDataset?.() || null; } + function getKabbalahPathNo(detail) { + if (!detail || typeof detail !== "object") { + return null; + } + + const rawValue = detail.pathNo ?? detail["path-no"] ?? null; + if (rawValue === null || rawValue === undefined || rawValue === "") { + return null; + } + + const numericValue = Number(rawValue); + return Number.isFinite(numericValue) ? numericValue : null; + } + const DETAIL_VIEW_SELECTOR_BY_SECTION = { tarot: "#tarot-browse-view .tarot-layout", cube: "#cube-layout", @@ -31,6 +45,10 @@ iching: "#iching-section .planet-layout", gods: "#gods-section .planet-layout", calendar: "#calendar-section .planet-layout", + kabbalah: "#kabbalah-section .planet-layout", + "kabbalah-worlds": "#kabbalah-worlds-section .planet-layout", + "kabbalah-paths": "#kabbalah-paths-section .planet-layout", + "kabbalah-cross": "#kabbalah-cross-section .kab-rose-layout", "kabbalah-tree": "#kabbalah-tree-section .kab-layout", planets: "#planet-section .planet-layout", elements: "#elements-section .planet-layout" @@ -167,12 +185,28 @@ setActiveSection(getActiveSection() === "kabbalah" ? "home" : "kabbalah"); }); + bindClick(elements.openKabbalahSephirotEl, () => { + setActiveSection("kabbalah"); + }); + + bindClick(elements.openKabbalahWorldsEl, () => { + setActiveSection("kabbalah-worlds"); + }); + + bindClick(elements.openKabbalahPathsEl, () => { + setActiveSection("kabbalah-paths"); + }); + + bindClick(elements.openKabbalahCrossEl, () => { + setActiveSection("kabbalah-cross"); + }); + bindClick(elements.openKabbalahTreeEl, () => { - setActiveSection(getActiveSection() === "kabbalah-tree" ? "home" : "kabbalah-tree"); + setActiveSection("kabbalah-tree"); }); bindClick(elements.openKabbalahCubeEl, () => { - setActiveSection(getActiveSection() === "cube" ? "home" : "cube"); + setActiveSection("cube"); }); bindClick(elements.openAlphabetWordEl, () => { @@ -403,17 +437,21 @@ document.addEventListener("nav:kabbalah-path", (event) => { const magickDataset = getMagickDataset(); - const pathNo = event?.detail?.pathNo; + const pathNo = getKabbalahPathNo(event?.detail); if (typeof ensure.ensureKabbalahSection === "function" && magickDataset) { ensure.ensureKabbalahSection(magickDataset); } - setActiveSection("kabbalah-tree"); if (pathNo != null) { + const targetSection = Number(pathNo) >= 11 ? "kabbalah-paths" : "kabbalah"; + setActiveSection(targetSection); requestAnimationFrame(() => { window.KabbalahSectionUi?.selectNode?.(pathNo); - scheduleSectionDetailOnly("kabbalah-tree"); + scheduleSectionDetailOnly(targetSection); }); + return; } + + setActiveSection("kabbalah-paths"); }); document.addEventListener("nav:planet", (event) => { @@ -473,7 +511,7 @@ }); document.addEventListener("tarot:view-kab-path", (event) => { - setActiveSection("kabbalah-tree"); + setActiveSection("kabbalah-paths"); const pathNumber = event?.detail?.pathNumber; if (pathNumber != null) { requestAnimationFrame(() => { @@ -483,7 +521,7 @@ } else { kabbalahUi?.selectPathByNumber?.(pathNumber); } - scheduleSectionDetailOnly("kabbalah-tree"); + scheduleSectionDetailOnly("kabbalah-paths"); }); } }); diff --git a/app/ui-planets.js b/app/ui-planets.js index 783353c..0ace64c 100644 --- a/app/ui-planets.js +++ b/app/ui-planets.js @@ -21,6 +21,7 @@ monthRefsByPlanetId: new Map(), cubePlacementsByPlanetId: new Map() }; + let detailNavigator = null; function normalizePlanetToken(value) { return String(value || "") @@ -80,12 +81,16 @@ function getElements() { return { + planetSectionEl: document.getElementById("planet-section"), planetCardListEl: document.getElementById("planet-card-list"), planetSearchInputEl: document.getElementById("planet-search-input"), planetSearchClearEl: document.getElementById("planet-search-clear"), planetCountEl: document.getElementById("planet-card-count"), planetDetailNameEl: document.getElementById("planet-detail-name"), planetDetailTypeEl: document.getElementById("planet-detail-type"), + planetDetailPrevEl: document.getElementById("planet-detail-prev"), + planetDetailPositionEl: document.getElementById("planet-detail-position"), + planetDetailNextEl: document.getElementById("planet-detail-next"), planetDetailSummaryEl: document.getElementById("planet-detail-summary"), planetDetailFactsEl: document.getElementById("planet-detail-facts"), planetDetailAtmosphereEl: document.getElementById("planet-detail-atmosphere"), @@ -156,11 +161,14 @@ if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) { if (state.filteredEntries.length > 0) { selectById(state.filteredEntries[0].id, elements); + } else { + syncDetailNavigation(elements); } return; } updateSelection(elements); + syncDetailNavigation(elements); } function clearChildren(element) { @@ -434,6 +442,68 @@ }); } + function getSequenceState() { + const total = state.filteredEntries.length; + const currentIndex = state.filteredEntries.findIndex((entry) => entry.id === state.selectedId); + + return { + total, + currentIndex, + previousId: currentIndex > 0 ? state.filteredEntries[currentIndex - 1].id : "", + nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredEntries[currentIndex + 1].id : "" + }; + } + + function getDetailNavigator() { + if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return detailNavigator; + } + + detailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.planetSectionEl && elements.planetSectionEl.hidden === false), + getSequenceState, + getPrevButton: (elements) => elements?.planetDetailPrevEl, + getNextButton: (elements) => elements?.planetDetailNextEl, + getPositionEl: (elements) => elements?.planetDetailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + const suffix = state.searchQuery ? " shown" : ""; + return `${currentIndex + 1} of ${total}${suffix}`; + } + + return total > 0 ? `${total} bodies` : "No bodies"; + }, + selectTarget: (targetId, elements) => { + selectById(targetId, elements); + return true; + }, + afterSelect: (targetId, elements) => { + scrollEntryIntoView(targetId, elements); + } + }); + + return detailNavigator; + } + + function syncDetailNavigation(elements) { + getDetailNavigator()?.sync(elements); + } + + function scrollEntryIntoView(id, elements) { + elements?.planetCardListEl + ?.querySelector(`[data-planet-id="${id}"]`) + ?.scrollIntoView({ block: "nearest" }); + } + + function selectAdjacentEntry(offset, elements) { + return getDetailNavigator()?.step(offset, elements) === true; + } + + function bindKeyboardNavigation(elements) { + getDetailNavigator()?.bind(elements); + } + function selectById(id, elements) { const entry = state.entries.find((planet) => planet.id === id); if (!entry) { @@ -443,6 +513,7 @@ state.selectedId = entry.id; updateSelection(elements); renderDetail(entry, elements); + syncDetailNavigation(elements); } function renderList(elements) { @@ -580,6 +651,8 @@ }); } + bindKeyboardNavigation(elements); + state.initialized = true; } @@ -594,9 +667,7 @@ ); if (!entry) return; selectById(entry.id, el); - el.planetCardListEl - ?.querySelector(`[data-planet-id="${entry.id}"]`) - ?.scrollIntoView({ block: "nearest" }); + scrollEntryIntoView(entry.id, el); } window.PlanetSectionUi = { diff --git a/app/ui-section-state.js b/app/ui-section-state.js index bdf7027..9e5a991 100644 --- a/app/ui-section-state.js +++ b/app/ui-section-state.js @@ -19,6 +19,9 @@ "elements", "iching", "kabbalah", + "kabbalah-worlds", + "kabbalah-paths", + "kabbalah-cross", "kabbalah-tree", "cube", "alphabet", @@ -109,9 +112,12 @@ const isElementsOpen = activeSection === "elements"; const isIChingOpen = activeSection === "iching"; const isKabbalahOpen = activeSection === "kabbalah"; + const isKabbalahWorldsOpen = activeSection === "kabbalah-worlds"; + const isKabbalahPathsOpen = activeSection === "kabbalah-paths"; + const isKabbalahCrossOpen = activeSection === "kabbalah-cross"; const isKabbalahTreeOpen = activeSection === "kabbalah-tree"; const isCubeOpen = activeSection === "cube"; - const isKabbalahMenuOpen = isKabbalahOpen || isKabbalahTreeOpen || isCubeOpen; + const isKabbalahMenuOpen = isKabbalahOpen || isKabbalahWorldsOpen || isKabbalahPathsOpen || isKabbalahCrossOpen || isKabbalahTreeOpen || isCubeOpen; const isAlphabetOpen = activeSection === "alphabet"; const isAlphabetLettersOpen = activeSection === "alphabet-letters"; const isAlphabetTextOpen = activeSection === "alphabet-text"; @@ -137,6 +143,9 @@ setHidden(elements.elementsSectionEl, !isElementsOpen); setHidden(elements.ichingSectionEl, !isIChingOpen); setHidden(elements.kabbalahSectionEl, !isKabbalahOpen); + setHidden(elements.kabbalahWorldsSectionEl, !isKabbalahWorldsOpen); + setHidden(elements.kabbalahPathsSectionEl, !isKabbalahPathsOpen); + setHidden(elements.kabbalahCrossSectionEl, !isKabbalahCrossOpen); setHidden(elements.kabbalahTreeSectionEl, !isKabbalahTreeOpen); setHidden(elements.cubeSectionEl, !isCubeOpen); setHidden(elements.alphabetSectionEl, !isAlphabetOpen); @@ -168,6 +177,10 @@ setPressed(elements.openElementsEl, isElementsOpen); setPressed(elements.openIChingEl, isIChingOpen); setPressed(elements.openKabbalahEl, isKabbalahMenuOpen); + toggleActive(elements.openKabbalahSephirotEl, isKabbalahOpen); + toggleActive(elements.openKabbalahWorldsEl, isKabbalahWorldsOpen); + toggleActive(elements.openKabbalahPathsEl, isKabbalahPathsOpen); + toggleActive(elements.openKabbalahCrossEl, isKabbalahCrossOpen); toggleActive(elements.openKabbalahTreeEl, isKabbalahTreeOpen); toggleActive(elements.openKabbalahCubeEl, isCubeOpen); setPressed(elements.openAlphabetEl, isAlphabetMenuOpen); @@ -249,7 +262,7 @@ return; } - if (isKabbalahOpen || isKabbalahTreeOpen) { + if (isKabbalahOpen || isKabbalahWorldsOpen || isKabbalahPathsOpen || isKabbalahCrossOpen || isKabbalahTreeOpen) { ensure.ensureKabbalahSection?.(magickDataset); return; } diff --git a/app/ui-sequence-nav.js b/app/ui-sequence-nav.js new file mode 100644 index 0000000..2ee466b --- /dev/null +++ b/app/ui-sequence-nav.js @@ -0,0 +1,189 @@ +(function () { + "use strict"; + + function normalizeSequenceState(sequence) { + return { + total: Math.max(0, Number(sequence?.total) || 0), + currentIndex: Number.isFinite(Number(sequence?.currentIndex)) ? Number(sequence.currentIndex) : -1, + previousKey: String(sequence?.previousKey ?? sequence?.previousId ?? "").trim(), + nextKey: String(sequence?.nextKey ?? sequence?.nextId ?? "").trim() + }; + } + + function isEditableKeyTarget(target) { + if (!(target instanceof HTMLElement)) { + return false; + } + + return target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || target instanceof HTMLSelectElement + || target.isContentEditable + || Boolean(target.closest("[contenteditable='true']")); + } + + function hasOpenModalDialog() { + return Boolean(document.querySelector("[role='dialog'][aria-modal='true'][aria-hidden='false']")); + } + + function createSequenceNavigator(config = {}) { + const getElements = typeof config.getElements === "function" + ? config.getElements + : () => ({}); + + let buttonsBound = false; + let keyboardBound = false; + + function getSequenceState() { + return normalizeSequenceState( + typeof config.getSequenceState === "function" + ? config.getSequenceState() + : null + ); + } + + function getPrevButton(elements) { + return typeof config.getPrevButton === "function" ? config.getPrevButton(elements) : null; + } + + function getNextButton(elements) { + return typeof config.getNextButton === "function" ? config.getNextButton(elements) : null; + } + + function getPositionEl(elements) { + return typeof config.getPositionEl === "function" ? config.getPositionEl(elements) : null; + } + + function isActive(elements) { + return typeof config.isActive === "function" ? config.isActive(elements) !== false : true; + } + + function getTargetKey(sequence, offset) { + return offset < 0 ? sequence.previousKey : sequence.nextKey; + } + + function formatPositionText(sequence, elements) { + return typeof config.formatPositionText === "function" + ? String(config.formatPositionText(sequence, elements) || "") + : ""; + } + + function selectTarget(targetKey, elements, offset) { + if (!targetKey || typeof config.selectTarget !== "function") { + return false; + } + + return config.selectTarget(targetKey, elements, offset) !== false; + } + + function afterSelect(targetKey, elements, offset) { + if (typeof config.afterSelect === "function") { + config.afterSelect(targetKey, elements, offset); + } + } + + function shouldHandleKeyEvent(event, elements) { + if (!isActive(elements)) { + return false; + } + + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return false; + } + + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return false; + } + + if (hasOpenModalDialog()) { + return false; + } + + return !isEditableKeyTarget(event.target); + } + + function sync(elements = getElements()) { + const sequence = getSequenceState(); + const previousKey = getTargetKey(sequence, -1); + const nextKey = getTargetKey(sequence, 1); + const prevButton = getPrevButton(elements); + const nextButton = getNextButton(elements); + const positionEl = getPositionEl(elements); + + if (prevButton) { + prevButton.disabled = !previousKey; + } + + if (nextButton) { + nextButton.disabled = !nextKey; + } + + if (positionEl) { + positionEl.textContent = formatPositionText(sequence, elements); + } + } + + function step(offset, elements = getElements()) { + const sequence = getSequenceState(); + const targetKey = getTargetKey(sequence, offset); + if (!targetKey) { + return false; + } + + const didSelect = selectTarget(targetKey, elements, offset); + if (didSelect) { + afterSelect(targetKey, elements, offset); + } + + return didSelect; + } + + function bind(elements = getElements()) { + if (!buttonsBound) { + getPrevButton(elements)?.addEventListener("click", () => { + step(-1, getElements()); + }); + + getNextButton(elements)?.addEventListener("click", () => { + step(1, getElements()); + }); + + buttonsBound = true; + } + + if (!keyboardBound) { + document.addEventListener("keydown", (event) => { + const latestElements = getElements(); + if (!shouldHandleKeyEvent(event, latestElements)) { + return; + } + + const offset = event.key === "ArrowRight" ? 1 : -1; + const sequence = getSequenceState(); + if (!getTargetKey(sequence, offset)) { + return; + } + + event.preventDefault(); + step(offset, latestElements); + }); + + keyboardBound = true; + } + + sync(elements); + } + + return { + bind, + step, + sync, + getSequenceState + }; + } + + window.TarotSequenceNav = { + ...(window.TarotSequenceNav || {}), + createSequenceNavigator + }; +})(); \ No newline at end of file diff --git a/app/ui-settings.js b/app/ui-settings.js index baa28c9..e3dc99d 100644 --- a/app/ui-settings.js +++ b/app/ui-settings.js @@ -209,12 +209,17 @@ const activeDeckId = String(status?.activeDeckId || normalizeTarotDeck(getElements().tarotDeckEl?.value)).trim().toLowerCase(); const loadedCount = Math.max(0, Number(status?.selectedDeckLoadedCount) || 0); const totalCount = Math.max(0, Number(status?.selectedDeckTotalCount) || 0); + const warmedDeckCount = Math.max(0, Number(status?.warmedDeckCount) || 0); + const totalDeckCount = Math.max(0, Number(status?.totalDeckCount) || 0); + const backgroundProgress = totalDeckCount > 1 + ? ` (${Math.min(warmedDeckCount, totalDeckCount)}/${totalDeckCount} decks warmed)` + : ""; if (status?.selectedDeckPhase === "loading") { if (totalCount > 0) { - return `Caching selected deck images to this browser... (${loadedCount}/${totalCount})`; + return `Caching selected deck images to this browser... (${loadedCount}/${totalCount})${backgroundProgress}`; } - return "Caching selected deck images to this browser..."; + return `Caching selected deck images to this browser...${backgroundProgress}`; } if (status?.selectedDeckPhase === "error") { @@ -222,9 +227,21 @@ } if (status?.selectedDeckPhase === "ready") { + if (totalDeckCount > 1 && warmedDeckCount < totalDeckCount) { + return `Selected deck cached and ready for fullscreen use (${activeDeckId}). Warming the rest of the decks in background${backgroundProgress}.`; + } + + if (totalDeckCount > 1) { + return `All connected deck images cached and ready (${totalDeckCount}/${totalDeckCount} decks warmed).`; + } + return `Selected deck cached and ready for fullscreen use (${activeDeckId}).`; } + if (totalDeckCount > 1 && warmedDeckCount > 0) { + return `Deck cache idle. Background warmup has ${Math.min(warmedDeckCount, totalDeckCount)}/${totalDeckCount} decks ready.`; + } + return "Deck cache idle."; } @@ -570,6 +587,18 @@ const previousConnectionSettings = getConnectionSettings(); const connectionSettings = getConnectionSettingsFromInputs(); const connectionChanged = hasConnectionChanged(previousConnectionSettings, connectionSettings); + + if (connectionChanged) { + setSettingsPageStatus("Validating API connection...", "info", { + savedAt: loadLastSavedAt() + }); + + const probeResult = await window.TarotDataService?.probeConnection?.(connectionSettings); + if (!probeResult?.ok) { + throw new Error(probeResult?.message || "Unable to validate the API connection."); + } + } + const connectionResult = window.TarotAppConfig?.updateConnectionSettings?.(connectionSettings) || { didPersist: true }; const normalized = applySettingsToInputs(settings); syncSky( diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js index 0e20719..8212c54 100644 --- a/app/ui-tarot-detail.js +++ b/app/ui-tarot-detail.js @@ -5,6 +5,8 @@ getMagickDataset, resolveTarotCardImage, resolveTarotCardThumbnail, + getDeckVariantsForCard, + openDeckVariantLightbox, getDisplayCardName, buildTypeLabel, clearChildren, @@ -407,6 +409,73 @@ .filter((group) => group.items.length); } + function renderDeckVariants(card, elements, cardDisplayName) { + const galleryCardEl = elements?.tarotMetaDeckGalleryCardEl; + const galleryEl = elements?.tarotDetailDeckGalleryEl; + if (!galleryCardEl || !galleryEl) { + return; + } + + clearChildren(galleryEl); + const variants = typeof getDeckVariantsForCard === "function" + ? getDeckVariantsForCard(card) + : []; + + if (!Array.isArray(variants) || variants.length < 1) { + galleryCardEl.hidden = true; + return; + } + + variants.forEach((variant) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `tarot-deck-variant${variant?.isActive ? " is-active" : ""}`; + button.dataset.deckId = String(variant?.deckId || "").trim().toLowerCase(); + + const imageEl = document.createElement("img"); + imageEl.className = "tarot-deck-variant-image"; + imageEl.src = String(variant?.src || "").trim(); + imageEl.alt = `${String(variant?.label || "Deck").trim()} — ${cardDisplayName || card?.name || "Tarot card"}`; + imageEl.loading = "lazy"; + imageEl.decoding = "async"; + + const labelEl = document.createElement("span"); + labelEl.className = "tarot-deck-variant-label"; + + const deckNameEl = document.createElement("span"); + deckNameEl.className = "tarot-deck-variant-deck"; + deckNameEl.textContent = String(variant?.label || variant?.deckId || "Deck").trim() || "Deck"; + labelEl.appendChild(deckNameEl); + + const variantName = String(variant?.displayName || "").trim(); + if (variantName && variantName !== (cardDisplayName || card?.name || "")) { + const variantNameEl = document.createElement("span"); + variantNameEl.className = "tarot-deck-variant-name"; + variantNameEl.textContent = variantName; + labelEl.appendChild(variantNameEl); + } + + if (variant?.isActive) { + const activeEl = document.createElement("span"); + activeEl.className = "tarot-deck-variant-active"; + activeEl.textContent = "Current deck"; + labelEl.appendChild(activeEl); + } + + button.append(imageEl, labelEl); + + if (typeof openDeckVariantLightbox === "function" && button.dataset.deckId) { + button.addEventListener("click", () => { + openDeckVariantLightbox(card?.id, button.dataset.deckId); + }); + } + + galleryEl.appendChild(button); + }); + + galleryCardEl.hidden = false; + } + function renderDetail(card, elements) { if (!card || !elements) { return; @@ -454,6 +523,8 @@ elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--"; } + renderDeckVariants(card, elements, cardDisplayName || card.name); + const meaningText = String(card.meaning || card.meanings?.upright || "").trim(); if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) { if (meaningText) { diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js index 32834c7..05860f9 100644 --- a/app/ui-tarot-lightbox.js +++ b/app/ui-tarot-lightbox.js @@ -837,12 +837,14 @@ function normalizeCardRequest(request) { const normalized = normalizeOpenRequest(request); const label = String(normalized.label || normalized.altText || "Tarot card enlarged image").trim() || "Tarot card enlarged image"; + const cardId = String(normalized.cardId || "").trim(); return { src: String(normalized.src || "").trim(), previewSrc: String(normalized.previewSrc || "").trim(), altText: String(normalized.altText || label).trim() || label, label, - cardId: String(normalized.cardId || "").trim(), + cardId, + sequenceId: String(normalized.sequenceId || cardId).trim(), deckId: String(normalized.deckId || "").trim(), deckLabel: String(normalized.deckLabel || normalized.deckId || "").trim(), missingReason: String(normalized.missingReason || "").trim(), @@ -995,19 +997,20 @@ }); } - function resolveCardRequestById(cardId) { - if (!cardId || typeof lightboxState.resolveCardById !== "function") { + function resolveCardRequestById(sequenceId) { + if (!sequenceId || typeof lightboxState.resolveCardById !== "function") { return null; } - const resolved = lightboxState.resolveCardById(cardId); + const resolved = lightboxState.resolveCardById(sequenceId); if (!resolved) { return null; } return normalizeCardRequest({ ...resolved, - cardId + sequenceId: String(resolved.sequenceId || sequenceId).trim() || String(sequenceId), + cardId: String(resolved.cardId || sequenceId).trim() }); } @@ -3277,7 +3280,10 @@ return; } - const anchorId = lightboxState.secondaryCard?.cardId || lightboxState.primaryCard?.cardId; + const anchorId = lightboxState.secondaryCard?.sequenceId + || lightboxState.secondaryCard?.cardId + || lightboxState.primaryCard?.sequenceId + || lightboxState.primaryCard?.cardId; const startIndex = sequence.indexOf(anchorId); if (startIndex < 0) { return; @@ -3285,12 +3291,13 @@ for (let offset = 1; offset <= sequence.length; offset += 1) { const nextIndex = (startIndex + direction * offset + sequence.length) % sequence.length; - const nextCardId = sequence[nextIndex]; - if (!nextCardId || nextCardId === lightboxState.primaryCard?.cardId) { + const nextSequenceId = sequence[nextIndex]; + const primarySequenceId = lightboxState.primaryCard?.sequenceId || lightboxState.primaryCard?.cardId; + if (!nextSequenceId || nextSequenceId === primarySequenceId) { continue; } - const nextCard = resolveCardRequestById(nextCardId); + const nextCard = resolveCardRequestById(nextSequenceId); if (nextCard && setSecondaryCard(nextCard, true)) { break; } @@ -3303,14 +3310,15 @@ return; } - const startIndex = sequence.indexOf(lightboxState.primaryCard?.cardId); + const primarySequenceId = lightboxState.primaryCard?.sequenceId || lightboxState.primaryCard?.cardId; + const startIndex = sequence.indexOf(primarySequenceId); if (startIndex < 0) { return; } const nextIndex = (startIndex + direction + sequence.length) % sequence.length; - const nextCardId = sequence[nextIndex]; - const nextCard = resolveCardRequestById(nextCardId); + const nextSequenceId = sequence[nextIndex]; + const nextCard = resolveCardRequestById(nextSequenceId); if (!nextCard?.src) { return; } diff --git a/app/ui-tarot.js b/app/ui-tarot.js index 527d8ff..66b1e86 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -47,6 +47,7 @@ courtCardByDecanId: new Map(), loadingPromise: null }; + let detailNavigator = null; const TAROT_TRUMP_NUMBER_BY_NAME = { "the fool": 0, @@ -255,9 +256,14 @@ tarotDetailImageEl: document.getElementById("tarot-detail-image"), tarotDetailNameEl: document.getElementById("tarot-detail-name"), tarotDetailTypeEl: document.getElementById("tarot-detail-type"), + tarotDetailPrevEl: document.getElementById("tarot-detail-prev"), + tarotDetailPositionEl: document.getElementById("tarot-detail-position"), + tarotDetailNextEl: document.getElementById("tarot-detail-next"), tarotDetailSummaryEl: document.getElementById("tarot-detail-summary"), tarotDetailUprightEl: document.getElementById("tarot-detail-upright"), tarotDetailReversedEl: document.getElementById("tarot-detail-reversed"), + tarotMetaDeckGalleryCardEl: document.getElementById("tarot-meta-deck-gallery-card"), + tarotDetailDeckGalleryEl: document.getElementById("tarot-detail-deck-gallery"), tarotMetaMeaningCardEl: document.getElementById("tarot-meta-meaning-card"), tarotDetailMeaningEl: document.getElementById("tarot-detail-meaning"), tarotDetailKeywordsEl: document.getElementById("tarot-detail-keywords"), @@ -355,6 +361,8 @@ getMagickDataset: () => state.magickDataset, resolveTarotCardImage, resolveTarotCardThumbnail, + getDeckVariantsForCard, + openDeckVariantLightbox, getDisplayCardName, buildTypeLabel, clearChildren, @@ -523,11 +531,14 @@ if (!state.filteredCards.some((card) => card.id === state.selectedCardId)) { if (state.filteredCards.length > 0) { selectCardById(state.filteredCards[0].id, elements); + } else { + syncDetailNavigation(elements); } return; } updateListSelection(elements); + syncDetailNavigation(elements); } function clearChildren(element) { @@ -723,6 +734,62 @@ }); } + function getCardSequenceState() { + const total = state.filteredCards.length; + const currentIndex = state.filteredCards.findIndex((card) => card.id === state.selectedCardId); + + return { + total, + currentIndex, + previousId: currentIndex > 0 ? state.filteredCards[currentIndex - 1].id : "", + nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredCards[currentIndex + 1].id : "" + }; + } + + function getDetailNavigator() { + if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") { + return detailNavigator; + } + + detailNavigator = window.TarotSequenceNav.createSequenceNavigator({ + getElements, + isActive: (elements) => Boolean(elements?.tarotSectionEl && elements.tarotSectionEl.hidden === false), + getSequenceState: getCardSequenceState, + getPrevButton: (elements) => elements?.tarotDetailPrevEl, + getNextButton: (elements) => elements?.tarotDetailNextEl, + getPositionEl: (elements) => elements?.tarotDetailPositionEl, + formatPositionText: ({ total, currentIndex }) => { + if (total > 0 && currentIndex >= 0) { + const suffix = state.searchQuery ? " shown" : ""; + return `${currentIndex + 1} of ${total}${suffix}`; + } + + return total > 0 ? `${total} cards` : "No cards"; + }, + selectTarget: (targetId, elements) => { + selectCardById(targetId, elements); + return true; + }, + afterSelect: (targetId, elements) => { + scrollCardIntoView(targetId, elements); + } + }); + + return detailNavigator; + } + + function syncDetailNavigation(elements) { + getDetailNavigator()?.sync(elements); + } + + function selectAdjacentCard(offset, elements = getElements()) { + return getDetailNavigator()?.step(offset, elements) === true; + } + + function bindKeyboardNavigation(elements) { + getDetailNavigator()?.bind(elements); + } + function selectCardById(cardIdToSelect, elements) { const card = state.cards.find((entry) => entry.id === cardIdToSelect); if (!card) { @@ -733,6 +800,7 @@ updateListSelection(elements); updateHouseSelection(elements); renderDetail(card, elements); + syncDetailNavigation(elements); } function scrollCardIntoView(cardIdToReveal, elements) { @@ -758,6 +826,47 @@ return Array.from(getRegisteredDeckOptionMap().values()); } + function getDeckVariantsForCard(card) { + if (!card) { + return []; + } + + const trumpNumber = Number.isFinite(Number(card?.number)) ? Number(card.number) : undefined; + const activeDeckId = String(getActiveDeck?.() || "").trim().toLowerCase(); + + return getRegisteredDeckList() + .map((deck) => { + const deckId = String(deck?.id || "").trim().toLowerCase(); + if (!deckId) { + return null; + } + + const imageOptions = { deckId, trumpNumber }; + const src = (typeof resolveTarotCardThumbnail === "function" + ? resolveTarotCardThumbnail(card.name, imageOptions) + : "") || (typeof resolveTarotCardImage === "function" + ? resolveTarotCardImage(card.name, imageOptions) + : ""); + + if (!src) { + return null; + } + + const displayName = (typeof getTarotCardDisplayName === "function" + ? String(getTarotCardDisplayName(card.name, imageOptions) || "").trim() + : "") || getDisplayCardName(card) || card.name; + + return { + deckId, + label: String(deck?.label || deckId).trim() || deckId, + src, + displayName, + isActive: deckId === activeDeckId + }; + }) + .filter(Boolean); + } + function buildDeckLightboxCardRequest(cardIdToResolve, deckIdToResolve = "") { const card = state.cards.find((entry) => entry.id === cardIdToResolve); if (!card) { @@ -790,8 +899,8 @@ }; } - function buildLightboxCardRequestById(cardIdToResolve) { - const request = buildDeckLightboxCardRequest(cardIdToResolve, getActiveDeck?.() || ""); + function buildLightboxCardRequestById(cardIdToResolve, deckIdToResolve = "") { + const request = buildDeckLightboxCardRequest(cardIdToResolve, deckIdToResolve || getActiveDeck?.() || ""); if (!request?.src) { return null; } @@ -799,19 +908,67 @@ return request; } + function getDefaultLightboxSequenceIds(cardIdToOpen = "") { + const normalizedCardId = String(cardIdToOpen || "").trim(); + const filteredIds = state.filteredCards + .map((card) => String(card?.id || "").trim()) + .filter(Boolean); + + if (normalizedCardId && filteredIds.includes(normalizedCardId)) { + return filteredIds; + } + + return state.cards + .map((card) => String(card?.id || "").trim()) + .filter(Boolean); + } + + function getDeckVariantSequenceEntries(cardIdToResolve) { + const card = state.cards.find((entry) => entry.id === cardIdToResolve); + if (!card) { + return []; + } + + return getDeckVariantsForCard(card) + .map((variant) => { + const deckId = String(variant?.deckId || "").trim().toLowerCase(); + if (!deckId) { + return null; + } + + return { + sequenceId: deckId, + deckId + }; + }) + .filter(Boolean); + } + function openCardLightboxById(cardIdToOpen, options = {}) { const normalizedCardId = String(cardIdToOpen || "").trim(); if (!normalizedCardId) { return; } - const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId); + const requestedDeckId = String(options?.deckId || getActiveDeck?.() || "").trim(); + const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId, requestedDeckId); if (!primaryCardRequest?.src) { return; } - const activeDeckId = String(getActiveDeck?.() || primaryCardRequest.deckId || "").trim(); + const activeDeckId = String(primaryCardRequest.deckId || requestedDeckId || getActiveDeck?.() || "").trim(); const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeDeckId); + const requestedSequenceIds = Array.isArray(options?.sequenceIds) + ? options.sequenceIds + .map((sequenceId) => String(sequenceId || "").trim()) + .filter(Boolean) + : []; + const sequenceIds = requestedSequenceIds.length > 0 + ? requestedSequenceIds + : getDefaultLightboxSequenceIds(normalizedCardId); + const resolveCardById = typeof options?.resolveCardById === "function" + ? options.resolveCardById + : (nextCardId) => buildLightboxCardRequestById(nextCardId, activeDeckId); const onSelectCardId = typeof options?.onSelectCardId === "function" ? options.onSelectCardId : (nextCardId) => { @@ -825,6 +982,7 @@ altText: primaryCardRequest.altText, label: primaryCardRequest.label, cardId: primaryCardRequest.cardId, + sequenceId: String(options?.sequenceId || primaryCardRequest.sequenceId || normalizedCardId).trim(), deckId: primaryCardRequest.deckId || activeDeckId, deckLabel: primaryCardRequest.deckLabel || "", compareDetails: primaryCardRequest.compareDetails || [], @@ -834,13 +992,72 @@ activeDeckLabel: primaryCardRequest.deckLabel || "", availableCompareDecks, maxCompareDecks: 2, - sequenceIds: state.cards.map((card) => card.id), - resolveCardById: buildLightboxCardRequestById, + sequenceIds, + resolveCardById, resolveDeckCardById: buildDeckLightboxCardRequest, onSelectCardId }); } + function openDeckVariantLightbox(cardIdToOpen, deckIdToOpen = "") { + const normalizedCardId = String(cardIdToOpen || "").trim(); + if (!normalizedCardId) { + return; + } + + const variantEntries = getDeckVariantSequenceEntries(normalizedCardId); + if (variantEntries.length < 1) { + openCardLightboxById(normalizedCardId, { deckId: deckIdToOpen }); + return; + } + + const requestedDeckId = String(deckIdToOpen || getActiveDeck?.() || "").trim().toLowerCase(); + const activeVariant = variantEntries.find((variant) => variant.deckId === requestedDeckId) || variantEntries[0]; + if (!activeVariant?.deckId) { + openCardLightboxById(normalizedCardId); + return; + } + + const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId, activeVariant.deckId); + if (!primaryCardRequest?.src) { + return; + } + + const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeVariant.deckId); + + window.TarotUiLightbox?.open?.({ + ...primaryCardRequest, + deckId: activeVariant.deckId, + sequenceId: activeVariant.sequenceId, + activeDeckId: activeVariant.deckId, + activeDeckLabel: primaryCardRequest.deckLabel || "", + availableCompareDecks, + maxCompareDecks: 2, + allowOverlayCompare: true, + allowDeckCompare: true, + sequenceIds: variantEntries.map((variant) => variant.sequenceId), + resolveDeckCardById: buildDeckLightboxCardRequest, + resolveCardById: (sequenceId) => { + const normalizedSequenceId = String(sequenceId || "").trim().toLowerCase(); + const matchingVariant = variantEntries.find((variant) => variant.sequenceId === normalizedSequenceId); + if (!matchingVariant?.deckId) { + return null; + } + + const request = buildLightboxCardRequestById(normalizedCardId, matchingVariant.deckId); + return request + ? { + ...request, + sequenceId: matchingVariant.sequenceId, + deckId: matchingVariant.deckId + } + : null; + }, + onSelectCardId: () => { + } + }); + } + function renderList(elements) { if (!elements?.tarotCardListEl) { return; @@ -940,6 +1157,7 @@ const selected = state.cards.find((card) => card.id === state.selectedCardId); if (selected) { renderDetail(selected, elements); + syncDetailNavigation(elements); } } return; @@ -1007,6 +1225,8 @@ }); } + bindKeyboardNavigation(elements); + if (elements.tarotHouseTopCardsVisibleEl) { elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => { state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked); @@ -1104,12 +1324,7 @@ return; } - const request = buildLightboxCardRequestById(state.selectedCardId); - if (!request?.src) { - return; - } - - window.TarotUiLightbox?.open?.(request); + openCardLightboxById(state.selectedCardId); }); } diff --git a/index.html b/index.html index 55622d2..ca536bf 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +
@@ -63,8 +63,12 @@
@@ -216,6 +220,11 @@

--

Select a month to explore
+
+ +
--
+ +
@@ -252,6 +261,11 @@

--

Select a holiday to explore
+
+ +
--
+ +
@@ -273,10 +287,14 @@
- Tarot card image

--

--
+
+ +
--
+ +
--
@@ -291,6 +309,10 @@
+