From 4fc942923e856403a587064d8364e3f276191b7a Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 24 Apr 2026 16:38:20 -0500 Subject: [PATCH 1/4] feat: chapter picker for reading view --- src/components/FieldError.tsx | 20 +- src/messages/ar.json | 78 +++++ src/messages/en.json | 78 +++++ .../study/components/ChapterPickerDialog.tsx | 275 ++++++++++++++++++ src/ui/study/components/CommandInput.tsx | 135 ++++----- src/verse-utils.ts | 27 ++ 6 files changed, 535 insertions(+), 78 deletions(-) create mode 100644 src/ui/study/components/ChapterPickerDialog.tsx diff --git a/src/components/FieldError.tsx b/src/components/FieldError.tsx index 366fd4c1..7ab814f0 100644 --- a/src/components/FieldError.tsx +++ b/src/components/FieldError.tsx @@ -6,19 +6,31 @@ export interface FieldErrorProps { id?: string; name: string; messages?: Record; + error?: string; } -export default function FieldError({ id, name, messages }: FieldErrorProps) { +export default function FieldError({ + id, + name, + messages, + error, +}: FieldErrorProps) { const formContext = useFormContext(); const errors = formContext?.state === "error" ? formContext.validation?.[name] : undefined; - if (!errors || errors.length === 0) return null; + let errorMessage; + if (error) { + errorMessage = error; + } else if (errors?.[0]) { + errorMessage = messages?.[errors[0]] ?? "Invalid"; + } + + if (!errorMessage) return null; - const errorMessage = messages?.[errors[0]] ?? "Invalid"; return (
- {errorMessage} + {error ?? errorMessage}
); } diff --git a/src/messages/ar.json b/src/messages/ar.json index b06b7260..2e7d56b3 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -459,6 +459,84 @@ "رؤيا" ] }, + "ChapterPickerDialog": { + "title": "اختر الفصل", + "reference": "مرجع الفصل", + "back_to_books": "العودة إلى الأسفار", + "invalid": "يرجى إدخال مرجع فصل صحيح", + "cancel": "إلغاء", + "go": "اذهب", + "close": "إغلاق منتقي الفصول", + "verse_reference": "{bookId, select, 1 {التكوين} 2 {الخروج} 3 {اللاويين} 4 {العدد} 5 {التثنية} 6 {يشوع} 7 {القضاة} 8 {راعوث} 9 {صموئيل الأول} 10 {صموئيل الثاني} 11 {الملوك الأول} 12 {الملوك الثاني} 13 {أخبار الأيام الأول} 14 {أخبار الأيام الثاني} 15 {عزرا} 16 {نحميا} 17 {استير} 18 {أيوب} 19 {المزامير} 20 {الأمثال} 21 {الجامعة} 22 {نشيد الأنشاد} 23 {إشعياء} 24 {أرميا} 25 {مراثي أرميا} 26 {حزقيال} 27 {دانيال} 28 {هوشع} 29 {يوئيل} 30 {عاموس} 31 {عوبديا} 32 {يونان} 33 {ميخا} 34 {ناحوم} 35 {حبقوق} 36 {صفنيا} 37 {حجاي} 38 {زكريا} 39 {ملاخي} 40 {متى} 41 {مرقس} 42 {لوقا} 43 {يوحنا} 44 {أعمال الرسل} 45 {رومية} 46 {كورنثوس الأولى} 47 {كورنثوس الثانية} 48 {غلاطية} 49 {أفسس} 50 {فيلبي} 51 {كولوسي} 52 {تسالونيكي الأولى} 53 {تسالونيكي الثانية} 54 {تيموثاوس الأولى} 55 {تيموثاوس الثانية} 56 {تيطس} 57 {فليمون} 58 {العبرانيين} 59 {يعقوب} 60 {بطرس الأولى} 61 {بطرس الثانية} 62 {يوحنا الأولى} 63 {يوحنا الثانية} 64 {يوحنا الثالثة} 65 {يهوذا} 66 {رؤيا} other {}} {chapter}", + "book_names": [ + "التكوين", + "الخروج", + "اللاويين", + "العدد", + "التثنية", + "يشوع", + "القضاة", + "راعوث", + "صموئيل الأول", + "صموئيل الثاني", + "الملوك الأول", + "الملوك الثاني", + "أخبار الأيام الأول", + "أخبار الأيام الثاني", + "عزرا", + "نحميا", + "استير", + "أيوب", + "المزامير", + "الأمثال", + "الجامعة", + "نشيد الأنشاد", + "إشعياء", + "أرميا", + "مراثي أرميا", + "حزقيال", + "دانيال", + "هوشع", + "يوئيل", + "عاموس", + "عوبديا", + "يونان", + "ميخا", + "ناحوم", + "حبقوق", + "صفنيا", + "حجاي", + "زكريا", + "ملاخي", + "متى", + "مرقس", + "لوقا", + "يوحنا", + "أعمال الرسل", + "رومية", + "كورنثوس الأولى", + "كورنثوس الثانية", + "غلاطية", + "أفسس", + "فيلبي", + "كولوسي", + "تسالونيكي الأولى", + "تسالونيكي الثانية", + "تيموثاوس الأولى", + "تيموثاوس الثانية", + "تيطس", + "فليمون", + "العبرانيين", + "يعقوب", + "بطرس الأولى", + "بطرس الثانية", + "يوحنا الأولى", + "يوحنا الثانية", + "يوحنا الثالثة", + "يهوذا", + "رؤيا" + ] + }, "ResetPasswordPage": { "title": "إعادة تعيين كلمة المرور", "form": { diff --git a/src/messages/en.json b/src/messages/en.json index 4241de7e..cce2a83c 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -441,6 +441,84 @@ "Revelation" ] }, + "ChapterPickerDialog": { + "title": "Choose chapter", + "reference": "Chapter reference", + "back_to_books": "Back to books", + "invalid": "Please enter a valid chapter reference", + "cancel": "Cancel", + "go": "Go", + "close": "Close chapter picker", + "verse_reference": "{bookId, select, 1 {Genesis} 2 {Exodus} 3 {Leviticus} 4 {Numbers} 5 {Deuteronomy} 6 {Joshua} 7 {Judges} 8 {Ruth} 9 {1 Samuel} 10 {2 Samuel} 11 {1 Kings} 12 {2 Kings} 13 {1 Chronicles} 14 {2 Chronicles} 15 {Ezra} 16 {Nehemiah} 17 {Esther} 18 {Job} 19 {Psalm} 20 {Proverbs} 21 {Ecclesiastes} 22 {Song of Songs} 23 {Isaiah} 24 {Jeremiah} 25 {Lamentations} 26 {Ezekiel} 27 {Daniel} 28 {Hosea} 29 {Joel} 30 {Amos} 31 {Obadiah} 32 {Jonah} 33 {Micah} 34 {Nahum} 35 {Habakkuk} 36 {Zephaniah} 37 {Haggai} 38 {Zechariah} 39 {Malachi} 40 {Matthew} 41 {Mark} 42 {Luke} 43 {John} 44 {Acts} 45 {Romans} 46 {1 Corinthians} 47 {2 Corinthians} 48 {Galatians} 49 {Ephesians} 50 {Philippians} 51 {Colossians} 52 {1 Thessalonians} 53 {2 Thessalonians} 54 {1 Timothy} 55 {2 Timothy} 56 {Titus} 57 {Philemon} 58 {Hebrews} 59 {James} 60 {1 Peter} 61 {2 Peter} 62 {1 John} 63 {2 John} 64 {3 John} 65 {Jude} 66 {Revelation} other {}} {chapter}", + "book_names": [ + "Genesis", + "Exodus", + "Leviticus", + "Numbers", + "Deuteronomy", + "Joshua", + "Judges", + "Ruth", + "1 Samuel", + "2 Samuel", + "1 Kings", + "2 Kings", + "1 Chronicles", + "2 Chronicles", + "Ezra", + "Nehemiah", + "Esther", + "Job", + "Psalm", + "Proverbs", + "Ecclesiastes", + "Song of Songs", + "Isaiah", + "Jeremiah", + "Lamentations", + "Ezekiel", + "Daniel", + "Hosea", + "Joel", + "Amos", + "Obadiah", + "Jonah", + "Micah", + "Nahum", + "Habakkuk", + "Zephaniah", + "Haggai", + "Zechariah", + "Malachi", + "Matthew", + "Mark", + "Luke", + "John", + "Acts", + "Romans", + "1 Corinthians", + "2 Corinthians", + "Galatians", + "Ephesians", + "Philippians", + "Colossians", + "1 Thessalonians", + "2 Thessalonians", + "1 Timothy", + "2 Timothy", + "Titus", + "Philemon", + "Hebrews", + "James", + "1 Peter", + "2 Peter", + "1 John", + "2 John", + "3 John", + "Jude", + "Revelation" + ] + }, "ResetPasswordPage": { "title": "Reset Password", "form": { diff --git a/src/ui/study/components/ChapterPickerDialog.tsx b/src/ui/study/components/ChapterPickerDialog.tsx new file mode 100644 index 00000000..f86ae19e --- /dev/null +++ b/src/ui/study/components/ChapterPickerDialog.tsx @@ -0,0 +1,275 @@ +import Button from "@/components/Button"; +import FieldError from "@/components/FieldError"; +import { Icon } from "@/components/Icon"; +import TextInput from "@/components/TextInput"; +import { + chapterCount, + findBookMatches, + generateChapterId, + parseReferenceParts, + parseVerseId, +} from "@/verse-utils"; +import { SubmitEvent, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations } from "use-intl"; + +interface ChapterPickerDialogProps { + chapterId: string; + onCancel(): void; + onSubmit(chapterId: string): void; +} + +export default function ChapterPickerDialog({ + chapterId, + onCancel, + onSubmit, +}: ChapterPickerDialogProps) { + const dialogRef = useRef(null); + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + dialog.showModal(); + + dialog.querySelector("input")?.focus(); + }, []); + + const t = useTranslations("ChapterPickerDialog"); + + const { bookId: currentBookId, chapterNumber: currentChapterNumber } = + parseVerseId(chapterId + "001"); + const currentReference = t("verse_reference", { + bookId: currentBookId, + chapter: currentChapterNumber, + }); + + const bookNames = t.raw("book_names") as string[]; + const books = useMemo(() => { + return bookNames.map((name, i) => ({ + name, + id: i + 1, + chapterCount: chapterCount(i + 1), + })); + }, [bookNames]); + + const [reference, setReference] = useState(""); + const options = useMemo( + () => getChapterReferenceOptions(reference, books), + [reference, books], + ); + + async function _onSubmit(e: SubmitEvent) { + e.preventDefault(); + + if (options.chapterId) { + onSubmit(options.chapterId); + return; + } + + if (options.book) { + setReference(options.book.name); + return; + } + } + + return ( + { + if (e.target === e.currentTarget) { + onCancel(); + } + }} + > + +
+

{t("title")}

+ +
+ { + setReference(e.target.value); + }} + name="reference" + autoComplete="off" + placeholder={currentReference} + aria-label={t("reference")} + /> + +
+ + {options.book ? + <> +
+ +
+
    + {Array.from({ length: options.book.chapterCount }, (_, i) => { + const chapterNumber = i + 1; + const nextChapterId = `${options.book!.id.toString().padStart(2, "0")}${chapterNumber.toString().padStart(3, "0")}`; + + return ( + + ); + })} +
+
+ + :
    + {options.books.map((book) => { + return ( +
  1. + +
  2. + ); + })} +
+ } + +
+ + +
+ +
+ ); +} + +interface ChapterReferenceOptions { + books: Array<{ id: number; name: string; chapterCount: number }>; + book?: { id: number; name: string; chapterCount: number }; + chapterNumber?: number; + chapterId?: string; + invalid: boolean; +} + +function getChapterReferenceOptions( + reference: string, + books: Array<{ id: number; name: string; chapterCount: number }>, +): ChapterReferenceOptions { + const match = parseReferenceParts(reference); + if (!match) { + return { + books, + invalid: reference.length > 0, + }; + } + + const filteredBooks = + match.bookToken ? findBookMatches(match.bookToken, books) : books; + + if (filteredBooks.length === 0) { + return { books, invalid: true }; + } else if (filteredBooks.length > 1) { + return { books, invalid: false }; + } + + const book = filteredBooks[0]; + + if (!match.chapterToken) { + if (book.chapterCount === 1) { + return { + books, + book, + chapterNumber: 1, + chapterId: generateChapterId({ + bookId: book.id, + chapterNumber: 1, + }), + invalid: false, + }; + } + + return { + books, + book, + invalid: false, + }; + } + + const chapterNumber = parseInt(match.chapterToken, 10); + + const hasValidChapterNumber = + !Number.isNaN(chapterNumber) && + chapterNumber >= 1 && + chapterNumber <= chapterCount(book.id); + if (!hasValidChapterNumber) { + return { + books, + book, + invalid: true, + }; + } + + return { + books, + book, + chapterNumber, + chapterId: generateChapterId({ + bookId: book.id, + chapterNumber, + }), + invalid: false, + }; +} diff --git a/src/ui/study/components/CommandInput.tsx b/src/ui/study/components/CommandInput.tsx index bb443ca7..1d9e1451 100644 --- a/src/ui/study/components/CommandInput.tsx +++ b/src/ui/study/components/CommandInput.tsx @@ -1,8 +1,5 @@ -"use client"; - import Button from "@/components/Button"; import { Icon } from "@/components/Icon"; -import TextInput from "@/components/TextInput"; import { useEffect, useState } from "react"; import { useTranslations } from "use-intl"; import { hasShortcutModifier } from "@/utils/keyboard-shortcuts"; @@ -11,10 +8,9 @@ import { bookLastChapterId, decrementChapterId, incrementChapterId, - parseReference, } from "@/verse-utils"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { useFlash } from "@/flash"; +import ChapterPickerDialog from "./ChapterPickerDialog"; export default function CommandInput() { const { chapterId, code: languageCode } = useParams({ @@ -26,11 +22,9 @@ export default function CommandInput() { const bookId = parseInt(chapterId.slice(0, 2)) || 1; const chapter = parseInt(chapterId.slice(2, 5)) || 1; + const reference = t("verse_reference", { bookId, chapter }); - const [reference, setReference] = useState(""); - useEffect(() => { - setReference(t("verse_reference", { bookId, chapter })); - }, [bookId, chapter, t]); + const [openPicker, setOpenPicker] = useState(false); useEffect(() => { if (!chapterId) return; @@ -81,71 +75,64 @@ export default function CommandInput() { return () => window.removeEventListener("keydown", keydownCallback); }, [navigate, chapterId, languageCode]); - const flash = useFlash(); - return ( -
{ - e.preventDefault(); - - const referenceElement = e.target.elements.namedItem("reference"); - if (!(referenceElement instanceof HTMLInputElement)) return; - - const verseId = parseReference( - referenceElement.value, - t.raw("book_names"), - ); - if (!verseId) { - flash.error( - t("invalid_reference", { reference: referenceElement.value }), - ); - return; - } - - navigate({ - to: "/read/$code/$chapterId", - params: { code: languageCode, chapterId: verseId.slice(0, -3) }, - }); - }} - > - - setReference(e.target.value)} - name="reference" - autoComplete="off" - onFocus={(e) => e.target.select()} - aria-label={t("chapter")} - /> - - - + <> +
+ + + +
+ {openPicker && ( + setOpenPicker(false)} + onSubmit={async (nextChapterId) => { + await navigate({ + to: "/read/$code/$chapterId", + params: { code: languageCode, chapterId: nextChapterId }, + }); + setOpenPicker(false); + }} + /> + )} + ); } diff --git a/src/verse-utils.ts b/src/verse-utils.ts index 9edefe56..97b26c71 100644 --- a/src/verse-utils.ts +++ b/src/verse-utils.ts @@ -78,6 +78,33 @@ export function bookLastVerseId(bookId: number) { const REFERENCE_REGEX = /^(.+?)(?:[.]?\s*(\d+)(?:([:.,]|\s)(\d+))?)?[;.,]?$/; +export function parseReferenceParts( + reference: string, +): { bookToken?: string; chapterToken?: string } | undefined { + const trimmedReference = reference.trim(); + const matches = trimmedReference.match(REFERENCE_REGEX); + + if (!matches) { + return; + } + + const bookToken = matches?.[1]; + const chapterToken = matches?.[2]; + + return { bookToken, chapterToken }; +} + +export function findBookMatches( + input: string, + books: Array, +): Array { + return fuzzysort + .go(input.toLowerCase(), books, { + key: "name", + }) + .map((result) => result.obj); +} + export function parseReference( reference: string, bookNameList: string[], From f4c76e429a81f473896c72c79cd09191651ac42c Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 24 Apr 2026 20:40:10 -0500 Subject: [PATCH 2/4] feat: show which books have glosses in reading view --- package-lock.json | 14 +++ package.json | 1 + src/components/Icon.tsx | 4 + src/messages/ar.json | 6 ++ src/messages/en.json | 6 ++ .../study/components/ChapterPickerDialog.tsx | 94 +++++++++++++++---- src/ui/study/components/CommandInput.tsx | 8 +- src/ui/study/components/ReadingToolbar.tsx | 5 +- .../getReadBookProgressReadModel.ts | 60 ++++++++++++ src/ui/study/routes/$code.tsx | 4 +- src/ui/study/serverFns/getReadLayoutData.ts | 6 +- 11 files changed, 185 insertions(+), 23 deletions(-) create mode 100644 src/ui/study/readModels/getReadBookProgressReadModel.ts diff --git a/package-lock.json b/package-lock.json index e1748f5b..6d6aa887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@fontsource/noto-sans-arabic": "5.2.10", "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-brands-svg-icons": "6.6.0", + "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", "@google-cloud/translate": "9.3.0", @@ -3251,6 +3252,19 @@ "node": ">=6" } }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", + "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "dev": true, + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@fortawesome/free-solid-svg-icons": { "version": "6.6.0", "dev": true, diff --git a/package.json b/package.json index c3a38126..3c60200e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@fontsource/noto-sans-arabic": "5.2.10", "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-brands-svg-icons": "6.6.0", + "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", "@google-cloud/translate": "9.3.0", diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index b19c96f7..b43c49a4 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -1,5 +1,6 @@ import { config } from "@fortawesome/fontawesome-svg-core"; import * as FaSolid from "@fortawesome/free-solid-svg-icons"; +import * as FaRegular from "@fortawesome/free-regular-svg-icons"; import * as FaBrands from "@fortawesome/free-brands-svg-icons"; import { FontAwesomeIcon, @@ -30,6 +31,8 @@ const iconMap = { "chevron-down": FaSolid.faChevronDown, "chevron-up": FaSolid.faChevronUp, "chart-line": FaSolid.faChartLine, + circle: FaSolid.faCircle, + "circle-hollow": FaRegular.faCircle, "circle-play": FaSolid.faCirclePlay, close: FaSolid.faXmark, database: FaSolid.faDatabase, @@ -39,6 +42,7 @@ const iconMap = { "exclamation-circle": FaSolid.faCircleExclamation, "exclamation-triangle": FaSolid.faTriangleExclamation, "external-link": FaSolid.faUpRightFromSquare, + hourglass: FaSolid.faHourglass, maximize: FaSolid.faMaximize, feather: FaSolid.faFeather, "file-arrow-down": FaSolid.faFileArrowDown, diff --git a/src/messages/ar.json b/src/messages/ar.json index 2e7d56b3..d426436e 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -463,6 +463,12 @@ "title": "اختر الفصل", "reference": "مرجع الفصل", "back_to_books": "العودة إلى الأسفار", + "book_column": "السفر", + "glosses_column": "اللمعات", + "status_complete": "مكتمل", + "status_none": "لا شيء", + "status_many": "كثير", + "status_some": "بعض", "invalid": "يرجى إدخال مرجع فصل صحيح", "cancel": "إلغاء", "go": "اذهب", diff --git a/src/messages/en.json b/src/messages/en.json index cce2a83c..7df6ddd6 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -445,6 +445,12 @@ "title": "Choose chapter", "reference": "Chapter reference", "back_to_books": "Back to books", + "book_column": "Book", + "glosses_column": "Glosses", + "status_complete": "Complete", + "status_none": "None", + "status_many": "Many", + "status_some": "Some", "invalid": "Please enter a valid chapter reference", "cancel": "Cancel", "go": "Go", diff --git a/src/ui/study/components/ChapterPickerDialog.tsx b/src/ui/study/components/ChapterPickerDialog.tsx index f86ae19e..38d6d271 100644 --- a/src/ui/study/components/ChapterPickerDialog.tsx +++ b/src/ui/study/components/ChapterPickerDialog.tsx @@ -11,15 +11,22 @@ import { } from "@/verse-utils"; import { SubmitEvent, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "use-intl"; +import { + BookProgressReadModel, + ProgressByBookIdReadModel, +} from "../readModels/getReadBookProgressReadModel"; +import ProgressBar from "@/ui/admin/components/ProgressBar"; interface ChapterPickerDialogProps { chapterId: string; + progressByBookId: ProgressByBookIdReadModel; onCancel(): void; onSubmit(chapterId: string): void; } export default function ChapterPickerDialog({ chapterId, + progressByBookId, onCancel, onSubmit, }: ChapterPickerDialogProps) { @@ -159,24 +166,77 @@ export default function ChapterPickerDialog({
- :
    - {options.books.map((book) => { - return ( -
  1. - -
  2. - ); - })} -
+ +
+ {progress.approvedWords === progress.totalWords ? + <> + + {t("status_complete")} + + : progress.approvedWords === 0 ? + <> + + {t("status_none")} + + : progress.approvedWords / progress.totalWords > 0.8 ? + <> + + {t("status_many")} + + : <> + + {t("status_some")} + + } +
+ + ); + })} + +
}
diff --git a/src/ui/study/components/CommandInput.tsx b/src/ui/study/components/CommandInput.tsx index 1d9e1451..053e2aa7 100644 --- a/src/ui/study/components/CommandInput.tsx +++ b/src/ui/study/components/CommandInput.tsx @@ -11,8 +11,13 @@ import { } from "@/verse-utils"; import { useNavigate, useParams } from "@tanstack/react-router"; import ChapterPickerDialog from "./ChapterPickerDialog"; +import { ProgressByBookIdReadModel } from "../readModels/getReadBookProgressReadModel"; -export default function CommandInput() { +export default function CommandInput({ + progressByBookId, +}: { + progressByBookId: ProgressByBookIdReadModel; +}) { const { chapterId, code: languageCode } = useParams({ from: "/_main/read/$code/$chapterId", }); @@ -123,6 +128,7 @@ export default function CommandInput() { {openPicker && ( setOpenPicker(false)} onSubmit={async (nextChapterId) => { await navigate({ diff --git a/src/ui/study/components/ReadingToolbar.tsx b/src/ui/study/components/ReadingToolbar.tsx index 10c832c1..a8b7d081 100644 --- a/src/ui/study/components/ReadingToolbar.tsx +++ b/src/ui/study/components/ReadingToolbar.tsx @@ -10,14 +10,17 @@ import SettingsMenu from "./SettingsMenu"; import CommandInput from "./CommandInput"; import { useFlash } from "@/flash"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { ProgressByBookIdReadModel } from "../readModels/getReadBookProgressReadModel"; export interface TranslationToolbarProps { languages: { englishName: string; localName: string; code: string }[]; + progressByBookId: ProgressByBookIdReadModel; children: ReactNode; } export default function ReadingToolbar({ languages, + progressByBookId, children, }: TranslationToolbarProps) { const t = useTranslations("ReadingToolbar"); @@ -48,7 +51,7 @@ export default function ReadingToolbar({ shadow-md dark:shadow-none dark:border-b dark:border-gray-700 bg-white dark:bg-gray-900 " > - + ({ diff --git a/src/ui/study/readModels/getReadBookProgressReadModel.ts b/src/ui/study/readModels/getReadBookProgressReadModel.ts new file mode 100644 index 00000000..b602d58e --- /dev/null +++ b/src/ui/study/readModels/getReadBookProgressReadModel.ts @@ -0,0 +1,60 @@ +import { getDb } from "@/db"; + +export interface BookProgressReadModel { + totalWords: number; + approvedWords: number; +} + +export type ProgressByBookIdReadModel = Record; + +export async function getReadBookProgressReadModel( + code: string, +): Promise { + const language = await getDb() + .selectFrom("language") + .select("id") + .where("code", "=", code) + .executeTakeFirst(); + + if (!language) { + return {}; + } + + const bookProgressRows = await getDb() + .with("word_count", (db) => + db + .selectFrom("book_word_map") + .groupBy("book_id") + .select(["book_id", (eb) => eb.fn.countAll().as("count")]), + ) + .with("book_progress", (db) => + db + .selectFrom("book_completion_progress") + .where("language_id", "=", language.id) + .groupBy("book_id") + .select([ + "book_id", + (eb) => eb.fn.sum("word_count").as("count"), + ]), + ) + .selectFrom("book") + .innerJoin("word_count", "word_count.book_id", "book.id") + .leftJoin("book_progress", "book_progress.book_id", "book.id") + .select([ + "book.id as bookId", + "word_count.count as totalWords", + (eb) => + eb.fn.coalesce("book_progress.count", eb.lit(0)).as("approvedWords"), + ]) + .execute(); + + const progressByBookId: ProgressByBookIdReadModel = {}; + for (const row of bookProgressRows) { + progressByBookId[row.bookId.toString()] = { + totalWords: row.totalWords, + approvedWords: row.approvedWords, + }; + } + + return progressByBookId; +} diff --git a/src/ui/study/routes/$code.tsx b/src/ui/study/routes/$code.tsx index 3e852959..678af98d 100644 --- a/src/ui/study/routes/$code.tsx +++ b/src/ui/study/routes/$code.tsx @@ -8,10 +8,10 @@ export const Route = createFileRoute("/_main/read/$code")({ }); function ReadingLayout() { - const { languages } = Route.useLoaderData(); + const { languages, progressByBookId } = Route.useLoaderData(); return ( - + ); diff --git a/src/ui/study/serverFns/getReadLayoutData.ts b/src/ui/study/serverFns/getReadLayoutData.ts index 89167bec..041a1906 100644 --- a/src/ui/study/serverFns/getReadLayoutData.ts +++ b/src/ui/study/serverFns/getReadLayoutData.ts @@ -1,6 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import * as z from "zod"; import { getReadLanguagesReadModel } from "../readModels/getReadLanguagesReadModel"; +import { getReadBookProgressReadModel } from "../readModels/getReadBookProgressReadModel"; const requestSchema = z.object({ code: z.string(), @@ -8,8 +9,9 @@ const requestSchema = z.object({ export const getReadLayoutData = createServerFn({ method: "GET" }) .inputValidator((input: unknown) => requestSchema.parse(input)) - .handler(async () => { + .handler(async ({ data }) => { const languages = await getReadLanguagesReadModel(); + const progressByBookId = await getReadBookProgressReadModel(data.code); - return { languages }; + return { languages, progressByBookId }; }); From 3d312dc9c3653e8140afb6ddebcf48d474ae1a41 Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 24 Apr 2026 20:44:58 -0500 Subject: [PATCH 3/4] chore: remove unused icon --- src/components/Icon.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index b43c49a4..e234cc88 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -42,7 +42,6 @@ const iconMap = { "exclamation-circle": FaSolid.faCircleExclamation, "exclamation-triangle": FaSolid.faTriangleExclamation, "external-link": FaSolid.faUpRightFromSquare, - hourglass: FaSolid.faHourglass, maximize: FaSolid.faMaximize, feather: FaSolid.faFeather, "file-arrow-down": FaSolid.faFileArrowDown, From e40bc7fb40cda4823b592f48a9f80af7576058f9 Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 24 Apr 2026 21:02:26 -0500 Subject: [PATCH 4/4] fix: lint issue --- src/ui/study/components/ChapterPickerDialog.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/ui/study/components/ChapterPickerDialog.tsx b/src/ui/study/components/ChapterPickerDialog.tsx index 38d6d271..3e9c6beb 100644 --- a/src/ui/study/components/ChapterPickerDialog.tsx +++ b/src/ui/study/components/ChapterPickerDialog.tsx @@ -11,11 +11,7 @@ import { } from "@/verse-utils"; import { SubmitEvent, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "use-intl"; -import { - BookProgressReadModel, - ProgressByBookIdReadModel, -} from "../readModels/getReadBookProgressReadModel"; -import ProgressBar from "@/ui/admin/components/ProgressBar"; +import { ProgressByBookIdReadModel } from "../readModels/getReadBookProgressReadModel"; interface ChapterPickerDialogProps { chapterId: string;