This commit is contained in:
louiscklaw
2025-06-05 11:29:42 +08:00
parent 8c46a93e61
commit d909805283
207 changed files with 10412 additions and 46 deletions

View File

@@ -0,0 +1,63 @@
import {
IonButton,
IonCol,
IonContent,
IonGrid,
IonHeader,
IonIcon,
IonPage,
IonRow,
IonTitle,
IonToolbar,
useIonActionSheet,
} from '@ionic/react';
import styles from './Home.module.scss';
import { informationCircleOutline } from 'ionicons/icons';
const Home = () => {
const [show, hide] = useIonActionSheet();
return (
<IonPage>
<IonContent fullscreen>
<IonGrid>
<IonRow>
<IonCol size="12" className="ion-text-center">
<img src="/assets/DemoQuizApp/main.png" alt="title" className={styles.title} />
</IonCol>
</IonRow>
</IonGrid>
<IonRow className={styles.buttons}>
<IonCol size="12">
<IonButton
routerLink="/demo-quiz-app/quiz"
color="light"
expand="block"
className={styles.playButton}
>
Start Playing
</IonButton>
<IonButton
color="dark"
className={styles.helpButton}
onClick={() =>
show({
buttons: [{ text: 'Close' }],
header: 'How to play',
subHeader:
'Pick a category and difficulty, then proceed to answer each question. You will gain a score by getting an answer right and you will also be indicated whether your answer was correct or incorrect. Have fun!',
})
}
>
<IonIcon icon={informationCircleOutline} /> How to play
</IonButton>
</IonCol>
</IonRow>
</IonContent>
</IonPage>
);
};
export default Home;

View File

@@ -0,0 +1,43 @@
.title {
height: 10rem;
margin-top: 30%;
}
.buttons {
position: absolute;
bottom: 3rem;
width: 100%;
}
.playButton {
height: 4rem;
--border-radius: 500px;
width: fit-content;
--padding-start: 5rem;
--padding-end: 5rem;
margin: 0 auto;
}
.helpButton {
display: flex;
flex-direction: row;
justify-content: center;
align-content: center;
text-align: center;
width: fit-content;
margin: 0 auto;
margin-top: 3rem;
opacity: 70%;
--border-radius: 10rem !important;
--padding-end: 1.25rem;
ion-icon {
margin-top: 0.2rem;
margin-right: 0.5rem;
}
}

View File

