Compare commits

...

8 Commits

Author SHA1 Message Date
720838f137 Merge branch 'develop/ionic_mobile/i18n/trunk' into develop/ionic_mobile/login-flow/trunk 2025-05-16 23:38:56 +08:00
c92ac33ade ```
replace setting tab button with conditional rendering based on user authentication status, showing avatar if available
```
2025-05-16 23:37:39 +08:00
72e478937d ``update Add QR code generation feature with dynamic sizing and styling, implement screen-width-based conditional rendering, update i18n translations, and adjust context providers structure`` 2025-05-16 22:51:33 +08:00
62d8519da5 ``delete Remove i18n configuration file and English translation resources; likely replaced by a JSON-based or externalized translation management approach`` 2025-05-16 22:50:24 +08:00
8d746f3aa9 ``update Refactor navigation URLs by replacing hardcoded LESSON_LINK with Paths.LESSON_LINK across App, RouteConfig, and multiple components; remove redundant LESSON_LINK from constants`` 2025-05-16 22:00:04 +08:00
69b2ef59e5 ``update Upgrade dependencies including i18next, react-i18next, eslint, and testing-library; adjust peer dependencies and add new ESLint plugins`` 2025-05-16 21:59:52 +08:00
f9c0deb2e9 ``add Add CMS project code-workspace file to define multi-root workspace including documentation and AI workspace directories`` 2025-05-16 21:57:52 +08:00
47760fa7b2 ``add Add code-workspace files for documentation, ionic_mobile, and test projects to define multi-root workspace configurations`` 2025-05-16 21:57:41 +08:00
29 changed files with 1535 additions and 503 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -43,14 +43,15 @@
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.8.1",
"i18next": "^24.2.2",
"i18next": "^24.2.0",
"ionicons": "^7.0.0",
"lodash": "^4.17.21",
"pocketbase": "^0.26.0",
"qr-code-styling": "^1.9.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "7.50.1",
"react-i18next": "^15.4.1",
"react-i18next": "^15.2.0",
"react-markdown": "^9.0.3",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4",
@@ -65,15 +66,18 @@
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@testing-library/dom": ">=7.21.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.4.3",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-legacy": "^5.0.0",
"@vitejs/plugin-react": "^4.0.1",
"cypress": "^13.5.0",
"eslint": "^8.35.0",
"eslint": "^9.20.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"jsdom": "^22.1.0",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0",
@@ -81,6 +85,7 @@
"sass": "^1.88.0",
"terser": "^5.4.0",
"typescript": "^5.1.6",
"typescript-eslint": "^8.24.0",
"vite": "~5.2.0",
"vitest": "^0.34.6"
},

View File

