diff --git a/app.js b/app.js index a5bf17f..c97c4c2 100644 --- a/app.js +++ b/app.js @@ -10,6 +10,7 @@ const { ensureIChingSection } = window.IChingSectionUi || {}; const { ensureKabbalahSection } = window.KabbalahSectionUi || {}; const { ensureCubeSection } = window.CubeSectionUi || {}; const { ensureAlphabetSection } = window.AlphabetSectionUi || {}; +const { ensureAlphabetTextSection } = window.AlphabetTextUi || {}; const { ensureZodiacSection } = window.ZodiacSectionUi || {}; const { ensureQuizSection } = window.QuizSectionUi || {}; const { ensureGodsSection } = window.GodsSectionUi || {}; @@ -46,6 +47,7 @@ const kabbalahTreeSectionEl = document.getElementById("kabbalah-tree-section"); const cubeSectionEl = document.getElementById("cube-section"); const alphabetSectionEl = document.getElementById("alphabet-section"); const alphabetLettersSectionEl = document.getElementById("alphabet-letters-section"); +const alphabetTextSectionEl = document.getElementById("alphabet-text-section"); const numbersSectionEl = document.getElementById("numbers-section"); const zodiacSectionEl = document.getElementById("zodiac-section"); const quizSectionEl = document.getElementById("quiz-section"); @@ -67,6 +69,7 @@ const openKabbalahTreeEl = document.getElementById("open-kabbalah-tree"); const openKabbalahCubeEl = document.getElementById("open-kabbalah-cube"); const openAlphabetEl = document.getElementById("open-alphabet"); const openAlphabetLettersEl = document.getElementById("open-alphabet-letters"); +const openAlphabetTextEl = document.getElementById("open-alphabet-text"); const openNumbersEl = document.getElementById("open-numbers"); const openZodiacEl = document.getElementById("open-zodiac"); const openNatalEl = document.getElementById("open-natal"); @@ -407,6 +410,7 @@ sectionStateUi.init?.({ cubeSectionEl, alphabetSectionEl, alphabetLettersSectionEl, + alphabetTextSectionEl, numbersSectionEl, zodiacSectionEl, quizSectionEl, @@ -428,6 +432,7 @@ sectionStateUi.init?.({ openKabbalahCubeEl, openAlphabetEl, openAlphabetLettersEl, + openAlphabetTextEl, openNumbersEl, openZodiacEl, openNatalEl, @@ -444,6 +449,7 @@ sectionStateUi.init?.({ ensureKabbalahSection, ensureCubeSection, ensureAlphabetSection, + ensureAlphabetTextSection, ensureZodiacSection, ensureQuizSection, ensureGodsSection, @@ -521,6 +527,7 @@ navigationUi.init?.({ openKabbalahCubeEl, openAlphabetEl, openAlphabetLettersEl, + openAlphabetTextEl, openNumbersEl, openZodiacEl, openNatalEl, @@ -537,6 +544,7 @@ navigationUi.init?.({ ensureKabbalahSection, ensureCubeSection, ensureAlphabetSection, + ensureAlphabetTextSection, ensureZodiacSection, ensureGodsSection, ensureCalendarSection diff --git a/app/data-service.js b/app/data-service.js index 23b31e6..22f1988 100644 --- a/app/data-service.js +++ b/app/data-service.js @@ -5,6 +5,12 @@ const deckManifestCache = new Map(); let quizCategoriesCache = null; const quizTemplatesCache = new Map(); + let textLibraryCache = null; + const textSourceCache = new Map(); + const textSectionCache = new Map(); + const textLexiconCache = new Map(); + const textLexiconOccurrencesCache = new Map(); + const textSearchCache = new Map(); const DATA_ROOT = "data"; const MAGICK_ROOT = DATA_ROOT; @@ -219,8 +225,14 @@ magickDataCache = null; deckOptionsCache = null; quizCategoriesCache = null; + textLibraryCache = null; deckManifestCache.clear(); quizTemplatesCache.clear(); + textSourceCache.clear(); + textSectionCache.clear(); + textLexiconCache.clear(); + textLexiconOccurrencesCache.clear(); + textSearchCache.clear(); } function normalizeTarotName(value) { @@ -378,6 +390,127 @@ })); } + async function loadTextLibrary(forceRefresh = false) { + if (!forceRefresh && textLibraryCache) { + return textLibraryCache; + } + + textLibraryCache = await fetchJson(buildApiUrl("/api/v1/texts")); + return textLibraryCache; + } + + async function loadTextSource(sourceId, forceRefresh = false) { + const normalizedSourceId = String(sourceId || "").trim().toLowerCase(); + if (!normalizedSourceId) { + return null; + } + + if (!forceRefresh && textSourceCache.has(normalizedSourceId)) { + return textSourceCache.get(normalizedSourceId); + } + + const payload = await fetchJson(buildApiUrl(`/api/v1/texts/${encodeURIComponent(normalizedSourceId)}`)); + textSourceCache.set(normalizedSourceId, payload); + return payload; + } + + async function loadTextSection(sourceId, workId, sectionId, forceRefresh = false) { + const normalizedSourceId = String(sourceId || "").trim().toLowerCase(); + const normalizedWorkId = String(workId || "").trim().toLowerCase(); + const normalizedSectionId = String(sectionId || "").trim().toLowerCase(); + if (!normalizedSourceId || !normalizedWorkId || !normalizedSectionId) { + return null; + } + + const cacheKey = `${normalizedSourceId}::${normalizedWorkId}::${normalizedSectionId}`; + if (!forceRefresh && textSectionCache.has(cacheKey)) { + return textSectionCache.get(cacheKey); + } + + const payload = await fetchJson(buildApiUrl( + `/api/v1/texts/${encodeURIComponent(normalizedSourceId)}/works/${encodeURIComponent(normalizedWorkId)}/sections/${encodeURIComponent(normalizedSectionId)}` + )); + textSectionCache.set(cacheKey, payload); + return payload; + } + + async function loadTextLexiconEntry(lexiconId, entryId, forceRefresh = false) { + const normalizedLexiconId = String(lexiconId || "").trim().toLowerCase(); + const normalizedEntryId = String(entryId || "").trim().toUpperCase(); + if (!normalizedLexiconId || !normalizedEntryId) { + return null; + } + + const cacheKey = `${normalizedLexiconId}::${normalizedEntryId}`; + if (!forceRefresh && textLexiconCache.has(cacheKey)) { + return textLexiconCache.get(cacheKey); + } + + const payload = await fetchJson(buildApiUrl( + `/api/v1/texts/lexicons/${encodeURIComponent(normalizedLexiconId)}/entries/${encodeURIComponent(normalizedEntryId)}` + )); + textLexiconCache.set(cacheKey, payload); + return payload; + } + + async function loadTextLexiconOccurrences(lexiconId, entryId, options = {}, forceRefresh = false) { + const normalizedLexiconId = String(lexiconId || "").trim().toLowerCase(); + const normalizedEntryId = String(entryId || "").trim().toUpperCase(); + const normalizedLimit = Number.parseInt(options?.limit, 10); + const limit = Number.isFinite(normalizedLimit) ? normalizedLimit : 100; + if (!normalizedLexiconId || !normalizedEntryId) { + return null; + } + + const cacheKey = `${normalizedLexiconId}::${normalizedEntryId}::${limit}`; + if (!forceRefresh && textLexiconOccurrencesCache.has(cacheKey)) { + return textLexiconOccurrencesCache.get(cacheKey); + } + + const payload = await fetchJson(buildApiUrl( + `/api/v1/texts/lexicons/${encodeURIComponent(normalizedLexiconId)}/entries/${encodeURIComponent(normalizedEntryId)}/occurrences`, + { limit } + )); + textLexiconOccurrencesCache.set(cacheKey, payload); + return payload; + } + + async function searchTextLibrary(query, options = {}, forceRefresh = false) { + const normalizedQuery = String(query || "").trim(); + const normalizedSourceId = String(options?.sourceId || "").trim().toLowerCase(); + const normalizedLimit = Number.parseInt(options?.limit, 10); + const limit = Number.isFinite(normalizedLimit) ? normalizedLimit : 50; + + if (!normalizedQuery) { + return { + query: "", + normalizedQuery: "", + scope: normalizedSourceId ? { type: "source", sourceId: normalizedSourceId } : { type: "global" }, + limit, + totalMatches: 0, + resultCount: 0, + truncated: false, + results: [] + }; + } + + const cacheKey = `${normalizedSourceId || "global"}::${limit}::${normalizedQuery.toLowerCase()}`; + if (!forceRefresh && textSearchCache.has(cacheKey)) { + return textSearchCache.get(cacheKey); + } + + const path = normalizedSourceId + ? `/api/v1/texts/${encodeURIComponent(normalizedSourceId)}/search` + : "/api/v1/texts/search"; + + const payload = await fetchJson(buildApiUrl(path, { + q: normalizedQuery, + limit + })); + textSearchCache.set(cacheKey, payload); + return payload; + } + async function loadDeckOptions(forceRefresh = false) { if (!forceRefresh && deckOptionsCache) { return deckOptionsCache; @@ -515,6 +648,12 @@ loadReferenceData, loadMagickManifest, loadMagickDataset, + loadTextLibrary, + loadTextSource, + searchTextLibrary, + loadTextSection, + loadTextLexiconEntry, + loadTextLexiconOccurrences, probeConnection, pullQuizQuestion, pullTarotSpread, diff --git a/app/styles.css b/app/styles.css index 365e970..436691e 100644 --- a/app/styles.css +++ b/app/styles.css @@ -21,7 +21,9 @@ border-bottom: 1px solid #27272a; background: #18181b; min-width: 0; - overflow: hidden; + overflow: visible; + position: relative; + z-index: 40; } .topbar-home-button { padding: 0; @@ -49,7 +51,12 @@ justify-content: flex-start; overflow-x: auto; overflow-y: hidden; - padding-bottom: 2px; + padding-bottom: 132px; + margin-bottom: -130px; + pointer-events: none; + } + .topbar-actions > * { + pointer-events: auto; } .topbar-actions::-webkit-scrollbar { height: 6px; @@ -77,6 +84,10 @@ .topbar-dropdown.is-open .topbar-dropdown-menu { display: grid; } + .topbar-dropdown:hover .topbar-dropdown-menu, + .topbar-dropdown:focus-within .topbar-dropdown-menu { + display: grid; + } .topbar-sub-trigger { width: 100%; text-align: left; @@ -1095,6 +1106,8 @@ align-items: baseline; justify-content: space-between; gap: 8px; + position: relative; + z-index: 1; } .planet-list-count { color: #a1a1aa; @@ -1144,6 +1157,10 @@ gap: 16px; background: #18181b; } + .planet-detail-heading { + position: relative; + z-index: 1; + } .planet-detail-heading h2 { margin: 0; font-size: 24px; @@ -1979,11 +1996,17 @@ .sidebar-toggle-inline { margin-left: auto; align-self: center; + position: relative; + z-index: 2; + pointer-events: auto; } .detail-toggle-inline { margin-left: auto; align-self: center; + position: relative; + z-index: 2; + pointer-events: auto; } .sidebar-popout-open { @@ -3072,6 +3095,16 @@ border-color: #818cf8; color: #e0e7ff; } + .alpha-nav-btn--ghost { + background: transparent; + border-color: #3f3f46; + color: #d4d4d8; + } + .alpha-nav-btn--ghost:hover { + background: #18181b; + border-color: #71717a; + color: #fafafa; + } .alpha-nav-btn.is-selected, .alpha-nav-btn[aria-pressed="true"] { background: #4338ca; @@ -3126,6 +3159,507 @@ color: #a1a1aa; } + #alphabet-text-section { + height: calc(100vh - 61px); + background: #18181b; + box-sizing: border-box; + overflow: hidden; + } + + #alphabet-text-section[hidden] { + display: none; + } + + .alpha-text-controls { + display: grid; + gap: 10px; + padding: 12px; + border-top: 1px solid #27272a; + background: #101018; + } + + .alpha-text-search-controls { + display: grid; + gap: 10px; + } + + .alpha-text-search-controls--sidebar { + padding: 12px; + border-bottom: 1px solid #27272a; + background: + linear-gradient(180deg, rgba(30, 27, 75, 0.22), rgba(16, 16, 24, 0.96)); + } + + .alpha-text-search-controls--detail { + padding: 14px; + border: 1px solid #2f2f39; + border-radius: 14px; + background: + linear-gradient(180deg, rgba(24, 24, 38, 0.98), rgba(12, 12, 18, 0.98)); + box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.08); + } + + .alpha-text-control { + display: grid; + gap: 4px; + } + + .alpha-text-control > span { + color: #a1a1aa; + font-size: 11px; + letter-spacing: 0.02em; + } + + .alpha-text-select { + width: 100%; + padding: 7px 8px; + border-radius: 6px; + border: 1px solid #3f3f46; + background: #09090b; + color: #f4f4f5; + box-sizing: border-box; + font-size: 13px; + } + + .alpha-text-search-input { + width: 100%; + padding: 9px 10px; + border-radius: 8px; + border: 1px solid #4338ca; + background: #09090b; + color: #f4f4f5; + box-sizing: border-box; + font-size: 13px; + } + + .alpha-text-search-input::placeholder { + color: #71717a; + } + + .alpha-text-search-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + } + + .alpha-text-search-controls--sidebar .alpha-text-search-actions .alpha-nav-btn { + flex: 1 1 0; + min-height: 34px; + } + + .alpha-text-search-controls--detail .alpha-text-search-actions .alpha-nav-btn { + min-height: 34px; + padding-inline: 12px; + } + + .alpha-text-source-btn { + align-items: flex-start; + } + + .alpha-text-source-meta { + font-size: 12px; + line-height: 1.4; + color: #a1a1aa; + } + + .alpha-text-detail-body { + display: grid; + gap: 12px; + min-width: 0; + } + + .alpha-text-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + align-items: start; + } + + .alpha-text-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; + } + + .alpha-text-reader { + display: grid; + gap: 0; + } + + .alpha-text-search-summary { + margin: 0 0 10px; + color: #a1a1aa; + font-size: 12px; + line-height: 1.5; + } + + .alpha-text-search-results { + display: grid; + gap: 10px; + } + + .alpha-text-search-result { + display: grid; + gap: 8px; + width: 100%; + padding: 12px; + border: 1px solid #2f2f39; + border-radius: 12px; + background: #0c0c12; + color: #f4f4f5; + text-align: left; + cursor: pointer; + box-sizing: border-box; + } + + .alpha-text-search-result:hover { + border-color: #6366f1; + background: #131325; + } + + .alpha-text-search-result.is-active { + border-color: #a5b4fc; + background: #1e1b4b; + box-shadow: inset 0 0 0 1px rgba(199, 210, 254, 0.18); + } + + .alpha-text-search-result-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + align-items: baseline; + } + + .alpha-text-search-reference { + color: #eef2ff; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; + } + + .alpha-text-search-location { + color: #a5b4fc; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .alpha-text-search-preview { + margin: 0; + color: #e4e4e7; + font-size: 14px; + line-height: 1.6; + } + + .alpha-text-mark { + padding: 0 2px; + border-radius: 4px; + background: rgba(251, 191, 36, 0.24); + color: #fef3c7; + } + + .alpha-text-mark--lexicon { + background: rgba(129, 140, 248, 0.28); + color: #e0e7ff; + box-shadow: inset 0 0 0 1px rgba(165, 180, 252, 0.2); + } + + .alpha-text-verse { + display: grid; + gap: 8px; + padding: 12px 0; + border-top: 1px solid #27272a; + } + + .alpha-text-verse.is-highlighted { + margin: 0 -12px; + padding: 12px; + border-radius: 12px; + border-top-color: transparent; + background: rgba(67, 56, 202, 0.18); + box-shadow: inset 0 0 0 1px rgba(165, 180, 252, 0.22); + } + + .alpha-text-verse:first-child { + border-top: none; + padding-top: 0; + } + + .alpha-text-verse-head { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + } + + .alpha-text-verse-reference { + color: #c4b5fd; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + } + + .alpha-text-verse-text { + margin: 0; + color: #e4e4e7; + font-size: 15px; + line-height: 1.65; + } + + .alpha-text-token-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .alpha-text-token { + display: grid; + gap: 3px; + min-width: 96px; + padding: 8px 10px; + border: 1px solid #2f2f39; + border-radius: 10px; + background: #0c0c12; + color: #f4f4f5; + text-align: left; + box-sizing: border-box; + } + + .alpha-text-token--interactive { + cursor: pointer; + } + + .alpha-text-token--interactive:hover { + background: #141427; + border-color: #6366f1; + } + + .alpha-text-token-gloss { + color: #f4f4f5; + font-size: 12px; + font-weight: 600; + line-height: 1.3; + } + + .alpha-text-token-original { + color: #c4b5fd; + font-size: 15px; + font-family: var(--font-script-display); + line-height: 1.25; + } + + .alpha-text-token-strongs { + color: #a1a1aa; + font-size: 10px; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .alpha-text-lexicon-term { + display: grid; + gap: 8px; + } + + .alpha-text-lexicon-head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .alpha-text-lexicon-id { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border: 1px solid #4f46e5; + border-radius: 999px; + background: #1e1b4b; + color: #c7d2fe; + font-size: 11px; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .alpha-text-lexicon-id--button { + cursor: pointer; + } + + .alpha-text-lexicon-id--button:hover { + background: #312e81; + border-color: #818cf8; + color: #e0e7ff; + } + + .alpha-text-lexicon-id--button[aria-expanded="true"] { + background: #312e81; + border-color: #a5b4fc; + color: #eef2ff; + } + + .alpha-text-lexicon-hint { + margin: 0; + color: #a1a1aa; + font-size: 12px; + line-height: 1.5; + } + + .alpha-text-lexicon-occurrences { + display: grid; + gap: 10px; + padding-top: 6px; + border-top: 1px solid #27272a; + } + + .alpha-text-lexicon-occurrence-list { + display: grid; + gap: 8px; + } + + .alpha-text-lexicon-occurrence { + display: grid; + gap: 6px; + width: 100%; + padding: 10px 12px; + border: 1px solid #2f2f39; + border-radius: 10px; + background: #0c0c12; + color: #f4f4f5; + text-align: left; + cursor: pointer; + box-sizing: border-box; + } + + .alpha-text-lexicon-occurrence:hover { + background: #141427; + border-color: #6366f1; + } + + .alpha-text-search-preview--compact { + font-size: 13px; + line-height: 1.5; + } + + .alpha-text-lexicon-popup { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 24px; + box-sizing: border-box; + } + + .alpha-text-lexicon-popup[hidden] { + display: none; + } + + .alpha-text-lexicon-popup-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.68); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + } + + .alpha-text-lexicon-popup-card { + position: relative; + z-index: 1; + width: min(720px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + overflow: auto; + padding: 18px; + border: 1px solid #3f3f46; + border-radius: 16px; + background: linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(9, 9, 11, 0.98)); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6); + box-sizing: border-box; + } + + .alpha-text-lexicon-popup-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + } + + .alpha-text-lexicon-popup-heading { + display: grid; + gap: 4px; + } + + .alpha-text-lexicon-popup-heading h3 { + margin: 0; + color: #f4f4f5; + font-size: 22px; + line-height: 1.2; + } + + .alpha-text-lexicon-popup-subtitle { + margin: 0; + color: #a1a1aa; + font-size: 13px; + line-height: 1.5; + } + + .alpha-text-lexicon-popup-close { + padding: 8px 12px; + border: 1px solid #3f3f46; + border-radius: 8px; + background: #18181b; + color: #f4f4f5; + cursor: pointer; + } + + .alpha-text-lexicon-popup-close:hover { + background: #27272a; + } + + .alpha-text-lexicon-popup-body { + display: grid; + gap: 14px; + } + + .alpha-text-lexicon-popup-body .alpha-dl { + margin: 0; + } + + .alpha-text-empty { + color: #a1a1aa; + font-size: 13px; + line-height: 1.5; + } + + @media (max-width: 720px) { + .alpha-text-meta-grid { + grid-template-columns: 1fr; + } + + .alpha-text-token { + min-width: 100%; + } + + .alpha-text-lexicon-popup { + padding: 16px; + } + + .alpha-text-lexicon-popup-card { + width: min(100vw - 20px, 100%); + max-height: calc(100vh - 32px); + padding: 16px; + } + + .alpha-text-lexicon-popup-header { + flex-direction: column; + align-items: stretch; + } + } + /* ── Zodiac section ──────────────────────────────────────────────────── */ #zodiac-section { height: calc(100vh - 61px); diff --git a/app/ui-alphabet-text.js b/app/ui-alphabet-text.js new file mode 100644 index 0000000..7467a2c --- /dev/null +++ b/app/ui-alphabet-text.js @@ -0,0 +1,1322 @@ +(function () { + "use strict"; + + const dataService = window.TarotDataService || {}; + + const state = { + initialized: false, + catalog: null, + selectedSourceId: "", + selectedWorkId: "", + selectedSectionId: "", + currentPassage: null, + lexiconEntry: null, + lexiconRequestId: 0, + lexiconOccurrenceResults: null, + lexiconOccurrenceLoading: false, + lexiconOccurrenceError: "", + lexiconOccurrenceVisible: false, + lexiconOccurrenceRequestId: 0, + globalSearchQuery: "", + localSearchQuery: "", + activeSearchScope: "global", + searchQuery: "", + searchResults: null, + searchLoading: false, + searchError: "", + searchRequestId: 0, + highlightedVerseId: "" + }; + + let sourceListEl; + let sourceCountEl; + let globalSearchFormEl; + let globalSearchInputEl; + let globalSearchClearEl; + let localSearchFormEl; + let localSearchInputEl; + let localSearchClearEl; + let workSelectEl; + let sectionSelectEl; + let detailNameEl; + let detailSubEl; + let detailBodyEl; + let lexiconPopupEl; + let lexiconPopupTitleEl; + let lexiconPopupSubtitleEl; + let lexiconPopupBodyEl; + let lexiconPopupCloseEl; + let lexiconReturnFocusEl = null; + + function normalizeId(value) { + return String(value || "") + .trim() + .toLowerCase(); + } + + function getElements() { + sourceListEl = document.getElementById("alpha-text-source-list"); + sourceCountEl = document.getElementById("alpha-text-source-count"); + globalSearchFormEl = document.getElementById("alpha-text-global-search-form"); + globalSearchInputEl = document.getElementById("alpha-text-global-search-input"); + globalSearchClearEl = document.getElementById("alpha-text-global-search-clear"); + localSearchFormEl = document.getElementById("alpha-text-local-search-form"); + localSearchInputEl = document.getElementById("alpha-text-local-search-input"); + localSearchClearEl = document.getElementById("alpha-text-local-search-clear"); + workSelectEl = document.getElementById("alpha-text-work-select"); + sectionSelectEl = document.getElementById("alpha-text-section-select"); + detailNameEl = document.getElementById("alpha-text-detail-name"); + detailSubEl = document.getElementById("alpha-text-detail-sub"); + detailBodyEl = document.getElementById("alpha-text-detail-body"); + ensureLexiconPopup(); + } + + function ensureLexiconPopup() { + if (lexiconPopupEl instanceof HTMLElement) { + return; + } + + const popup = document.createElement("div"); + popup.className = "alpha-text-lexicon-popup"; + popup.hidden = true; + popup.setAttribute("aria-hidden", "true"); + + const backdrop = document.createElement("div"); + backdrop.className = "alpha-text-lexicon-popup-backdrop"; + backdrop.addEventListener("click", closeLexiconEntry); + + const card = document.createElement("section"); + card.className = "alpha-text-lexicon-popup-card"; + card.setAttribute("role", "dialog"); + card.setAttribute("aria-modal", "true"); + card.setAttribute("aria-labelledby", "alpha-text-lexicon-popup-title"); + card.setAttribute("tabindex", "-1"); + + const header = document.createElement("div"); + header.className = "alpha-text-lexicon-popup-header"; + + const headingWrap = document.createElement("div"); + headingWrap.className = "alpha-text-lexicon-popup-heading"; + + const title = document.createElement("h3"); + title.id = "alpha-text-lexicon-popup-title"; + title.textContent = "Lexicon Entry"; + + const subtitle = document.createElement("p"); + subtitle.className = "alpha-text-lexicon-popup-subtitle"; + subtitle.textContent = "Strong's definition"; + + headingWrap.append(title, subtitle); + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "alpha-text-lexicon-popup-close"; + closeButton.textContent = "Close"; + closeButton.addEventListener("click", closeLexiconEntry); + + header.append(headingWrap, closeButton); + + const body = document.createElement("div"); + body.className = "alpha-text-lexicon-popup-body"; + + card.append(header, body); + popup.append(backdrop, card); + document.body.appendChild(popup); + + lexiconPopupEl = popup; + lexiconPopupTitleEl = title; + lexiconPopupSubtitleEl = subtitle; + lexiconPopupBodyEl = body; + lexiconPopupCloseEl = closeButton; + } + + function getSources() { + return Array.isArray(state.catalog?.sources) ? state.catalog.sources : []; + } + + function findById(entries, value) { + const needle = normalizeId(value); + return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null; + } + + function getSelectedSource() { + return findById(getSources(), state.selectedSourceId); + } + + function getSelectedWork(source = getSelectedSource()) { + return findById(source?.works, state.selectedWorkId); + } + + function getSelectedSection(source = getSelectedSource(), work = getSelectedWork(source)) { + return findById(work?.sections, state.selectedSectionId); + } + + function getSearchInput(scope) { + return scope === "source" ? localSearchInputEl : globalSearchInputEl; + } + + function getStoredSearchQuery(scope) { + return scope === "source" ? state.localSearchQuery : state.globalSearchQuery; + } + + function setStoredSearchQuery(scope, value) { + if (scope === "source") { + state.localSearchQuery = value; + return; + } + + state.globalSearchQuery = value; + } + + function updateSearchControls() { + if (globalSearchClearEl instanceof HTMLButtonElement) { + globalSearchClearEl.disabled = !String(globalSearchInputEl?.value || state.globalSearchQuery || "").trim(); + } + + if (localSearchClearEl instanceof HTMLButtonElement) { + localSearchClearEl.disabled = !String(localSearchInputEl?.value || state.localSearchQuery || "").trim(); + } + } + + function clearSearchState() { + state.searchQuery = ""; + state.searchResults = null; + state.searchLoading = false; + state.searchError = ""; + state.highlightedVerseId = ""; + state.searchRequestId += 1; + + updateSearchControls(); + } + + function clearScopedSearch(scope) { + setStoredSearchQuery(scope, ""); + + const input = getSearchInput(scope); + if (input instanceof HTMLInputElement) { + input.value = ""; + } + + if (state.activeSearchScope === scope) { + clearSearchState(); + } else { + updateSearchControls(); + } + } + + function escapeRegExp(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + function appendHighlightedText(target, text, query) { + if (!(target instanceof HTMLElement)) { + return; + } + + const sourceText = String(text || ""); + const normalizedQuery = String(query || "").trim(); + target.replaceChildren(); + if (!normalizedQuery) { + target.textContent = sourceText; + return; + } + + const matcher = new RegExp(escapeRegExp(normalizedQuery), "ig"); + let lastIndex = 0; + let match = matcher.exec(sourceText); + while (match) { + if (match.index > lastIndex) { + target.appendChild(document.createTextNode(sourceText.slice(lastIndex, match.index))); + } + + const mark = document.createElement("mark"); + mark.className = "alpha-text-mark"; + mark.textContent = sourceText.slice(match.index, match.index + match[0].length); + target.appendChild(mark); + + lastIndex = match.index + match[0].length; + match = matcher.exec(sourceText); + } + + if (lastIndex < sourceText.length) { + target.appendChild(document.createTextNode(sourceText.slice(lastIndex))); + } + } + + function isHighlightedVerse(verse) { + return normalizeId(verse?.id) && normalizeId(verse?.id) === normalizeId(state.highlightedVerseId); + } + + function scrollHighlightedVerseIntoView() { + const highlightedVerse = detailBodyEl?.querySelector?.(".alpha-text-verse.is-highlighted"); + const detailPanel = highlightedVerse?.closest?.(".planet-detail-panel"); + if (!(highlightedVerse instanceof HTMLElement) || !(detailPanel instanceof HTMLElement)) { + return; + } + + const verseRect = highlightedVerse.getBoundingClientRect(); + const panelRect = detailPanel.getBoundingClientRect(); + const targetTop = detailPanel.scrollTop + + (verseRect.top - panelRect.top) + - (detailPanel.clientHeight / 2) + + (verseRect.height / 2); + + detailPanel.scrollTo({ + top: Math.max(0, targetTop), + behavior: "smooth" + }); + } + + function createCard(title) { + const card = document.createElement("div"); + card.className = "planet-meta-card"; + if (title) { + const heading = document.createElement("strong"); + heading.textContent = title; + card.appendChild(heading); + } + return card; + } + + function createEmptyMessage(text) { + const message = document.createElement("div"); + message.className = "alpha-text-empty"; + message.textContent = text; + return message; + } + + function renderPlaceholder(title, subtitle, message) { + if (detailNameEl) { + detailNameEl.textContent = title; + } + if (detailSubEl) { + detailSubEl.textContent = subtitle; + } + if (!detailBodyEl) { + return; + } + + detailBodyEl.replaceChildren(); + const card = createCard("Text Reader"); + card.appendChild(createEmptyMessage(message)); + detailBodyEl.appendChild(card); + } + + function syncSelectionForSource(source) { + const works = Array.isArray(source?.works) ? source.works : []; + if (!works.length) { + state.selectedWorkId = ""; + state.selectedSectionId = ""; + return; + } + + if (!findById(works, state.selectedWorkId)) { + state.selectedWorkId = works[0].id; + } + + const work = getSelectedWork(source); + const sections = Array.isArray(work?.sections) ? work.sections : []; + if (!findById(sections, state.selectedSectionId)) { + state.selectedSectionId = sections[0]?.id || ""; + } + } + + async function ensureCatalogLoaded(forceRefresh = false) { + if (!forceRefresh && state.catalog) { + return state.catalog; + } + + const payload = await dataService.loadTextLibrary?.(forceRefresh); + state.catalog = payload && typeof payload === "object" + ? payload + : { meta: {}, sources: [], lexicons: [] }; + + if (!state.selectedSourceId) { + state.selectedSourceId = getSources()[0]?.id || ""; + } + + syncSelectionForSource(getSelectedSource()); + return state.catalog; + } + + function fillSelect(selectEl, entries, selectedValue, labelBuilder) { + if (!(selectEl instanceof HTMLSelectElement)) { + return; + } + + selectEl.replaceChildren(); + (Array.isArray(entries) ? entries : []).forEach((entry) => { + const option = document.createElement("option"); + option.value = entry.id; + option.textContent = typeof labelBuilder === "function" ? labelBuilder(entry) : String(entry?.label || entry?.title || entry?.id || ""); + option.selected = normalizeId(entry.id) === normalizeId(selectedValue); + selectEl.appendChild(option); + }); + + selectEl.disabled = !selectEl.options.length; + } + + function renderSourceList() { + if (!sourceListEl) { + return; + } + + sourceListEl.replaceChildren(); + const sources = getSources(); + sources.forEach((source) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "planet-list-item alpha-text-source-btn"; + button.dataset.sourceId = source.id; + button.setAttribute("role", "option"); + + const isSelected = normalizeId(source.id) === normalizeId(state.selectedSourceId); + button.classList.toggle("is-selected", isSelected); + button.setAttribute("aria-selected", isSelected ? "true" : "false"); + + const name = document.createElement("span"); + name.className = "planet-list-name"; + name.textContent = source.title; + + const meta = document.createElement("span"); + meta.className = "alpha-text-source-meta"; + const sectionLabel = source.sectionLabel || "Section"; + meta.textContent = `${source.shortTitle || source.title} · ${source.stats?.workCount || 0} ${source.workLabel || "Works"} · ${source.stats?.sectionCount || 0} ${sectionLabel.toLowerCase()}s`; + + button.append(name, meta); + button.addEventListener("click", () => { + if (normalizeId(source.id) === normalizeId(state.selectedSourceId)) { + return; + } + + state.selectedSourceId = source.id; + state.currentPassage = null; + state.lexiconEntry = null; + state.highlightedVerseId = ""; + syncSelectionForSource(getSelectedSource()); + renderSourceList(); + renderSelectors(); + + if (state.searchQuery && state.activeSearchScope === "source") { + void Promise.all([loadSelectedPassage(), runSearch("source")]); + return; + } + + void loadSelectedPassage(); + }); + + sourceListEl.appendChild(button); + }); + + if (!sources.length) { + sourceListEl.appendChild(createEmptyMessage("No text sources are available.")); + } + + if (sourceCountEl) { + sourceCountEl.textContent = `${sources.length} sources`; + } + } + + function renderSelectors() { + const source = getSelectedSource(); + const work = getSelectedWork(source); + const works = Array.isArray(source?.works) ? source.works : []; + const sections = Array.isArray(work?.sections) ? work.sections : []; + + fillSelect(workSelectEl, works, state.selectedWorkId, (entry) => `${entry.title} (${entry.sectionCount} ${String(source?.sectionLabel || "section").toLowerCase()}s)`); + fillSelect(sectionSelectEl, sections, state.selectedSectionId, (entry) => `${entry.label} · ${entry.verseCount} verses`); + } + + function closeLexiconEntry() { + dismissLexiconEntry(); + } + + function clearLexiconOccurrenceState() { + state.lexiconOccurrenceResults = null; + state.lexiconOccurrenceLoading = false; + state.lexiconOccurrenceError = ""; + state.lexiconOccurrenceVisible = false; + state.lexiconOccurrenceRequestId += 1; + } + + function dismissLexiconEntry(options = {}) { + const shouldRestoreFocus = options.restoreFocus !== false; + state.lexiconRequestId += 1; + state.lexiconEntry = null; + clearLexiconOccurrenceState(); + renderLexiconPopup(); + + const returnFocusEl = lexiconReturnFocusEl; + lexiconReturnFocusEl = null; + + if (shouldRestoreFocus && returnFocusEl instanceof HTMLElement && returnFocusEl.isConnected) { + requestAnimationFrame(() => { + if (returnFocusEl.isConnected) { + returnFocusEl.focus(); + } + }); + } + } + + async function toggleLexiconOccurrences() { + const lexiconId = state.lexiconEntry?.lexicon?.id || state.lexiconEntry?.lexiconId || ""; + const entryId = state.lexiconEntry?.entryId || ""; + if (!lexiconId || !entryId) { + return; + } + + if (state.lexiconOccurrenceVisible && !state.lexiconOccurrenceLoading) { + state.lexiconOccurrenceVisible = false; + renderLexiconPopup(); + return; + } + + state.lexiconOccurrenceVisible = true; + if (state.lexiconOccurrenceResults || state.lexiconOccurrenceError) { + renderLexiconPopup(); + return; + } + + const requestId = state.lexiconOccurrenceRequestId + 1; + state.lexiconOccurrenceRequestId = requestId; + state.lexiconOccurrenceLoading = true; + state.lexiconOccurrenceError = ""; + renderLexiconPopup(); + + try { + const payload = await dataService.loadTextLexiconOccurrences?.(lexiconId, entryId, { limit: 100 }); + if (requestId !== state.lexiconOccurrenceRequestId) { + return; + } + + state.lexiconOccurrenceResults = payload; + state.lexiconOccurrenceLoading = false; + renderLexiconPopup(); + } catch (error) { + if (requestId !== state.lexiconOccurrenceRequestId) { + return; + } + + state.lexiconOccurrenceLoading = false; + state.lexiconOccurrenceError = error?.message || "Unable to load verse occurrences for this Strong's entry."; + renderLexiconPopup(); + } + } + + async function openLexiconOccurrence(result) { + dismissLexiconEntry({ restoreFocus: false }); + await openSearchResult(result); + } + + function appendLexiconOccurrencePreview(target, result) { + if (!(target instanceof HTMLElement)) { + return; + } + + target.replaceChildren(); + + const previewTokens = Array.isArray(result?.previewTokens) ? result.previewTokens : []; + if (!previewTokens.length) { + target.textContent = result?.preview || result?.reference || ""; + return; + } + + previewTokens.forEach((token, index) => { + const text = String(token?.text || "").trim(); + if (!text) { + return; + } + + const previousText = String(previewTokens[index - 1]?.text || "").trim(); + if (index > 0 && text !== "..." && previousText !== "...") { + target.appendChild(document.createTextNode(" ")); + } + + if (token?.isMatch) { + const mark = document.createElement("mark"); + mark.className = "alpha-text-mark alpha-text-mark--lexicon"; + mark.textContent = text; + target.appendChild(mark); + return; + } + + target.appendChild(document.createTextNode(text)); + }); + } + + function renderLexiconPopup() { + ensureLexiconPopup(); + + if (!(lexiconPopupEl instanceof HTMLElement) || !(lexiconPopupBodyEl instanceof HTMLElement)) { + return; + } + + const payload = state.lexiconEntry; + const wasHidden = lexiconPopupEl.hidden; + + if (!payload) { + lexiconPopupEl.hidden = true; + lexiconPopupEl.setAttribute("aria-hidden", "true"); + lexiconPopupTitleEl.textContent = "Lexicon Entry"; + lexiconPopupSubtitleEl.textContent = "Strong's definition"; + lexiconPopupBodyEl.replaceChildren(); + return; + } + + lexiconPopupTitleEl.textContent = payload.entryId ? `Strong's ${payload.entryId}` : "Lexicon Entry"; + lexiconPopupSubtitleEl.textContent = payload.loading + ? "Loading definition..." + : "Strong's definition"; + lexiconPopupBodyEl.replaceChildren(); + + if (payload.loading) { + lexiconPopupBodyEl.appendChild(createEmptyMessage(`Loading ${payload.entryId}...`)); + } else if (payload.error) { + lexiconPopupBodyEl.appendChild(createEmptyMessage(payload.error)); + } else { + const entry = payload.entry || {}; + const head = document.createElement("div"); + head.className = "alpha-text-lexicon-head"; + + const idPill = document.createElement("button"); + idPill.type = "button"; + idPill.className = "alpha-text-lexicon-id alpha-text-lexicon-id--button"; + idPill.textContent = payload.entryId || "--"; + idPill.setAttribute("aria-expanded", state.lexiconOccurrenceVisible ? "true" : "false"); + idPill.addEventListener("click", () => { + void toggleLexiconOccurrences(); + }); + head.appendChild(idPill); + + if (entry.lemma) { + const lemma = document.createElement("span"); + lemma.className = "alpha-text-token-original"; + lemma.textContent = entry.lemma; + head.appendChild(lemma); + } + + lexiconPopupBodyEl.appendChild(head); + + const rows = [ + ["Transliteration", entry.xlit], + ["Pronunciation", entry.pron], + ["Derivation", entry.derivation], + ["Strong's Definition", entry.strongs_def], + ["KJV Definition", entry.kjv_def] + ].filter(([, value]) => String(value || "").trim()); + + if (rows.length) { + const dl = document.createElement("dl"); + dl.className = "alpha-dl"; + rows.forEach(([label, value]) => { + const dt = document.createElement("dt"); + dt.textContent = label; + const dd = document.createElement("dd"); + dd.textContent = String(value || "").trim(); + dl.append(dt, dd); + }); + lexiconPopupBodyEl.appendChild(dl); + } + + const occurrenceHint = document.createElement("p"); + occurrenceHint.className = "alpha-text-lexicon-hint"; + occurrenceHint.textContent = "Click the Strong's number to show verses that use this entry."; + lexiconPopupBodyEl.appendChild(occurrenceHint); + + if (state.lexiconOccurrenceVisible) { + const occurrenceSection = document.createElement("section"); + occurrenceSection.className = "alpha-text-lexicon-occurrences"; + + const occurrenceTitle = document.createElement("strong"); + occurrenceTitle.textContent = "Verse Occurrences"; + occurrenceSection.appendChild(occurrenceTitle); + + if (state.lexiconOccurrenceLoading) { + occurrenceSection.appendChild(createEmptyMessage(`Loading verses for ${payload.entryId}...`)); + } else if (state.lexiconOccurrenceError) { + occurrenceSection.appendChild(createEmptyMessage(state.lexiconOccurrenceError)); + } else { + const occurrencePayload = state.lexiconOccurrenceResults; + const totalMatches = Number(occurrencePayload?.totalMatches) || 0; + const summary = document.createElement("p"); + summary.className = "alpha-text-search-summary"; + summary.textContent = totalMatches + ? `${totalMatches} verses use ${payload.entryId}.${occurrencePayload?.truncated ? ` Showing the first ${occurrencePayload.resultCount} results.` : ""}` + : `No verses found for ${payload.entryId}.`; + occurrenceSection.appendChild(summary); + + if (Array.isArray(occurrencePayload?.results) && occurrencePayload.results.length) { + const occurrenceList = document.createElement("div"); + occurrenceList.className = "alpha-text-lexicon-occurrence-list"; + + occurrencePayload.results.forEach((result) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "alpha-text-lexicon-occurrence"; + + const headRow = document.createElement("div"); + headRow.className = "alpha-text-search-result-head"; + + const reference = document.createElement("span"); + reference.className = "alpha-text-search-reference"; + reference.textContent = result.reference || `${result.workTitle} ${result.sectionLabel}:${result.verseNumber}`; + + const location = document.createElement("span"); + location.className = "alpha-text-search-location"; + location.textContent = `${result.sourceShortTitle || result.sourceTitle} · ${result.workTitle} · ${result.sectionLabel}`; + + const preview = document.createElement("p"); + preview.className = "alpha-text-search-preview alpha-text-search-preview--compact"; + appendLexiconOccurrencePreview(preview, result); + + button.addEventListener("click", () => { + void openLexiconOccurrence(result); + }); + + headRow.append(reference, location); + button.append(headRow, preview); + occurrenceList.appendChild(button); + }); + + occurrenceSection.appendChild(occurrenceList); + } + } + + lexiconPopupBodyEl.appendChild(occurrenceSection); + } + } + + lexiconPopupEl.hidden = false; + lexiconPopupEl.setAttribute("aria-hidden", "false"); + + if (wasHidden && lexiconPopupCloseEl instanceof HTMLButtonElement) { + requestAnimationFrame(() => { + lexiconPopupCloseEl.focus(); + }); + } + } + + async function loadLexiconEntry(lexiconId, entryId, triggerElement) { + if (!lexiconId || !entryId) { + return; + } + + if (triggerElement instanceof HTMLElement) { + lexiconReturnFocusEl = triggerElement; + } + + const requestId = state.lexiconRequestId + 1; + state.lexiconRequestId = requestId; + clearLexiconOccurrenceState(); + state.lexiconEntry = { + loading: true, + lexiconId, + entryId: String(entryId).toUpperCase() + }; + renderDetail(); + + try { + const payload = await dataService.loadTextLexiconEntry?.(lexiconId, entryId); + if (requestId !== state.lexiconRequestId) { + return; + } + state.lexiconEntry = payload; + renderDetail(); + } catch (error) { + if (requestId !== state.lexiconRequestId) { + return; + } + state.lexiconEntry = { + error: error?.message || "Unable to load lexicon entry.", + lexiconId, + entryId: String(entryId).toUpperCase() + }; + renderDetail(); + } + } + + function createMetaGrid(passage) { + const source = passage?.source || getSelectedSource(); + const work = passage?.work || getSelectedWork(source); + const section = passage?.section || getSelectedSection(source, work); + const metaGrid = document.createElement("div"); + metaGrid.className = "alpha-text-meta-grid"; + + const overviewCard = createCard("Source Overview"); + overviewCard.innerHTML += ` +
+
Source
${source?.title || "--"}
+
Tradition
${source?.tradition || "--"}
+
Language
${source?.language || "--"}
+
Script
${source?.script || "--"}
+
${source?.workLabel || "Work"}
${work?.title || "--"}
+
${source?.sectionLabel || "Section"}
${section?.label || "--"}
+
+ `; + metaGrid.appendChild(overviewCard); + + const navigationCard = createCard("Navigation"); + const toolbar = document.createElement("div"); + toolbar.className = "alpha-text-toolbar"; + + const previousButton = document.createElement("button"); + previousButton.type = "button"; + previousButton.className = "alpha-nav-btn"; + previousButton.textContent = "← Previous"; + previousButton.disabled = !passage?.navigation?.previous; + previousButton.addEventListener("click", () => { + if (!passage?.navigation?.previous) { + return; + } + state.selectedWorkId = passage.navigation.previous.workId; + state.selectedSectionId = passage.navigation.previous.sectionId; + state.lexiconEntry = null; + renderSelectors(); + void loadSelectedPassage(); + }); + + const nextButton = document.createElement("button"); + nextButton.type = "button"; + nextButton.className = "alpha-nav-btn"; + nextButton.textContent = "Next →"; + nextButton.disabled = !passage?.navigation?.next; + nextButton.addEventListener("click", () => { + if (!passage?.navigation?.next) { + return; + } + state.selectedWorkId = passage.navigation.next.workId; + state.selectedSectionId = passage.navigation.next.sectionId; + state.lexiconEntry = null; + renderSelectors(); + void loadSelectedPassage(); + }); + + const location = document.createElement("div"); + location.className = "planet-text"; + location.textContent = `${work?.title || "--"} · ${section?.title || "--"}`; + + toolbar.append(previousButton, nextButton); + navigationCard.append(toolbar, location); + metaGrid.appendChild(navigationCard); + + if (source?.features?.hasTokenAnnotations) { + const noteCard = createCard("Reader Mode"); + noteCard.appendChild(createEmptyMessage("This source is tokenized. Click a Strong's code chip to open its lexicon entry.")); + metaGrid.appendChild(noteCard); + } + + return metaGrid; + } + + function createPlainVerse(verse) { + const article = document.createElement("article"); + article.className = "alpha-text-verse"; + article.classList.toggle("is-highlighted", isHighlightedVerse(verse)); + + const head = document.createElement("div"); + head.className = "alpha-text-verse-head"; + + const reference = document.createElement("span"); + reference.className = "alpha-text-verse-reference"; + reference.textContent = verse.reference || (verse.number ? `Verse ${verse.number}` : ""); + + const text = document.createElement("p"); + text.className = "alpha-text-verse-text"; + appendHighlightedText(text, verse.text || "", isHighlightedVerse(verse) ? state.searchQuery : ""); + + head.append(reference); + article.append(head, text); + return article; + } + + function buildTokenTranslationText(tokens, fallbackText) { + const glossText = (Array.isArray(tokens) ? tokens : []) + .map((token) => String(token?.gloss || "").trim()) + .filter(Boolean) + .join(" ") + .replace(/\s+([,.;:!?])/g, "$1") + .trim(); + + return glossText || String(fallbackText || "").trim(); + } + + function createTokenVerse(verse, lexiconId) { + const article = document.createElement("article"); + article.className = "alpha-text-verse alpha-text-verse--interlinear"; + article.classList.toggle("is-highlighted", isHighlightedVerse(verse)); + + const head = document.createElement("div"); + head.className = "alpha-text-verse-head"; + + const reference = document.createElement("span"); + reference.className = "alpha-text-verse-reference"; + reference.textContent = verse.reference || (verse.number ? `Verse ${verse.number}` : ""); + + const gloss = document.createElement("p"); + gloss.className = "alpha-text-verse-text"; + appendHighlightedText( + gloss, + buildTokenTranslationText(verse?.tokens, verse?.text), + isHighlightedVerse(verse) ? state.searchQuery : "" + ); + + const tokenGrid = document.createElement("div"); + tokenGrid.className = "alpha-text-token-grid"; + + (Array.isArray(verse?.tokens) ? verse.tokens : []).forEach((token) => { + const strongId = Array.isArray(token?.strongs) ? token.strongs[0] : ""; + const tokenEl = document.createElement(strongId ? "button" : "div"); + tokenEl.className = `alpha-text-token${strongId ? " alpha-text-token--interactive" : ""}`; + if (tokenEl instanceof HTMLButtonElement) { + tokenEl.type = "button"; + tokenEl.addEventListener("click", () => { + void loadLexiconEntry(lexiconId, strongId, tokenEl); + }); + } + + const glossEl = document.createElement("span"); + glossEl.className = "alpha-text-token-gloss"; + glossEl.textContent = token?.gloss || "—"; + + const originalEl = document.createElement("span"); + originalEl.className = "alpha-text-token-original"; + originalEl.textContent = token?.original || "—"; + + tokenEl.append(glossEl, originalEl); + + if (strongId) { + const strongsEl = document.createElement("span"); + strongsEl.className = "alpha-text-token-strongs"; + strongsEl.textContent = Array.isArray(token.strongs) ? token.strongs.join(" · ") : strongId; + tokenEl.appendChild(strongsEl); + } + + tokenGrid.appendChild(tokenEl); + }); + + head.append(reference); + article.append(head, gloss, tokenGrid); + return article; + } + + function createReaderCard(passage) { + const source = passage?.source || getSelectedSource(); + const card = createCard(`${source?.title || "Text"} Reader`); + const reader = document.createElement("div"); + reader.className = "alpha-text-reader"; + + if (passage?.errorMessage) { + reader.appendChild(createEmptyMessage(passage.errorMessage)); + card.appendChild(reader); + return card; + } + + const verses = Array.isArray(passage?.verses) ? passage.verses : []; + if (!verses.length) { + reader.appendChild(createEmptyMessage("No verses were found for this section.")); + card.appendChild(reader); + return card; + } + + verses.forEach((verse) => { + const verseEl = source?.features?.hasTokenAnnotations + ? createTokenVerse(verse, source.features.lexiconIds?.[0] || "") + : createPlainVerse(verse); + reader.appendChild(verseEl); + }); + + card.appendChild(reader); + return card; + } + + function createSearchCard() { + const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery; + if (!hasSearchState) { + return null; + } + + const card = createCard("Search Results"); + const scopeLabel = state.activeSearchScope === "global" + ? "all texts" + : (state.searchResults?.scope?.source?.title || getSelectedSource()?.title || "current source"); + + const summary = document.createElement("p"); + summary.className = "alpha-text-search-summary"; + + if (state.searchLoading) { + summary.textContent = `Searching ${scopeLabel} for \"${state.searchQuery}\"...`; + card.appendChild(summary); + return card; + } + + if (state.searchError) { + summary.textContent = `Search scope: ${scopeLabel}`; + card.append(summary, createEmptyMessage(state.searchError)); + return card; + } + + const payload = state.searchResults; + const totalMatches = Number(payload?.totalMatches) || 0; + const truncatedNote = payload?.truncated ? ` Showing the first ${payload.resultCount} results.` : ""; + summary.textContent = `${totalMatches} matches in ${scopeLabel}.${truncatedNote}`; + card.appendChild(summary); + + if (!Array.isArray(payload?.results) || !payload.results.length) { + card.appendChild(createEmptyMessage(`No matches found for \"${state.searchQuery}\".`)); + return card; + } + + const resultsEl = document.createElement("div"); + resultsEl.className = "alpha-text-search-results"; + + payload.results.forEach((result) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "alpha-text-search-result"; + button.classList.toggle( + "is-active", + normalizeId(result?.sourceId) === normalizeId(state.selectedSourceId) + && normalizeId(result?.workId) === normalizeId(state.selectedWorkId) + && normalizeId(result?.sectionId) === normalizeId(state.selectedSectionId) + && normalizeId(result?.verseId) === normalizeId(state.highlightedVerseId) + ); + + const head = document.createElement("div"); + head.className = "alpha-text-search-result-head"; + + const reference = document.createElement("span"); + reference.className = "alpha-text-search-reference"; + reference.textContent = result.reference || `${result.workTitle} ${result.sectionLabel}:${result.verseNumber}`; + + const location = document.createElement("span"); + location.className = "alpha-text-search-location"; + location.textContent = state.activeSearchScope === "global" + ? `${result.sourceShortTitle || result.sourceTitle} · ${result.workTitle} · ${result.sectionLabel}` + : `${result.workTitle} · ${result.sectionLabel}`; + + const preview = document.createElement("p"); + preview.className = "alpha-text-search-preview"; + appendHighlightedText(preview, result.preview || result.reference || "", state.searchQuery); + + button.addEventListener("click", () => { + void openSearchResult(result); + }); + + head.append(reference, location); + button.append(head, preview); + resultsEl.appendChild(button); + }); + + card.appendChild(resultsEl); + return card; + } + + function isGlobalSearchOnlyMode() { + return state.activeSearchScope === "global" + && Boolean(state.searchQuery) + && !state.highlightedVerseId; + } + + function renderDetail() { + const source = getSelectedSource(); + const work = getSelectedWork(source); + const section = getSelectedSection(source, work); + const globalSearchOnlyMode = isGlobalSearchOnlyMode(); + + if (!source || !work || !section) { + renderPlaceholder("Text Reader", "Select a source to begin", "Choose a text source and section from the left panel."); + renderLexiconPopup(); + return; + } + + if (detailNameEl) { + detailNameEl.textContent = globalSearchOnlyMode + ? `Global Search${state.searchQuery ? `: ${state.searchQuery}` : ""}` + : (state.currentPassage?.section?.title || section.title); + } + if (detailSubEl) { + detailSubEl.textContent = globalSearchOnlyMode + ? "All text sources" + : `${source.title} · ${work.title}`; + } + if (!detailBodyEl) { + return; + } + + detailBodyEl.replaceChildren(); + + const searchCard = createSearchCard(); + if (searchCard) { + detailBodyEl.appendChild(searchCard); + } + + if (globalSearchOnlyMode) { + renderLexiconPopup(); + return; + } + + if (!state.currentPassage) { + const loadingCard = createCard("Text Reader"); + loadingCard.appendChild(createEmptyMessage("Loading section…")); + detailBodyEl.appendChild(loadingCard); + renderLexiconPopup(); + return; + } + + detailBodyEl.appendChild(createMetaGrid(state.currentPassage)); + detailBodyEl.appendChild(createReaderCard(state.currentPassage)); + renderLexiconPopup(); + } + + async function loadSelectedPassage() { + const source = getSelectedSource(); + const work = getSelectedWork(source); + const section = getSelectedSection(source, work); + if (!source || !work || !section) { + state.currentPassage = null; + renderDetail(); + return; + } + + state.currentPassage = null; + renderDetail(); + + try { + state.currentPassage = await dataService.loadTextSection?.(source.id, work.id, section.id); + renderDetail(); + if (state.highlightedVerseId) { + requestAnimationFrame(scrollHighlightedVerseIntoView); + } + } catch (error) { + state.currentPassage = { + source, + work, + section, + verses: [], + errorMessage: error?.message || "Unable to load this section." + }; + renderDetail(); + } + } + + async function runSearch(scope, forceRefresh = false) { + const searchFn = dataService.searchTextLibrary; + if (typeof searchFn !== "function") { + state.searchError = "Text search is unavailable."; + state.searchLoading = false; + state.searchResults = null; + renderDetail(); + return; + } + + const normalizedScope = scope === "source" ? "source" : "global"; + const query = String(getSearchInput(normalizedScope)?.value || getStoredSearchQuery(normalizedScope) || "").trim(); + setStoredSearchQuery(normalizedScope, query); + state.activeSearchScope = normalizedScope; + state.searchQuery = query; + state.searchError = ""; + state.searchResults = null; + state.highlightedVerseId = ""; + updateSearchControls(); + + if (!query) { + clearSearchState(); + renderDetail(); + return; + } + + const requestId = state.searchRequestId + 1; + state.searchRequestId = requestId; + state.searchLoading = true; + renderDetail(); + + try { + const payload = await searchFn(query, { + sourceId: normalizedScope === "source" ? state.selectedSourceId : "", + limit: 50 + }, forceRefresh); + + if (requestId !== state.searchRequestId) { + return; + } + + state.searchResults = payload; + state.searchLoading = false; + renderDetail(); + } catch (error) { + if (requestId !== state.searchRequestId) { + return; + } + + state.searchLoading = false; + state.searchError = error?.message || "Unable to search this text library."; + renderDetail(); + } + } + + async function openSearchResult(result) { + if (!result) { + return; + } + + state.selectedSourceId = result.sourceId; + state.selectedWorkId = result.workId; + state.selectedSectionId = result.sectionId; + state.highlightedVerseId = result.verseId; + dismissLexiconEntry({ restoreFocus: false }); + syncSelectionForSource(getSelectedSource()); + renderSourceList(); + renderSelectors(); + await loadSelectedPassage(); + } + + function bindControls() { + if (state.initialized) { + return; + } + + if (globalSearchFormEl instanceof HTMLFormElement) { + globalSearchFormEl.addEventListener("submit", (event) => { + event.preventDefault(); + void runSearch("global"); + }); + } + + if (globalSearchInputEl instanceof HTMLInputElement) { + globalSearchInputEl.addEventListener("input", () => { + state.globalSearchQuery = String(globalSearchInputEl.value || "").trim(); + updateSearchControls(); + if (!state.globalSearchQuery && state.activeSearchScope === "global" && state.searchQuery) { + clearSearchState(); + renderDetail(); + } + }); + } + + if (globalSearchClearEl instanceof HTMLButtonElement) { + globalSearchClearEl.addEventListener("click", () => { + clearScopedSearch("global"); + renderDetail(); + }); + } + + if (localSearchFormEl instanceof HTMLFormElement) { + localSearchFormEl.addEventListener("submit", (event) => { + event.preventDefault(); + void runSearch("source"); + }); + } + + if (localSearchInputEl instanceof HTMLInputElement) { + localSearchInputEl.addEventListener("input", () => { + state.localSearchQuery = String(localSearchInputEl.value || "").trim(); + updateSearchControls(); + if (!state.localSearchQuery && state.activeSearchScope === "source" && state.searchQuery) { + clearSearchState(); + renderDetail(); + } + }); + } + + if (localSearchClearEl instanceof HTMLButtonElement) { + localSearchClearEl.addEventListener("click", () => { + clearScopedSearch("source"); + renderDetail(); + }); + } + + if (workSelectEl) { + workSelectEl.addEventListener("change", () => { + state.selectedWorkId = String(workSelectEl.value || ""); + const source = getSelectedSource(); + syncSelectionForSource(source); + state.currentPassage = null; + state.lexiconEntry = null; + state.highlightedVerseId = ""; + renderSelectors(); + void loadSelectedPassage(); + }); + } + + if (sectionSelectEl) { + sectionSelectEl.addEventListener("change", () => { + state.selectedSectionId = String(sectionSelectEl.value || ""); + state.currentPassage = null; + state.lexiconEntry = null; + state.highlightedVerseId = ""; + void loadSelectedPassage(); + }); + } + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.lexiconEntry) { + closeLexiconEntry(); + } + }); + + state.initialized = true; + } + + async function ensureAlphabetTextSection() { + getElements(); + bindControls(); + + if (!sourceListEl || !detailBodyEl) { + return; + } + + await ensureCatalogLoaded(); + renderSourceList(); + renderSelectors(); + updateSearchControls(); + + if (!state.currentPassage) { + await loadSelectedPassage(); + return; + } + + renderDetail(); + } + + function resetState() { + state.catalog = null; + state.currentPassage = null; + state.lexiconEntry = null; + state.selectedSourceId = ""; + state.selectedWorkId = ""; + state.selectedSectionId = ""; + state.lexiconRequestId = 0; + state.lexiconOccurrenceResults = null; + state.lexiconOccurrenceLoading = false; + state.lexiconOccurrenceError = ""; + state.lexiconOccurrenceVisible = false; + state.lexiconOccurrenceRequestId = 0; + state.globalSearchQuery = ""; + state.localSearchQuery = ""; + state.activeSearchScope = "global"; + state.searchQuery = ""; + state.searchResults = null; + state.searchLoading = false; + state.searchError = ""; + state.searchRequestId = 0; + state.highlightedVerseId = ""; + lexiconReturnFocusEl = null; + + if (globalSearchInputEl instanceof HTMLInputElement) { + globalSearchInputEl.value = ""; + } + + if (localSearchInputEl instanceof HTMLInputElement) { + localSearchInputEl.value = ""; + } + + updateSearchControls(); + renderLexiconPopup(); + } + + document.addEventListener("connection:updated", resetState); + + window.AlphabetTextUi = { + ensureAlphabetTextSection + }; +})(); \ No newline at end of file diff --git a/app/ui-navigation.js b/app/ui-navigation.js index bd1a6db..b1c1484 100644 --- a/app/ui-navigation.js +++ b/app/ui-navigation.js @@ -86,6 +86,10 @@ setActiveSection(getActiveSection() === "alphabet-letters" ? "home" : "alphabet-letters"); }); + bindClick(elements.openAlphabetTextEl, () => { + setActiveSection(getActiveSection() === "alphabet-text" ? "home" : "alphabet-text"); + }); + bindClick(elements.openNumbersEl, () => { setActiveSection(getActiveSection() === "numbers" ? "home" : "numbers"); }); diff --git a/app/ui-section-state.js b/app/ui-section-state.js index dc2bf59..3743eb4 100644 --- a/app/ui-section-state.js +++ b/app/ui-section-state.js @@ -18,6 +18,7 @@ "cube", "alphabet", "alphabet-letters", + "alphabet-text", "numbers", "zodiac", "quiz", @@ -101,7 +102,8 @@ const isKabbalahMenuOpen = isKabbalahOpen || isKabbalahTreeOpen || isCubeOpen; const isAlphabetOpen = activeSection === "alphabet"; const isAlphabetLettersOpen = activeSection === "alphabet-letters"; - const isAlphabetMenuOpen = isAlphabetOpen || isAlphabetLettersOpen; + const isAlphabetTextOpen = activeSection === "alphabet-text"; + const isAlphabetMenuOpen = isAlphabetOpen || isAlphabetLettersOpen || isAlphabetTextOpen; const isNumbersOpen = activeSection === "numbers"; const isQuizOpen = activeSection === "quiz"; const isGodsOpen = activeSection === "gods"; @@ -122,6 +124,7 @@ setHidden(elements.cubeSectionEl, !isCubeOpen); setHidden(elements.alphabetSectionEl, !isAlphabetOpen); setHidden(elements.alphabetLettersSectionEl, !isAlphabetLettersOpen); + setHidden(elements.alphabetTextSectionEl, !isAlphabetTextOpen); setHidden(elements.numbersSectionEl, !isNumbersOpen); setHidden(elements.zodiacSectionEl, !isZodiacOpen); setHidden(elements.quizSectionEl, !isQuizOpen); @@ -146,6 +149,7 @@ toggleActive(elements.openKabbalahCubeEl, isCubeOpen); setPressed(elements.openAlphabetEl, isAlphabetMenuOpen); toggleActive(elements.openAlphabetLettersEl, isAlphabetLettersOpen); + toggleActive(elements.openAlphabetTextEl, isAlphabetTextOpen); setPressed(elements.openNumbersEl, isNumbersOpen); toggleActive(elements.openZodiacEl, isZodiacOpen); toggleActive(elements.openNatalEl, isNatalOpen); @@ -216,6 +220,11 @@ return; } + if (isAlphabetTextOpen) { + ensure.ensureAlphabetTextSection?.(); + return; + } + if (isNumbersOpen) { ensure.ensureNumbersSection?.(); return; diff --git a/index.html b/index.html index daa3c7c..4ab56c4 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +
@@ -58,6 +58,7 @@
@@ -703,6 +704,55 @@
+ +