@@ -0,0 +1,193 @@
import {
IonBadge,
IonButton,
IonCard,
IonCardContent,
IonCardHeader,
IonCardSubtitle,
IonCardTitle,
IonCol,
IonContent,
IonGrid,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonNote,
IonPage,
IonRow,
IonTitle,
IonToolbar,
useIonRouter,
useIonViewDidEnter,
} from '@ionic/react';
import styles from './Quiz.module.scss';
import { useStoreState } from 'pullstate';
import { SettingsStore } from '../store';
import {
getCategories,
getChosenCategory,
getChosenDifficulty,
getDifficulties,
} from '../store/Selectors';
import { Category, Difficulty } from '../components/Settings';
import { useState } from 'react';
import { useEffect } from 'react';
import { fetchQuestions } from '../questions';
// Import Swiper React components
import { Swiper, SwiperSlide } from 'swiper/react';
// Import Swiper styles
// import 'swiper/swiper.scss';
import 'swiper/css';
import { useRef } from 'react';
import { updateChosenCategory, updateChosenDifficulty } from '../store/SettingsStore';
import { Answer } from '../components/Answer';
import { CompletedCard } from '../components/CompletedCard';
import { QuizStats } from '../components/QuizStats';
const Questions = () => {
const mainContainerRef = useRef();
const completionContainerRef = useRef();
const swiperRef = useRef(null);
const router = useIonRouter();
const chosenCategory = useStoreState(SettingsStore, getChosenCategory);
const chosenDifficulty = useStoreState(SettingsStore, getChosenDifficulty);
const [currentQuestion, setCurrentQuestion] = useState(1);
const [score, setScore] = useState(0);
const [completed, setCompleted] = useState(false);
const [questions, setQuestions] = useState(false);
const [slideSpace, setSlideSpace] = useState(0);
useEffect(() => {
const getQuestions = async () => {
const fetchedQuestions = await fetchQuestions(chosenCategory, chosenDifficulty);
setQuestions(fetchedQuestions);
};
getQuestions();
}, []);
useIonViewDidEnter(() => {
setSlideSpace(40);
});
const handleAnswerClick = (event, answer, question) => {
const isCorrect = question.correct_answers[`${answer}_correct`] === 'true';
if (isCorrect) {
event.target.setAttribute('color', 'success');
} else {
event.target.setAttribute('color', 'danger');
}
setTimeout(() => {
isCorrect && setScore((score) => score + 1);
event.target.setAttribute('color', 'light');
swiperRef.current.swiper.slideNext();
checkIfComplete();
}, 1000);
};
const checkIfComplete = () => {
if (currentQuestion === questions.length) {
// Quiz has finished
// Hide Slides and show completion screen
mainContainerRef.current.classList.add('animate__zoomOutDown');
setTimeout(() => {
setCompleted(true);
completionContainerRef.current.classList.add('animate__zoomInUp');
}, 1000);
}
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>
<img src="/assets/DemoQuizApp/main.png" style={{ width: '30%' }} alt="logo" />
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="background">
{!completed && (
<IonGrid className={`${styles.mainGrid} animate__animated`} ref={mainContainerRef}>
<QuizStats
chosenCategory={chosenCategory}
chosenDifficulty={chosenDifficulty}
questionsLength={questions.length}
currentQuestion={currentQuestion}
score={score}
/>
<IonRow className={styles.mainRow}>
<IonCol size="12">
<IonRow>
<Swiper
ref={swiperRef}
spaceBetween={slideSpace}
slidesPerView={1}
onSlideChange={(e) => setCurrentQuestion(e.activeIndex + 1)}
>
{questions &&
questions.map((question, index) => {
return (
<SwiperSlide key={`question_${index}`}>
<IonCard id="questionCard" className="animate__animated">
<IonCardHeader className="ion-text-center">
<IonCardSubtitle>{question.category}</IonCardSubtitle>
{question.tags.length > 0 && (
<IonBadge color="success">{question.tags[0].name}</IonBadge>
)}
<IonCardTitle className={styles.questionTitle}>
{question.question}
</IonCardTitle>
</IonCardHeader>
<IonCardContent>
{Object.keys(question.answers).map((answer, index) => {
if (question.answers[answer] !== null) {
return (
<Answer
key={`answer_${index}`}
answer={answer}
question={question}
handleAnswerClick={handleAnswerClick}
/>
);
}
})}
</IonCardContent>
</IonCard>
</SwiperSlide>
);
})}
</Swiper>
</IonRow>
</IonCol>
</IonRow>
</IonGrid>
)}
{completed && (
<CompletedCard
completionContainerRef={completionContainerRef}
score={score}
questionsLength={questions.length}
/>
)}
</IonContent>
</IonPage>
);
};
export default Questions;

View File

