Reading view feature added

This commit is contained in:
Akil 2025-07-14 11:49:49 +03:00
parent f08592e0f8
commit cc8f30a8ce
7 changed files with 1838 additions and 52 deletions

1479
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,9 @@
"react": "^18",
"react-dom": "^18",
"react-i18next": "^15.4.1",
"react-icons": "^5.3.0"
"react-icons": "^5.3.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"eslint": "^8",

View File

@ -0,0 +1,42 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useTextSelection } from '../hooks/useTextSelection';
function Portal(props) {
const mountNode = props.mount || (typeof document !== 'undefined' ? document.body : null);
if (!mountNode) {
return null;
}
return createPortal(props.children, mountNode);
}
export function Popover(props) {
const { children, selectionRef: target } = props;
const textSelectionProps = useTextSelection(target);
// If there's no selection or it's collapsed, don't render anything
if (textSelectionProps.isCollapsed || !textSelectionProps.clientRect) {
return null;
}
// Get the position of the selected text
const { clientRect } = textSelectionProps;
const popoverStyle = {
position: 'absolute',
left: `${clientRect.left + window.scrollX}px`,
top: `${clientRect.top + window.scrollY + 40}px`,
zIndex: 9999
};
return (
<Portal mount={props.mount}>
<div style={popoverStyle}>
{typeof children === 'function'
? children(textSelectionProps)
: children}
</div>
</Portal>
);
}

View File

