This commit is contained in:
2026-08-08 18:56:53 +03:30
commit bf5c561a24
276 changed files with 31671 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
function Answer(props) {
return (
<Segment id='answer' color={props.color} colorWeight={300} borderWeight={2}>
<span className='font-semibold'>جواب:</span>
<div dangerouslySetInnerHTML={{ __html: props.answer }} />
</Segment>
);
}
Answer.propTypes = {
answer: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
color: PropTypes.string.isRequired
};
export default Answer;

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import OptionContainer from './option-container';
import Question from './question';
import Answer from './answer';
import Reviews from './reviews';
import Header from './header';
import Footer from './footer';
import { CORRECT_COLOR, DEFAULT_COLOR } from './utils';
import { updateStudentCardRate } from '../../redux/actions/student-card';
function Card(props) {
const { card } = props;
const [color, setColor] = useState(DEFAULT_COLOR);
const initialRate = props.card.studentCard?.rate || 0;
const [rate, setRate] = useState(initialRate);
useEffect(async () => {
if (rate !== initialRate && card.studentCard) {
await props.updateStudentCardRate(card.studentCard.id, rate);
}
}, [rate]);
return (
<div className='mb-20'>
<Header chapter={props.chapter} lessonName={props.card.lesson.name} />
<Question
cardId={card.id}
question={card.question}
color={color}
rate={rate}
setRate={setRate}
/>
<OptionContainer
cardId={card.id}
options={card.options}
setColor={setColor}
rate={rate}
rateUpdated={rate !== initialRate}
/>
{color !== DEFAULT_COLOR && <Reviews color={color} />}
{color !== DEFAULT_COLOR && <Answer answer={card.answer} color={color} />}
<Footer isAnyOptionSelected={color !== DEFAULT_COLOR} showArchive={color === CORRECT_COLOR} />
</div>
);
}
Card.propTypes = {
card: PropTypes.object.isRequired,
chapter: PropTypes.object.isRequired,
updateStudentCardRate: PropTypes.func.isRequired
};
export default connect(null, { updateStudentCardRate })(Card);

View File

@@ -0,0 +1,32 @@
import { useParams } from 'react-router-dom';
import Card from './card';
import Error from './error';
import MultiFetcher from '../shared/fetches/multi-fetcher';
import { fetchNextCard } from '../../redux/actions/card';
import { fetchChapter } from '../../redux/actions/chapter';
function CardsContainer() {
const { chapterId, cardType } = useParams();
const urlSearchParams = new URLSearchParams(location.search);
const priority = urlSearchParams.get('priority');
const rateType = urlSearchParams.get('rate-type');
const minRate = rateType ? { starred: 1, active: 0 }[rateType] : null;
return (
<MultiFetcher
actions={[
fetchNextCard.bind(null, chapterId, cardType, priority, minRate),
fetchChapter.bind(null, chapterId, priority, minRate)
]}
stateSelectors={{
card: state => state.card,
chapter: state => state.chapter
}}
renderError={error => <Error error={error} />}
>
{({ card, chapter }) => <Card card={card} chapter={chapter} />}
</MultiFetcher>
);
}
export default CardsContainer;

View File

