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[],