@ -13,7 +13,7 @@ export default function Toggle({className}) {
<center className={className}>
<div className={styles.switch}>
<input id="language-toggle" className={`${styles.checkToggle} ${styles.checkToggleRoundFlat}`} type="checkbox" onChange={onLanguageChange} />
<label for="language-toggle"></label>
<label htmlFor="language-toggle"></label>
<span className={styles.on}>EN</span>
<span className={styles.off}>AR</span>
</div>

View File

@ -0,0 +1,159 @@
import { useCallback, useLayoutEffect, useState } from 'react';
type ClientRect = Record<keyof Omit<DOMRect, "toJSON">, number>;
function roundValues(_rect: ClientRect) {
const rect = {
..._rect
}
for (const key of Object.keys(rect)) {
// @ts-ignore
rect[key] = Math.round(rect[key])
}
return rect
}
function shallowDiff(prev: any, next: any) {
if (prev != null && next != null) {
for (const key of Object.keys(next)) {
if (prev[key] != next[key]) {
return true
}
}
} else if (prev != next) {
return true
}
return false
}
type Verse = {
surahNo?: number,
verseNo: number,
verseText: string
};
type TextSelectionState = {
clientRect?: ClientRect,
isCollapsed?: boolean,
textContent?: string,
selectedVerses?: Verse[],
clearSelection?: () => void
}
const defaultState: TextSelectionState = {}
/**
* useTextSelection(ref)
*
* @description
* hook to get information about the current text selection
*
*/
import { RefObject } from 'react';
export function useTextSelection(target?: HTMLElement | RefObject<HTMLElement>) {
const [{
clientRect,
isCollapsed,
textContent,
selectedVerses
}, setState] = useState<TextSelectionState>(defaultState)
const reset = useCallback(() => {
setState(defaultState)
}, [])
const clearSelection = useCallback(() => {
//@ts-ignore
var sel = window.getSelection ? window.getSelection() : document.selection;
if (sel) {
if (sel.removeAllRanges) {
sel.removeAllRanges();
} else if (sel.empty) {
sel.empty();
}
}
}, [])
const handler = useCallback(() => {
let newRect: ClientRect
const selection = window.getSelection()
let newState: TextSelectionState = { }
if (selection == null || !selection.rangeCount) {
setState(newState)
return
}
const range = selection.getRangeAt(0)
let targetElement: HTMLElement | null | undefined = null;
if (target) {
if ('current' in target) {
targetElement = target.current;
} else {
targetElement = target;
}
}
if (targetElement != null && !targetElement.contains(range.commonAncestorContainer)) {
setState(newState)
return
}
if (range == null) {
setState(newState)
return
}
const contents = range.cloneContents();
if (contents.textContent != null) {
newState.textContent = contents.textContent;
newState.selectedVerses = Array.from(contents.querySelectorAll('span[verseno]')).map((span) => {
const surahNo = parseInt(span.getAttribute('surahno') || '0');
const verseNo = parseInt(span.getAttribute('verseno') || '0');
const verseText = span.textContent || '';
return { surahNo, verseNo, verseText };
});
}
const rects = range.getClientRects()
if (rects.length === 0 && range.commonAncestorContainer != null) {
const el = range.commonAncestorContainer as HTMLElement
newRect = roundValues(el.getBoundingClientRect().toJSON())
} else {
if (rects.length < 1) return
newRect = roundValues(rects[0].toJSON())
}
if (shallowDiff(clientRect, newRect)) {
newState.clientRect = newRect
}
newState.isCollapsed = targetElement !== null ? range.collapsed : true;
setState(newState)
}, [target])
useLayoutEffect(() => {
document.addEventListener('selectionchange', handler)
document.addEventListener('keydown', handler)
document.addEventListener('keyup', handler)
window.addEventListener('resize', handler)
return () => {
document.removeEventListener('selectionchange', handler)
document.removeEventListener('keydown', handler)
document.removeEventListener('keyup', handler)
window.removeEventListener('resize', handler)
}
}, [target])
return {
clientRect,
isCollapsed,
textContent,
selectedVerses,
clearSelection
}
}

View File

@ -2,29 +2,35 @@ import fs from 'fs';
import path from 'path';
import React from 'react';
import Link from 'next/link';
import { FaCopy, FaShareAlt } from 'react-icons/fa';
import SeoHead from '../../components/SeoHead';
import styles from '../../styles/Surah.module.css';
import convertToArabicNumerals, { getNumberConversion } from '../../utils/convertToArabicNumerals';
import { useTranslation } from 'react-i18next';
import { getSurahName, getSurahRevealationPlace } from '@/utils/surahUtil';
import { useLanguage } from '@/utils/LanguageProvider';
import SelectableParagraph from '@/components/SelectableParagraph';
import { Button, Modal } from 'antd';
import { Button, Modal, Switch } from 'antd';
import axios from 'axios';
import { Popover } from '@/components/Popover';
import { useTextSelection } from '@/hooks/useTextSelection';
import { FaBook, FaBookOpen, FaCopy, FaShareAlt } from 'react-icons/fa';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
const SurahPage = ({ surah, prevSurah, nextSurah }) => {
const { t } = useTranslation();
const { language } = useLanguage();
const inputFocusRef = React.useRef(null);
const refVerseContainer = React.useRef(null);
const selectedText = useTextSelection(refVerseContainer);
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [isReadingMode, setIsReadingMode] = React.useState(true);
const [selectedVerse, setSelectedVerse] = React.useState({
surahNo: surah ? surah.number : null,
verseNo: null,
text: "",
detail: null
verses: [],
tafsir: null
});
const [loading, setLoading] = React.useState(false);
if (!surah) {
return (
<main className={styles.main}>
@ -59,29 +65,27 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
}
};
const onDetailClick = async (detail) => {
// setSelectedVerse(detail);
const handleAction = async () => {
selectedText.clearSelection();
setIsModalOpen(true);
setLoading(true);
try {
setLoading(true);
const res = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/tafsir', {
verse_key: `${detail.surahNo}:${detail.verseNo}`,
verse_text: detail.selection
});
console.log('Tafsir response:', res.data);
const verseDetail = {
surahNo: detail.surahNo,
verseNo: detail.verseNo,
text: detail.selection,
detail: res.data
};
setSelectedVerse(verseDetail);
setLoading(false);
} catch (error) {
console.error('Error during action:', error);
return;
}
const res = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/tafsir', {
verses: selectedText.selectedVerses
});
if (res.data){
const verseDetail = {
verses: selectedText.selectedVerses,
tafsir: res.data.tafsir
};
setSelectedVerse(verseDetail);
}
setLoading(false);
} catch (error) {
console.error('Error during action:', error);
setLoading(false);
return;
}
};
return (
@ -119,6 +123,9 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
</Link>
)}
</span>
<span><Switch style={{backgroundColor: isReadingMode ? '#4DB6AC' : '#999999'}} checkedChildren={<span className={styles.switch}><FaBookOpen /></span>} unCheckedChildren={<span className={styles.switch}><FaBook /></span>} defaultChecked onChange={() => setIsReadingMode(!isReadingMode)} /></span>
<span className={styles.navButtonWrapper}>
{nextSurah && (
<Link href={`/quran/${nextSurah.number}`} className={styles.navButton}>
@ -128,11 +135,43 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
)}
</span>
</div>
<div className={styles.surahText}>
{prefaceText && (
{prefaceText && (
<p className={styles.bismillah}>{prefaceText}</p>
)}
)}
{isReadingMode ?
<div className={styles.surahContainer} ref={refVerseContainer} suppressContentEditableWarning>
{surah.verses.map((verse, index) => {
// const formattedText = `
// "${verse.text.ar}"
// 🌐 ترجمة:
// "${verse.text.en}"
// 🔖 ${surah.name.ar}:${index + 1}`;
let willPageChange = false;
const nextVerse = surah.verses[index + 1];
if (nextVerse && nextVerse.page !== verse.page) {
willPageChange = true;
}
return (
<span key={`vp_${index}`}>
<span key={`verse_${index}`} className={styles.verseText} surahno={surah.number} verseno={index + 1}>
{verse.text.ar}
</span>
<span key={`stop_${index}`} className={styles.ayahNumber} title={index + 1} aria-label={index + 1}>
{convertToArabicNumerals(index + 1)}
</span>
{willPageChange && <div className={styles.pageSeperator}>{verse.page}</div>}
</span>
);
})}
</div>
:
<div className={styles.surahText} suppressContentEditableWarning>
{surah.verses.map((verse, index) => {
const formattedText = `
"${verse.text.ar}"
@ -146,11 +185,12 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
return (
<div key={index} className={styles.verseContainer}>
<div className={styles.verseBox}>
<SelectableParagraph surahNo={surah.number} verseNo={index + 1} key={`p_${index}`}
{/* <SelectableParagraph surahNo={surah.number} verseNo={index + 1} key={`p_${index}`}
index={index}
onDetailClick={onDetailClick}
content={<p className={styles.verseText} title={verse.text.ar} aria-label={verse.text.ar.split(" ").join("_")}>{verse.text.ar}</p>}>
</SelectableParagraph>
</SelectableParagraph> */}
<p className={styles.verseText} title={verse.text.ar} aria-label={verse.text.ar.split(" ").join("_")}>{verse.text.ar}</p>
<p className={styles.verseTextEn} title={verse.text.en} aria-label={verse.text.en.split(" ").join("_")}>{verse.text.en}</p>
</div>
<span className={styles.VerseNumber} title={index + 1} aria-label={index + 1}>
@ -168,8 +208,24 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
</div>
</div>
)
})}
</div>
})}
</div> }
<Popover selectionRef={refVerseContainer}>
<div style={{
background: 'grey',
borderRadius: '10px',
overflow: 'hidden',
transition: '300ms',
whiteSpace: 'nowrap',
display: 'flex',
userSelect: 'none',
}}>
<p style={{ margin: '0', padding: '.5em' }}>
<Button size="middle" onClick={() => handleAction()}>{t('surah.meta-tafsir')}</Button>
</p>
</div>
</Popover>
</main>
<Modal
title={<p>{`${getSurahName(surah, language)}`}</p>}
@ -182,13 +238,23 @@ const SurahPage = ({ surah, prevSurah, nextSurah }) => {
loading={loading}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
maskClosable={false}
>
<div>
<h1 className={styles.title}>{t('surah.title')} {getSurahName(surah, language)}</h1>
<div>
<p className={styles.details}><b>{t('surah.meta-verse')}</b>: <span>{selectedVerse.verseNo && getNumberConversion(selectedVerse.verseNo, language)}</span></p>
<p className={styles.details}><b>{t('surah.meta-selected-verse')}</b>: <span>{selectedVerse.text && selectedVerse.text}</span></p>
<p className={styles.details}><b>{t('surah.meta-tafsir')}</b>: <span>{selectedVerse.detail && selectedVerse.detail.tafsir}</span></p>
<div className={styles.detailsParent}>
<div className={styles.details}><b>{t('surah.meta-selected-verse')}</b>:
<span>
{selectedVerse.verses && selectedVerse.verses.map((verse, index) => (
<div key={`vn_${index}`} className={styles.detailsSelectedVerses}>{`${verse.surahNo}:${verse.verseNo} - ${verse.verseText}`}</div>
))}
</span>
</div>
<div className={styles.details}><b>{t('surah.meta-tafsir')}</b>:
<span>
<Markdown remarkPlugins={[remarkGfm]}>{selectedVerse.tafsir && selectedVerse.tafsir}</Markdown>
</span>
</div>
</div>
</div>
</Modal>

View File

@ -26,11 +26,22 @@
margin-top: 20px;
}
.details {
overflow-y: hidden;
.detailsParent{
display: flex;
flex-direction: column;
gap: 10px;
max-height: 400px;
overflow-y: auto;
scroll-behavior: smooth;
}
.detailsSelectedVerses {
font-family: "hafs";
font-size: 14px;
}
.details {
scroll-behavior: smooth;
max-height: 200px;
background-color: var(--box-color);
padding-left: 20px;
padding-right: 20px;
@ -70,12 +81,41 @@
margin-bottom: 20px;
}
.surahContainer {
width: 60%;
text-align: center;
align-self: center;
}
.ayahNumber {
font-family: "hafs";
font-size: 29px;
margin: 0 10px;
color: var(--secondary-color);
font-weight: bold;
}
.pageSeperator{
background-color: var(--secondary-color);;
width: 100%;
height: 25px;
margin-bottom: 10px;
font-weight: 500;
color: var(--text-white);
}
/* حاوية الآية */
.surahText {
display: flex;
flex-direction: column;
}
.switch{
display: flex;
height: 22px;
align-items: center;
}
.verseContainer {
display: flex;
align-items: center;
@ -109,6 +149,7 @@
.verseText {
font-family: "hafs";
font-size: 25px;
word-break: auto-phrase;
}
.verseTextEn {
font-family: "hafs";
@ -150,6 +191,7 @@
.navButtonWrapper {
display: inline-block;
text-decoration: none;
min-width: 230px
}
.navButton {
@ -208,4 +250,10 @@
.actions button:focus {
outline: 2px solid var(--primary-color);
}
@media (max-width: 768px) {
.surahContainer {
width: 100%;
}
}