@@ -0,0 +1,153 @@
import {
IonButton,
IonCard,
IonCardContent,
IonCardHeader,
IonCardSubtitle,
IonCol,
IonContent,
IonGrid,
IonHeader,
IonIcon,
IonPage,
IonRow,
IonTitle,
IonToolbar,
useIonRouter,
useIonToast,
} from '@ionic/react';
import styles from './Quiz.module.scss';
import { useStoreState } from 'pullstate';
import { SettingsStore } from '../store';
import {
getCategories,
getChosenCategory,
getChosenDifficulty,
getDifficulties,
} from '../store/Selectors';
import { Category, Difficulty } from '../components/Settings';
const Quiz = () => {
const router = useIonRouter();
const categories = useStoreState(SettingsStore, getCategories);
const difficulties = useStoreState(SettingsStore, getDifficulties);
const chosenCategory = useStoreState(SettingsStore, getChosenCategory);
const chosenDifficulty = useStoreState(SettingsStore, getChosenDifficulty);
const [show, hide] = useIonToast();
const startQuiz = async () => {
if (chosenCategory && chosenDifficulty) {
const chosenCategoryElement = document.getElementById(`categoryButton_${chosenCategory}`);
const chosenDifficultyElement = document.getElementById(
`difficultyButton_${chosenDifficulty}`
);
const categoriesCardElement = document.getElementById('categoriesCard');
const difficultiesCardElement = document.getElementById('difficultiesCard');
chosenCategoryElement.classList.add('ontop');
chosenDifficultyElement.classList.add('ontop');
chosenCategoryElement.classList.add('animate__heartBeat');
chosenDifficultyElement.classList.add('animate__heartBeat');
setTimeout(() => {
chosenCategoryElement.classList.remove('animate__heartBeat');
chosenDifficultyElement.classList.remove('animate__heartBeat');
chosenCategoryElement.classList.remove('ontop');
chosenDifficultyElement.classList.remove('ontop');
}, 1000);
setTimeout(() => {
categoriesCardElement.classList.add('animate__slideOutRight');
difficultiesCardElement.classList.add('animate__slideOutLeft');
setTimeout(() => {
categoriesCardElement.classList.remove('animate__slideOutRight');
difficultiesCardElement.classList.remove('animate__slideOutLeft');
}, 1000);
}, 1100);
setTimeout(() => {
router.push('/questions');
}, 1700);
} else {
show({
header: 'Hang on there!',
message: 'You must choose a category and difficulty!',
duration: 3000,
color: 'warning',
});
}
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>
<img src="/assets/DemoQuizApp/main.png" style={{ width: '30%' }} alt="logo" />
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="background">
<IonGrid className={styles.mainGrid}>
<IonRow className={styles.mainRow}>
<IonCol size="12">
<IonCard id="categoriesCard" className="animate__animated">
<IonCardHeader className="ion-text-center">
<IonCardSubtitle>Choose a category</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<IonRow>
{categories.map((category, index) => {
const chosen = category.value === chosenCategory;
return <Category key={`category_${index}`} {...category} chosen={chosen} />;
})}
</IonRow>
</IonCardContent>
</IonCard>
</IonCol>
</IonRow>
<IonRow className={styles.difficultyContainer}>
<IonCol size="12">
<IonCard id="difficultiesCard" className="animate__animated">
<IonCardHeader className="ion-text-center">
<IonCardSubtitle>Choose a difficulty</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<IonRow>
{difficulties.map((difficulty, index) => {
const chosen = difficulty.value === chosenDifficulty;
return (
<Difficulty key={`difficulty_${index}`} {...difficulty} chosen={chosen} />
);
})}
</IonRow>
</IonCardContent>
</IonCard>
</IonCol>
</IonRow>
<IonRow>
<IonCol size="12">
<div className={styles.startButton} onClick={startQuiz}>
Start Quiz!
</div>
</IonCol>
</IonRow>
</IonGrid>
</IonContent>
</IonPage>
);
};
export default Quiz;

View File

@@ -0,0 +1,46 @@
.difficultyContainer {
margin-top: -2rem !important;
}
.startButton {
background-color: #994ec1;
padding: 1.25rem;
margin: 1rem;
margin-top: -1rem;
border-radius: 5px;
text-align: center;
color: white;
border: 2px solid #632485;
}
.questionTitle {
font-size: 1rem;
}
.answerButton {
height: fit-content;
--padding-top: 1rem;
--padding-bottom: 1rem;
}
.mainGrid {
// margin-top: -2rem;
}
.mainRow {
margin-top: -2rem;
}
.emoji {
font-size: 4rem;
padding: 0;
margin: 0;
padding-top: 1rem;
}