@@ -0,0 +1,80 @@
import { useParams } from 'react-router-dom';
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
import NavLink from '../shared/buttons/nav-link';
import ErrorComponent from '../shared/error';
function Error(props) {
const { cardType } = useParams();
const urlSearchParams = new URLSearchParams(location.search);
const priority = urlSearchParams.get('priority');
const rateType = urlSearchParams.get('rate-type');
const { error } = props;
const backURL = `/decks?${urlSearchParams.toString()}`;
if (error.card && error.card.status === 404) {
switch (cardType) {
case 'review':
return (
<Segment>
<p className='my-4 text-center text-xl'>
<span>فلش کارت </span>
<span className='font-bold'>{'مروری '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-row justify-center'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
case 'new':
return (
<Segment>
<p className='text-center'>
<span>فلش کارت </span>
<span className='font-bold'>{'جدید '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-col items-center'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
case 'archived':
return (
<Segment>
<p className='text-center'>
<span>فلش کارت </span>
<span className='font-bold'>{'آرشیو شده '}</span>
{_(priority) && <span className='font-bold'>{_(priority) + ' '}</span>}
{_(rateType) && <span className='font-bold'>{_(rateType) + ' '}</span>}
<span> دیگری یافت نشد.</span>
</p>
<div className='my-3 flex flex-col items-center gap-3'>
<NavLink to={backURL} color='red' content='بازگشت' />
</div>
</Segment>
);
default:
return null;
}
}
return <ErrorComponent />;
}
function _(text) {
return !text ? '' : { starred: 'ستاره دار', active: 'فعال', high: 'ضروری ' }[text];
}
Error.propTypes = {
error: PropTypes.object.isRequired
};
export default Error;

View File

@@ -0,0 +1,98 @@
import { Fragment, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { ArrowSmLeftIcon, ArrowSmRightIcon } from '@heroicons/react/outline';
import Button from '../shared/buttons/button';
import { archiveStudentCard } from '../../redux/actions/student-card';
import ConfirmModal from '../shared/confirm-modal';
import NavLink from '../shared/buttons/nav-link';
import { DEFAULT_DECKS_ROUTE } from '../utils/index';
function Footer(props) {
const history = useHistory();
const { chapterId, cardType } = useParams();
const [showConfirmModal, setShowConfirmModal] = useState(false);
let previousURL = `/reviews/last/ch-${chapterId}/${cardType}`;
let nextURL = `/cards/ch-${chapterId}/${cardType}`;
const urlSearchParamsSTR = new URLSearchParams(location.search).toString();
if (urlSearchParamsSTR) {
previousURL += `?${urlSearchParamsSTR}`;
nextURL += `?${urlSearchParamsSTR}`;
}
const archiveText = props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت';
return (
<Fragment>
<ConfirmModal
showModal={showConfirmModal}
content={
<p>
<span>آیا از </span>
<span className='font-medium'>{archiveText}</span>
<span> این فلش کارت مطمئن هستید؟</span>
</p>
}
onConfirm={async () => {
await props.archiveStudentCard();
history.push(nextURL);
}}
onCancel={() => setShowConfirmModal(false)}
/>
<div
className={
'align-center fixed bottom-0 left-0 flex w-full flex-row-reverse justify-between bg-black bg-opacity-60'
}
>
{props.isAnyOptionSelected ? (
<Button color='red' onClick={() => history.push(DEFAULT_DECKS_ROUTE)} className='m-2'>
بازگشت
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</Button>
) : (
<NavLink to={previousURL} color='red' className='m-2'>
قبلی
<ArrowSmLeftIcon width={20} height={20} className='stroke-current text-white' />
</NavLink>
)}
{props.showArchive && (
<Button
color={props.isPermanentArchive ? 'indigo' : 'blue'}
onClick={() => setShowConfirmModal(true)}
className='m-2'
>
{props.isPermanentArchive ? 'آرشیو' : 'آرشیو موقت'}
</Button>
)}
<NavLink
replace
to={nextURL}
color='green'
textColor='white'
disabled={!props.isAnyOptionSelected}
className='m-2'
>
<ArrowSmRightIcon width={20} height={20} className='stroke-current text-white' />
بعدی
</NavLink>
</div>
</Fragment>
);
}
Footer.propTypes = {
isAnyOptionSelected: PropTypes.bool.isRequired,
showArchive: PropTypes.bool.isRequired,
archiveStudentCard: PropTypes.func.isRequired,
isPermanentArchive: PropTypes.bool.isRequired
};
function mapStateToProps({ review }, { showArchive }) {
return {
isPermanentArchive: !!review.data.studentCard && review.data.studentCard.status === 1,
showArchive: showArchive && !!review.data.studentCard && review.data.studentCard.status !== 2
};
}
export default connect(mapStateToProps, { archiveStudentCard })(Footer);

View File

@@ -0,0 +1,38 @@
import { useParams } from 'react-router-dom';
import Segment from '../shared/segment';
import PropTypes from 'prop-types';
import useQueryParams from '../../hooks/query-params';
function Header(props) {
const { cardType } = useParams();
const { priority } = useQueryParams();
return (
<Segment textAlign='center' color={color[cardType]} backgroundColor={color[cardType]}>
<div className='flex flex-wrap items-center justify-around gap-3 xl:justify-between'>
<div>
<span>فلش کارتهای </span>
{priority && <span className='font-medium text-red-600'>{_[priority] + ' '}</span>}
<span className={`font-medium text-${color[cardType]}-600`}>{_[cardType]}</span>
<span> درس </span>
<span className='font-medium'>{props.lessonName}</span>
<span> بخش </span>
<span className='font-medium'>{props.chapter.name}</span>
</div>
<div className={`rounded-full bg-${color[cardType]}-500 py-1 px-2.5 text-white`}>
<span className='font-medium'>{props.chapter[`${cardType}CardCount`]}</span>
</div>
</div>
</Segment>
);
}
const _ = { review: 'مروری', new: 'جدید', archived: 'آرشیو شده', high: 'ضروری ' };
const color = { review: 'blue', new: 'green', archived: 'yellow' };
Header.propTypes = {
lessonName: PropTypes.string.isRequired,
chapter: PropTypes.object.isRequired
};
export default Header;

View File

@@ -0,0 +1,26 @@
import { LightBulbIcon as OutlineLightBulbIcon } from '@heroicons/react/outline';
import { LightBulbIcon as SolidLightBulbIcon } from '@heroicons/react/solid';
import PropTypes from 'prop-types';
function HideIcon({ hide, onUpdate }) {
return hide ? (
<OutlineLightBulbIcon
width={30}
onClick={onUpdate}
className='text-gray-300 hover:text-gray-400'
/>
) : (
<SolidLightBulbIcon
width={30}
onClick={onUpdate}
className='text-yellow-300 hover:text-yellow-400'
/>
);
}
HideIcon.propTypes = {
hide: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default HideIcon;

View File

@@ -0,0 +1,22 @@
import PropTypes from 'prop-types';
import StarIcon from './star';
import HideIcon from './hide';
import ReportIcon from './report';
function Icons({ cardId, rate, setRate }) {
return (
<div className='flex items-center justify-center gap-2'>
{rate >= 0 && <StarIcon star={rate > 0} onUpdate={() => setRate(rate => 1 - rate)} />}
{rate <= 0 && <HideIcon hide={rate < 0} onUpdate={() => setRate(rate => -1 - rate)} />}
<ReportIcon cardId={cardId} />
</div>
);
}
Icons.propTypes = {
cardId: PropTypes.number.isRequired,
rate: PropTypes.number,
setRate: PropTypes.func.isRequired
};
export default Icons;

View File

@@ -0,0 +1,27 @@
import ExclamationIcon from '@heroicons/react/outline/ExclamationIcon';
import PropTypes from 'prop-types';
import { useState } from 'react';
import ReportModal from '../report-modal';
function ReportIcon({ cardId }) {
const [showModal, setShowModal] = useState(false);
return (
<>
<ExclamationIcon
width={30}
onClick={() => setShowModal(!showModal)}
className='text-orange-400 hover:text-orange-500'
/>
{showModal && (
<ReportModal cardId={cardId} showModal={showModal} onClose={() => setShowModal(false)} />
)}
</>
);
}
ReportIcon.propTypes = {
cardId: PropTypes.number.isRequired
};
export default ReportIcon;

View File

@@ -0,0 +1,22 @@
import { StarIcon as OutlineStarIcon } from '@heroicons/react/outline';
import { StarIcon as SolidStarIcon } from '@heroicons/react/solid';
import PropTypes from 'prop-types';
function StarIcon({ star, onUpdate }) {
return star ? (
<SolidStarIcon
width={30}
onClick={onUpdate}
className='text-yellow-300 hover:text-yellow-400'
/>
) : (
<OutlineStarIcon width={30} onClick={onUpdate} className='text-gray-300 hover:text-gray-400' />
);
}
StarIcon.propTypes = {
star: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default StarIcon;

View File

@@ -0,0 +1,12 @@
import MainLayout from '../layouts/main';
import CardsContainer from './container';
function CardPage() {
return (
<MainLayout withFooter>
<CardsContainer />
</MainLayout>
);
}
export default CardPage;

View File

@@ -0,0 +1,60 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Option from './option';
import { createReview } from '../../redux/actions/review';
import { CORRECT_COLOR, DONT_KNOW_COLOR, WRONG_COLOR } from './utils';
import { updateStudentCardRate } from '../../redux/actions/student-card';
class OptionContainer extends Component {
constructor(props) {
super(props);
this.state = {
selectedOptionId: null,
options: [...props.options, { id: 0, text: 'نمی دانم', isCorrect: false }]
};
}
handleSelect = async optionId => {
if (this.state.selectedOptionId !== null) return null;
this.setState({ selectedOptionId: optionId });
if (optionId !== 0) this.setState({ options: [...this.props.options] });
const { payload: review } = await this.props.createReview(this.props.cardId, optionId);
if (this.props.rateUpdated) {
await this.props.updateStudentCardRate(review.studentCard.id, this.props.rate);
}
this.setState({ selectedOptionId: optionId });
this.props.setColor(
optionId ? (review.status === 1 ? CORRECT_COLOR : WRONG_COLOR) : DONT_KNOW_COLOR
);
};
render() {
return (
<div id='options' className='space-y-3 px-4'>
{this.state.options.map((option, index) => (
<Option
key={option.id}
option={option}
index={index}
isAnyOptionSelected={this.state.selectedOptionId !== null}
isSelectedOption={this.state.selectedOptionId === option.id}
onSelect={this.handleSelect}
/>
))}
</div>
);
}
}
OptionContainer.propTypes = {
cardId: PropTypes.number.isRequired,
options: PropTypes.arrayOf(PropTypes.object).isRequired,
setColor: PropTypes.func.isRequired,
createReview: PropTypes.func.isRequired,
rate: PropTypes.number,
rateUpdated: PropTypes.bool.isRequired,
updateStudentCardRate: PropTypes.func.isRequired
};
export default connect(null, { createReview, updateStudentCardRate })(OptionContainer);

View File

@@ -0,0 +1,79 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { ExclamationIcon } from '@heroicons/react/outline';
import { CheckIcon, XIcon } from '@heroicons/react/solid';
import Button from '../shared/buttons/button';
function Option(props) {
const { option, isAnyOptionSelected, isSelectedOption } = props;
const color =
isAnyOptionSelected && option.isCorrect
? 'green'
: isAnyOptionSelected && isSelectedOption && option.id
? 'red'
: option.id
? 'gray'
: 'yellow';
return (
<Button
id={`option ${option.id}`}
outline={!isAnyOptionSelected}
color={color}
colorWeight={300}
borderWeight={2}
disabled={isAnyOptionSelected}
textColor='gray'
textColorWeight='600'
size='xl'
justifyContent={option.id ? 'start' : 'between'}
onClick={() => props.onSelect(option.id)}
className={classNames('w-full', { [`border-2 border-${color}-300`]: isAnyOptionSelected })}
>
<span className={classNames('flex', { 'mx-auto': !option.id })}>
{option.id !== 0 && (
<span className='shrink-0 font-semibold'>{`گزینه ${props.index + 1}:`}</span>
)}
<span className={classNames('mx-4 text-justify', { 'font-semibold': !option.id })}>
{option.text}
</span>
</span>
{isSelectedOption &&
(!option.id ? (
<ExclamationIcon
width={25}
height={25}
className={`shrink-0 stroke-current text-yellow-600`}
/>
) : option.isCorrect ? (
<CheckIcon
width={25}
height={25}
className={`shrink-0 rounded-lg
border-2 border-green-500
stroke-current text-green-500 ms-auto`}
/>
) : (
<XIcon
width={25}
height={25}
className={classNames(
`shrink-0 rounded-lg
border-2 border-red-500
stroke-current text-red-500 ms-auto`
)}
/>
))}
</Button>
);
}
Option.propTypes = {
option: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
isAnyOptionSelected: PropTypes.bool.isRequired,
isSelectedOption: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired
};
export default Option;

View File

@@ -0,0 +1,32 @@
import PropTypes from 'prop-types';
import Segment from '../shared/segment';
import { DEFAULT_COLOR } from './utils';
import Icons from './icons';
function Question(props) {
return (
<Segment id='question' color={props.color} colorWeight={300} borderWeight={2}>
<div>
<div className='mb-4 flex w-full items-center '>
<div className='flex-1 font-semibold'>سوال:</div>
<div className='col-span-1'>
<Icons cardId={props.cardId} rate={props.rate} setRate={props.setRate} />
</div>
</div>
<div dangerouslySetInnerHTML={{ __html: props.question }} />
</div>
</Segment>
);
}
Question.defaultProps = { color: DEFAULT_COLOR };
Question.propTypes = {
cardId: PropTypes.number.isRequired,
question: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired,
color: PropTypes.string.isRequired,
rate: PropTypes.number,
setRate: PropTypes.func.isRequired
};
export default Question;

View File

@@ -0,0 +1,96 @@
import { connect } from 'react-redux';
import { Field, Form, Formik } from 'formik';
import PropTypes from 'prop-types';
import Modal from '../shared/modal';
import FormSelect from '../shared/forms/select';
import FormTextArea from '../shared/forms/textarea';
import Button from '../shared/buttons/button';
import { required } from '../shared/forms/validations';
import { createReport } from '../../redux/actions/report';
import { useToasts } from 'react-toast-notifications';
import { useState } from 'react';
import Segment from '../shared/segment';
function ReportModal(props) {
const [error, setError] = useState('');
const { addToast } = useToasts();
async function submit(values) {
try {
setError('');
await props.createReport(values);
addToast('با تشکر، گزارش خطای شما ثبت گردید.', { appearance: 'success' });
props.onClose();
} catch (e) {
setError(e.statusText);
}
}
return (
<Modal
showModal={props.showModal}
onClose={props.onClose}
header='فرم گزارش خطا'
body={
<Formik initialValues={{ card: props.cardId, type: '', description: '' }} onSubmit={submit}>
<Form id='report-form' className='flex flex-col gap-3'>
{error && (
<Segment
color='red'
backgroundColor='red'
className='text-center font-medium text-red-500'
>
<p>{error}</p>
<p>لطفا مدتی بعد مجددا تلاش کنید.</p>
</Segment>
)}
<input type='hidden' id='card' name='card' />
<Field
required
name='type'
label='نوع خطا'
component={FormSelect}
validate={required}
options={types}
/>
<Field
required
name='description'
label='توضیحات'
component={FormTextArea}
validate={required}
rows={5}
/>
</Form>
</Formik>
}
footer={
<div className='mx-3 flex justify-between gap-2'>
<Button
type='submit'
form='report-form'
color='green'
content='ثبت گزارش'
className='grow'
/>
<Button color='red' content='بازگشت' onClick={props.onClose} className='grow' />
</div>
}
/>
);
}
const types = [
{ name: 'Scientific', text: 'علمی', value: 'Scientific' },
{ name: 'Typo', text: 'نگارشی', value: 'Typo' },
{ name: 'Other', text: 'سایر', value: 'Other' }
];
ReportModal.propTypes = {
cardId: PropTypes.number.isRequired,
showModal: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
createReport: PropTypes.func.isRequired
};
export default connect(null, { createReport })(ReportModal);

View File

@@ -0,0 +1,77 @@
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { ExclamationIcon } from '@heroicons/react/outline';
import { CheckIcon, XIcon } from '@heroicons/react/solid';
import Segment from '../shared/segment';
function Reviews(props) {
const { color, studentCard } = props;
return (
<Segment id='history' color={color} colorWeight={300} borderWeight={2}>
<div className='flex flex-wrap justify-around gap-4'>
<p>
<span className='font-medium'>شناسه فلش کارت: </span>
{studentCard.card}
</p>
{studentCard.status > 0 && (
<p>
<span className='font-medium'>وضعیت: </span>
{statuses[studentCard.status]}
</p>
)}
<p>
<span className='font-medium'>مرور شماره: </span>
{studentCard.reviewCount}
</p>
<div className='flex flex-wrap justify-center gap-x-2 gap-y-4'>
<span className='font-medium'>نتایج: </span>
<div>
{studentCard.reviews.map(review => (
<span key={review.id} className='px-px'>
{!review.status ? (
<ExclamationIcon
width={25}
height={25}
className={`inline-flex shrink-0 stroke-current text-yellow-500`}
/>
) : review.status === 1 ? (
<CheckIcon
width={25}
height={25}
className={`inline-flex shrink-0 rounded-lg
border-2 border-green-500
stroke-current text-green-500 ms-auto`}
/>
) : (
<XIcon
width={25}
height={25}
className={classNames(
`inline-flex shrink-0 rounded-lg
border-2 border-red-500
stroke-current text-red-500 ms-auto`
)}
/>
)}
</span>
))}
</div>
</div>
</div>
</Segment>
);
}
const statuses = ['عادی', 'آرشیو موقت', 'آرشیو دائم'];
Reviews.propTypes = {
studentCard: PropTypes.object.isRequired,
color: PropTypes.string.isRequired
};
function mapStateToProps({ review }) {
return { studentCard: review.data.studentCard };
}
export default connect(mapStateToProps)(Reviews);

View File

@@ -0,0 +1,4 @@
export const DEFAULT_COLOR = 'gray';
export const CORRECT_COLOR = 'green';
export const WRONG_COLOR = 'red';
export const DONT_KNOW_COLOR = 'yellow';