diff --git a/CHANGELOG.md b/CHANGELOG.md index 98150ab..10fe138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Made Bible book and chapter strips dynamically fit the available space with arrow paging while keeping the current book/chapter centered. +- Reordered the study margin around verse notes, lemma text, linked English text, definitions, and translation notes, with a settings control for changing that order. +- Moved audio controls into the study stack and enlarged/color-coded the stack book tabs. - Moved the main menu from the fixed left rail into a compact top-left hamburger popover and reclaimed the unused right-side reader padding. - Completed Phase 9 Bible desk layout by replacing the 50/50 reader/resource split with a resizable Bible page, verse-focused margin, and tabbed study stack. - Added verse-anchored margin notes with markers, translation/context anchors, visible verse markers, and export metadata in JSON, Markdown, HTML, and CSV. diff --git a/src/App.css b/src/App.css index 0667c5e..f5c0258 100644 --- a/src/App.css +++ b/src/App.css @@ -425,22 +425,24 @@ textarea:focus-visible { display: flex; flex-wrap: nowrap; justify-content: center; - gap: 0; + gap: 3px; max-width: 100%; - max-height: 18px; + min-height: 25px; overflow: hidden; white-space: nowrap; } .chapter-tabs button { - min-width: 0; - min-height: 0; - padding: 0 3px; - border: 0; - border-radius: 0; - background: transparent; + flex: 0 0 auto; + min-width: 28px; + min-height: 22px; + padding: 2px 6px; + border: 1px solid #dfd6c2; + border-radius: 5px; + background: #fbf8ef; color: #6f5a24; - font-size: 10px; + font-size: 12px; + font-weight: 800; line-height: 1.1; text-decoration: none; box-shadow: none; @@ -449,11 +451,23 @@ textarea:focus-visible { .chapter-tabs button.active, .chapter-tabs button:hover, .chapter-tabs button:focus-visible { - background: transparent; + background: #d6a84f; color: #151510; - text-decoration: underline; - text-decoration-color: #d6a84f; - text-underline-offset: 2px; + text-decoration: none; +} + +.chapter-tabs .tab-scroll-button { + min-width: 24px; + padding: 2px 5px; + background: #fffef8; + color: #657061; + font-size: 14px; +} + +.chapter-tabs .tab-scroll-button:disabled, +.book-tabs .tab-scroll-button:disabled { + opacity: 0.32; + cursor: default; } .page-columns { @@ -818,9 +832,7 @@ textarea:focus-visible { .dictionary-card, .map-card, .audio-card, -.margin-note-card, -.compact-commentary-card, -.margin-stack-links { +.margin-note-card { grid-column: 1 / -1; } @@ -828,8 +840,7 @@ textarea:focus-visible { background: #fffdf8; } -.marker-row, -.margin-stack-links div { +.marker-row { display: flex; flex-wrap: wrap; gap: 6px; @@ -860,20 +871,6 @@ textarea:focus-visible { font-size: 13px; } -.compact-commentary-card p { - margin: 0; - color: #3f443d; - font-size: 12px; - line-height: 1.42; -} - -.compact-commentary-card button, -.margin-stack-links button { - justify-self: start; - border-color: #d8cfbf; - background: #fbf8ef; -} - .commentary-list, .dictionary-list { display: grid; @@ -1134,19 +1131,19 @@ textarea:focus-visible { } .study-stack.pinned { - grid-template-columns: 42px minmax(0, 1fr); + grid-template-columns: 58px minmax(0, 1fr); } .study-stack.unpinned { position: relative; z-index: 18; - width: 44px; + width: 58px; } .study-stack.unpinned .study-stack-panel { position: fixed; top: 0; - right: 44px; + right: 58px; width: min(560px, calc(100vw - 104px)); height: 100vh; box-shadow: -18px 0 42px rgba(17, 20, 17, 0.24); @@ -1154,7 +1151,7 @@ textarea:focus-visible { .study-stack-tabs { display: grid; - grid-auto-rows: minmax(42px, auto); + grid-auto-rows: minmax(54px, auto); gap: 0; align-content: start; min-height: 0; @@ -1166,14 +1163,14 @@ textarea:focus-visible { .study-stack-tabs button { width: 100%; - min-height: 42px; - padding: 5px 3px; + min-height: 54px; + padding: 7px 4px; border: 0; border-bottom: 1px solid #3c4239; border-radius: 0; background: #252b25; color: #f6f3eb; - font-size: 10px; + font-size: 12px; line-height: 1.05; writing-mode: vertical-rl; text-orientation: mixed; @@ -1190,6 +1187,41 @@ textarea:focus-visible { font-weight: 900; } +.study-stack-tabs .stack-tab-notebook { + background: #2f6f5e; +} + +.study-stack-tabs .stack-tab-dictionary { + background: #735c0f; +} + +.study-stack-tabs .stack-tab-lexicon { + background: #245ba7; +} + +.study-stack-tabs .stack-tab-atlas { + background: #6a7b34; +} + +.study-stack-tabs .stack-tab-commentary { + background: #8b4f2f; +} + +.study-stack-tabs .stack-tab-audio { + background: #6e4f8f; +} + +.study-stack-tabs .stack-tab-search { + background: #5b6570; +} + +.study-stack-tabs button.active, +.study-stack-tabs button:hover, +.study-stack-tabs button:focus-visible { + filter: brightness(1.18) saturate(1.08); + outline: none; +} + .study-stack-panel { min-width: 0; min-height: 0; @@ -1630,12 +1662,12 @@ textarea:focus-visible { } .book-tabs { - display: grid; + display: flex; grid-column: 1; - grid-template-columns: repeat(24, minmax(0, 1fr)); - gap: 3px; + gap: 5px; align-items: flex-start; - padding: 0 16px 10px; + justify-content: center; + padding: 0 14px 10px; overflow: hidden; background: #ece9df; } @@ -1649,28 +1681,29 @@ textarea:focus-visible { } .book-tabs button { - min-width: 0; - height: 30px; - padding: 4px 3px; + flex: 0 0 auto; + min-width: 52px; + height: 34px; + padding: 5px 8px; border-color: #c8bea9; border-top: 0; border-radius: 0 0 7px 7px; background: #efe4c9; color: #4c422f; - font-size: 10px; + font-size: 12px; font-weight: 800; line-height: 1.05; - white-space: normal; - overflow-wrap: anywhere; + white-space: nowrap; box-shadow: 0 5px 10px rgba(44, 48, 42, 0.08); } -.book-tabs button:nth-child(4n + 2) { - height: 34px; -} - -.book-tabs button:nth-child(4n + 3) { - height: 27px; +.book-tabs .tab-scroll-button { + min-width: 30px; + height: 31px; + padding: 4px 6px; + background: #fbf8ef; + color: #657061; + font-size: 16px; } .book-tabs button:hover, @@ -1858,6 +1891,39 @@ textarea:focus-visible { padding: 0; } +.margin-order-list { + display: grid; + gap: 6px; +} + +.margin-order-list div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; + align-items: center; + padding: 6px; + border: 1px solid #ded6c7; + border-radius: 6px; + background: #fbf8ef; +} + +.margin-order-list span { + min-width: 0; + overflow: hidden; + color: #3f443d; + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.margin-order-list button { + min-height: 26px; + padding: 4px 7px; + border-color: #d8cfbf; + background: #fffef8; +} + .checkbox-row { display: flex !important; gap: 9px !important; @@ -1977,6 +2043,7 @@ textarea:focus-visible { .theme-dark .original-language-verse, .theme-dark .english-strong-line, .theme-dark .resource-card, +.theme-dark .margin-order-list div, .theme-dark .place-list section, .theme-dark .route-list span, .theme-dark .audio-controls .secondary-button { @@ -2044,7 +2111,8 @@ textarea:focus-visible { .theme-dark .session-facts dt, .theme-dark .about-list dt, .theme-dark .modal-section label, -.theme-dark .page-controls label { +.theme-dark .page-controls label, +.theme-dark .margin-order-list span { color: #aaa996; } diff --git a/src/App.tsx b/src/App.tsx index dd520ac..ef8a9ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { addTagToVerse, getCommentaryForVerse, @@ -44,6 +44,7 @@ import type { import type { StrongDisplayMode, HostedUser, + MarginPanelId, SessionNote, StudyBook, StudySession, @@ -60,11 +61,55 @@ const pageSize = 34 const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1' type PageMotion = 'idle' | 'turn-forward' | 'turn-back' type DeskResizeTarget = 'bible-margin' | 'margin-stack' +type WindowedItems = { + items: T[] + start: number + canMoveBack: boolean + canMoveForward: boolean +} function reference(verse: Verse) { return `${verse.book} ${verse.chapter}:${verse.verse}` } +function centeredWindow(items: T[], activeIndex: number, visibleCount: number, offset = 0): WindowedItems { + if (!items.length) return { items: [], start: 0, canMoveBack: false, canMoveForward: false } + const safeVisibleCount = Math.max(1, Math.min(visibleCount, items.length)) + const maxStart = Math.max(0, items.length - safeVisibleCount) + const centeredStart = activeIndex - Math.floor(safeVisibleCount / 2) + const start = Math.min(maxStart, Math.max(0, centeredStart + offset)) + + return { + items: items.slice(start, start + safeVisibleCount), + start, + canMoveBack: start > 0, + canMoveForward: start < maxStart, + } +} + +function useMeasuredTabCapacity(minItemWidth: number, reservedWidth: number, fallbackCount: number) { + const ref = useRef(null) + const [visibleCount, setVisibleCount] = useState(fallbackCount) + + useEffect(() => { + const element = ref.current + if (!element) return + + const measure = () => { + const available = Math.max(0, element.clientWidth - reservedWidth) + setVisibleCount(Math.max(1, Math.floor(available / minItemWidth))) + } + + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + + return () => observer.disconnect() + }, [fallbackCount, minItemWidth, reservedWidth]) + + return { ref, visibleCount } +} + function verseAnchor(verse: Verse, translationId: string, endVerse = verse.verse): VerseAnchor { const startVerse = Math.min(verse.verse, endVerse) const finalVerse = Math.max(verse.verse, endVerse) @@ -181,6 +226,14 @@ function abbreviateBook(book: string) { return abbreviations[book] ?? book.slice(0, 4) } +const marginPanelLabels: Record = { + 'margin-note': 'Margin Note', + original: 'Hebrew/Greek Lemma Line', + linked: 'English Linked Text', + definition: 'Definition', + 'study-notes': 'Translation Study Notes', +} + function dictionaryTermsForVerse(verse: Verse) { const stopWords = new Set([ 'a', @@ -250,6 +303,8 @@ function App() { const [selectedId, setSelectedId] = useState(null) const [hoveredId, setHoveredId] = useState(null) const [pageStartId, setPageStartId] = useState(null) + const [bookTabOffset, setBookTabOffset] = useState(0) + const [chapterTabOffset, setChapterTabOffset] = useState(0) const [selectedStrong, setSelectedStrong] = useState(null) const [strongDisplay, setStrongDisplay] = useState('highlight') const [tagInput, setTagInput] = useState('') @@ -293,6 +348,13 @@ function App() { const [marginColumnPercent, setMarginColumnPercent] = useState(20) const [studyStackPinned, setStudyStackPinned] = useState(true) const [activeStudyBook, setActiveStudyBook] = useState('notebook') + const [marginPanelOrder, setMarginPanelOrder] = useState([ + 'margin-note', + 'original', + 'linked', + 'definition', + 'study-notes', + ]) const [commentaryEntries, setCommentaryEntries] = useState([]) const [dictionaryEntries, setDictionaryEntries] = useState([]) const [mapContext, setMapContext] = useState({ @@ -327,6 +389,8 @@ function App() { const audioQueueIndexRef = useRef(0) const hydratedNoteChapters = useRef(new Set()) const workspaceHydrated = useRef(false) + const bookTabCapacity = useMeasuredTabCapacity(58, 72, 8) + const chapterTabCapacity = useMeasuredTabCapacity(34, 58, 10) function applyStudySettings(settings?: Partial) { if (!settings) return @@ -352,6 +416,7 @@ function App() { setMarginColumnPercent(settings.marginColumnPercent ?? 20) setStudyStackPinned(settings.studyStackPinned ?? true) setActiveStudyBook(settings.activeStudyBook ?? 'notebook') + setMarginPanelOrder(settings.marginPanelOrder ?? ['margin-note', 'original', 'linked', 'definition', 'study-notes']) setThemeMode(settings.themeMode ?? 'light') } @@ -505,6 +570,7 @@ function App() { marginColumnPercent, studyStackPinned, activeStudyBook, + marginPanelOrder, themeMode, }), [ @@ -519,6 +585,7 @@ function App() { linkedCollapsed, linkedFontSize, marginColumnPercent, + marginPanelOrder, notesPinned, originalCollapsed, showCommentaryPanel, @@ -664,6 +731,14 @@ function App() { [activeSession?.baseTranslation, translations], ) + useEffect(() => { + setBookTabOffset(0) + }, [selectedVerse?.book]) + + useEffect(() => { + setChapterTabOffset(0) + }, [selectedVerse?.book, selectedVerse?.chapter]) + useEffect(() => { let cancelled = false @@ -916,16 +991,12 @@ function App() { () => Array.from(new Set(libraryVerses.map((verse) => verse.book))), [libraryVerses], ) - const visibleBookTabs = useMemo(() => { - if (!selectedVerse || !books.length) return [] - const visibleCount = 24 + const visibleBookTabs = useMemo>(() => { + if (!selectedVerse || !books.length) return centeredWindow([], 0, 0) const selectedIndex = Math.max(0, books.indexOf(selectedVerse.book)) - const halfWindow = Math.floor(visibleCount / 2) - const maxStart = Math.max(0, books.length - visibleCount) - const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart) - return books.slice(start, start + visibleCount) - }, [books, selectedVerse]) + return centeredWindow(books, selectedIndex, bookTabCapacity.visibleCount, bookTabOffset) + }, [bookTabCapacity.visibleCount, bookTabOffset, books, selectedVerse]) const chapters = useMemo(() => { if (!selectedVerse) return [] return Array.from( @@ -936,6 +1007,12 @@ function App() { ), ) }, [libraryVerses, selectedVerse]) + const visibleChapterTabs = useMemo>(() => { + if (!selectedVerse || !chapters.length) return centeredWindow([], 0, 0) + const selectedIndex = Math.max(0, chapters.indexOf(selectedVerse.chapter)) + + return centeredWindow(chapters, selectedIndex, chapterTabCapacity.visibleCount, chapterTabOffset) + }, [chapterTabCapacity.visibleCount, chapterTabOffset, chapters, selectedVerse]) const verseNumbers = useMemo(() => { if (!selectedVerse) return [] return libraryVerses @@ -1112,6 +1189,19 @@ function App() { recordDisplaySetting(`Theme: ${nextThemeMode}`) } + function moveMarginPanel(panel: MarginPanelId, direction: -1 | 1) { + setMarginPanelOrder((current) => { + const index = current.indexOf(panel) + const nextIndex = index + direction + if (index < 0 || nextIndex < 0 || nextIndex >= current.length) return current + const next = [...current] + const [item] = next.splice(index, 1) + next.splice(nextIndex, 0, item) + recordDisplaySetting(`Moved ${marginPanelLabels[panel]} ${direction < 0 ? 'up' : 'down'}`) + return next + }) + } + function toggleStudyStackPinned(nextPinned = !studyStackPinned) { setStudyStackPinned(nextPinned) setNotesDrawerOpen(nextPinned) @@ -1867,126 +1957,6 @@ function App() { ) } - function renderAudioSection() { - if (activeSession?.audioEnabled === false) { - return ( -
-
- - Off -
-

Audio Bible is disabled for this session.

-
- ) - } - - const selectedSegment = audioContext?.selectedSegment ?? null - const chapterSegments = audioContext?.chapterPlaylist?.segments ?? [] - const selectedIndex = selectedSegment - ? Math.max(0, chapterSegments.findIndex((segment) => segment.id === selectedSegment.id)) - : 0 - - return ( -
-
{ - setAudioCollapsed((current) => !current) - recordDisplaySetting('Toggled audio Bible accordion') - }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - setAudioCollapsed((current) => !current) - recordDisplaySetting('Toggled audio Bible accordion') - } - }} - > - - - {audioResourcesLoading - ? 'Loading' - : audioContext - ? audioContext.shortTitle - : 'None'} - -
- {!audioCollapsed && audioContext && selectedSegment ? ( - <> -
- - - - -
-
- - -
-

- {audioPlayingSegmentId ? 'Playing' : 'Status'} - {audioStatus} -

-

{audioContext.attribution}

-
- ) - } - function renderMarginNoteEditor() { if (!selectedVerse) return null const markers = selectedMarginNote?.markers ?? [] @@ -2020,32 +1990,6 @@ function App() { ) } - function renderCompactCommentarySection() { - if (!showCommentaryPanel) return null - const firstEntry = commentaryEntries[0] - - return ( -
-
- - {commentaryEntries.length ? 'Ready' : marginResourcesLoading ? 'Loading' : 'None'} -
- {firstEntry ? ( - <> -

{firstEntry.text.slice(0, 220)}{firstEntry.text.length > 220 ? '...' : ''}

- - - ) : ( -

- {marginResourcesLoading ? 'Loading commentary...' : 'No commentary matched this verse.'} -

- )} -
- ) - } - function renderNotebookBook() { if (!selectedVerse) return null @@ -2246,6 +2190,108 @@ function App() { ) } + function renderAudioBook() { + if (activeSession?.audioEnabled === false) { + return ( +
+
+
+ Audio +

Audio Bible

+
+ Off +
+

Audio Bible is disabled for this session.

+
+ ) + } + + const selectedSegment = audioContext?.selectedSegment ?? null + const chapterSegments = audioContext?.chapterPlaylist?.segments ?? [] + const selectedIndex = selectedSegment + ? Math.max(0, chapterSegments.findIndex((segment) => segment.id === selectedSegment.id)) + : 0 + + return ( +
+
+
+ Audio +

{audioContext?.title ?? 'Audio Bible'}

+
+ {audioResourcesLoading ? 'Loading' : audioContext?.shortTitle ?? 'None'} +
+ {audioContext && selectedSegment ? ( + <> +
+ + + + +
+
+ + +
+

+ {audioPlayingSegmentId ? 'Playing' : 'Status'} + {audioStatus} +

+

{audioContext.attribution}

+ + ) : ( +

+ {audioResourcesLoading + ? 'Loading audio metadata...' + : 'No audio package is attached to this translation yet.'} +

+ )} +
+ ) + } + function renderSearchBook() { return (
@@ -2301,6 +2347,8 @@ function App() { return renderAtlasBook() case 'commentary': return renderCommentaryBook() + case 'audio': + return renderAudioBook() case 'search': return renderSearchBook() case 'notebook': @@ -2316,6 +2364,7 @@ function App() { { id: 'lexicon', label: 'Lex' }, { id: 'atlas', label: 'Atlas' }, { id: 'commentary', label: 'Comm' }, + { id: 'audio', label: 'Audio' }, { id: 'search', label: 'Search' }, ] @@ -2355,7 +2404,10 @@ function App() {
{pageRange} -
- {chapters.map((chapter) => ( +
+ + {visibleChapterTabs.items.map((chapter) => ( ))} +
+ {selectedStrongEntry.language} + + {!definitionCollapsed ? ( + <> +
+ {selectedStrongEntry.strong} + {selectedStrongEntry.language} +
+

{selectedStrongEntry.lemma}

+

{selectedStrongEntry.transliteration}

+ {selectedStrongEntry.pronunciation ? ( +

Pronunciation: {selectedStrongEntry.pronunciation}

+ ) : null} +

{selectedStrongEntry.definition}

+ + + ) : null} + + ), + 'study-notes': renderStudyNotesSection(), + } + return ( ) @@ -2625,8 +2676,17 @@ function App() { if (!selectedVerse) return null return ( -
+ +
+

Margin Order

+
+ {marginPanelOrder.map((panel, index) => ( +
+ {marginPanelLabels[panel]} + + +
+ ))} +
+
@@ -3229,7 +3325,7 @@ function App() { const stackColumnPercent = Math.max(24, 100 - bibleColumnPercent - marginColumnPercent) const pinnedDeskTemplate = `${bibleColumnPercent}fr 6px ${marginColumnPercent}fr 6px ${stackColumnPercent}fr` const expandedBiblePercent = (bibleColumnPercent / (bibleColumnPercent + marginColumnPercent)) * 100 - const unpinnedDeskTemplate = `${expandedBiblePercent}fr 6px ${100 - expandedBiblePercent}fr 44px` + const unpinnedDeskTemplate = `${expandedBiblePercent}fr 6px ${100 - expandedBiblePercent}fr 58px` const deskTemplate = studyStackPinned ? pinnedDeskTemplate : unpinnedDeskTemplate if (authRequired && getAuthClient().enabled) { @@ -3445,6 +3541,7 @@ function App() { {renderAboutModal()} {renderSettingsModal()} {renderTimelineModal()} +