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'),
|
||||
|
||||
/** بدونِ 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`),
|
||||
|
||||
/**
|
||||
@@ -136,7 +139,11 @@ export const api = {
|
||||
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
||||
*/
|
||||
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,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T.AskDone> {
|
||||
|
||||
@@ -82,7 +82,22 @@ export interface HomeStats {
|
||||
coins: number;
|
||||
}
|
||||
|
||||
/** یک کتابِ ریاضیِ پایه (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته). */
|
||||
export interface Subject {
|
||||
/** همان که به `api.home(subject)` داده میشود. */
|
||||
key: string;
|
||||
title: string;
|
||||
/** نامِ آیکنِ lucide. */
|
||||
icon: string;
|
||||
color: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface HomeResponse {
|
||||
/** کتابی که `units` از آن است؛ null فقط وقتی پایه فصلی ندارد. */
|
||||
subject: Subject | null;
|
||||
/** همهی کتابهای این پایه؛ نوارِ کتابها فقط با بیش از یکی نشان داده میشود. */
|
||||
subjects: Subject[];
|
||||
units: HomeUnit[];
|
||||
stats: HomeStats;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToastProvider } from '@/components/Toast';
|
||||
import { ErrorState } from '@/components/ui';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
import { askStore } from '@/state/askStore';
|
||||
import { fetchHome, subjectStore } from '@/state/subject';
|
||||
import { clearAsyncCache } from '@/lib/useAsync';
|
||||
import { LoginScreen } from '@/screens/LoginScreen';
|
||||
import { OnboardingScreen } from '@/screens/OnboardingScreen';
|
||||
@@ -34,7 +35,8 @@ export function App() {
|
||||
const enter = useCallback(async () => {
|
||||
// نقشه و آمار همزمان با پروفایل گرفته میشوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
|
||||
// شکستشان ورود را نمیخواباند: 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();
|
||||
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
|
||||
else setFlow({ kind: 'setup', profile, grades });
|
||||
@@ -147,8 +149,11 @@ export function App() {
|
||||
}
|
||||
|
||||
/** دادهی صفحهی اول؛ هر کدام که نرسید null میماند و AppData خودش دوباره میگیرد. */
|
||||
async function prefetchMain(): Promise<Prefetch> {
|
||||
const [home, summary] = await Promise.allSettled([api.home(), api.summary()]);
|
||||
async function prefetchMain(profile: Promise<UserProfile>): Promise<Prefetch> {
|
||||
// کتابِ انتخابشده به پایه بسته است؛ فقط وقتی چیزی ذخیره شده منتظرِ پروفایل میمانیم (پایههای ≤ دهم
|
||||
// معمولاً هیچ انتخابی ندارند و نقشه همزمان با پروفایل میآید).
|
||||
const gradeId = Object.keys(subjectStore.snapshot()).length ? (await profile).gradeId : null;
|
||||
const [home, summary] = await Promise.allSettled([fetchHome(gradeId), api.summary()]);
|
||||
return {
|
||||
home: home.status === 'fulfilled' ? home.value : null,
|
||||
summary: summary.status === 'fulfilled' ? summary.value : null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
* مجموعهی کوچک در باندل است تا کلِ کتابخانهی آیکن بار نشود.
|
||||
*/
|
||||
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];
|
||||
|
||||
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 RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g');
|
||||
// «نویسهی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: f، x، A). بدونِ حرفِ لاتین «−f(x)» تکه حساب
|
||||
// نمیشد و در متنِ راستبهچپ بهصورتِ «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_END = /[\s،؛:!؟«»"'.=]+$/;
|
||||
@@ -48,7 +52,7 @@ export function MathText({ text }: { text: string }) {
|
||||
const edge = trimUnpaired(run);
|
||||
const head = lead + edge.head;
|
||||
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(<span key={i++} style={LTR}>{run}</span>);
|
||||
last = start + head.length + run.length;
|
||||
|
||||
@@ -29,4 +29,6 @@ export const KEYS = {
|
||||
sound: 'tadrisino.sound',
|
||||
referralCode: 'tadrisino.referralCode',
|
||||
companion: 'tadrisino.companion',
|
||||
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
|
||||
subjects: 'tadrisino.subjects',
|
||||
} as const;
|
||||
|
||||
@@ -19,7 +19,8 @@ const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ
|
||||
|
||||
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
|
||||
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 chat = useAskState();
|
||||
const [input, setInput] = useState('');
|
||||
@@ -90,6 +91,9 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
userQuestion: question,
|
||||
conversationId: askStore.get().conversationId ?? undefined,
|
||||
lessonId: askStore.get().lessonId ?? undefined,
|
||||
// بیدرس، کتابِ انتخابیِ دانشآموز (هندسه، گسسته، …) را میفرستیم تا دستیار از همان کتاب جواب بدهد؛
|
||||
// با درس، سرور کتاب را از خودِ درس درمیآورد.
|
||||
subject: askStore.get().lessonId ? undefined : subjectTitle,
|
||||
questionId: forQuestionId,
|
||||
},
|
||||
(delta) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MathText } from '@/lib/MathText';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
import { SubjectBar } from '@/components/SubjectBar';
|
||||
|
||||
/**
|
||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||
@@ -108,6 +109,8 @@ export function HomeScreen() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SubjectBar />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!home ? (
|
||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
||||
|
||||
@@ -12,6 +12,7 @@ import { iconFor } from '@/components/NodeIcon';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { useFirstVisit } from '@/lib/firstVisit';
|
||||
import { SubjectBar } from '@/components/SubjectBar';
|
||||
|
||||
/**
|
||||
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||||
@@ -53,6 +54,8 @@ export function LessonsScreen() {
|
||||
)}
|
||||
</ScreenHeader>
|
||||
|
||||
<SubjectBar />
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
||||
{!home ? (
|
||||
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);
|
||||
setProfile({ ...profile, gradeId });
|
||||
askStore.reset(null);
|
||||
await Promise.all([refreshHome(), refreshSummary()]);
|
||||
await Promise.all([refreshHome(gradeId), refreshSummary()]);
|
||||
toast('پایهت عوض شد', 'success');
|
||||
onClose();
|
||||
} 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 { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { fetchHome } from '@/state/subject';
|
||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
@@ -449,7 +450,7 @@ function Result({
|
||||
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 }) {
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
const { companion, profile } = useAppData();
|
||||
const [loadingNext, setLoadingNext] = useState(false);
|
||||
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحهی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخابشده.
|
||||
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
|
||||
@@ -468,7 +469,7 @@ function Result({
|
||||
}
|
||||
setLoadingNext(true);
|
||||
try {
|
||||
const fresh = await api.home();
|
||||
const fresh = await fetchHome(profile.gradeId);
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
|
||||
const next = ref ? nextLessonOf(ref.node) : null;
|
||||
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 { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
|
||||
import { companionStore, useCompanion } from '@/state/companion';
|
||||
import { fetchHome, subjectStore } from '@/state/subject';
|
||||
|
||||
/**
|
||||
* دادههایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
|
||||
@@ -18,7 +19,12 @@ interface AppData {
|
||||
/** همراهی که کاربر در صفحهی کاراکترها انتخاب کرده؛ پیشفرض سیگما. */
|
||||
companion: SeyarChar;
|
||||
unreadCount: number;
|
||||
refreshHome: () => Promise<void>;
|
||||
/** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */
|
||||
refreshHome: (gradeId?: number) => Promise<void>;
|
||||
/** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمونها و درسها هر دو همین را میخوانند. */
|
||||
selectSubject: (key: string) => void;
|
||||
/** کتابی که روی `home` است؛ بینقشه (هنوز نرسیده) null. */
|
||||
subjectKey: string | null;
|
||||
refreshSummary: () => Promise<void>;
|
||||
refreshUnread: () => Promise<void>;
|
||||
setProfile: (profile: UserProfile) => void;
|
||||
@@ -54,14 +60,35 @@ export function AppDataProvider({
|
||||
const setCompanionChar = companionStore.set;
|
||||
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 {
|
||||
setHome(await api.home());
|
||||
const fresh = await fetchHome(target);
|
||||
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
|
||||
setHome(fresh);
|
||||
setHomeError(null);
|
||||
} catch (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 () => {
|
||||
try {
|
||||
@@ -106,13 +133,15 @@ export function AppDataProvider({
|
||||
companion,
|
||||
unreadCount,
|
||||
refreshHome,
|
||||
selectSubject,
|
||||
subjectKey: home?.subject?.key ?? null,
|
||||
refreshSummary,
|
||||
refreshUnread,
|
||||
setProfile,
|
||||
setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'),
|
||||
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>;
|
||||
|
||||
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