This commit is contained in:
louiscklaw
2025-04-28 08:03:21 +08:00
parent d0ea7e5452
commit 4c72861eda
20 changed files with 877 additions and 237 deletions

View File

@@ -22,7 +22,10 @@ import FavVocabularyPage from './pages/Favorite/Vocabulary';
import FavoriteVocabularyPage from './pages/Favorite/WordPage'; import FavoriteVocabularyPage from './pages/Favorite/WordPage';
import ConnectivesPage from './pages/Lesson/ConnectivesPage'; import ConnectivesPage from './pages/Lesson/ConnectivesPage';
import Lesson from './pages/Lesson/index'; import Lesson from './pages/Lesson/index';
import LessonWordPage from './pages/Lesson/WordPage';
// NOTES: old version using json file
import LessonWordPageByDb from './pages/Lesson/LessonWordPageByDb';
import WordPage from './pages/Lesson/WordPage';
// //
import ListeningPractice from './pages/ListeningPractice'; import ListeningPractice from './pages/ListeningPractice';
import PracticeFinish from './pages/ListeningPractice/Finish'; import PracticeFinish from './pages/ListeningPractice/Finish';
@@ -58,7 +61,7 @@ function RouteConfig() {
<Route exact path={`${LESSON_LINK}/a/:act_category`}> <Route exact path={`${LESSON_LINK}/a/:act_category`}>
<Lesson /> <Lesson />
</Route> </Route>
{/* */}
<Route exact path={LESSON_LINK}> <Route exact path={LESSON_LINK}>
<Lesson /> <Lesson />
</Route> </Route>
@@ -122,9 +125,17 @@ function RouteConfig() {
<FavConnectivesPage /> <FavConnectivesPage />
</Route> </Route>
{/* TODO: change lesson_idx to lesson_type_idx, need to modify LessonWordPage as well */}
{/* http://localhost:5173/lesson_word_page/v/000000000000001/000000000000003/0 */}
<Route exact path={`${LESSON_WORD_PAGE_LINK}/v/:lesson_idx/:cat_idx/:word_idx`}>
<WordPage />
</Route>
{/*
<Route exact path={`${LESSON_WORD_PAGE_LINK}/v/:lesson_idx/:cat_idx/:word_idx`}> <Route exact path={`${LESSON_WORD_PAGE_LINK}/v/:lesson_idx/:cat_idx/:word_idx`}>
<LessonWordPage /> <LessonWordPage />
</Route> </Route>
*/}
<Route exact path={`${LESSON_WORD_PAGE_LINK}/c/:lesson_idx/:cat_idx/:word_idx`}> <Route exact path={`${LESSON_WORD_PAGE_LINK}/c/:lesson_idx/:cat_idx/:word_idx`}>
<ConnectivesPage /> <ConnectivesPage />
</Route> </Route>

View File

@@ -1,3 +1,4 @@
// CHANGELOG:
// 0.0.1 - implement screen // 0.0.1 - implement screen
// 0.0.2 - implement logic // 0.0.2 - implement logic
// 0.0.3 - first demo // 0.0.3 - first demo

View File

@@ -1,8 +1,12 @@
import { PocketBaseProvider } from '../hooks/usePocketBase';
import { AppStateProvider } from './AppState'; import { AppStateProvider } from './AppState';
import { MyIonFavoriteProvider } from './MyIonFavorite'; import { MyIonFavoriteProvider } from './MyIonFavorite';
import { MyIonMetricProvider } from './MyIonMetric'; import { MyIonMetricProvider } from './MyIonMetric';
import { MyIonQuizProvider } from './MyIonQuiz'; import { MyIonQuizProvider } from './MyIonQuiz';
import { MyIonStoreProvider } from './MyIonStore'; import { MyIonStoreProvider } from './MyIonStore';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
const ContextMeta = ({ children }: { children: React.ReactNode }) => { const ContextMeta = ({ children }: { children: React.ReactNode }) => {
return ( return (
@@ -12,8 +16,12 @@ const ContextMeta = ({ children }: { children: React.ReactNode }) => {
<MyIonFavoriteProvider> <MyIonFavoriteProvider>
<MyIonQuizProvider> <MyIonQuizProvider>
<MyIonMetricProvider> <MyIonMetricProvider>
{/* */} <QueryClientProvider client={queryClient}>
{children} <PocketBaseProvider>
{children}
{/* */}
</PocketBaseProvider>
</QueryClientProvider>
</MyIonMetricProvider> </MyIonMetricProvider>
</MyIonQuizProvider> </MyIonQuizProvider>
</MyIonFavoriteProvider> </MyIonFavoriteProvider>

View File

@@ -0,0 +1,29 @@
import { usePocketBase } from './usePocketBase.tsx';
import type LessonsTypes from '../types/LessonsTypes';
import { useQuery } from '@tanstack/react-query';
import Vocabularies from '../types/Vocabularies.tsx';
const useGetVocabularyRoute = (lessonType: string, catId: string) => {
const { user, pb } = usePocketBase();
return useQuery({
queryKey: ['useGetVocabularyRoute', lessonType, catId, 'feeds', 'all', user?.id || ''],
staleTime: 60 * 1000,
queryFn: async ({
queryKey,
}: {
queryKey: ['useGetVocabularyRoute', string, string, 'feeds', 'all', string | null];
}) => {
console.log('calling useGetLessonCategoriesRoute');
return await pb.collection('Vocabularies').getList<Vocabularies[]>(1, 9999, {
// TODO: sort by field -> pos
sort: 'id',
filter: `lesson_type_id = "${lessonType}" && cat_id = "${catId}"`,
$autoCancel: false,
expand: 'lesson_type_id,cat_id',
});
},
// enabled: !!user?.id,
});
};
export default useGetVocabularyRoute;

View File

@@ -0,0 +1,15 @@
import React from 'react'
export default function useHelloworld() {
const [count, setCount] = React.useState(0)
const handleIncrement = () => {
// setCount(count + 1)
console.log("helloworld")
}
return {
count,
handleIncrement,
}
}

View File

@@ -0,0 +1,38 @@
import { usePocketBase } from './usePocketBase.tsx';
import { useQuery } from '@tanstack/react-query';
import { LessonsType } from '../types/LessonsTypes';
const useListAllLessonTypes = () => {
const { user, pb } = usePocketBase();
return useQuery({
queryKey: [
'useListAllLessonTypes',
'feeds',
'all',
user?.id || '',
//
],
staleTime: 60 * 1000,
queryFn: async ({
queryKey,
}: {
queryKey: [
'useListAllLessonTypes',
'feeds',
'all',
string | null,
//
];
}) => {
console.log('calling useListAllLessonTypes');
return await pb.collection('LessonsTypes').getFullList<LessonsType>({
// TODO: sort by field -> pos
sort: 'id',
$autoCancel: false,
});
},
// enabled: !!user?.id,
});
};
export default useListAllLessonTypes;

View File

@@ -0,0 +1,49 @@
import { useCallback, useState, useEffect, createContext, useContext } from 'react';
import PocketBase, { AuthRecord } from 'pocketbase';
type PocketBaseContextValue = {
pb: PocketBase;
user: AuthRecord;
logout: () => void;
};
const PocketBaseContext = createContext<PocketBaseContextValue | null>(null);
const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL;
export const PocketBaseProvider = ({ children }: { children: any }) => {
const [pb, _] = useState(new PocketBase(POCKETBASE_URL));
const [user, setUser] = useState(pb.authStore.record);
useEffect(() => {
// Update user state when auth store changes
const unsubscribe = pb.authStore.onChange((_, model) => {
setUser(model);
});
return unsubscribe;
}, [pb.authStore]);
const logout = useCallback(() => pb.authStore.clear(), [pb.authStore]);
return <PocketBaseContext.Provider value={{ pb, user, logout }}>{children}</PocketBaseContext.Provider>;
};
export const usePocketBase = () => {
const context = useContext(PocketBaseContext);
if (context === null) {
throw new Error('usePocketBase must be used within a PocketBaseProvider');
}
return context;
};
export const useRequireAuth = () => {
const { pb, user } = usePocketBase();
const navigate = useNavigate();
useEffect(() => {
if (!pb.authStore.isValid) {
navigate(URLS.LOGIN);
}
}, [pb.authStore.isValid, navigate]);
return user;
};

View File

@@ -9,6 +9,8 @@ interface IWordCard {
sample_c: string; sample_c: string;
image_url: string; image_url: string;
sound_url: string; sound_url: string;
//
id: string;
} }
export default IWordCard; export default IWordCard;

View File

@@ -1,82 +0,0 @@
import { IonButton, useIonRouter } from '@ionic/react';
import './Lesson.css';
import { useEffect, useState } from 'react';
import { LoadingScreen } from '../../components/LoadingScreen';
import { COLOR_TEXT } from '../../constants';
import { useMyIonStore } from '../../contexts/MyIonStore';
import { listLessonCategories } from '../../public_data/listLessonCategories';
import { getLessonConnectivesLink } from './getLessonConnectivesLink';
import { getLessonVocabularyLink } from './getLessonWordLink';
let lessonLinkProxy = [getLessonVocabularyLink, getLessonConnectivesLink];
interface ContainerProps {
test_active_lesson_idx: number;
}
const LessonContainer: React.FC<ContainerProps> = ({ test_active_lesson_idx = 1 }) => {
const router = useIonRouter();
const [loading, setLoading] = useState<boolean>(true);
const { lesson_contents, setLessonContent } = useMyIonStore();
const [active_lesson_idx, setActiveLessonIdx] = useState<number>(0);
const [selected_content, setSelectedContent] = useState<any>([]);
useEffect(() => {
listLessonCategories().then((cats: any) => {
console.log({ cats });
setLessonContent(cats);
setActiveLessonIdx(test_active_lesson_idx);
setLoading(false);
});
}, []);
useEffect(() => {
if (loading) return;
console.log('active_category changed', active_lesson_idx);
let selected_category = lesson_contents[test_active_lesson_idx];
setSelectedContent(selected_category['content']);
}, [active_lesson_idx, loading, test_active_lesson_idx]);
if (loading) return <LoadingScreen />;
return (
<>
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' }}>
{selected_content.map((content: any, cat_idx: number) => (
<IonButton
key={cat_idx}
style={{ width: '45vw', height: '45vw' }}
fill="clear"
// href={lessonLinkProxy[test_active_lesson_idx](test_active_lesson_idx.toString(), cat_idx.toString(), '0')}
onClick={() => {
router.push(
lessonLinkProxy[test_active_lesson_idx](test_active_lesson_idx.toString(), cat_idx.toString(), '0'),
undefined,
'replace',
);
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div
key={cat_idx}
style={{
width: '100px',
height: '100px',
backgroundImage: `url(${content.cat_image})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
borderRadius: '0.5rem',
margin: '.5rem',
}}
></div>
<span style={{ color: COLOR_TEXT }}>{content.cat_name}</span>
</div>
</IonButton>
))}
</div>
{/* <EndOfList /> */}
</>
);
};
export default LessonContainer;

View File

@@ -0,0 +1,91 @@
import { IonButton, useIonRouter } from '@ionic/react';
import './Lesson.css';
import { useEffect, useState } from 'react';
import { LoadingScreen } from '../../../components/LoadingScreen';
import { COLOR_TEXT } from '../../../constants';
import { useMyIonStore } from '../../../contexts/MyIonStore';
import { listLessonCategories } from '../../../public_data/listLessonCategories';
import { getLessonConnectivesLink } from '../getLessonConnectivesLink';
import { getLessonVocabularyLink } from '../getLessonWordLink';
let lessonLinkProxy = [getLessonVocabularyLink, getLessonConnectivesLink];
interface ContainerProps {
test_active_lesson_idx: number;
lesson_type_id: string;
}
const LessonContainer: React.FC<ContainerProps> = ({
test_active_lesson_idx = 1,
lesson_type_id = '000000000000001',
}) => {
const router = useIonRouter();
const [loading, setLoading] = useState<boolean>(true);
const { lesson_contents, setLessonContent } = useMyIonStore();
const [active_lesson_idx, setActiveLessonIdx] = useState<number>(0);
const [selected_content, setSelectedContent] = useState<any>([]);
useEffect(() => {
listLessonCategories().then((cats: any) => {
console.log({ cats });
setLessonContent(cats);
setActiveLessonIdx(test_active_lesson_idx);
setLoading(false);
});
}, []);
useEffect(() => {
if (loading) return;
console.log('active_category changed', active_lesson_idx);
let selected_category = lesson_contents[test_active_lesson_idx];
setSelectedContent(selected_category['content']);
}, [active_lesson_idx, loading, test_active_lesson_idx]);
if (loading) return <LoadingScreen />;
return (
<>
<div
style={{
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
//
}}
>
{selected_content.map((content: any, cat_idx: number) => (
<>
<IonButton
key={cat_idx}
style={{ width: '45vw', height: '45vw' }}
fill="clear"
onClick={() => {
router.push(`/lesson_word_page/v/${lesson_type_id}/${content.id}/0`, undefined, 'replace');
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div
key={cat_idx}
style={{
width: '100px',
height: '100px',
backgroundImage: `url(${content.cat_image})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
borderRadius: '0.5rem',
margin: '.5rem',
}}
></div>
<span style={{ color: COLOR_TEXT }}>{content.cat_name}</span>
</div>
</IonButton>
</>
))}
</div>
{/* <EndOfList /> */}
</>
);
};
export default LessonContainer;

View File

@@ -0,0 +1,28 @@
import { FunctionComponent, useEffect } from 'react';
import { useGlobalAudioPlayer } from 'react-use-audio-player';
import { useListeningPracticeTimeSpent } from '../../../contexts/MyIonMetric/ListeningPracticeTimeSpent';
export const AudioControls: FunctionComponent<{ audio_src: string }> = ({ audio_src }) => {
const { play, pause, playing, duration } = useGlobalAudioPlayer();
const { load, src: loadedSrc } = useGlobalAudioPlayer();
let { myIonMetricIncListeningPracticeTimeSpent } = useListeningPracticeTimeSpent();
useEffect(() => {
if (audio_src) {
load(audio_src);
}
}, [audio_src]);
useEffect(() => {
if (loadedSrc) {
}
}, [loadedSrc]);
useEffect(() => {
if (playing) {
myIonMetricIncListeningPracticeTimeSpent(duration);
}
}, [playing]);
return <></>;
};

View File

@@ -0,0 +1,319 @@
import { IonButton, IonButtons, IonContent, IonIcon, IonModal, IonPage, IonToolbar, useIonRouter } from '@ionic/react';
import './style.css';
import {
arrowBackCircleOutline,
chevronBack,
chevronForward,
close,
heart,
heartOutline,
play,
volumeHighOutline,
} from 'ionicons/icons';
import { useEffect, useRef, useState } from 'react';
//
import Markdown from 'react-markdown';
import { useParams } from 'react-router';
import { useGlobalAudioPlayer } from 'react-use-audio-player';
import remarkGfm from 'remark-gfm';
import { LoadingScreen } from '../../../components/LoadingScreen';
import RemoveFavoritePrompt from '../../../components/RemoveFavoritePrompt';
import { LESSON_LINK } from '../../../constants';
import { useMyIonFavorite } from '../../../contexts/MyIonFavorite';
//
import { useMyIonStore } from '../../../contexts/MyIonStore';
import ILesson from '../../../interfaces/ILesson';
import ILessonCategory from '../../../interfaces/ILessonCategory';
import IWordCard from '../../../interfaces/IWordCard';
import { getFavLessonVocabularyLink, getLessonVocabularyLink } from '../getLessonWordLink';
import { AudioControls } from './AudioControls';
const LessonWordPageByDb: React.FC = () => {
const [loading, setLoading] = useState(true);
const router = useIonRouter();
const modal = useRef<HTMLIonModalElement>(null);
const { lesson_idx, cat_idx, word_idx } = useParams<{ lesson_idx: string; cat_idx: string; word_idx: string }>();
const [open_remove_modal, setOpenRemoveModal] = useState(false);
const [lesson_info, setLessonInfo] = useState<ILesson | undefined>(undefined);
const [cat_info, setCatInfo] = useState<ILessonCategory | undefined>(undefined);
const [word_info, setWordInfo] = useState<IWordCard | undefined>(undefined);
// const { play: play_word, playing } = useGlobalAudioPlayer();
const { myIonStoreAddFavoriteVocabulary, myIonStoreRemoveFavoriteVocabulary, myIonStoreFindInFavoriteVocabulary } =
useMyIonFavorite();
let [favorite_address, setFavoriteAddress] = useState(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx));
useEffect(() => {
setFavoriteAddress(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx));
// if (lesson_contents.length > 0) {
// let lesson_content: ILesson = lesson_contents[parseInt(lesson_idx)];
// let category_content: ILessonCategory = lesson_content.content[parseInt(cat_idx)];
// let word_content: IWordCard = category_content.content[parseInt(word_idx)];
// setWordInfo(word_content);
// }
}, [lesson_idx, cat_idx, word_idx]);
function dismiss() {
setOpenRemoveModal(false);
}
const [isOpen, setIsOpen] = useState(false);
let { lesson_contents } = useMyIonStore();
useEffect(() => {
// NOTES: lesson_content == [] during loading
if (lesson_contents.length > 0) {
let lesson_content: ILesson = lesson_contents[parseInt(lesson_idx)];
let category_content: ILessonCategory = lesson_content.content[parseInt(cat_idx)];
let word_content: IWordCard = category_content.content[parseInt(word_idx)];
setLessonInfo(lesson_content);
setCatInfo(category_content);
setWordInfo(word_content);
setLoading(false);
}
}, [lesson_contents]);
let [in_fav, setInFav] = useState(false);
const isInFavorite = async (string_to_search: string) => {
let result = await myIonStoreFindInFavoriteVocabulary(string_to_search);
setInFav(result);
};
const addToFavorite = async (string_to_add: string) => {
await myIonStoreAddFavoriteVocabulary(string_to_add);
await isInFavorite(string_to_add);
setInFav(!in_fav);
};
function handleUserRemoveFavorite() {
setOpenRemoveModal(true);
}
const removeFromFavorite = async (string_to_remove: string) => {
await myIonStoreRemoveFavoriteVocabulary(string_to_remove);
await isInFavorite(string_to_remove);
};
useEffect(() => {
(async () => {
await isInFavorite(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx));
})();
}, [lesson_idx, cat_idx, word_idx]);
return <>should not see me</>;
if (lesson_info == undefined) return <LoadingScreen />;
if (!cat_info || !word_info) return <LoadingScreen />;
return (
<>
<IonPage>
<IonContent fullscreen>
<div style={{ position: 'fixed' }}>
<IonButton
size="large"
shape="round"
fill="clear"
color={'dark'}
// href={`${LESSON_LINK}/a/${lesson_info.name}`}
onClick={() => {
router.push(`${LESSON_LINK}/a/${lesson_info.name}`);
}}
>
<IonIcon size="large" icon={arrowBackCircleOutline} />
</IonButton>
</div>
<div style={{ marginTop: '3rem' }}>
<div style={{ textAlign: 'center', fontSize: '1.2rem' }}>{cat_info.cat_name}</div>
</div>
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{/* <LessonContainer name='Tab 1 page' /> */}
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<div>
<IonButton
size="large"
shape="round"
fill="clear"
color={parseInt(word_idx) === 0 ? 'medium' : 'dark'}
disabled={parseInt(word_idx) === 0}
// href={getLessonVocabularyLink(lesson_idx, cat_idx, Math.max(0, parseInt(word_idx) - 1).toString())}
onClick={() => {
router.push(
getLessonVocabularyLink(lesson_idx, cat_idx, Math.max(0, parseInt(word_idx) - 1).toString()),
);
}}
>
<IonIcon slot="icon-only" size="large" icon={chevronBack}></IonIcon>
</IonButton>
</div>
<div
style={{
width: '66vw',
height: '66vw',
backgroundImage: `url(${word_info.image_url})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
borderRadius: '0.5rem',
margin: '.5rem',
}}
></div>
<div>
<IonButton
size="large"
shape="round"
fill="clear"
color={parseInt(word_idx) === cat_info.content.length - 1 ? 'medium' : 'dark'}
disabled={parseInt(word_idx) === cat_info.content.length - 1}
// href={getLessonVocabularyLink(
// lesson_idx,
// cat_idx,
// Math.min(cat_info.content.length - 1, parseInt(word_idx) + 1).toString()
// )}
onClick={() => {
router.push(
getLessonVocabularyLink(
lesson_idx,
cat_idx,
Math.min(cat_info.content.length - 1, parseInt(word_idx) + 1).toString(),
),
);
}}
>
<IonIcon slot="icon-only" size="large" icon={chevronForward}></IonIcon>
</IonButton>
</div>
</div>
<div style={{ marginTop: '1rem' }}>
<div
style={{
width: '2rem',
height: '2rem',
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '1rem',
backgroundColor: 'black',
color: 'white',
}}
>
{parseInt(word_idx) + 1}
</div>
</div>
<div>
<div
style={{ display: 'flex', flexDirection: 'row', gap: '1rem', alignItems: 'center', marginTop: '1rem' }}
>
<div>
<AudioControls audio_src={getFile(word_info.id, word_info.sound)} />
<IonButton
size="large"
color="dark"
shape="round"
fill="clear"
disabled={playing}
onClick={() => (playing ? null : play_word())}
>
<IonIcon
size="large"
color="dark"
slot="icon-only"
icon={playing ? play : volumeHighOutline}
></IonIcon>
</IonButton>
</div>
<div style={{ fontWeight: 'bold', fontSize: '1.5rem' }}>{word_info.word}</div>
<div>
<IonButton
color="danger"
shape="round"
size="large"
fill="clear"
// id='open-modal'
onClick={() => {
in_fav ? handleUserRemoveFavorite() : addToFavorite(favorite_address);
}}
>
<IonIcon
size="large"
color="danger"
slot="icon-only"
icon={in_fav ? heart : heartOutline}
></IonIcon>
</IonButton>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginTop: '1rem' }}>
<div style={{ fontWeight: 'bold', fontSize: '1.3rem' }}>{word_info.word_c}</div>
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
alignItems: 'center',
marginTop: '2rem',
}}
>
<Markdown remarkPlugins={[remarkGfm]}>{word_info.sample_e}</Markdown>
<Markdown remarkPlugins={[remarkGfm]}>{word_info.sample_c}</Markdown>
</div>
</div>
</IonContent>
</IonPage>
{/* */}
<IonModal isOpen={open_remove_modal} id="example-modal" ref={modal}>
<IonContent>
<IonToolbar>
<IonButtons slot="end">
<IonButton onClick={() => dismiss()} shape="round" fill="clear">
<IonIcon size="large" slot="icon-only" icon={close}></IonIcon>
</IonButton>
</IonButtons>
</IonToolbar>
<div style={{ marginTop: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div>Are you sure to remove favorite ?</div>
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem', marginTop: '1rem' }}>
<div>
<IonButton color="dark" onClick={() => dismiss()} fill="outline">
Cancel
</IonButton>
</div>
<div>
<IonButton
onClick={() => {
removeFromFavorite(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx));
setIsOpen(true);
dismiss();
}}
fill="solid"
color="danger"
>
Remove
</IonButton>
</div>
</div>
</div>
</div>
</IonContent>
</IonModal>
<RemoveFavoritePrompt open={isOpen} setIsOpen={setIsOpen} />
</>
);
};
export default LessonWordPageByDb;

View File

@@ -0,0 +1,31 @@
.bold {
font-weight: bold;
}
ion-modal#example-modal {
--height: 33%;
--width: 80%;
--border-radius: 16px;
--box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
ion-modal#example-modal::part(backdrop) {
/* background: rgba(209, 213, 219); */
opacity: 1;
}
ion-modal#example-modal ion-toolbar {
/* --background: rgb(14 116 144); */
/* --color: white; */
--color: black;
}
ion-toast.custom-toast::part(message) {
text-align: center;
font-size: 1.5rem;
color: rgba(0, 0, 0, 0.9);
}
ion-toast.custom-toast::part(container) {
bottom: 100px;
}

View File

@@ -10,8 +10,7 @@ import {
play, play,
volumeHighOutline, volumeHighOutline,
} from 'ionicons/icons'; } from 'ionicons/icons';
import { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
// import { StoreContext, useMyIonStore } from '../../contexts/store';
// //
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -26,19 +25,25 @@ import { useMyIonStore } from '../../../contexts/MyIonStore';
import ILesson from '../../../interfaces/ILesson'; import ILesson from '../../../interfaces/ILesson';
import ILessonCategory from '../../../interfaces/ILessonCategory'; import ILessonCategory from '../../../interfaces/ILessonCategory';
import IWordCard from '../../../interfaces/IWordCard'; import IWordCard from '../../../interfaces/IWordCard';
import { getFavLessonVocabularyLink, getLessonVocabularyLink } from '../../Lesson/getLessonWordLink'; import {
getFavLessonVocabularyLink,
getLessonVocabularyLink,
getLessonVocabularyLinkString,
} from '../../Lesson/getLessonWordLink';
import { AudioControls } from './AudioControls'; import { AudioControls } from './AudioControls';
import useGetVocabularyRoute from '../../../hooks/useGetVocabularyRoute';
import { UseQueryResult } from '@tanstack/react-query';
import { ListResult } from 'pocketbase';
import LessonsTypes from '../../../types/LessonsTypes';
// function WordPage(): React.JSX.Element {
const LessonWordPage: React.FC = () => {
const router = useIonRouter();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [open_remove_modal, setOpenRemoveModal] = useState(false);
const router = useIonRouter();
const modal = useRef<HTMLIonModalElement>(null); const modal = useRef<HTMLIonModalElement>(null);
const { lesson_idx, cat_idx, word_idx } = useParams<{ lesson_idx: string; cat_idx: string; word_idx: string }>(); const { lesson_idx, cat_idx, word_idx } = useParams<{ lesson_idx: string; cat_idx: string; word_idx: string }>();
const [open_remove_modal, setOpenRemoveModal] = useState(false);
const [lesson_info, setLessonInfo] = useState<ILesson | undefined>(undefined); const [lesson_info, setLessonInfo] = useState<ILesson | undefined>(undefined);
const [cat_info, setCatInfo] = useState<ILessonCategory | undefined>(undefined); const [cat_info, setCatInfo] = useState<ILessonCategory | undefined>(undefined);
const [word_info, setWordInfo] = useState<IWordCard | undefined>(undefined); const [word_info, setWordInfo] = useState<IWordCard | undefined>(undefined);
@@ -46,78 +51,64 @@ const LessonWordPage: React.FC = () => {
const { play: play_word, playing } = useGlobalAudioPlayer(); const { play: play_word, playing } = useGlobalAudioPlayer();
const { myIonStoreAddFavoriteVocabulary, myIonStoreRemoveFavoriteVocabulary, myIonStoreFindInFavoriteVocabulary } = const { myIonStoreAddFavoriteVocabulary, myIonStoreRemoveFavoriteVocabulary, myIonStoreFindInFavoriteVocabulary } =
useMyIonFavorite(); useMyIonFavorite();
//
// const lesson_vocab_address = `/lesson_word_page/v/${lesson_idx}/${cat_idx}/${word_idx}`;
let [favorite_address, setFavoriteAddress] = useState(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx)); let [favorite_address, setFavoriteAddress] = useState(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx));
useEffect(() => { let { status, data: tempResult } = useGetVocabularyRoute(lesson_idx, cat_idx);
setFavoriteAddress(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx));
if (lesson_contents.length > 0) { function getFile(recordId: string, fileName: string) {
let lesson_content: ILesson = lesson_contents[parseInt(lesson_idx)]; return `http://127.0.0.1:8090/api/files/Vocabularies/${recordId}/${fileName}`;
let category_content: ILessonCategory = lesson_content.content[parseInt(cat_idx)];
let word_content: IWordCard = category_content.content[parseInt(word_idx)];
setWordInfo(word_content);
}
}, [lesson_idx, cat_idx, word_idx]);
//
function dismiss() {
setOpenRemoveModal(false);
}
const [isOpen, setIsOpen] = useState(false);
let { lesson_contents } = useMyIonStore();
useEffect(() => {
// NOTES: lesson_content == [] during loading
if (lesson_contents.length > 0) {
let lesson_content: ILesson = lesson_contents[parseInt(lesson_idx)];
let category_content: ILessonCategory = lesson_content.content[parseInt(cat_idx)];
let word_content: IWordCard = category_content.content[parseInt(word_idx)];
setLessonInfo(lesson_content);
setCatInfo(category_content);
setWordInfo(word_content);
setLoading(false);
}
}, [lesson_contents]);
let [in_fav, setInFav] = useState(false);
const isInFavorite = async (string_to_search: string) => {
let result = await myIonStoreFindInFavoriteVocabulary(string_to_search);
setInFav(result);
};
const addToFavorite = async (string_to_add: string) => {
await myIonStoreAddFavoriteVocabulary(string_to_add);
await isInFavorite(string_to_add);
setInFav(!in_fav);
};
function handleUserRemoveFavorite() {
setOpenRemoveModal(true);
} }
const removeFromFavorite = async (string_to_remove: string) => { useEffect(() => {
await myIonStoreRemoveFavoriteVocabulary(string_to_remove); setLoading(!(status === 'success'));
await isInFavorite(string_to_remove); }, [status]);
};
// useEffect(() => {
// // console.log({ lesson_idx, cat_idx, word_idx });
// if (tempResult) {
// try {
// setCatInfo(tempResult.items[parseInt(word_idx)].expand.cat_id as unknown as ILessonCategory);
// //
// setWordInfo(tempResult.items[parseInt(word_idx)] as unknown as IWordCard);
// } catch (error) {
// console.error(error);
// }
// }
// }, [lesson_idx, cat_idx, word_idx]);
let [lastWord, setLastWord] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
(async () => { if (tempResult) {
await isInFavorite(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx)); try {
})(); setCatInfo(tempResult.items[parseInt(word_idx)].expand.cat_id as unknown as ILessonCategory);
//
setWordInfo(tempResult.items[parseInt(word_idx)] as unknown as IWordCard);
//
setLastWord(parseInt(word_idx) === tempResult.items.length - 1);
} catch (error) {
console.error(error);
}
}
}, [lesson_idx, cat_idx, word_idx]); }, [lesson_idx, cat_idx, word_idx]);
// if (loading) return <>loading</>; useEffect(() => {
// if (!word_info) return <>loading</>; // console.log({ lesson_idx, cat_idx, word_idx });
if (tempResult) {
if (lesson_info == undefined) return <LoadingScreen />; try {
setCatInfo(tempResult.items[parseInt(word_idx)].expand.cat_id as unknown as ILessonCategory);
//
setWordInfo(tempResult.items[parseInt(word_idx)] as unknown as IWordCard);
//
setLastWord(parseInt(word_idx) === tempResult.items.length - 1);
} catch (error) {
console.error(error);
}
}
}, [tempResult]);
if (!cat_info || !word_info) return <LoadingScreen />; if (!cat_info || !word_info) return <LoadingScreen />;
if (!tempResult) return <LoadingScreen />;
return ( return (
<> <>
<IonPage> <IonPage>
@@ -128,9 +119,8 @@ const LessonWordPage: React.FC = () => {
shape="round" shape="round"
fill="clear" fill="clear"
color={'dark'} color={'dark'}
// href={`${LESSON_LINK}/a/${lesson_info.name}`}
onClick={() => { onClick={() => {
router.push(`${LESSON_LINK}/a/${lesson_info.name}`); router.push(`${LESSON_LINK}/a/Vocabulary`);
}} }}
> >
<IonIcon size="large" icon={arrowBackCircleOutline} /> <IonIcon size="large" icon={arrowBackCircleOutline} />
@@ -152,42 +142,67 @@ const LessonWordPage: React.FC = () => {
// href={getLessonVocabularyLink(lesson_idx, cat_idx, Math.max(0, parseInt(word_idx) - 1).toString())} // href={getLessonVocabularyLink(lesson_idx, cat_idx, Math.max(0, parseInt(word_idx) - 1).toString())}
onClick={() => { onClick={() => {
router.push( router.push(
getLessonVocabularyLink(lesson_idx, cat_idx, Math.max(0, parseInt(word_idx) - 1).toString()), getLessonVocabularyLinkString(
lesson_idx,
cat_idx,
Math.max(0, parseInt(word_idx) - 1).toString(),
),
); );
}} }}
> >
<IonIcon slot="icon-only" size="large" icon={chevronBack}></IonIcon> <IonIcon slot="icon-only" size="large" icon={chevronBack}></IonIcon>
</IonButton> </IonButton>
</div> </div>
<div {word_info && word_info?.image != '' ? (
style={{ <div
width: '66vw', style={{
height: '66vw', width: '66vw',
backgroundImage: `url(${word_info.image_url})`, height: '66vw',
backgroundPosition: 'center', backgroundImage: `url(${getFile(word_info.id, word_info.image)})`,
backgroundSize: 'cover', backgroundPosition: 'center',
borderRadius: '0.5rem', backgroundSize: 'cover',
margin: '.5rem', borderRadius: '0.5rem',
}} margin: '.5rem',
></div> }}
></div>
) : (
<div
style={{
width: '66vw',
height: '66vw',
backgroundPosition: 'center',
backgroundSize: 'cover',
borderRadius: '0.5rem',
margin: '.5rem',
//
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.5rem',
}}
>
empty pic
</div>
)}
<div> <div>
<IonButton <IonButton
size="large" size="large"
shape="round" shape="round"
fill="clear" fill="clear"
color={parseInt(word_idx) === cat_info.content.length - 1 ? 'medium' : 'dark'} color={lastWord ? 'medium' : 'dark'}
disabled={parseInt(word_idx) === cat_info.content.length - 1} disabled={lastWord}
// href={getLessonVocabularyLink( // href={getLessonVocabularyLinkString(
// lesson_idx, // lesson_idx,
// cat_idx, // cat_idx,
// Math.min(cat_info.content.length - 1, parseInt(word_idx) + 1).toString() // Math.min(tempResult.items.length - 1, parseInt(word_idx) + 1).toString(),
// )} // )}
onClick={() => { onClick={() => {
router.push( router.push(
getLessonVocabularyLink( getLessonVocabularyLinkString(
lesson_idx, lesson_idx,
cat_idx, cat_idx,
Math.min(cat_info.content.length - 1, parseInt(word_idx) + 1).toString(), Math.min(tempResult.items.length - 1, parseInt(word_idx) + 1).toString(),
), ),
); );
}} }}
@@ -216,10 +231,20 @@ const LessonWordPage: React.FC = () => {
<div> <div>
<div <div
style={{ display: 'flex', flexDirection: 'row', gap: '1rem', alignItems: 'center', marginTop: '1rem' }} style={{
display: 'flex',
flexDirection: 'row',
gap: '1rem',
alignItems: 'center',
marginTop: '1rem',
}}
> >
<div> <div>
<AudioControls audio_src={word_info.sound_url} /> {word_info && word_info?.sound != '' ? (
<AudioControls audio_src={getFile(word_info.id, word_info.sound)} />
) : (
<></>
)}
<IonButton <IonButton
size="large" size="large"
color="dark" color="dark"
@@ -236,6 +261,7 @@ const LessonWordPage: React.FC = () => {
></IonIcon> ></IonIcon>
</IonButton> </IonButton>
</div> </div>
<div style={{ fontWeight: 'bold', fontSize: '1.5rem' }}>{word_info.word}</div> <div style={{ fontWeight: 'bold', fontSize: '1.5rem' }}>{word_info.word}</div>
<div> <div>
<IonButton <IonButton
@@ -244,21 +270,30 @@ const LessonWordPage: React.FC = () => {
size="large" size="large"
fill="clear" fill="clear"
// id='open-modal' // id='open-modal'
onClick={() => { // onClick={() => {
in_fav ? handleUserRemoveFavorite() : addToFavorite(favorite_address); // in_fav ? handleUserRemoveFavorite() : addToFavorite(favorite_address);
}} // }}
> >
<IonIcon <IonIcon
size="large" size="large"
color="danger" color="danger"
slot="icon-only" slot="icon-only"
icon={in_fav ? heart : heartOutline} icon={heartOutline}
// icon={in_fav ? heart : heartOutline}
></IonIcon> ></IonIcon>
</IonButton> </IonButton>
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginTop: '1rem' }}> <div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: '1rem',
//
}}
>
<div style={{ fontWeight: 'bold', fontSize: '1.3rem' }}>{word_info.word_c}</div> <div style={{ fontWeight: 'bold', fontSize: '1.3rem' }}>{word_info.word_c}</div>
</div> </div>
</div> </div>
@@ -272,55 +307,21 @@ const LessonWordPage: React.FC = () => {
marginTop: '2rem', marginTop: '2rem',
}} }}
> >
<Markdown remarkPlugins={[remarkGfm]}>{word_info.sample_e}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>
<Markdown remarkPlugins={[remarkGfm]}>{word_info.sample_c}</Markdown> {word_info.sample_e}
{/* */}
</Markdown>
<Markdown remarkPlugins={[remarkGfm]}>
{word_info.sample_c}
{/* */}
</Markdown>
</div> </div>
</div> </div>
</IonContent> </IonContent>
</IonPage> </IonPage>
{/* */} {/* */}
<IonModal isOpen={open_remove_modal} id="example-modal" ref={modal}>
<IonContent>
<IonToolbar>
<IonButtons slot="end">
<IonButton onClick={() => dismiss()} shape="round" fill="clear">
<IonIcon size="large" slot="icon-only" icon={close}></IonIcon>
</IonButton>
</IonButtons>
</IonToolbar>
<div style={{ marginTop: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div>Are you sure to remove favorite ?</div>
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem', marginTop: '1rem' }}>
<div>
<IonButton color="dark" onClick={() => dismiss()} fill="outline">
Cancel
</IonButton>
</div>
<div>
<IonButton
onClick={() => {
removeFromFavorite(getFavLessonVocabularyLink(lesson_idx, cat_idx, word_idx));
setIsOpen(true);
dismiss();
}}
fill="solid"
color="danger"
>
Remove
</IonButton>
</div>
</div>
</div>
</div>
</IonContent>
</IonModal>
<RemoveFavoritePrompt open={isOpen} setIsOpen={setIsOpen} />
</> </>
); );
}; }
export default LessonWordPage; export default WordPage;

