Update requirement files with new feature templates and fix backend API error message, along with mobile project config updates and documentation improvements

This commit is contained in:
louiscklaw
2025-06-13 12:11:47 +08:00
parent f23a6b7d9c
commit 346992d4ec
3102 changed files with 220182 additions and 2896 deletions

View File

@@ -18,11 +18,11 @@ import { QuoteItem } from '../components/QuoteItem';
import { QuoteStore } from '../store';
import { getQuotes } from '../store/Selectors';
const Home = () => {
const quotes = useStoreState(QuoteStore, getQuotes);
const [amountLoaded, setAmountLoaded] = useState(20);
const Home: React.FC = () => {
const quotes: any = useStoreState(QuoteStore, getQuotes);
const [amountLoaded, setAmountLoaded] = useState<number>(20);
const fetchMore = async (e) => {
const fetchMore = async (e: any) => {
setAmountLoaded((amountLoaded) => amountLoaded + 20);
e.target.complete();
};
@@ -51,11 +51,13 @@ const Home = () => {
<IonList>
<IonRow>
{quotes.map((quote, index) => {
if (index <= amountLoaded && quote.author) {
return <QuoteItem key={index} quote={quote} />;
} else return '';
})}
{quotes.map(
(quote: { id: string | number; text: string; author: string }, index: number) => {
if (index <= amountLoaded && quote.author) {
return <QuoteItem key={index} quote={quote} />;
} else return '';
}
)}
</IonRow>
</IonList>

View File

@@ -1,88 +0,0 @@
import { IonBackButton, IonButton, IonButtons, IonCard, IonCardContent, IonCol, IonContent, IonHeader, IonIcon, IonImg, IonPage, IonRow, IonTitle, IonToolbar, useIonToast } from '@ionic/react';
import { bookmarkOutline, checkmarkOutline, copyOutline } from 'ionicons/icons';
import { useStoreState } from 'pullstate';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { QuoteStore } from '../store';
import { addSavedQuote, removeSavedQuote } from '../store/QuoteStore';
import { getQuote, getSavedQuotes } from '../store/Selectors';
import { Clipboard } from '@capacitor/clipboard';
const Quote = () => {
const { id } = useParams();
const quote = useStoreState(QuoteStore, getQuote(id));
const saved = useStoreState(QuoteStore, getSavedQuotes);
const [ bookmarked, setBookmarked ] = useState(false);
const [ present ] = useIonToast();
useEffect(() => {
setBookmarked(saved.includes(parseInt(id)));
}, [ saved, id ]);
const copyQuote = async () => {
await Clipboard.write({
string: quote.text
});
present({
header: "Success",
message: "Quote copied to clipboard!",
duration: 2500,
color: "primary"
});
}
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton text="Home" />
</IonButtons>
<IonTitle>Quote</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Quote</IonTitle>
</IonToolbar>
</IonHeader>
<IonCard className="animate__animated animate__slideInRight animate__faster">
<IonImg src={ quote.image } alt="quote cover" />
<IonCardContent>
<h1>{ quote.text }</h1>
<p>- { quote.author }</p>
</IonCardContent>
<IonRow>
<IonCol size="6">
<IonButton fill={ !bookmarked ? "outline" : "solid" } onClick={ () => bookmarked ? removeSavedQuote(quote.id) : addSavedQuote(quote.id) }>
<IonIcon icon={ bookmarked ? checkmarkOutline : bookmarkOutline } />
&nbsp;{ bookmarked ? "Bookmarked" : "Save as Bookmark" }
</IonButton>
</IonCol>
<IonCol size="4">
<IonButton fill="outline" onClick={ copyQuote }>
<IonIcon icon={ copyOutline } />
&nbsp;Copy Quote
</IonButton>
</IonCol>
</IonRow>
</IonCard>
</IonContent>
</IonPage>
);
};
export default Quote;

View File

@@ -0,0 +1,121 @@
import {
IonBackButton,
IonButton,
IonButtons,
IonCard,
IonCardContent,
IonCol,
IonContent,
IonHeader,
IonIcon,
IonImg,
IonPage,
IonRow,
IonTitle,
IonToolbar,
useIonToast,
} from '@ionic/react';
import { bookmarkOutline, checkmarkOutline, copyOutline } from 'ionicons/icons';
import { useStoreState } from 'pullstate';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { QuoteStore } from '../store';
import { addSavedQuote, removeSavedQuote } from '../store/QuoteStore';
import { getQuote, getSavedQuotes } from '../store/Selectors';
import { Clipboard } from '@capacitor/clipboard';
const Quote: React.FC = () => {
const { id } = useParams<{ id: string }>();
const quote = useStoreState(QuoteStore, (s) => {
try {
const quotes = getQuote(id);
if (!Array.isArray(quotes)) {
return undefined;
}
return quotes.find((q) => q.id === id);
} catch (error) {
return undefined;
}
});
const saved = useStoreState(QuoteStore, getSavedQuotes);
if (!quote) {
return (
<IonPage>
<IonContent>Quote not found</IonContent>
</IonPage>
);
}
const [bookmarked, setBookmarked] = useState<boolean>(false);
const [present] = useIonToast();
useEffect(() => {
const quoteId = typeof id === 'string' ? parseInt(id) : id;
setBookmarked(saved.some((item) => item.id === quoteId));
}, [saved, id]);
const copyQuote = async () => {
await Clipboard.write({
string: quote.text,
});
present({
header: 'Success',
message: 'Quote copied to clipboard!',
duration: 2500,
color: 'primary',
});
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton text="Home" />
</IonButtons>
<IonTitle>Quote</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Quote</IonTitle>
</IonToolbar>
</IonHeader>
<IonCard className="animate__animated animate__slideInRight animate__faster">
<IonImg src={quote.image} alt="quote cover" />
<IonCardContent>
<h1>{quote.text}</h1>
<p>- {quote.author}</p>
</IonCardContent>
<IonRow>
<IonCol size="6">
<IonButton
fill={!bookmarked ? 'outline' : 'solid'}
onClick={() => (bookmarked ? removeSavedQuote(quote.id) : addSavedQuote(quote.id))}
>
<IonIcon icon={bookmarked ? checkmarkOutline : bookmarkOutline} />
&nbsp;{bookmarked ? 'Bookmarked' : 'Save as Bookmark'}
</IonButton>
</IonCol>
<IonCol size="4">
<IonButton fill="outline" onClick={copyQuote}>
<IonIcon icon={copyOutline} />
&nbsp;Copy Quote
</IonButton>
</IonCol>
</IonRow>
</IonCard>
</IonContent>
</IonPage>
);
};
export default Quote;

View File

@@ -1,74 +0,0 @@
import { IonButtons, IonCol, IonContent, IonGrid, IonHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonList, IonMenuButton, IonPage, IonRow, IonTitle, IonToolbar } from '@ionic/react';
import { useStoreState } from 'pullstate';
import { useState } from 'react';
import { QuoteItem } from '../components/QuoteItem';
import { QuoteStore } from '../store';
import { getQuotes, getSavedQuotes } from '../store/Selectors';
const Saved = () => {
const quotes = useStoreState(QuoteStore, getQuotes);
const saved = useStoreState(QuoteStore, getSavedQuotes);
const [ amountLoaded, setAmountLoaded ] = useState(20);
const fetchMore = async e => {
setAmountLoaded(amountLoaded => amountLoaded + 20);
e.target.complete();
}
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Bookmarks</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Bookmarks</IonTitle>
</IonToolbar>
</IonHeader>
<IonGrid>
{ quotes.length > 0 &&
<IonList>
<IonRow>
{ quotes.map((quote, index) => {
if ((index <= amountLoaded) && saved.includes(parseInt(quote.id))) {
return (
<QuoteItem key={ index } quote={ quote } />
);
} else return "";
})}
<IonInfiniteScroll threshold="200px" onIonInfinite={ fetchMore }>
<IonInfiniteScrollContent loadingSpinner="bubbles" loadingText="Getting more quotes...">
</IonInfiniteScrollContent>
</IonInfiniteScroll>
</IonRow>
</IonList>
}
{ quotes.length < 1 &&
<IonRow>
<IonCol size="12">
<h3>You haven't saved any bookmarks yet.</h3>
</IonCol>
</IonRow>
}
</IonGrid>
</IonContent>
</IonPage>
);
};
export default Saved;

View File

@@ -0,0 +1,87 @@
import {
IonButtons,
IonCol,
IonContent,
IonGrid,
IonHeader,
IonInfiniteScroll,
IonInfiniteScrollContent,
IonList,
IonMenuButton,
IonPage,
IonRow,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { useStoreState } from 'pullstate';
import { useState } from 'react';
import { QuoteItem } from '../components/QuoteItem';
import { QuoteStore } from '../store';
import { getQuotes, getSavedQuotes } from '../store/Selectors';
const Saved: React.FC = () => {
const quotes = useStoreState(QuoteStore, getQuotes);
const saved = useStoreState(QuoteStore, getSavedQuotes);
const [amountLoaded, setAmountLoaded] = useState<number>(20);
const fetchMore = async (e: CustomEvent<void>) => {
setAmountLoaded((amountLoaded) => amountLoaded + 20);
const target = e.target as HTMLIonInfiniteScrollElement;
target.complete();
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Bookmarks</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Bookmarks</IonTitle>
</IonToolbar>
</IonHeader>
<IonGrid>
{quotes.length > 0 && (
<IonList>
<IonRow>
{quotes.map(
(quote: { id: string | number; text: string; author: string }, index: number) => {
const quoteId = typeof quote.id === 'string' ? parseInt(quote.id) : quote.id;
if (index <= amountLoaded && saved.some((item) => item.id === quoteId)) {
return <QuoteItem key={index} quote={quote} />;
} else return '';
}
)}
<IonInfiniteScroll threshold="200px" onIonInfinite={fetchMore}>
<IonInfiniteScrollContent
loadingSpinner="bubbles"
loadingText="Getting more quotes..."
></IonInfiniteScrollContent>
</IonInfiniteScroll>
</IonRow>
</IonList>
)}
{quotes.length < 1 && (
<IonRow>
<IonCol size="12">
<h3>You haven't saved any bookmarks yet.</h3>
</IonCol>
</IonRow>
)}
</IonGrid>
</IonContent>
</IonPage>
);
};
export default Saved;

View File

@@ -18,7 +18,6 @@ import { QuoteStore } from '../store';
import { getSavedQuotes } from '../store/Selectors';
const Menu = () => {
const location = useLocation();
const saved = useStoreState(QuoteStore, getSavedQuotes);
@@ -27,14 +26,14 @@ const Menu = () => {
title: 'Home',
url: '/home',
iosIcon: homeOutline,
mdIcon: homeSharp
mdIcon: homeSharp,
},
{
title: `Bookmarks (${ saved.length })`,
title: `Bookmarks (${saved.length})`,
url: '/saved',
iosIcon: bookmarkOutline,
mdIcon: bookmarkSharp
}
mdIcon: bookmarkSharp,
},
];
return (
@@ -46,7 +45,13 @@ const Menu = () => {
{appPages.map((appPage, index) => {
return (
<IonMenuToggle key={index} autoHide={false}>
<IonItem className={location.pathname === appPage.url ? 'selected' : ''} routerLink={appPage.url} routerDirection="none" lines="none" detail={false}>
<IonItem
className={location.pathname === appPage.url ? 'selected' : ''}
routerLink={appPage.url}
routerDirection="none"
lines="none"
detail={false}
>
<IonIcon slot="start" ios={appPage.iosIcon} md={appPage.mdIcon} />
<IonLabel>{appPage.title}</IonLabel>
</IonItem>

View File

@@ -1,17 +0,0 @@
import { IonCol, IonItem, IonLabel } from "@ionic/react";
import styles from "./QuoteItem.module.css";
export const QuoteItem = ({ quote }) => {
return (
<IonCol size="6" className="animate__animated animate__fadeIn">
<IonItem lines="none" className={ styles.quoteItem } routerLink={ `/quote/${ quote.id }`}>
<IonLabel className={ styles.quoteText }>
<h2>{ quote.text }</h2>
<p>{ quote.author }</p>
</IonLabel>
</IonItem>
</IonCol>
);
}

View File

@@ -0,0 +1,25 @@
import { IonCol, IonItem, IonLabel } from '@ionic/react';
import styles from './QuoteItem.module.css';
interface Quote {
id: string | number;
text: string;
author: string;
}
interface QuoteItemProps {
quote: Quote;
}
export const QuoteItem: React.FC<QuoteItemProps> = ({ quote }) => {
return (
<IonCol size="6" className="animate__animated animate__fadeIn">
<IonItem lines="none" className={styles.quoteItem} routerLink={`/quote/${quote.id}`}>
<IonLabel className={styles.quoteText}>
<h2>{quote.text}</h2>
<p>{quote.author}</p>
</IonLabel>
</IonItem>
</IonCol>
);
};

View File

@@ -0,0 +1,2 @@
export const addSavedQuote: (id: string | number) => void;
export const removeSavedQuote: (id: string | number) => void;

View File

@@ -1,33 +1,34 @@
import { Store } from "pullstate";
import { Store } from 'pullstate';
const QuoteStore = new Store({
quotes: [],
saved: []
quotes: [],
saved: [],
});
export default QuoteStore;
export const addSavedQuote = id => {
export const addSavedQuote = (id) => {
QuoteStore.update((s) => {
s.saved = [...s.saved, id];
});
};
QuoteStore.update(s => { s.saved = [ ...s.saved, id ] });
}
export const removeSavedQuote = id => {
QuoteStore.update(s => { s.saved = s.saved.filter(savedId => parseInt(savedId) !== parseInt(id)) });
}
export const removeSavedQuote = (id) => {
QuoteStore.update((s) => {
s.saved = s.saved.filter((savedId) => parseInt(savedId) !== parseInt(id));
});
};
export const fetchQuotes = async () => {
const response = await fetch('https://type.fit/api/quotes');
const data = await response.json();
const response = await fetch("https://type.fit/api/quotes");
const data = await response.json();
await data.filter((quote, index) => {
quote.id = Date.now() + index;
quote.image = `https://source.unsplash.com/random/1200x400?sig=${quote.id}`;
});
await data.filter((quote, index) => {
quote.id = (Date.now() + index);
quote.image = `https://source.unsplash.com/random/1200x400?sig=${ quote.id }`;
});
QuoteStore.update(s => { s.quotes = data });
}
QuoteStore.update((s) => {
s.quotes = data;
});
};

View File

@@ -0,0 +1,19 @@
export const getQuotes: () => Array<{
id: string | number;
text: string;
author: string;
}>;
export const getQuote: (id: string | number) =>
| {
id: string | number;
text: string;
author: string;
}
| undefined;
export const getSavedQuotes: () => Array<{
id: string | number;
text: string;
author: string;
}>;

View File

@@ -1,10 +1,14 @@
import { createSelector } from 'reselect';
const getState = state => state;
const getState = (state) => state;
// General getters
export const getQuotes = createSelector(getState, state => state.quotes);
export const getSavedQuotes = createSelector(getState, state => state.saved);
export const getQuotes = createSelector(getState, (state) => state.quotes);
export const getSavedQuotes = createSelector(getState, (state) => state.saved);
// Specific getters
export const getQuote = id => createSelector(getState, state => state.quotes.filter(q => parseInt(q.id) === parseInt(id))[0]);
export const getQuote = (id) =>
createSelector(
getState,
(state) => state.quotes.filter((q) => parseInt(q.id) === parseInt(id))[0]
);

View File

@@ -0,0 +1,9 @@
import { Store } from 'pullstate';
interface IQuoteStore {
savedQuotes: Array<string | number>;
isQuoteSaved: (id: string | number) => boolean;
}
export const QuoteStore: Store<IQuoteStore>;
export default QuoteStore;

View File

@@ -1 +1 @@
export { default as QuoteStore } from "./QuoteStore";
export { default as QuoteStore } from './QuoteStore';