@@ -1,4 +1,4 @@
import { DEBUG, DEBUG_LINK, LESSON_LINK, QUIZ_MAIN_MENU_LINK, RECORD_LINK, SETTING_LINK } from './constants';
import { DEBUG_LINK, isDevelop, QUIZ_MAIN_MENU_LINK, RECORD_LINK, SETTING_LINK } from './constants';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
@@ -30,6 +30,8 @@ import './google_fonts.css';
import { App as CapacitorApp } from '@capacitor/app';
import {
IonApp,
IonFab,
IonFabButton,
IonIcon,
IonLabel,
IonRouterOutlet,
@@ -48,6 +50,9 @@ import ContextMeta from './contexts';
import { useAppStateContext } from './contexts/AppState';
import { useMyIonQuizContext } from './contexts/MyIonQuiz';
import { RouteConfig } from './RouteConfig';
import { Paths } from './Paths';
import { useUser } from './hooks/use-user';
import getImageUrlFromFile from './lib/get-image-url-from-file.ts';
let active_color = 'tomato';
let inactive_color = 'gray';
@@ -68,6 +73,7 @@ setupIonicReact();
const TabButtons: React.FC = () => {
let router = useIonRouter();
const { t } = useTranslation();
const { user, error, isLoading } = useUser();
let {
url_push_after_user_confirm,
@@ -99,8 +105,8 @@ const TabButtons: React.FC = () => {
<IonTabBar slot="bottom">
<IonTabButton
tab="lesson"
onClick={() => goSwitchPage(LESSON_LINK)}
style={{ color: tab_active == LESSON_LINK ? active_color : inactive_color }}
onClick={() => goSwitchPage(Paths.LESSON_LINK)}
style={{ color: tab_active == Paths.LESSON_LINK ? active_color : inactive_color }}
>
<IonIcon aria-hidden="true" icon={bookOutline} size="large" />
</IonTabButton>
@@ -130,15 +136,43 @@ const TabButtons: React.FC = () => {
{/* */}
{/* 003_remove_setting_screen, hide setting on bottom tabs */}
<IonTabButton
tab="setting"
onClick={() => goSwitchPage(SETTING_LINK)}
style={{ color: tab_active == SETTING_LINK ? active_color : inactive_color }}
>
<IonIcon aria-hidden="true" icon={settingsOutline} size="large" />
</IonTabButton>
{user ? (
<IonTabButton
tab="setting"
onClick={() => goSwitchPage(SETTING_LINK)}
style={{ color: tab_active == SETTING_LINK ? active_color : inactive_color }}
>
<div
style={{
width: '50px',
height: '50px',
padding: 5,
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: `url("${getImageUrlFromFile(user.collectionId, user.id, user.avatar)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
//
borderRadius: '50%',
}}
></div>
</div>
</IonTabButton>
) : (
<IonTabButton
tab="setting"
onClick={() => goSwitchPage(SETTING_LINK)}
style={{ color: tab_active == SETTING_LINK ? active_color : inactive_color }}
>
<IonIcon aria-hidden="true" icon={settingsOutline} size="large" />
</IonTabButton>
)}
{DEBUG ? (
{false ? (
<IonTabButton
tab="debug"
onClick={() => goSwitchPage(DEBUG_LINK)}

View File

@@ -1,4 +1,6 @@
const Paths = {
LESSON_LINK: '/lesson',
//
AuthHome: `/auth/home`,
AuthLogin: `/auth/login`,
AuthSignUp: `/auth/signup`,

View File

@@ -3,7 +3,6 @@ import {
CONNECTIVE_REVISION_LINK,
DEBUG_LINK,
FAVORITE_LINK,
LESSON_LINK,
LESSON_WORD_PAGE_LINK,
LISTENING_PRACTICE_LINK,
MATCHING_FRENZY_LINK,
@@ -70,11 +69,11 @@ function RouteConfig() {
<MyAchievementPage />
</Route>
{/* */}
<Route exact path={`${LESSON_LINK}/a/:act_category`}>
<Route exact path={`${Paths.LESSON_LINK}/a/:act_category`}>
<Lesson />
</Route>
{/* */}
<Route exact path={LESSON_LINK}>
<Route exact path={Paths.LESSON_LINK}>
<Lesson />
</Route>
{/* */}
@@ -184,17 +183,29 @@ function RouteConfig() {
</Route>
{/* protected page */}
<AuthGuard>
<Route exact path={Paths.StudentInfo}>
<Route exact path="/">
<Redirect to={Paths.LESSON_LINK} />
</Route>
<Route exact path={DEBUG_LINK}>
<DebugPage />
</Route>
<Route exact path={Paths.StudentInfo}>
<AuthGuard>
<StudentInfo />
</Route>
<Route exact path={Paths.StudentMenu}>
</AuthGuard>
</Route>
<Route exact path={Paths.StudentMenu}>
<AuthGuard>
<StudentMenu />
</Route>
<Route exact path={Paths.AuthorizedTest}>
</AuthGuard>
</Route>
<Route exact path={Paths.AuthorizedTest}>
<AuthGuard>
<AuthorizedTest />
</Route>
</AuthGuard>
</AuthGuard>
</Route>
{/* TODO: remove below */}
<Route exact path="/tab1">
@@ -212,12 +223,6 @@ function RouteConfig() {
<Route path="/page/:name" exact={true}>
<Page />
</Route>
<Route exact path={DEBUG_LINK}>
<DebugPage />
</Route>
<Route exact path="/">
<Redirect to={LESSON_LINK} />
</Route>
</>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
function Footer(): React.JSX.Element {
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
width: '100vw',
//
position: 'fixed',
bottom: '3rem',
//
fontWeight: '300',
fontSize: '0.7rem',
opacity: '0.9',
}}
>
2025 louislabs
</div>
);
}
export default Footer;

View File

@@ -1,6 +1,6 @@
import { IonButton, IonIcon, useIonRouter } from '@ionic/react';
import { arrowBack } from 'ionicons/icons';
import { LESSON_LINK, VERSIONS } from '../../constants';
import { VERSIONS } from '../../constants';
import SettingSvg from './image.svg';
import { Paths } from '../../Paths';
import { pb } from '../../lib/pb';
@@ -52,7 +52,7 @@ const SettingContainer: React.FC<ContainerProps> = ({ name }) => {
<IonButton onClick={handleUserProfileClick}>User Profile</IonButton>
<IonButton
onClick={() => {
router.push(LESSON_LINK, undefined, 'replace');
router.push(Paths.LESSON_LINK, undefined, 'replace');
}}
style={{ marginTop: '1rem' }}
>

View File

@@ -25,7 +25,8 @@ const MATCHING_FRENZY_LINK = '/matching_frenzy';
const FAVORITE_LINK = '/fav';
const LESSON_WORD_PAGE_LINK = '/lesson_word_page';
const CONNECTIVE_REVISION_LINK = '/connective_revision';
const LESSON_LINK = '/lesson';
// TODO: remove me
// const LESSON_LINK_mdel = '/lesson';
const QUIZ_MAIN_MENU_LINK = '/quizzes_main_menu';
const RECORD_LINK = '/record';
const SETTING_LINK = '/setting';
@@ -89,6 +90,8 @@ export const COL_USER_METAS = 'UserMetas';
//
export const RUNNING_PLATFORM = Capacitor.getPlatform();
export const isDevelop = import.meta.env.DEV;
export {
//
API_URL,
@@ -119,7 +122,6 @@ export {
//
HELLOWORLD,
HELLOWORLD_MP3,
LESSON_LINK,
LESSON_WORD_PAGE_LINK,
LISTENING_PRACTICE_LINK,
LISTENING_PRACTICE_TIME_SPENT,

View File

@@ -1,14 +1,14 @@
import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import RemoveFavoritePrompt from '../components/RemoveFavoritePrompt';
import { LESSON_LINK } from '../constants';
import { Paths } from '../Paths';
const AppStateContext = createContext<AppStateContextProps | undefined>(undefined);
export const AppStateProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [my_context, setMyContext] = useState<string>('initial value');
const [tab_active, setTabActive] = useState<string>(LESSON_LINK);
const [tab_active, setTabActive] = useState<string>(Paths.LESSON_LINK);
const [show_confirm_user_exit, useShowConfirmUserExit] = useState<boolean>(false);
const [url_push_after_user_confirm, setURLPushAfterUserConfirm] = useState<string>(LESSON_LINK);
const [url_push_after_user_confirm, setURLPushAfterUserConfirm] = useState<string>(Paths.LESSON_LINK);
const [matching_frenzy_in_progress, setMatchingFrenzyInProgress] = useState<boolean>(false);
const [connective_revision_in_progress, setConnectiveRevisionInProgress] = useState<boolean>(false);

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react';
import './styles.css';
import QRCodeStyling from 'qr-code-styling';
import AvatarJpg from './avatar.jpg';
const qrCode = new QRCodeStyling({
width: 200,
height: 200,
image: AvatarJpg,
dotsOptions: {
color: '#4267b2',
type: 'rounded',
},
imageOptions: {
crossOrigin: 'anonymous',
margin: 2,
},
});
export default function App() {
const [url, setUrl] = useState(window.location.href);
const [fileExt, setFileExt] = useState('png');
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
qrCode.append(ref.current);
}
}, []);
useEffect(() => {
qrCode.update({
data: url,
});
}, [url]);
const onUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
setUrl(event.target.value);
};
const onExtensionChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setFileExt(event.target.value);
};
return (
<div>
<div ref={ref} />
</div>
);
}

View File

@@ -0,0 +1,4 @@
.App {
font-family: sans-serif;
text-align: center;
}

View File

@@ -0,0 +1,43 @@
import * as React from 'react';
import QrHere from './QrHere';
import { IonText } from '@ionic/react';
import { useTranslation } from 'react-i18next';
import Footer from '../../components/Footer';
export interface ProviderProps {
children: React.ReactNode;
}
export function CheckScreenWidth({ children }: ProviderProps): React.JSX.Element {
const { t } = useTranslation();
const [showQrCode, setShowQrCode] = React.useState(false);
React.useEffect(() => {
setShowQrCode(window.screen.width > 800);
}, []);
return (
<>
{showQrCode ? (
<>
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: '1rem',
}}
>
<QrHere />
<IonText>{t('please-use-mobile-for-better-experience')}</IonText>
</div>
<Footer />
</>
) : (
<>{children}</>
)}
</>
);
}

View File

@@ -4,16 +4,15 @@ import { useTranslation } from 'react-i18next';
import '../i18n';
export interface I18nProviderProps {
children: React.ReactNode;
language?: string;
}
export function I18nProvider({ children, language = 'en' }: I18nProviderProps): React.JSX.Element {
export function I18nProvider({ language = 'en' }: I18nProviderProps): React.JSX.Element {
const { i18n } = useTranslation();
React.useEffect(() => {
//
}, [i18n, language]);
return <>{children}</>;
return <></>;
}

View File

@@ -1,6 +1,7 @@
import { PocketBaseProvider } from '../hooks/usePocketBase';
import { AppStateProvider } from './AppState';
import { UserProvider } from './auth/user-context';
import { CheckScreenWidth } from './CheckScreenWidth';
import { I18nProvider } from './I18nProvider';
import { MyIonFavoriteProvider } from './MyIonFavorite';
import { MyIonMetricProvider } from './MyIonMetric';
@@ -13,7 +14,8 @@ const queryClient = new QueryClient();
const ContextMeta = ({ children }: { children: React.ReactNode }) => {
return (
<>
<I18nProvider>
<I18nProvider></I18nProvider>
<CheckScreenWidth>
<AppStateProvider>
<UserProvider>
<MyIonStoreProvider>
@@ -32,7 +34,7 @@ const ContextMeta = ({ children }: { children: React.ReactNode }) => {
</MyIonStoreProvider>
</UserProvider>
</AppStateProvider>
</I18nProvider>
</CheckScreenWidth>
</>
);
};

View File

@@ -5,26 +5,10 @@ import { LessonsType } from '../types/LessonsTypes';
const useListAllLessonTypes = () => {
const { user, pb } = usePocketBase();
return useQuery({
queryKey: [
'useListAllLessonTypes',
'feeds',
'all',
user?.id || '',
//
],
queryKey: ['useListAllLessonTypes', 'feeds', 'all', user?.id || ''],
staleTime: 60 * 1000,
queryFn: async ({
queryKey,
}: {
queryKey: [
'useListAllLessonTypes',
'feeds',
'all',
string | null,
//
];
}) => {
console.log('calling useListAllLessonTypes');
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',

View File

@@ -1,107 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
// the translations
// (tip move them in a JSON file and import them)
const resources = {
en: {
translation: {
'Lesson': 'Lesson',
'Quiz': 'Quiz',
'Favorite': 'Favorite',
'Loading': 'Loading',
'menu.link-home': 'Home',
'menu.link-stats': 'Stats',
'home.title': 'Remove duplicate songs from your Spotify library.',
'home.description':
"Spotify Dedup cleans up your playlists and liked songs from your Spotify account. It's easy and fast.",
'home.review':
'Read what {{-supportersCount}} supporters think about Spotify Dedup on {{- linkOpen}}Buy Me a Coffee{{- linkClose}}',
'home.login-button': 'Log in with Spotify',
'meta.title': 'Spotify Dedup - Remove duplicate songs from your Spotify library',
'meta.description':
'Delete repeated songs from your Spotify playlists and liked songs automatically. Fix your music library. Quickly and easy.',
'features.find-remove.header': 'Find & remove',
'features.find-remove.body':
'Dedup checks your playlists and liked songs in {{- strongOpen}}your Spotify library{{- strongClose}}. Once Dedup finds duplicates you can remove them on a per-playlist basis.',
'features.safer.header': 'Safer',
'features.safer.body':
'Dedup will only remove {{- strongOpen}}duplicate songs{{- strongClose}}, leaving the rest of the playlist and liked songs untouched.',
'features.open-source.header': 'Open Source',
'features.open-source.body':
"You might want to have a look at the {{- linkGithubOpen}}source code on GitHub{{- linkGithubClose}}. This web app uses the {{- linkWebApiOpen}}Spotify Web API{{- linkWebApiClose}} to manage user's playlists and liked songs.",
'reviews.title': 'This is what users are saying',
'footer.author': 'Made with ♥ by {{- linkOpen}}JMPerez 👨‍💻{{- linkClose}}',
'footer.github': 'Check out the {{- linkOpen}}code on GitHub 📃{{- linkClose}}',
'footer.bmc': 'Support the project {{- linkOpen}}buying a coffee ☕{{- linkClose}}',
'bmc.button': 'Would you buy me a coffee?',
'result.duplicate.reason-same-id': 'Duplicate',
'result.duplicate.reason-same-data': 'Duplicate (same name, artist and duration)',
'result.duplicate.track': '<0>{{trackName}}</0> <2>by</2> <4>{{trackArtistName}}</4>',
'process.status.finding': 'Finding duplicates in your playlists and liked songs…',
'process.status.complete': 'Processing complete!',
'process.status.complete.body': 'Your playlists and liked songs have been processed!',
'process.status.complete.dups.body':
'Click on the {{- strongOpen}}Remove duplicates{{- strongClose}} button to get rid of duplicates in that playlist or liked songs collection.',
'process.status.complete.nodups.body': "Congrats! You don't have duplicates in your playlists nor liked songs.",
'process.reading-library': 'Going through your library, finding the playlists you own and your liked songs…',
'process.processing_one': 'Searching for duplicate songs, wait a sec. Still to process {{count}} playlist…',
'process.processing_other': 'Searching for duplicate songs, wait a sec. Still to process {{count}} playlists…',
'process.saved.title': 'liked songs in your library',
'process.saved.duplicates_one': 'This collection has {{count}} duplicate song',
'process.saved.duplicates_other': 'This collection has {{count}} duplicate songs',
'process.saved.remove-button': 'Remove duplicates from your liked songs',
'process.playlist.duplicates_one': 'This playlist has {{count}} duplicate song',
'process.playlist.duplicates_other': 'This playlist has {{count}} duplicate songs',
'process.playlist.remove-button': 'Remove duplicates from this playlist',
'process.items.removed': 'Duplicates removed',
'faq.section-title': 'Frequently asked questions',
'faq.question-1': 'What does this web application do?',
'faq.answer-1':
'Spotify Dedup helps you clean up your music libraries on Spotify by identifying and deleting duplicate songs across playlists and liked songs.',
'faq.question-2': 'How does it find duplicates?',
'faq.answer-2':
"Dedup finds duplicates based on the songs identifier, title, artist, and duration similarity. It identifies duplicates that Spotify's application does not catch.",
'faq.question-3': "How is Dedup better than Spotify's built-in duplicate detection?",
'faq.answer-3':
"Spotify's applications only warn about duplicates when adding a song to a playlit or liked songs with the exact same song identifier. However, the same song can have multiple identifiers on Spotify that both in the same release or in several ones. Dedup detects duplicates based on title, artist, and duration similarity.",
'faq.question-4': 'When duplicates are found, which songs are removed?',
'faq.answer-4': 'Dedup will keep the first song within a group of duplicate songs, and will remove the rest.',
'faq.question-5': 'Is my data safe with this web application?',
'faq.answer-5':
'Yes, this web application does not store any user data on its servers. It only requests the minimum set of permissions necessary to process your library.',
'faq.question-6': 'What permissions does this web application require?',
'faq.answer-6':
"This web application uses Spotify's authentication service to access your liked songs and playlists in your library.",
'faq.question-7': 'How has this tool been tested?',
'faq.answer-7':
'This tool has been battle-tested by thousands of users who have used it to identify duplicates in millions of playlists since 2014.',
'faq.question-8': 'Can this tool delete duplicates across multiple playlists?',
'faq.answer-8':
"This tool can identify and delete duplicates on all playlists in a library, but doesn't detect duplicates of a song across multiple playlists.",
'faq.question-9': 'How can I revoke the permissions granted to this web application?',
'faq.answer-9':
"You can revoke the permissions granted to this web application at any time on your Spotify account, under the 'Apps' section.",
'faq.question-10': 'Does this tool work with other music streaming services?',
'faq.answer-10': "No, this tool only works with Spotify through Spotify's Web API.",
},
},
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
// the translations
// (tip move them in a JSON file and import them,
// or even better, manage them via a UI: https://react.i18next.com/guides/multiple-translation-files#manage-your-translations-with-a-management-gui)
// if you're using a language detector, do not define the lng option
lng: 'en',
fallbackLng: 'en',
// debug: true,
interpolation: {
escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
},
resources,
});
export default i18n;

View File

@@ -1,26 +1,19 @@
import i18n from 'i18next';
// import Backend from "i18next-http-backend";
// import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from 'react-i18next';
import resources from './locale/resources';
import en from './locale/en';
import zh from './locale/zh';
i18n
.use(initReactI18next) // passes i18n down to react-i18next
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
// the translations
// (tip move them in a JSON file and import them,
// or even better, manage them via a UI: https://react.i18next.com/guides/multiple-translation-files#manage-your-translations-with-a-management-gui)
//
// TODO: need to troubleshoot resource for building webpage
// resources,
//
// if you're using a language detector, do not define the lng option
lng: 'en',
resources: { en, zh },
fallbackLng: 'en',
//
// debug: true,
//
interpolation: {
escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
},
});
export default i18n;

View File

@@ -2,9 +2,10 @@
// (tip move them in a JSON file and import them)
const en = {
translation: {
hello: 'world',
Loading: 'loading',
lesson: 'lesson',
'hello': 'world',
'Loading': 'loading',
'lesson': 'lesson',
'please-use-mobile-for-better-experience': 'Please use mobile for better experience',
},
};

View File

@@ -1,5 +0,0 @@
import en from './en';
export default {
en,
};

View File

@@ -0,0 +1,12 @@
// the translations
// (tip move them in a JSON file and import them)
const zh = {
translation: {
'hello': '你好',
'Loading': '加載中',
'lesson': '課程',
'please-use-mobile-for-better-experience': '為了更好的體驗,請使用手機瀏覽 😊',
},
};
export default zh;

View File

@@ -9,5 +9,5 @@ const root = createRoot(container!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
</React.StrictMode>
);

View File

@@ -16,9 +16,7 @@ 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 ILesson from '../../../interfaces/ILesson';
import { listLessonContent } from '../../../public_data/listLessonContent';

View File

@@ -15,7 +15,7 @@ import { useParams } from 'react-router';
import { useGlobalAudioPlayer } from 'react-use-audio-player';
import remarkGfm from 'remark-gfm';
import { LoadingScreen } from '../../../components/LoadingScreen';
import { LESSON_LINK, POCKETBASE_URL } from '../../../constants';
import { POCKETBASE_URL } from '../../../constants';
import { useMyIonFavorite } from '../../../contexts/MyIonFavorite';
//
import ILesson from '../../../interfaces/ILesson';
@@ -25,6 +25,7 @@ import { getFavLessonVocabularyLink, getLessonVocabularyLinkString } from '../..
import { AudioControls } from './AudioControls';
import useGetVocabularyRoute from '../../../hooks/useGetVocabularyRoute';
import { Vocabulary } from '../../../types/Vocabularies';
import { Paths } from '../../../Paths';
const WordPage: React.FC = () => {
const [loading, setLoading] = useState<boolean>(true);
@@ -109,7 +110,7 @@ const WordPage: React.FC = () => {
fill="clear"
color={'dark'}
onClick={() => {
router.push(`${LESSON_LINK}/a/Vocabulary`);
router.push(`${Paths.LESSON_LINK}/a/Vocabulary`);
}}
>
<IonIcon size="large" icon={arrowBackCircleOutline} />

View File

@@ -14,7 +14,7 @@ import styles from './style.module.scss';
import { useParams } from 'react-router';
import { Wave } from '../../../components/Wave';
import { Paths } from '../../../Paths';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { getUserMetaById } from '../../../db/UserMetas/GetById';
import { useTranslation } from 'react-i18next';
import { DBUserMeta } from '../../../db/UserMetas/type';
@@ -151,4 +151,4 @@ function StudentInfo(): React.JSX.Element {
);
}
export default StudentInfo;
export default React.memo(StudentInfo);