Subject bar under the header in Quizzes and Lessons, synced between tabs; bidi fix for Latin math in RTL text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,8 +81,11 @@ export const api = {
|
|||||||
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
|
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
|
||||||
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
|
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
|
||||||
|
|
||||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
/**
|
||||||
home: () => http.get<T.HomeResponse>('/home'),
|
* بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
|
||||||
|
* کتابِ ناموجود در پایه ۴۰۰ میدهد (برای کلیدِ ذخیرهشدهی قدیمی از `fetchHome` استفاده کنید).
|
||||||
|
*/
|
||||||
|
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
|
||||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +139,11 @@ export const api = {
|
|||||||
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
||||||
*/
|
*/
|
||||||
async ask(
|
async ask(
|
||||||
input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string },
|
input: {
|
||||||
|
userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string;
|
||||||
|
/** نامِ کتاب («هندسه»)؛ برای پرسشِ بیدرس تا دستیار از کتابِ همان انتخابِ کاربر جواب بدهد. */
|
||||||
|
subject?: string;
|
||||||
|
},
|
||||||
onDelta: (text: string) => void,
|
onDelta: (text: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T.AskDone> {
|
): Promise<T.AskDone> {
|
||||||
|
|||||||
@@ -82,7 +82,22 @@ export interface HomeStats {
|
|||||||
coins: number;
|
coins: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** یک کتابِ ریاضیِ پایه (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته). */
|
||||||
|
export interface Subject {
|
||||||
|
/** همان که به `api.home(subject)` داده میشود. */
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
/** نامِ آیکنِ lucide. */
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HomeResponse {
|
export interface HomeResponse {
|
||||||
|
/** کتابی که `units` از آن است؛ null فقط وقتی پایه فصلی ندارد. */
|
||||||
|
subject: Subject | null;
|
||||||
|
/** همهی کتابهای این پایه؛ نوارِ کتابها فقط با بیش از یکی نشان داده میشود. */
|
||||||
|
subjects: Subject[];
|
||||||
units: HomeUnit[];
|
units: HomeUnit[];
|
||||||
stats: HomeStats;
|
stats: HomeStats;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ToastProvider } from '@/components/Toast';
|
|||||||
import { ErrorState } from '@/components/ui';
|
import { ErrorState } from '@/components/ui';
|
||||||
import { KEYS, store } from '@/lib/storage';
|
import { KEYS, store } from '@/lib/storage';
|
||||||
import { askStore } from '@/state/askStore';
|
import { askStore } from '@/state/askStore';
|
||||||
|
import { fetchHome, subjectStore } from '@/state/subject';
|
||||||
import { clearAsyncCache } from '@/lib/useAsync';
|
import { clearAsyncCache } from '@/lib/useAsync';
|
||||||
import { LoginScreen } from '@/screens/LoginScreen';
|
import { LoginScreen } from '@/screens/LoginScreen';
|
||||||
import { OnboardingScreen } from '@/screens/OnboardingScreen';
|
import { OnboardingScreen } from '@/screens/OnboardingScreen';
|
||||||
@@ -34,7 +35,8 @@ export function App() {
|
|||||||
const enter = useCallback(async () => {
|
const enter = useCallback(async () => {
|
||||||
// نقشه و آمار همزمان با پروفایل گرفته میشوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
|
// نقشه و آمار همزمان با پروفایل گرفته میشوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
|
||||||
// شکستشان ورود را نمیخواباند: AppData همانها را دوباره میگیرد و خطای نقشه را روی خانه نشان میدهد.
|
// شکستشان ورود را نمیخواباند: AppData همانها را دوباره میگیرد و خطای نقشه را روی خانه نشان میدهد.
|
||||||
const [profile, grades, prefetch] = await Promise.all([api.profile.get(), api.grades(), prefetchMain()]);
|
const profileReq = api.profile.get();
|
||||||
|
const [profile, grades, prefetch] = await Promise.all([profileReq, api.grades(), prefetchMain(profileReq)]);
|
||||||
await trackReferral();
|
await trackReferral();
|
||||||
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
|
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
|
||||||
else setFlow({ kind: 'setup', profile, grades });
|
else setFlow({ kind: 'setup', profile, grades });
|
||||||
@@ -147,8 +149,11 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** دادهی صفحهی اول؛ هر کدام که نرسید null میماند و AppData خودش دوباره میگیرد. */
|
/** دادهی صفحهی اول؛ هر کدام که نرسید null میماند و AppData خودش دوباره میگیرد. */
|
||||||
async function prefetchMain(): Promise<Prefetch> {
|
async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
|
||||||
const [home, summary] = await Promise.allSettled([api.home(), api.summary()]);
|
// کتابِ انتخابشده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل میمانیم (پایههای ≤ دهم
|
||||||
|
// معمولاً هیچ انتخابی ندارند و نقشه همزمان با پروفایل میآید).
|
||||||
|
const gradeId = Object.keys(subjectStore.snapshot()).length ? (await profile).gradeId : null;
|
||||||
|
const [home, summary] = await Promise.allSettled([fetchHome(gradeId), api.summary()]);
|
||||||
return {
|
return {
|
||||||
home: home.status === 'fulfilled' ? home.value : null,
|
home: home.status === 'fulfilled' ? home.value : null,
|
||||||
summary: summary.status === 'fulfilled' ? summary.value : null,
|
summary: summary.status === 'fulfilled' ? summary.value : null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
BookOpen, Brain, Compass, Grid3x3, LineChart, Play, Repeat, Sigma, Sparkles, TrendingUp,
|
BarChart3, BookOpen, Brain, Compass, Grid3x3, LineChart, Network, Play, Repeat, Sigma, Sparkles, TrendingUp,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
* مجموعهی کوچک در باندل است تا کلِ کتابخانهی آیکن بار نشود.
|
* مجموعهی کوچک در باندل است تا کلِ کتابخانهی آیکن بار نشود.
|
||||||
*/
|
*/
|
||||||
const ICONS: Record<string, LucideIcon> = {
|
const ICONS: Record<string, LucideIcon> = {
|
||||||
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play,
|
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play, BarChart3, Network,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK: LucideIcon[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain];
|
const FALLBACK: LucideIcon[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain];
|
||||||
|
|||||||
55
src/components/SubjectBar.tsx
Normal file
55
src/components/SubjectBar.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAppData } from '@/state/AppData';
|
||||||
|
import { onWhite } from '@/lib/color';
|
||||||
|
import { iconFor } from '@/components/NodeIcon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نوارِ نازکِ کتابها (ریاضی / هندسه / حسابان / آمار و احتمال / گسسته) زیرِ سربرگِ «آزمونها» و «درسها».
|
||||||
|
* انتخاب در AppData نگه داشته میشود و هر دو تب از همان `home` میخوانند، پس جابهجایی در یکی در دیگری
|
||||||
|
* هم دیده میشود. فقط وقتی پایه بیش از یک کتاب دارد نشان داده میشود؛ پایههای دیگر هیچ تغییری نمیبینند.
|
||||||
|
*
|
||||||
|
* دکمهها عرضِ نوار را پر میکنند (پایهی ۱۰ دو کتاب، ۱۱ و ۱۲ سه کتاب دارند)؛ اگر جا کم بود اسکرولِ افقی میشود.
|
||||||
|
*
|
||||||
|
* بیرونِ ناحیهی اسکرول است تا با پایینرفتنِ نقشه گم نشود و با سربرگِ چسبانِ فصلها همپوشانی نکند.
|
||||||
|
*/
|
||||||
|
export function SubjectBar() {
|
||||||
|
const { home, subjectKey, selectSubject } = useAppData();
|
||||||
|
const subjects = home?.subjects ?? [];
|
||||||
|
const activeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// کتابِ فعال در دید بماند وقتی پنج دکمه از عرضِ گوشی بیشتر است.
|
||||||
|
activeRef.current?.scrollIntoView({ inline: 'center', block: 'nearest' });
|
||||||
|
}, [subjectKey]);
|
||||||
|
|
||||||
|
if (subjects.length < 2) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="کتابها" className="flex-shrink-0 border-b border-line bg-white">
|
||||||
|
<div role="tablist" className="no-scrollbar flex gap-2 overflow-x-auto px-4 py-2">
|
||||||
|
{subjects.map((subject, index) => {
|
||||||
|
const active = subject.key === subjectKey;
|
||||||
|
const Icon = iconFor(subject.icon, index);
|
||||||
|
const color = onWhite(subject.color || '#4C5FD5');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={subject.key}
|
||||||
|
ref={active ? activeRef : undefined}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => !active && selectSubject(subject.key)}
|
||||||
|
className={`flex h-10 min-w-fit flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border px-3.5 text-[13px] font-black transition active:scale-95 ${
|
||||||
|
active ? 'border-transparent text-white shadow-sm' : 'border-line bg-white text-body'
|
||||||
|
}`}
|
||||||
|
style={active ? { backgroundColor: color, boxShadow: `0 3px 0 ${color}66` } : undefined}
|
||||||
|
>
|
||||||
|
<Icon size={16} strokeWidth={2.4} style={active ? undefined : { color }} />
|
||||||
|
{subject.title}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,8 +18,12 @@ import { Fragment } from 'react';
|
|||||||
*/
|
*/
|
||||||
// حروف، اِعراب (کسرهی «خودِ»)، و نیمفاصله؛ رقمهای ۰–۹ عمداً بیروناند.
|
// حروف، اِعراب (کسرهی «خودِ»)، و نیمفاصله؛ رقمهای ۰–۹ عمداً بیروناند.
|
||||||
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
|
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
|
||||||
// تکهای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِکم یک رقم دارد
|
// «نویسهی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: f، x، A). بدونِ حرفِ لاتین «−f(x)» تکه حساب
|
||||||
const RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g');
|
// نمیشد و در متنِ راستبهچپ بهصورتِ «f(x)−» دیده میشد (گزارشِ ۲۰۲۶-۱۰-۱۲).
|
||||||
|
const MATH_CH = '0-9۰-۹A-Za-z';
|
||||||
|
// تکهای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِکم یک نویسهی ریاضی دارد
|
||||||
|
const RUN = new RegExp(`[^${LETTER}\\n]*[${MATH_CH}][^${LETTER}\\n]*`, 'g');
|
||||||
|
const HAS_MATH = new RegExp(`[${MATH_CH}]`);
|
||||||
// نشانههایی که مالِ جملهی فارسیاند، نه ریاضی: از دو سرِ تکه بیرون میمانند
|
// نشانههایی که مالِ جملهی فارسیاند، نه ریاضی: از دو سرِ تکه بیرون میمانند
|
||||||
const EDGE_START = /^[\s،؛:!؟«»"'.=]+/;
|
const EDGE_START = /^[\s،؛:!؟«»"'.=]+/;
|
||||||
const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
|
const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
|
||||||
@@ -48,7 +52,7 @@ export function MathText({ text }: { text: string }) {
|
|||||||
const edge = trimUnpaired(run);
|
const edge = trimUnpaired(run);
|
||||||
const head = lead + edge.head;
|
const head = lead + edge.head;
|
||||||
run = edge.run;
|
run = edge.run;
|
||||||
if (!run || !/[0-9۰-۹]/.test(run)) continue;
|
if (!run || !HAS_MATH.test(run)) continue;
|
||||||
out.push(text.slice(last, start + head.length));
|
out.push(text.slice(last, start + head.length));
|
||||||
out.push(<span key={i++} style={LTR}>{run}</span>);
|
out.push(<span key={i++} style={LTR}>{run}</span>);
|
||||||
last = start + head.length + run.length;
|
last = start + head.length + run.length;
|
||||||
|
|||||||
@@ -29,4 +29,6 @@ export const KEYS = {
|
|||||||
sound: 'tadrisino.sound',
|
sound: 'tadrisino.sound',
|
||||||
referralCode: 'tadrisino.referralCode',
|
referralCode: 'tadrisino.referralCode',
|
||||||
companion: 'tadrisino.companion',
|
companion: 'tadrisino.companion',
|
||||||
|
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
|
||||||
|
subjects: 'tadrisino.subjects',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ
|
|||||||
|
|
||||||
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
|
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
|
||||||
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
|
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
|
||||||
const { profile } = useAppData();
|
const { profile, home } = useAppData();
|
||||||
|
const subjectTitle = home?.subject?.title;
|
||||||
const companion = useCompanion();
|
const companion = useCompanion();
|
||||||
const chat = useAskState();
|
const chat = useAskState();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -90,6 +91,9 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
|||||||
userQuestion: question,
|
userQuestion: question,
|
||||||
conversationId: askStore.get().conversationId ?? undefined,
|
conversationId: askStore.get().conversationId ?? undefined,
|
||||||
lessonId: askStore.get().lessonId ?? undefined,
|
lessonId: askStore.get().lessonId ?? undefined,
|
||||||
|
// بیدرس، کتابِ انتخابیِ دانشآموز (هندسه، گسسته، …) را میفرستیم تا دستیار از همان کتاب جواب بدهد؛
|
||||||
|
// با درس، سرور کتاب را از خودِ درس درمیآورد.
|
||||||
|
subject: askStore.get().lessonId ? undefined : subjectTitle,
|
||||||
questionId: forQuestionId,
|
questionId: forQuestionId,
|
||||||
},
|
},
|
||||||
(delta) => {
|
(delta) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MathText } from '@/lib/MathText';
|
|||||||
import { BrandFooter } from '@/components/BrandFooter';
|
import { BrandFooter } from '@/components/BrandFooter';
|
||||||
import { BrandMark } from '@/components/BrandMark';
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||||
|
import { SubjectBar } from '@/components/SubjectBar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||||
@@ -108,6 +109,8 @@ export function HomeScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SubjectBar />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{!home ? (
|
{!home ? (
|
||||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { iconFor } from '@/components/NodeIcon';
|
|||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
import { useFirstVisit } from '@/lib/firstVisit';
|
import { useFirstVisit } from '@/lib/firstVisit';
|
||||||
|
import { SubjectBar } from '@/components/SubjectBar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||||||
@@ -53,6 +54,8 @@ export function LessonsScreen() {
|
|||||||
)}
|
)}
|
||||||
</ScreenHeader>
|
</ScreenHeader>
|
||||||
|
|
||||||
|
<SubjectBar />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
||||||
{!home ? (
|
{!home ? (
|
||||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درسها رو میارم…" />
|
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درسها رو میارم…" />
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||||||
await api.profile.setGrade(gradeId);
|
await api.profile.setGrade(gradeId);
|
||||||
setProfile({ ...profile, gradeId });
|
setProfile({ ...profile, gradeId });
|
||||||
askStore.reset(null);
|
askStore.reset(null);
|
||||||
await Promise.all([refreshHome(), refreshSummary()]);
|
await Promise.all([refreshHome(gradeId), refreshSummary()]);
|
||||||
toast('پایهت عوض شد', 'success');
|
toast('پایهت عوض شد', 'success');
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react';
|
|||||||
import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
import { Brain, Check, ChevronLeft, Coins, Compass, Flag, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react';
|
||||||
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
|
import { fetchHome } from '@/state/subject';
|
||||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||||
import { useNav } from '@/nav/Navigator';
|
import { useNav } from '@/nav/Navigator';
|
||||||
import { newIdempotencyKey } from '@/api/http';
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
@@ -449,7 +450,7 @@ function Result({
|
|||||||
result, total, correct, lessonId, nodeId, color, streakUp,
|
result, total, correct, lessonId, nodeId, color, streakUp,
|
||||||
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
|
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const { companion } = useAppData();
|
const { companion, profile } = useAppData();
|
||||||
const [loadingNext, setLoadingNext] = useState(false);
|
const [loadingNext, setLoadingNext] = useState(false);
|
||||||
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحهی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخابشده.
|
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحهی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخابشده.
|
||||||
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
|
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
|
||||||
@@ -468,7 +469,7 @@ function Result({
|
|||||||
}
|
}
|
||||||
setLoadingNext(true);
|
setLoadingNext(true);
|
||||||
try {
|
try {
|
||||||
const fresh = await api.home();
|
const fresh = await fetchHome(profile.gradeId);
|
||||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
|
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
|
||||||
const next = ref ? nextLessonOf(ref.node) : null;
|
const next = ref ? nextLessonOf(ref.node) : null;
|
||||||
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
|
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
|
||||||
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
|
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
|
||||||
import { companionStore, useCompanion } from '@/state/companion';
|
import { companionStore, useCompanion } from '@/state/companion';
|
||||||
|
import { fetchHome, subjectStore } from '@/state/subject';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* دادههایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
|
* دادههایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
|
||||||
@@ -18,7 +19,12 @@ interface AppData {
|
|||||||
/** همراهی که کاربر در صفحهی کاراکترها انتخاب کرده؛ پیشفرض سیگما. */
|
/** همراهی که کاربر در صفحهی کاراکترها انتخاب کرده؛ پیشفرض سیگما. */
|
||||||
companion: SeyarChar;
|
companion: SeyarChar;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
refreshHome: () => Promise<void>;
|
/** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */
|
||||||
|
refreshHome: (gradeId?: number) => Promise<void>;
|
||||||
|
/** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمونها و درسها هر دو همین را میخوانند. */
|
||||||
|
selectSubject: (key: string) => void;
|
||||||
|
/** کتابی که روی `home` است؛ بینقشه (هنوز نرسیده) null. */
|
||||||
|
subjectKey: string | null;
|
||||||
refreshSummary: () => Promise<void>;
|
refreshSummary: () => Promise<void>;
|
||||||
refreshUnread: () => Promise<void>;
|
refreshUnread: () => Promise<void>;
|
||||||
setProfile: (profile: UserProfile) => void;
|
setProfile: (profile: UserProfile) => void;
|
||||||
@@ -54,14 +60,35 @@ export function AppDataProvider({
|
|||||||
const setCompanionChar = companionStore.set;
|
const setCompanionChar = companionStore.set;
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
const refreshHome = useCallback(async () => {
|
const gradeId = profile.gradeId;
|
||||||
|
// نقشهی کتابهایی که دیده شده؛ جابهجایی به کتابِ دیدهشده فوری است و پشتصحنه تازه میشود.
|
||||||
|
const homeCache = useRef(new Map<string, HomeResponse>());
|
||||||
|
|
||||||
|
const refreshHome = useCallback(async (forGrade?: number) => {
|
||||||
|
const target = forGrade ?? gradeId;
|
||||||
try {
|
try {
|
||||||
setHome(await api.home());
|
const fresh = await fetchHome(target);
|
||||||
|
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
|
||||||
|
setHome(fresh);
|
||||||
setHomeError(null);
|
setHomeError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setHomeError(err);
|
setHomeError(err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [gradeId]);
|
||||||
|
|
||||||
|
const selectSubject = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (gradeId == null) return;
|
||||||
|
subjectStore.set(gradeId, key);
|
||||||
|
const cached = homeCache.current.get(`${gradeId}:${key}`);
|
||||||
|
// کتابِ دیدهشده فوری میآید؛ دیدهنشده یک لحظه «در حال آوردن» است تا نقشهی کتابِ قبلی زیرِ نامِ
|
||||||
|
// کتابِ تازه نماند.
|
||||||
|
setHome(cached ?? null);
|
||||||
|
setHomeError(null);
|
||||||
|
void refreshHome();
|
||||||
|
},
|
||||||
|
[gradeId, refreshHome],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshSummary = useCallback(async () => {
|
const refreshSummary = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -106,13 +133,15 @@ export function AppDataProvider({
|
|||||||
companion,
|
companion,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
refreshHome,
|
refreshHome,
|
||||||
|
selectSubject,
|
||||||
|
subjectKey: home?.subject?.key ?? null,
|
||||||
refreshSummary,
|
refreshSummary,
|
||||||
refreshUnread,
|
refreshUnread,
|
||||||
setProfile,
|
setProfile,
|
||||||
setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'),
|
setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'),
|
||||||
setUnreadCount,
|
setUnreadCount,
|
||||||
}),
|
}),
|
||||||
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, refreshSummary, refreshUnread],
|
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, selectSubject, refreshSummary, refreshUnread],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;
|
return <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;
|
||||||
|
|||||||
68
src/state/subject.ts
Normal file
68
src/state/subject.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { api, ApiError, type HomeResponse } from '@/api';
|
||||||
|
import { KEYS, store } from '@/lib/storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کتابی که دانشآموز در هر پایه انتخاب کرده («هندسه»، «حسابان»، …). یک منبعِ حقیقت برای هر دو تبِ «آزمونها» و
|
||||||
|
* «درسها»: هر دو از `home` در AppData میخوانند و `home` همیشه نقشهی همین کتاب است، پس جابهجایی در یکی در
|
||||||
|
* دیگری هم دیده میشود.
|
||||||
|
*
|
||||||
|
* هر پایه جدا نگه داشته میشود (با عوضکردنِ پایه، انتخابِ پایهی قبلی نباید به دوازدهم بپرد) و در
|
||||||
|
* localStorage میماند تا بعد از بستنِ اپ همان کتاب باز شود. منبعِ حقیقتِ فهرستِ کتابها سرور است
|
||||||
|
* (`home.subjects`)؛ این فقط «کدام» را میگوید.
|
||||||
|
*/
|
||||||
|
type SubjectMap = Record<string, string>;
|
||||||
|
|
||||||
|
function read(): SubjectMap {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(store.get(KEYS.subjects) ?? '{}');
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as SubjectMap) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: SubjectMap = read();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export const subjectStore = {
|
||||||
|
get: (gradeId: number): string | null => current[String(gradeId)] ?? null,
|
||||||
|
set(gradeId: number, key: string | null) {
|
||||||
|
const id = String(gradeId);
|
||||||
|
if ((current[id] ?? null) === key) return;
|
||||||
|
const next = { ...current };
|
||||||
|
if (key) next[id] = key;
|
||||||
|
else delete next[id];
|
||||||
|
current = next;
|
||||||
|
store.set(KEYS.subjects, JSON.stringify(next));
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
},
|
||||||
|
subscribe(listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
snapshot: () => current,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** کتابِ انتخابشدهی این پایه؛ null یعنی هنوز انتخابی نشده (سرور اولین کتاب را میدهد). */
|
||||||
|
export function useSelectedSubject(gradeId: number | null | undefined): string | null {
|
||||||
|
const map = useSyncExternalStore(subjectStore.subscribe, subjectStore.snapshot, subjectStore.snapshot);
|
||||||
|
return gradeId == null ? null : map[String(gradeId)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نقشهی کتابِ انتخابشده. کلیدِ ذخیرهشدهای که سرور دیگر نمیشناسد (کتاب برداشته شده یا پایه عوض شده)
|
||||||
|
* ۴۰۰ میگیرد؛ آن را پاک میکنیم و نقشهی پیشفرض را میگیریم تا کاربر صفحهی خالی نبیند.
|
||||||
|
*/
|
||||||
|
export async function fetchHome(gradeId: number | null | undefined): Promise<HomeResponse> {
|
||||||
|
const wanted = gradeId == null ? null : subjectStore.get(gradeId);
|
||||||
|
try {
|
||||||
|
return await api.home(wanted ?? undefined);
|
||||||
|
} catch (err) {
|
||||||
|
if (wanted && gradeId != null && err instanceof ApiError && err.status === 400) {
|
||||||
|
subjectStore.set(gradeId, null);
|
||||||
|
return api.home();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user