View File

@@ -1,5 +1,13 @@
import { FAVORITE_LINK } from '../../constants'; import { FAVORITE_LINK } from '../../constants';
export function getLessonVocabularyLinkString(
s_active_lesson_idx: string,
s_cat_idx: string,
s_word_idx: string,
): string {
return `/lesson_word_page/v/${s_active_lesson_idx}/${s_cat_idx}/${s_word_idx}`;
}
export function getLessonVocabularyLink(i_active_lesson_idx: string, i_cat_idx: string, i_word_idx: string): string { export function getLessonVocabularyLink(i_active_lesson_idx: string, i_cat_idx: string, i_word_idx: string): string {
let s_active_lesson_idx = parseInt(i_active_lesson_idx); let s_active_lesson_idx = parseInt(i_active_lesson_idx);
let s_cat_idx = parseInt(i_cat_idx); let s_cat_idx = parseInt(i_cat_idx);

View File

@@ -25,6 +25,9 @@ import { LESSON_LINK } from '../../constants';
import { useMyIonStore } from '../../contexts/MyIonStore'; import { useMyIonStore } from '../../contexts/MyIonStore';
import { listLessonCategories } from '../../public_data/listLessonCategories'; import { listLessonCategories } from '../../public_data/listLessonCategories';
import LessonContainer from './LessonContainer'; import LessonContainer from './LessonContainer';
import useHelloworld from '../../hooks/useHelloworld';
import useListAllLessonTypes from '../../hooks/useListAllLessonTypes';
import LessonsTypes, { LessonsType } from '../../types/LessonsTypes';
const Lesson: React.FC = () => { const Lesson: React.FC = () => {
const { act_category } = useParams<{ act_category: string }>(); const { act_category } = useParams<{ act_category: string }>();
@@ -35,14 +38,14 @@ const Lesson: React.FC = () => {
let [active_lesson_idx, setActiveLessonIdx] = useState<number>(0); let [active_lesson_idx, setActiveLessonIdx] = useState<number>(0);
let [selected_content, setSelectedContent] = useState<any>([]); let [selected_content, setSelectedContent] = useState<any>([]);
useEffect(() => { // useEffect(() => {
listLessonCategories().then((cats: any) => { // listLessonCategories().then((cats: any) => {
console.log({ cats }); // console.log({ cats });
setLessonContent(cats); // setLessonContent(cats);
setActiveLessonIdx(0); // setActiveLessonIdx(0);
setLoading(false); // setLoading(false);
}); // });
}, []); // }, []);
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -65,7 +68,24 @@ const Lesson: React.FC = () => {
} }
}, [act_category, lesson_content]); }, [act_category, lesson_content]);
const lessonTypesQuery = useListAllLessonTypes();
const [lessonTypes, setLessonTypes] = useState<LessonsType[]>([]);
useEffect(() => {
if (lessonTypesQuery.status === 'success') {
const { data: tempLessonTypes } = lessonTypesQuery;
console.log({ tempLessonTypes });
if (tempLessonTypes) {
setLessonTypes(tempLessonTypes);
}
setLoading(false);
}
}, [lessonTypesQuery]);
if (loading) return <LoadingScreen />; if (loading) return <LoadingScreen />;
if (lessonTypes.length === 0) return <LoadingScreen />;
// return <pre>{JSON.stringify({ t: lessonTypes }, null, 2)}</pre>;
return ( return (
<IonPage> <IonPage>
@@ -98,6 +118,7 @@ const Lesson: React.FC = () => {
</IonTitle> </IonTitle>
</IonToolbar> </IonToolbar>
</IonHeader> </IonHeader>
{/* */}
<div <div
style={{ style={{
width: '100%', width: '100%',
@@ -109,9 +130,9 @@ const Lesson: React.FC = () => {
<IonList lines="none"> <IonList lines="none">
<IonItem> <IonItem>
<IonSelect value={active_lesson_idx} onIonChange={(e) => setActiveLessonIdx(e.detail.value)}> <IonSelect value={active_lesson_idx} onIonChange={(e) => setActiveLessonIdx(e.detail.value)}>
{lesson_content.map((category: any, idx: number) => ( {lessonTypes.map((lessonType: any, idx: number) => (
<IonSelectOption key={idx} value={idx}> <IonSelectOption key={idx} value={idx}>
{category.name} {lessonType.name}
</IonSelectOption> </IonSelectOption>
))} ))}
</IonSelect> </IonSelect>
@@ -119,7 +140,10 @@ const Lesson: React.FC = () => {
</IonList> </IonList>
</div> </div>
{/* */} {/* */}
<LessonContainer test_active_lesson_idx={active_lesson_idx} /> <LessonContainer
test_active_lesson_idx={active_lesson_idx}
lesson_type_id={lessonTypes[active_lesson_idx].id}
/>
{/* */} {/* */}
<CongratGenius /> <CongratGenius />
<CongratHardworker /> <CongratHardworker />

View File

@@ -0,0 +1,23 @@
type LessonCategory = {
isEmpty?: boolean;
//
id: string;
collectionId: string;
//
cat_name: string;
cat_image_url?: string;
cat_image?: string;
pos: number;
visible: string;
lesson_id: string;
description: string;
remarks: string;
createdAt: Date;
//
name: string;
avatar: string;
email: string;
phone: string;
quota: number;
status: 'pending' | 'active' | 'blocked' | 'NA';
};

View File

@@ -0,0 +1,20 @@
export type LessonsType = {
id: string;
isEmpty?: boolean;
name: string;
type: string;
pos: number;
visible: 'visible' | 'hidden';
createdAt: Date;
//
// original
// id: string;
// name: string;
//
avatar?: string;
email: string;
phone?: string;
quota: number;
status: 'pending' | 'active' | 'blocked';
// createdAt: Date;
};

View File

@@ -0,0 +1,24 @@
// RULES: interface for handling vocabulary record
type Vocabulary = {
//
id: string;
collectionId: string;
//
word: string;
word_c: string;
sample_e: string;
sample_c: string;
cat_id: string;
category: string;
lesson_type_id: string;
image: File[];
sound: File[];
//
expand?: {
cat_id: LessonCategory;
};
};
type Vocabularies = Vocabulary[];
export default Vocabularies;