Compare commits
18 Commits
develop/io
...
720838f137
Author | SHA1 | Date | |
---|---|---|---|
720838f137 | |||
c92ac33ade | |||
72e478937d | |||
62d8519da5 | |||
8d746f3aa9 | |||
69b2ef59e5 | |||
f9c0deb2e9 | |||
47760fa7b2 | |||
83bd86cc9b | |||
49189a532e | |||
6b917c9fb9 | |||
aa834a43c9 | |||
1775d8c5a3 | |||
779062e247 | |||
60df47fb8d | |||
c87357ff24 | |||
34a7ff7ac9 | |||
3556e77a7c |
@@ -2,7 +2,11 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LoadingButton } from '@mui/lab';
|
||||
import { Button } from '@mui/material';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { SignOut as SignOutIcon } from '@phosphor-icons/react/dist/ssr/SignOut';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { authClient } from '@/lib/auth/custom/client';
|
||||
import { logger } from '@/lib/default-logger';
|
||||
@@ -10,39 +14,44 @@ import { useUser } from '@/hooks/use-user';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
export function CustomSignOut(): React.JSX.Element {
|
||||
const { t } = useTranslation('sign_in');
|
||||
|
||||
const { checkSession } = useUser();
|
||||
const [buttonShowLoading, setButtonShowLoading] = React.useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = React.useCallback(async (): Promise<void> => {
|
||||
setButtonShowLoading(true);
|
||||
try {
|
||||
const { error } = await authClient.signOut();
|
||||
|
||||
if (error) {
|
||||
logger.error('Sign out error', error);
|
||||
toast.error('Something went wrong, unable to sign out');
|
||||
toast.error(t('something-went-wrong-unable-to-sign-out'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the auth state
|
||||
await checkSession?.();
|
||||
|
||||
// UserProvider, for this case, will not refresh the router and we need to do it manually
|
||||
router.refresh();
|
||||
// After refresh, AuthGuard will handle the redirect
|
||||
} catch (err) {
|
||||
logger.error('Sign out error', err);
|
||||
toast.error('Something went wrong, unable to sign out');
|
||||
toast.error(t('something-went-wrong-unable-to-sign-out'));
|
||||
}
|
||||
}, [checkSession, router]);
|
||||
}, [checkSession, router, t]);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
component="div"
|
||||
<LoadingButton
|
||||
onClick={handleSignOut}
|
||||
sx={{ justifyContent: 'center' }}
|
||||
sx={{ width: '100%' }}
|
||||
variant="text"
|
||||
disabled={buttonShowLoading}
|
||||
loading={buttonShowLoading}
|
||||
startIcon={<SignOutIcon />}
|
||||
color="secondary"
|
||||
>
|
||||
Sign out
|
||||
</MenuItem>
|
||||
{t('sign-out')}
|
||||
</LoadingButton>
|
||||
);
|
||||
}
|
@@ -16,15 +16,15 @@ import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
|
||||
import type { User } from '@/types/user';
|
||||
import { config } from '@/config';
|
||||
import { paths } from '@/paths';
|
||||
import { authClient } from '@/lib/auth/custom/client';
|
||||
import { AuthStrategy } from '@/lib/auth/strategy';
|
||||
import { logger } from '@/lib/default-logger';
|
||||
|
||||
import { Auth0SignOut } from './auth0-sign-out';
|
||||
import { CognitoSignOut } from './cognito-sign-out';
|
||||
import { CustomSignOut } from './custom-sign-out';
|
||||
import { FirebaseSignOut } from './firebase-sign-out';
|
||||
import { SupabaseSignOut } from './supabase-sign-out';
|
||||
import { authClient } from '@/lib/auth/custom/client';
|
||||
import { logger } from '@/lib/default-logger';
|
||||
|
||||
const defaultUser = {
|
||||
id: 'USR-000',
|
||||
@@ -55,7 +55,8 @@ export function UserPopover({ anchorEl, onClose, open }: UserPopoverProps): Reac
|
||||
void loadUserMeta();
|
||||
}, []);
|
||||
|
||||
if (!userMeta) return <>loading</>;
|
||||
// NOTE: delay when userMeta is null, used for sign-out
|
||||
if (!userMeta) return <></>;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
|
@@ -66,6 +66,7 @@ export function SideNav({ color = 'evident', items = [] }: SideNavProps): React.
|
||||
spacing={2}
|
||||
sx={{ p: 2 }}
|
||||
>
|
||||
{/* NOTE: hide logo
|
||||
<div>
|
||||
<Box
|
||||
component={RouterLink}
|
||||
@@ -79,6 +80,7 @@ export function SideNav({ color = 'evident', items = [] }: SideNavProps): React.
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
*/}
|
||||
<WorkspacesSwitch />
|
||||
</Stack>
|
||||
<Box
|
||||
|
4
002_source/ionic_mobile/.env.development
Normal file
4
002_source/ionic_mobile/.env.development
Normal file
@@ -0,0 +1,4 @@
|
||||
#
|
||||
# POCKETBASE running in wsl2
|
||||
#
|
||||
VITE_POCKETBASE_URL=http://192.168.222.199:8090
|
3
002_source/ionic_mobile/.env.example
Normal file
3
002_source/ionic_mobile/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# consider running ionic on the host
|
||||
# consider pocketbase is running in docker and export port 8090 to the host
|
||||
VITE_POCKETBASE_URL=http://192.168.222.199:8090
|
1
002_source/ionic_mobile/.gitignore
vendored
1
002_source/ionic_mobile/.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.env
|
||||
**/*.log
|
||||
**/*.del
|
||||
**/*.draft
|
||||
**/*.bak
|
||||
**/*del
|
||||
|
||||
|
1587
002_source/ionic_mobile/package-lock.json
generated
1587
002_source/ionic_mobile/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
6
002_source/ionic_mobile/scripts/001_build.sh
Executable file
6
002_source/ionic_mobile/scripts/001_build.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
npm run lint
|
||||
npx nodemon --ext ts,tsx --exec "npm run build"
|
@@ -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)}
|
||||
|
1
002_source/ionic_mobile/src/ERRORS.ts
Normal file
1
002_source/ionic_mobile/src/ERRORS.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ERR_POCKETBASE_URL_IS_EMPTY = 'POCKETBASE url is empty';
|
@@ -1,4 +1,6 @@
|
||||
const Paths = {
|
||||
LESSON_LINK: '/lesson',
|
||||
//
|
||||
AuthHome: `/auth/home`,
|
||||
AuthLogin: `/auth/login`,
|
||||
AuthSignUp: `/auth/signup`,
|
||||
|
@@ -3,7 +3,6 @@ import {
|
||||
CONNECTIVE_REVISION_LINK,
|
||||
DEBUG_LINK,
|
||||
FAVORITE_LINK,
|
||||
LESSON_LINK,
|
||||
LESSON_WORD_PAGE_LINK,
|
||||
LISTENING_PRACTICE_LINK,
|
||||
MATCHING_FRENZY_LINK,
|
||||
@@ -28,7 +27,7 @@ import { AuthSignUp } from './pages/auth/SignUp';
|
||||
import Lesson from './pages/Lesson/index';
|
||||
|
||||
// NOTES: old version using json file
|
||||
import LessonWordPageByDb from './pages/Lesson/LessonWordPageByDb';
|
||||
// import LessonWordPageByDb from './pages/Lesson/LessonWordPageByDb';
|
||||
import WordPage from './pages/Lesson/WordPage';
|
||||
//
|
||||
import ListeningPractice from './pages/ListeningPractice';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -7,7 +7,23 @@ interface CustomFieldProps {
|
||||
label: string;
|
||||
required: boolean;
|
||||
input: {
|
||||
props: { type: string; placeholder: string };
|
||||
props: {
|
||||
type:
|
||||
| 'date'
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'search'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'url'
|
||||
| 'time'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'datetime-local';
|
||||
placeholder: string;
|
||||
//
|
||||
};
|
||||
state: {
|
||||
value: string;
|
||||
reset: (newValue: any) => void;
|
||||
@@ -20,8 +36,8 @@ interface CustomFieldProps {
|
||||
}
|
||||
|
||||
function CustomField({ field, errors }: CustomFieldProps): React.JSX.Element {
|
||||
const error = errors && errors.filter((e) => e.id === field.id)[0];
|
||||
const errorMessage = error && errors.filter((e) => e.id === field.id)[0].message;
|
||||
const error = errors && errors.filter((e: { id: string }) => e.id === field.id)[0];
|
||||
const errorMessage = error && errors.filter((e: { id: string }) => e.id === field.id)[0].message;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -30,7 +46,12 @@ function CustomField({ field, errors }: CustomFieldProps): React.JSX.Element {
|
||||
{field.label}
|
||||
{error && <p className="animate__animated animate__bounceIn">{errorMessage}</p>}
|
||||
</IonLabel>
|
||||
<IonInput className={styles.customInput} {...field.input.props} {...field.input.state} />
|
||||
<IonInput
|
||||
className={styles.customInput}
|
||||
{...field.input.state}
|
||||
{...field.input.props}
|
||||
//
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
25
002_source/ionic_mobile/src/components/Footer/index.tsx
Normal file
25
002_source/ionic_mobile/src/components/Footer/index.tsx
Normal 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;
|
@@ -1,25 +1,32 @@
|
||||
import { IonContent, IonPage, IonSpinner } from '@ionic/react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LoadingScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
export function LoadingSpinner(): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
//
|
||||
marginTop: '33vh',
|
||||
}}
|
||||
>
|
||||
<IonSpinner></IonSpinner>
|
||||
<div>{t('Loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingScreen(): React.JSX.Element {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
//
|
||||
marginTop: '33vh',
|
||||
}}
|
||||
>
|
||||
<IonSpinner></IonSpinner>
|
||||
<div>{t('Loading')}</div>
|
||||
</div>
|
||||
<LoadingSpinner />
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
|
@@ -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' }}
|
||||
>
|
||||
|
@@ -8,6 +8,10 @@
|
||||
// 0.0.7 - add back button for listening practice, matching frenzy, connective revision
|
||||
// 0.0.8 - add back button for listening practice card, matching frenzy card, connective revision card
|
||||
// 0.0.9 - fix ticket 26,27,28,29
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { ERR_POCKETBASE_URL_IS_EMPTY } from './ERRORS';
|
||||
|
||||
// 0.0.10 - remove debug symbol and re-route ending page
|
||||
const VERSIONS = 'v0.0.10';
|
||||
const HELLOWORLD_MP3 = '/helloworld.mp3';
|
||||
@@ -21,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';
|
||||
@@ -76,11 +81,16 @@ const TEST = process.env.NODE_ENV === 'test';
|
||||
const MY_FAVORITE = 'My Favorite';
|
||||
|
||||
//
|
||||
if (!import.meta.env.VITE_POCKETBASE_URL) throw new Error(ERR_POCKETBASE_URL_IS_EMPTY);
|
||||
const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL;
|
||||
//
|
||||
// database constants
|
||||
export const COL_USERS = 'users';
|
||||
export const COL_USER_METAS = 'UserMetas';
|
||||
//
|
||||
export const RUNNING_PLATFORM = Capacitor.getPlatform();
|
||||
|
||||
export const isDevelop = import.meta.env.DEV;
|
||||
|
||||
export {
|
||||
//
|
||||
@@ -112,7 +122,6 @@ export {
|
||||
//
|
||||
HELLOWORLD,
|
||||
HELLOWORLD_MP3,
|
||||
LESSON_LINK,
|
||||
LESSON_WORD_PAGE_LINK,
|
||||
LISTENING_PRACTICE_LINK,
|
||||
LISTENING_PRACTICE_TIME_SPENT,
|
||||
|
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
@@ -0,0 +1,4 @@
|
||||
.App {
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
}
|
@@ -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}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
18
002_source/ionic_mobile/src/contexts/I18nProvider.tsx
Normal file
18
002_source/ionic_mobile/src/contexts/I18nProvider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import '../i18n';
|
||||
|
||||
export interface I18nProviderProps {
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function I18nProvider({ language = 'en' }: I18nProviderProps): React.JSX.Element {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
//
|
||||
}, [i18n, language]);
|
||||
|
||||
return <></>;
|
||||
}
|
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
import { MyIonQuizProvider } from './MyIonQuiz';
|
||||
@@ -12,24 +14,27 @@ const queryClient = new QueryClient();
|
||||
const ContextMeta = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<AppStateProvider>
|
||||
<UserProvider>
|
||||
<MyIonStoreProvider>
|
||||
<MyIonFavoriteProvider>
|
||||
<MyIonQuizProvider>
|
||||
<MyIonMetricProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PocketBaseProvider>
|
||||
{children}
|
||||
{/* */}
|
||||
</PocketBaseProvider>
|
||||
</QueryClientProvider>
|
||||
</MyIonMetricProvider>
|
||||
</MyIonQuizProvider>
|
||||
</MyIonFavoriteProvider>
|
||||
</MyIonStoreProvider>
|
||||
</UserProvider>
|
||||
</AppStateProvider>
|
||||
<I18nProvider></I18nProvider>
|
||||
<CheckScreenWidth>
|
||||
<AppStateProvider>
|
||||
<UserProvider>
|
||||
<MyIonStoreProvider>
|
||||
<MyIonFavoriteProvider>
|
||||
<MyIonQuizProvider>
|
||||
<MyIonMetricProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PocketBaseProvider>
|
||||
{children}
|
||||
{/* */}
|
||||
</PocketBaseProvider>
|
||||
</QueryClientProvider>
|
||||
</MyIonMetricProvider>
|
||||
</MyIonQuizProvider>
|
||||
</MyIonFavoriteProvider>
|
||||
</MyIonStoreProvider>
|
||||
</UserProvider>
|
||||
</AppStateProvider>
|
||||
</CheckScreenWidth>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -3,23 +3,23 @@ import { useState } from 'react';
|
||||
export const useFormInput = (initialValue = '') => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const handleChange = async (e) => {
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const tempValue = await e.currentTarget.value;
|
||||
setValue(tempValue);
|
||||
};
|
||||
|
||||
return {
|
||||
value,
|
||||
reset: (newValue) => setValue(newValue),
|
||||
reset: (newValue: string) => setValue(newValue),
|
||||
onIonChange: handleChange,
|
||||
onKeyUp: handleChange,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateForm = (fields) => {
|
||||
let errors = [];
|
||||
export const validateForm = (fields: { required: boolean; id: string; input: { state: { value: string } } }[]) => {
|
||||
let errors: { id: string; message: string }[] = [];
|
||||
|
||||
fields.forEach((field) => {
|
||||
fields.forEach((field: { required: boolean; id: string; input: { state: { value: string } } }) => {
|
||||
if (field.required) {
|
||||
const fieldValue = field.input.state.value;
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import { QuizMFQuestion } from '../types/QuizMFQuestion';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -10,12 +8,12 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMFQuestions = async (cat_id: string, pb: any) => {
|
||||
async function fetchMFQuestions(cat_id: string, pb: any) {
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: ['fetchData'],
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
return await pb.collection('QuizMFQuestions').getList<QuizMFQuestion>(1, 9999, {
|
||||
return await pb.collection('QuizMFQuestions').getList(1, 9999, {
|
||||
filter: `cat_id = "${cat_id}"`,
|
||||
$autoCancel: false,
|
||||
});
|
||||
@@ -23,6 +21,6 @@ const fetchMFQuestions = async (cat_id: string, pb: any) => {
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
export default fetchMFQuestions;
|
||||
|
@@ -1,9 +1,7 @@
|
||||
import { usePocketBase } from './usePocketBase.tsx';
|
||||
import type LessonsTypes from '../types/LessonsTypes';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Vocabularies from '../types/Vocabularies.tsx';
|
||||
|
||||
const useGetVocabularyRoute = (lessonType: string, catId: string) => {
|
||||
function useGetVocabularyRoute(lessonType: string, catId: string) {
|
||||
const { user, pb } = usePocketBase();
|
||||
return useQuery({
|
||||
queryKey: ['useGetVocabularyRoute', lessonType, catId, 'feeds', 'all', user?.id || ''],
|
||||
@@ -14,7 +12,7 @@ const useGetVocabularyRoute = (lessonType: string, catId: string) => {
|
||||
queryKey: ['useGetVocabularyRoute', string, string, 'feeds', 'all', string | null];
|
||||
}) => {
|
||||
console.log('calling useGetLessonCategoriesRoute');
|
||||
return await pb.collection('Vocabularies').getList<Vocabularies[]>(1, 9999, {
|
||||
return await pb.collection('Vocabularies').getList(1, 9999, {
|
||||
// TODO: sort by field -> pos
|
||||
sort: 'id',
|
||||
filter: `lesson_type_id = "${lessonType}" && cat_id = "${catId}"`,
|
||||
@@ -24,6 +22,6 @@ const useGetVocabularyRoute = (lessonType: string, catId: string) => {
|
||||
},
|
||||
// enabled: !!user?.id,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default useGetVocabularyRoute;
|
||||
|
@@ -1,30 +1,14 @@
|
||||
import { usePocketBase } from './usePocketBase.tsx';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
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 || '',
|
||||
//
|
||||
],
|
||||
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',
|
||||
|
@@ -1,7 +1,7 @@
|
||||
// CR = ConnectiveRevision
|
||||
import { usePocketBase } from './usePocketBase.tsx';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import IListeningPracticeCategory from '../interfaces/IListeningPracticeCategory.tsx';
|
||||
import IListeningPracticeCategory from '../interfaces/IListeningPracticeCategory';
|
||||
|
||||
const useListQuizCRCategories = () => {
|
||||
const { user, pb } = usePocketBase();
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { usePocketBase } from './usePocketBase.tsx';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { QuizCRQuestion } from '../types/QuizCRQuestion.ts/index.ts';
|
||||
import { QuizCRQuestion } from '../types/QuizCRQuestion';
|
||||
|
||||
const useListQuizCRQuestionByCRCategoryId = (CRCategoryId: string) => {
|
||||
const { user, pb } = usePocketBase();
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { usePocketBase } from './usePocketBase.tsx';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
// import { QuizLPQuestion } from '../types/QuizLPQuestion';
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { usePocketBase } from './usePocketBase.tsx';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import IListeningPracticeCategory from '../interfaces/IListeningPracticeCategory.tsx';
|
||||
import IListeningPracticeCategory from '../interfaces/IListeningPracticeCategory';
|
||||
|
||||
const useListQuizListeningPracticeContent = () => {
|
||||
const { user, pb } = usePocketBase();
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { QuizMFQuestion } from '../types/QuizMFQuestion';
|
||||
import { usePocketBase } from './usePocketBase';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { QueryClient, useQuery } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
@@ -25,7 +25,12 @@ export const PocketBaseProvider = ({ children }: { children: any }) => {
|
||||
|
||||
const logout = useCallback(() => pb.authStore.clear(), [pb.authStore]);
|
||||
|
||||
return <PocketBaseContext.Provider value={{ pb, user, logout }}>{children}</PocketBaseContext.Provider>;
|
||||
return (
|
||||
<PocketBaseContext.Provider value={{ pb, user, logout }}>
|
||||
{/* */}
|
||||
{children}
|
||||
</PocketBaseContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePocketBase = () => {
|
||||
@@ -36,15 +41,15 @@ export const usePocketBase = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useRequireAuth = () => {
|
||||
const { pb, user } = usePocketBase();
|
||||
const navigate = useNavigate();
|
||||
// export const useRequireAuth = () => {
|
||||
// const { pb, user } = usePocketBase();
|
||||
// const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pb.authStore.isValid) {
|
||||
navigate(URLS.LOGIN);
|
||||
}
|
||||
}, [pb.authStore.isValid, navigate]);
|
||||
// useEffect(() => {
|
||||
// if (!pb.authStore.isValid) {
|
||||
// navigate(URLS.LOGIN);
|
||||
// }
|
||||
// }, [pb.authStore.isValid, navigate]);
|
||||
|
||||
return user;
|
||||
};
|
||||
// return user;
|
||||
// };
|
||||
|
@@ -1,107 +1,19 @@
|
||||
import i18n from 'i18next';
|
||||
// import Backend from "i18next-http-backend";
|
||||
// import LanguageDetector from "i18next-browser-languagedetector";
|
||||
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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
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)
|
||||
resources,
|
||||
// if you're using a language detector, do not define the lng option
|
||||
lng: 'en',
|
||||
resources: { en, zh },
|
||||
fallbackLng: 'en',
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
|
||||
},
|
||||
// debug: true,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
@@ -0,0 +1,10 @@
|
||||
//
|
||||
// PURPOSE:
|
||||
// get file url from pocketbase record
|
||||
//
|
||||
|
||||
import { POCKETBASE_URL } from '../constants';
|
||||
|
||||
export default function getImageUrlFromFile(collectionId: string, id: string, imgFile: string | undefined): string {
|
||||
return `${POCKETBASE_URL}/api/files/${collectionId}/${id}/${imgFile}`;
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
import { POCKETBASE_URL } from '../constants';
|
||||
import { DBUserMeta } from '../db/UserMetas/type';
|
||||
|
||||
export function getStudentAvatar(studentMeta: DBUserMeta) {
|
||||
return `url(http://localhost:8090/api/files/${studentMeta.collectionId}/${studentMeta.id}/${studentMeta.avatar})`;
|
||||
export function getStudentAvatarUrl(studentMeta: DBUserMeta) {
|
||||
const { collectionId, id, avatar } = studentMeta;
|
||||
return `url(${POCKETBASE_URL}/api/files/${collectionId}/${id}/${avatar})`;
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import { ERR_POCKETBASE_URL_IS_EMPTY } from '../ERRORS';
|
||||
import { POCKETBASE_URL } from '../constants';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
const pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
export { pb };
|
||||
|
12
002_source/ionic_mobile/src/locale/en.ts
Normal file
12
002_source/ionic_mobile/src/locale/en.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// the translations
|
||||
// (tip move them in a JSON file and import them)
|
||||
const en = {
|
||||
translation: {
|
||||
'hello': 'world',
|
||||
'Loading': 'loading',
|
||||
'lesson': 'lesson',
|
||||
'please-use-mobile-for-better-experience': 'Please use mobile for better experience',
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
12
002_source/ionic_mobile/src/locale/zh.ts
Normal file
12
002_source/ionic_mobile/src/locale/zh.ts
Normal 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;
|
@@ -9,5 +9,5 @@ const root = createRoot(container!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
@@ -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';
|
||||
|
@@ -49,32 +49,30 @@ const LessonContainer: React.FC<ContainerProps> = ({ lesson_type_id: lesson_type
|
||||
}}
|
||||
>
|
||||
{selected_content.map((content: any, cat_idx: number) => (
|
||||
<>
|
||||
<IonButton
|
||||
key={cat_idx}
|
||||
style={{ width: '45vw', height: '45vw' }}
|
||||
fill="clear"
|
||||
onClick={() => {
|
||||
// TODO: review the layout type `v` and `c`
|
||||
router.push(`/lesson_word_page/v/${lesson_type_id}/${content.id}/0`, undefined, 'replace');
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
backgroundImage: `url(${getImage(content.collectionId, content.id, content.cat_image)})`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
borderRadius: '0.5rem',
|
||||
margin: '.5rem',
|
||||
}}
|
||||
></div>
|
||||
<span style={{ color: COLOR_TEXT }}>{content.cat_name}</span>
|
||||
</div>
|
||||
</IonButton>
|
||||
</>
|
||||
<IonButton
|
||||
key={cat_idx}
|
||||
style={{ width: '45vw', height: '45vw' }}
|
||||
fill="clear"
|
||||
onClick={() => {
|
||||
// TODO: review the layout type `v` and `c`
|
||||
router.push(`/lesson_word_page/v/${lesson_type_id}/${content.id}/0`, undefined, 'replace');
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
backgroundImage: `url(${getImage(content.collectionId, content.id, 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 /> */}
|
||||
|
@@ -1,28 +0,0 @@
|
||||
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 <></>;
|
||||
};
|
@@ -1,319 +0,0 @@
|
||||
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;
|
@@ -1,31 +0,0 @@
|
||||
.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;
|
||||
}
|
@@ -1,11 +1,9 @@
|
||||
import { IonButton, IonButtons, IonContent, IonIcon, IonModal, IonPage, IonToolbar, useIonRouter } from '@ionic/react';
|
||||
import { IonButton, IonContent, IonIcon, IonPage, useIonRouter } from '@ionic/react';
|
||||
import './style.css';
|
||||
import {
|
||||
arrowBackCircleOutline,
|
||||
chevronBack,
|
||||
chevronForward,
|
||||
close,
|
||||
heart,
|
||||
heartOutline,
|
||||
play,
|
||||
volumeHighOutline,
|
||||
@@ -17,24 +15,17 @@ 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 { POCKETBASE_URL } 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,
|
||||
getLessonVocabularyLinkString,
|
||||
} from '../../Lesson/getLessonWordLink';
|
||||
import { getFavLessonVocabularyLink, getLessonVocabularyLinkString } from '../../Lesson/getLessonWordLink';
|
||||
import { AudioControls } from './AudioControls';
|
||||
import useGetVocabularyRoute from '../../../hooks/useGetVocabularyRoute';
|
||||
import { UseQueryResult } from '@tanstack/react-query';
|
||||
import { ListResult } from 'pocketbase';
|
||||
import LessonsTypes from '../../../types/LessonsTypes';
|
||||
import { Vocabulary } from '../../../types/Vocabularies';
|
||||
import { Paths } from '../../../Paths';
|
||||
|
||||
const WordPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -56,7 +47,7 @@ const WordPage: React.FC = () => {
|
||||
let { status, data: tempResult } = useGetVocabularyRoute(lesson_idx, cat_idx);
|
||||
|
||||
function getFile(recordId: string, fileName: string) {
|
||||
return `http://127.0.0.1:8090/api/files/Vocabularies/${recordId}/${fileName}`;
|
||||
return `${POCKETBASE_URL}/api/files/Vocabularies/${recordId}/${fileName}`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,12 +69,11 @@ const WordPage: React.FC = () => {
|
||||
let [lastWord, setLastWord] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (tempResult) {
|
||||
if (tempResult && tempResult.items.length > 0) {
|
||||
let temp1 = tempResult.items[parseInt(word_idx)] as unknown as Vocabulary;
|
||||
try {
|
||||
setCatInfo(tempResult.items[parseInt(word_idx)].expand.cat_id as unknown as ILessonCategory);
|
||||
//
|
||||
setWordInfo(tempResult.items[parseInt(word_idx)] as unknown as IWordCard);
|
||||
//
|
||||
setCatInfo(temp1.expand.cat_id as unknown as ILessonCategory);
|
||||
setWordInfo(temp1 as unknown as IWordCard);
|
||||
setLastWord(parseInt(word_idx) === tempResult.items.length - 1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -92,12 +82,11 @@ const WordPage: React.FC = () => {
|
||||
}, [lesson_idx, cat_idx, word_idx]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log({ lesson_idx, cat_idx, word_idx });
|
||||
if (tempResult) {
|
||||
if (tempResult && tempResult.items.length > 0) {
|
||||
let temp1 = tempResult.items[parseInt(word_idx)] as unknown as Vocabulary;
|
||||
try {
|
||||
setCatInfo(tempResult.items[parseInt(word_idx)].expand.cat_id as unknown as ILessonCategory);
|
||||
//
|
||||
setWordInfo(tempResult.items[parseInt(word_idx)] as unknown as IWordCard);
|
||||
setCatInfo(temp1.expand.cat_id as unknown as ILessonCategory);
|
||||
setWordInfo(temp1 as unknown as IWordCard);
|
||||
//
|
||||
setLastWord(parseInt(word_idx) === tempResult.items.length - 1);
|
||||
} catch (error) {
|
||||
@@ -121,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} />
|
||||
@@ -143,11 +132,7 @@ const WordPage: React.FC = () => {
|
||||
// href={getLessonVocabularyLink(lesson_idx, cat_idx, Math.max(0, parseInt(word_idx) - 1).toString())}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
getLessonVocabularyLinkString(
|
||||
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())
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -203,8 +188,8 @@ const WordPage: React.FC = () => {
|
||||
getLessonVocabularyLinkString(
|
||||
lesson_idx,
|
||||
cat_idx,
|
||||
Math.min(tempResult.items.length - 1, parseInt(word_idx) + 1).toString(),
|
||||
),
|
||||
Math.min(tempResult.items.length - 1, parseInt(word_idx) + 1).toString()
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonList,
|
||||
IonPage,
|
||||
@@ -15,20 +18,18 @@ import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
import ExitButton from '../../components/ExitButton';
|
||||
import { LoadingScreen } from '../../components/LoadingScreen';
|
||||
import { LoadingScreen, LoadingSpinner } from '../../components/LoadingScreen';
|
||||
import CongratConnectiveConqueror from '../../components/Modal/Congratulation/ConnectiveConqueror';
|
||||
import CongratGenius from '../../components/Modal/Congratulation/Genius';
|
||||
import CongratHardworker from '../../components/Modal/Congratulation/Hardworker';
|
||||
import CongratListeningProgress from '../../components/Modal/Congratulation/ListeningProgress';
|
||||
import CongratMatchmaking from '../../components/Modal/Congratulation/Matchmaking';
|
||||
import { LESSON_LINK } from '../../constants';
|
||||
import { useMyIonStore } from '../../contexts/MyIonStore';
|
||||
import { listLessonCategories } from '../../public_data/listLessonCategories';
|
||||
import LessonContainer from './LessonContainer';
|
||||
import useHelloworld from '../../hooks/useHelloworld';
|
||||
import useListAllLessonTypes from '../../hooks/useListAllLessonTypes';
|
||||
import LessonsTypes, { LessonsType } from '../../types/LessonsTypes';
|
||||
import useListCategoriesByLessonId from '../../hooks/useListCategoriesByLessonId';
|
||||
import { LessonsType } from '../../types/LessonsTypes';
|
||||
import { ellipsisHorizontal, ellipsisVertical, personCircle, search } from 'ionicons/icons';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { RUNNING_PLATFORM } from '../../constants';
|
||||
|
||||
const Lesson: React.FC = () => {
|
||||
const { act_category } = useParams<{ act_category: string }>();
|
||||
@@ -60,30 +61,27 @@ const Lesson: React.FC = () => {
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<div
|
||||
style={{
|
||||
//
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0 0.5rem 0 0.5rem',
|
||||
}}
|
||||
>
|
||||
<IonTitle size="large" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
{t('Lesson')}
|
||||
</IonTitle>
|
||||
<div>
|
||||
{/* show when scroll up */}
|
||||
|
||||
{RUNNING_PLATFORM == 'android' ? (
|
||||
<IonButtons slot="primary">
|
||||
<ExitButton />
|
||||
</div>
|
||||
</div>
|
||||
</IonButtons>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<IonTitle>
|
||||
{t('lesson')} {t('hello')}
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen>
|
||||
<IonHeader collapse="condense">
|
||||
{/* show when no scrolling */}
|
||||
<IonToolbar>
|
||||
<IonTitle size="large" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div>{'Lesson'}</div>
|
||||
<div>{t('lesson')}</div>
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
@@ -113,7 +111,7 @@ const Lesson: React.FC = () => {
|
||||
{lessonTypes[active_lesson_idx]?.id ? (
|
||||
<LessonContainer lesson_type_id={lessonTypes[active_lesson_idx].id} />
|
||||
) : (
|
||||
<>loading (id undefined)</>
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
{/* */}
|
||||
<CongratGenius />
|
||||
|
@@ -14,12 +14,12 @@ 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';
|
||||
import { LoadingScreen } from '../../../components/LoadingScreen';
|
||||
import { getStudentAvatar } from '../../../lib/getStudentAvatar';
|
||||
import { getStudentAvatarUrl } from '../../../lib/getStudentAvatar';
|
||||
import { authClient } from '../../../lib/auth/custom/client';
|
||||
import { useUser } from '../../../hooks/use-user';
|
||||
|
||||
@@ -82,7 +82,7 @@ function StudentInfo(): React.JSX.Element {
|
||||
<IonCol size={'3'}>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: getStudentAvatar(studentMeta),
|
||||
backgroundImage: getStudentAvatarUrl(studentMeta),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
@@ -151,4 +151,4 @@ function StudentInfo(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default StudentInfo;
|
||||
export default React.memo(StudentInfo);
|
||||
|
@@ -1,5 +1,6 @@
|
||||
// RULES: interface for handling vocabulary record
|
||||
type Vocabulary = {
|
||||
|
||||
export type Vocabulary = {
|
||||
//
|
||||
id: string;
|
||||
collectionId: string;
|
||||
@@ -14,11 +15,7 @@ type Vocabulary = {
|
||||
image: File[];
|
||||
sound: File[];
|
||||
//
|
||||
expand?: {
|
||||
expand: {
|
||||
cat_id: LessonCategory;
|
||||
};
|
||||
};
|
||||
|
||||
type Vocabularies = Vocabulary[];
|
||||
|
||||
export default Vocabularies;
|
||||
|
@@ -10,6 +10,6 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/setupTests.ts'
|
||||
}
|
||||
setupFiles: './src/setupTests.ts',
|
||||
},
|
||||
});
|
||||
|
Reference in New Issue
Block a user