View File

@@ -0,0 +1,17 @@
import { IonButton, IonCol, IonRow } from '@ionic/react';
import styles from './Quiz.module.scss';
export const Answer = ({ answer, handleAnswerClick, question }) => (
<IonRow>
<IonCol size="12">
<IonButton
onClick={(e) => handleAnswerClick(e, answer, question)}
expand="block"
color="light"
className={`ion-text-wrap ${styles.answerButton}`}
>
{question.answers[answer]}
</IonButton>
</IonCol>
</IonRow>
);

View File

@@ -0,0 +1,58 @@
import {
IonButton,
IonCard,
IonCardContent,
IonCardHeader,
IonCardSubtitle,
IonCardTitle,
IonCol,
IonGrid,
IonNote,
IonRow,
useIonRouter,
} from '@ionic/react';
import styles from './Quiz.module.scss';
import { updateChosenCategory, updateChosenDifficulty } from '../store/SettingsStore';
export const CompletedCard = ({ completionContainerRef, score, questionsLength }) => {
const router = useIonRouter();
const playAgain = () => {
updateChosenCategory(false);
updateChosenDifficulty(false);
router.push('/');
};
return (
<IonGrid className="animate__animated" ref={completionContainerRef}>
<IonRow className="ion-text-center">
<IonCol size="12">
<IonCard>
<IonCardHeader>
<IonCardSubtitle>Congratulations</IonCardSubtitle>
<IonCardTitle>Quiz Complete!</IonCardTitle>
<p className={styles.emoji}>🎉</p>
</IonCardHeader>
<IonCardContent>
<IonNote>You scored</IonNote>
<IonCardTitle className="ion-margin-bottom">
{score}/{questionsLength}
</IonCardTitle>
<IonButton
onClick={playAgain}
color="success"
expand="block"
className="ion-margin-top"
>
Play Again!
</IonButton>
</IonCardContent>
</IonCard>
</IonCol>
</IonRow>
</IonGrid>
);
};

View File

@@ -0,0 +1,46 @@
.difficultyContainer {
margin-top: -2rem !important;
}
.startButton {
background-color: #994ec1;
padding: 1.25rem;
margin: 1rem;
margin-top: -1rem;
border-radius: 5px;
text-align: center;
color: white;
border: 2px solid #632485;
}
.questionTitle {
font-size: 1rem;
}
.answerButton {
height: fit-content;
--padding-top: 1rem;
--padding-bottom: 1rem;
}
.mainGrid {
// margin-top: -2rem;
}
.mainRow {
margin-top: -2rem;
}
.emoji {
font-size: 4rem;
padding: 0;
margin: 0;
padding-top: 1rem;
}

View File

@@ -0,0 +1,25 @@
import { IonCard, IonCardContent, IonCardSubtitle, IonCol, IonItem, IonLabel, IonNote, IonRow } from "@ionic/react";
export const QuizStats = ({ chosenCategory, chosenDifficulty, currentQuestion, questionsLength, score }) => (
<IonRow>
<IonCol size="12">
<IonCard>
<IonCardContent className="ion-text-center">
<IonCardSubtitle>{ chosenCategory } | { chosenDifficulty }</IonCardSubtitle>
<IonItem lines="none">
<IonLabel className="ion-text-center">
<IonCardSubtitle>Question</IonCardSubtitle>
<IonNote>{ currentQuestion } / { questionsLength }</IonNote>
</IonLabel>
<IonLabel className="ion-text-center">
<IonCardSubtitle>Score</IonCardSubtitle>
<IonNote>{ score }</IonNote>
</IonLabel>
</IonItem>
</IonCardContent>
</IonCard>
</IonCol>
</IonRow>
);

View File

@@ -0,0 +1,17 @@
import { IonCol } from "@ionic/react";
import { updateChosenCategory, updateChosenDifficulty } from "../store/SettingsStore";
import styles from "./Settings.module.scss";
export const Category = ({ label, value, set, chosen }) => (
<IonCol id={ `categoryButton_${ value }` } size="6" className={ `${styles.category} ${chosen && styles.chosen} animate__animated` } onClick={ () => updateChosenCategory(value) }>
<p>{ label }</p>
</IonCol>
);
export const Difficulty = ({ label, value, set, chosen }) => (
<IonCol id={ `difficultyButton_${ value }` } size="4" className={ `${ styles.category } ${ chosen && styles.chosen } animate__animated` } onClick={ () => updateChosenDifficulty(value) }>
<p>{ label }</p>
</IonCol>
);

View File

@@ -0,0 +1,20 @@
.category {
height: 4rem;
border: 5px solid rgb(255, 255, 255);
background-color: #994ec1;
color: white;
border-radius: 10px;
text-align: center;
justify-content: center;
align-content: center;
display: flex;
align-items: center;
font-weight: 700;
}
.chosen {
border: 2px solid #3a1d49;
font-weight: 700;
}

View File

@@ -0,0 +1,33 @@
import { IonRouterOutlet, IonTabs } from '@ionic/react';
import { Route, Redirect } from 'react-router';
import Home from './AppPages/Home';
import Quiz from './AppPages/Quiz';
import Questions from './AppPages/Questions';
import './style.scss';
function DemoQuizApp() {
return (
<IonTabs className="demo-quiz-app">
<IonRouterOutlet>
<Route exact path="/demo-quiz-app/home">
<Home />
</Route>
<Route exact path="/demo-quiz-app/quiz">
<Quiz />
</Route>
<Route exact path="/demo-quiz-app/questions">
<Questions />
</Route>
<Redirect exact path="/demo-quiz-app" to="/demo-quiz-app/home" />
</IonRouterOutlet>
</IonTabs>
);
}
export default DemoQuizApp;

View File

@@ -0,0 +1,11 @@
const API_URL = 'https://quizapi.io/api/v1/questions';
const API_KEY = 'B27jnk1wmfEOQ42FtmrgBogiNTLLhOArJj29y24a';
export const fetchQuestions = async (category, difficulty) => {
const response = await fetch(
`${API_URL}?apiKey=${API_KEY}&category=${category}&difficulty=${difficulty}&limit=10`
);
const questions = await response.json();
return questions;
};

View File

@@ -0,0 +1,10 @@
import { createSelector } from "reselect";
const getState = state => state;
// Getters
export const getCategories = createSelector(getState, state => state.categories);
export const getDifficulties = createSelector(getState, state => state.difficulties);
export const getChosenCategory = createSelector(getState, state => state.chosenCategory);
export const getChosenDifficulty = createSelector(getState, state => state.chosenDifficulty);

View File

@@ -0,0 +1,60 @@
import { Store } from "pullstate";
const SettingsStore = new Store({
categories: [
{
label: "Code",
value: "code",
},
{
label: "Linux",
value: "linux"
},
{
label: "Dev Ops",
value: "devops"
},
{
label: "Authentication",
value: "authentication"
},
{
label: "Bash",
value: "bash"
},
{
label: "SQL",
value: "sql"
}
],
difficulties: [
{
label: "Easy",
value: "easy"
},
{
label: "Medium",
value: "medium"
},
{
label: "Hard",
value: "hard"
}
],
chosenCategory: false,
chosenDifficulty: false
});
export default SettingsStore;
export const updateChosenCategory = category => {
SettingsStore.update(s => { s.chosenCategory = category });
}
export const updateChosenDifficulty = difficulty => {
SettingsStore.update(s => { s.chosenDifficulty = difficulty });
}

View File

@@ -0,0 +1 @@
export { default as SettingsStore } from "./SettingsStore";

File diff suppressed because one or more lines are too long