Compare commits

...

2 Commits

Author SHA1 Message Date
louiscklaw
bc731ea2b8 update demo-react-notes, 2025-06-08 19:07:48 +08:00
louiscklaw
c11dba6297 update demo-react-movie-app-with-algolia, 2025-06-08 19:07:38 +08:00
28 changed files with 1369 additions and 231 deletions

View File

@@ -0,0 +1,39 @@
import { IonCardSubtitle, IonCardTitle, IonCol, IonInfiniteScroll, IonInfiniteScrollContent, IonList, IonRow } from "@ionic/react";
import { connectInfiniteHits } from "react-instantsearch-core";
import CustomSearchHit from "./CustomSearchHit";
const CustomInfiniteHits = ({ hits, hasMore, refineNext }) => {
const getMore = (e, refine) => {
refine();
e.target.complete();
}
return (
<div className="ais-InfiniteHits">
<IonList className="ais-InfiniteHits-list">
<div className="ais-Hits__root">
<IonRow>
{ hits.map(hit => <CustomSearchHit key={ hit.objectID } hit={ hit } />) }
{ hits.length < 1 &&
<IonCol size="12" className="ion-text-center ion-padding-top ion-margin-top">
<IonCardTitle>No results found</IonCardTitle>
<IonCardSubtitle>Try something else</IonCardSubtitle>
</IonCol>
}
</IonRow>
<IonInfiniteScroll threshold="100px" onIonInfinite={ e => getMore(e, refineNext) }>
<IonInfiniteScrollContent loadingSpinner="bubbles" loadingText="Getting more movies..." />
</IonInfiniteScroll>
</div>
</IonList>
</div>
);
}
export default connectInfiniteHits(CustomInfiniteHits);

View File

@@ -0,0 +1,48 @@
import {
IonCardSubtitle,
IonCardTitle,
IonCol,
IonInfiniteScroll,
IonInfiniteScrollContent,
IonList,
IonRow,
} from '@ionic/react';
import { connectInfiniteHits } from 'react-instantsearch-core';
import CustomSearchHit from './CustomSearchHit';
const CustomInfiniteHits = ({ hits, hasMore, refineNext }): React.JSX.Element => {
const getMore = (e, refine) => {
refine();
e.target.complete();
};
return (
<div className="ais-InfiniteHits">
<IonList className="ais-InfiniteHits-list">
<div className="ais-Hits__root">
<IonRow>
{hits.map((hit) => (
<CustomSearchHit key={hit.objectID} hit={hit} />
))}
{hits.length < 1 && (
<IonCol size="12" className="ion-text-center ion-padding-top ion-margin-top">
<IonCardTitle>No results found</IonCardTitle>
<IonCardSubtitle>Try something else</IonCardSubtitle>
</IonCol>
)}
</IonRow>
<IonInfiniteScroll threshold="100px" onIonInfinite={(e) => getMore(e, refineNext)}>
<IonInfiniteScrollContent
loadingSpinner="bubbles"
loadingText="Getting more movies..."
/>
</IonInfiniteScroll>
</div>
</IonList>
</div>
);
};
export default connectInfiniteHits(CustomInfiniteHits);

View File

@@ -0,0 +1,29 @@
import { IonCol, IonRouterLink } from "@ionic/react";
import { Link } from "react-router-dom";
import styles from "../pages/Movies.module.scss";
const CustomSearchHit = ({ hit }) => {
// Any movies without an image, lets just exclude them
// So the UI looks nicer.
// When I scraped the data, I put a placeholder URL image in
// But after more thought, it looks cleaner without including these.
if (hit && hit.image !== "https://critics.io/img/movies/poster-placeholder.png" && hit.backdrop_path !== null) {
return (
<IonCol size="6" className={ styles.movie }>
<Link to={{ pathname: `/movie/${ hit.objectID }`, state: { movie: hit } }} className="non-link">
<div className={ styles.movieInfo }>
<img src={ hit.image } alt="movie poster" />
<h2>{ hit.title }</h2>
</div>
</Link>
</IonCol>
);
} else {
return null;
}
}
export default CustomSearchHit;

View File

@@ -0,0 +1,33 @@
import { IonCol, IonRouterLink } from '@ionic/react';
import { Link } from 'react-router-dom';
import styles from '../pages/Movies.module.scss';
const CustomSearchHit = ({ hit }): React.JSX.Element | null => {
// Any movies without an image, lets just exclude them
// So the UI looks nicer.
// When I scraped the data, I put a placeholder URL image in
// But after more thought, it looks cleaner without including these.
if (
hit &&
hit.image !== 'https://critics.io/img/movies/poster-placeholder.png' &&
hit.backdrop_path !== null
) {
return (
<IonCol size="6" className={styles.movie}>
<Link
to={{ pathname: `/movie/${hit.objectID}`, state: { movie: hit } }}
className="non-link"
>
<div className={styles.movieInfo}>
<img src={hit.image} alt="movie poster" />
<h2>{hit.title}</h2>
</div>
</Link>
</IonCol>
);
} else {
return null;
}
};
export default CustomSearchHit;

View File

@@ -0,0 +1,9 @@
import { IonSearchbar } from "@ionic/react";
import { searchCircleOutline } from "ionicons/icons";
import { connectSearchBox } from "react-instantsearch-core";
const CustomSearchBox = ({ currentRefinement, refine }) => (
<IonSearchbar animated={ true } onIonCancel={ () => refine('') } value={ currentRefinement } onIonChange={ event => refine(event.currentTarget.value) } onKeyUp={ event => refine(event.currentTarget.value) } placeholder="Try 'Avengers'" icon={ searchCircleOutline } slot="end" />
);
export default connectSearchBox(CustomSearchBox);

View File

@@ -0,0 +1,18 @@
import { IonSearchbar } from '@ionic/react';
import { searchCircleOutline } from 'ionicons/icons';
import { connectSearchBox } from 'react-instantsearch-core';
const CustomSearchBox = ({ currentRefinement, refine }): React.JSX.Element => (
<IonSearchbar
animated={true}
onIonCancel={() => refine('')}
value={currentRefinement}
onIonChange={(event) => refine(event.currentTarget.value)}
onKeyUp={(event) => refine(event.currentTarget.value)}
placeholder="Try 'Avengers'"
icon={searchCircleOutline}
slot="end"
/>
);
export default connectSearchBox(CustomSearchBox);

View File

@@ -6,7 +6,8 @@ import { Route, Redirect } from 'react-router';
import Tab1 from './AppPages/Tab1';
import Tab2 from './AppPages/Tab2';
import './style.scss';
// import './style.scss';
import './theme/variables.scss';
function DemoReactMovieAppWithAlgolia() {
return (
@@ -19,7 +20,11 @@ function DemoReactMovieAppWithAlgolia() {
<Tab2 />
</Route>
<Redirect exact path="/demo-react-movie-app-with-algolia" to="/demo-react-movie-app-with-algolia/tab1" />
<Redirect
exact
path="/demo-react-movie-app-with-algolia"
to="/demo-react-movie-app-with-algolia/tab1"
/>
</IonRouterOutlet>
{/* */}

View File

@@ -0,0 +1,86 @@
import { IonBackButton, IonBadge, IonButton, IonCardSubtitle, IonCardTitle, IonCol, IonContent, IonGrid, IonHeader, IonPage, IonRow } from '@ionic/react';
import styles from './Movie.module.scss';
import { useEffect, useState } from 'react';
import { arrowBack } from 'ionicons/icons';
import { useLocation, useParams } from 'react-router';
const Movie = () => {
const params = useParams();
const location = useLocation();
const [ movie, setMovie ] = useState(false);
useEffect(() => {
if (location.state.movie) {
const backdropPath = `https://image.tmdb.org/t/p/w500${ location.state.movie.backdrop_path }`;
location.state.movie.cover_image = backdropPath;
setMovie(location.state.movie);
}
}, [ params ]);
const parseDate = dateToParse => {
const dateParts = dateToParse.split("-");
return `${ dateParts[2] }-${ dateParts[1] }-${ dateParts[0] }`;
}
return (
<IonPage className={ styles.page }>
<IonHeader>
<img src={ movie.cover_image } alt="movie cover" />
<IonBackButton color="light" className={ styles.backButton } text="&nbsp;&nbsp;Back to search" icon={ arrowBack } />
</IonHeader>
<IonContent fullscreen>
<IonGrid>
<IonRow>
<IonCol size="5">
<img src={ movie.image } />
</IonCol>
<IonCol size="7" className={ styles.movieInfo }>
<IonCardSubtitle>Title</IonCardSubtitle>
<IonCardTitle>{ movie.title }</IonCardTitle>
<IonRow className="ion-justify-content-center ion-margin-top">
<IonCol size="6">
<IonCardSubtitle>Popularity</IonCardSubtitle>
<IonBadge className={ styles.infoBadge }>{ movie.popularity ? movie.popularity : "Unknown" }</IonBadge>
</IonCol>
<IonCol size="6">
<IonCardSubtitle>Release Date</IonCardSubtitle>
<IonBadge className={ styles.infoBadge }>{ movie.release_date ? parseDate(movie.release_date) : "Unknown" }</IonBadge>
</IonCol>
</IonRow>
<IonRow>
<IonCol size="12">
<IonCardSubtitle>Search for Youtube Trailer</IonCardSubtitle>
<a href={ `https://www.youtube.com/results?search_query=${ movie.title } trailer` } rel="noopener" target="_new">
<IonButton>
Search now
</IonButton>
</a>
</IonCol>
</IonRow>
</IonCol>
</IonRow>
<IonRow className={ styles.movieOverview }>
<IonCol size="12">
<IonCardSubtitle>Overview</IonCardSubtitle>
<p>{ movie.overview ? movie.overview : `No overview to show unfortunately. Try doing a google search for '${ movie.title }.` }</p>
</IonCol>
</IonRow>
</IonGrid>
</IonContent>
</IonPage>
);
};
export default Movie;

View File

@@ -0,0 +1,54 @@
.page {
ion-header {
background-color: #373b5e;
}
ion-toolbar {
--border-style: none;
--background: #373b5e;
--color: white;
--min-height: 8rem;
--stripe: #373b5e;
--bg: #3e4368;
--background: transparent;
background: linear-gradient(135deg, var(--bg) 25%, transparent 25%) -50px 0,
linear-gradient(225deg, var(--bg) 25%, transparent 25%) -50px 0,
linear-gradient(315deg, var(--bg) 25%, transparent 25%),
linear-gradient(45deg, var(--bg) 25%, transparent 25%);
background-size: 100px 100px;
background-color: var(--stripe);
ion-title {
--color: white;
font-size: 1rem;
}
}
}
.movieOverview {
background-color:#373b5e;
padding: 2rem;
color: white;
ion-card-subtitle {
--color: white;
}
}
.backButton {
padding: 0;
margin: 0;
padding-bottom: 0.4rem;
}
.infoBadge {
background-color:rgba(44, 53, 105, 0.8);
}

View File

@@ -0,0 +1,109 @@
import {
IonBackButton,
IonBadge,
IonButton,
IonCardSubtitle,
IonCardTitle,
IonCol,
IonContent,
IonGrid,
IonHeader,
IonPage,
IonRow,
} from '@ionic/react';
import styles from './Movie.module.scss';
import { useEffect, useState } from 'react';
import { arrowBack } from 'ionicons/icons';
import { useLocation, useParams } from 'react-router';
const Movie = (): React.JSX.Element => {
const params = useParams();
const location = useLocation();
const [movie, setMovie] = useState(false);
useEffect(() => {
if (location.state.movie) {
const backdropPath = `https://image.tmdb.org/t/p/w500${location.state.movie.backdrop_path}`;
location.state.movie.cover_image = backdropPath;
setMovie(location.state.movie);
}
}, [params]);
const parseDate = (dateToParse) => {
const dateParts = dateToParse.split('-');
return `${dateParts[2]}-${dateParts[1]}-${dateParts[0]}`;
};
return (
<IonPage className={styles.page}>
<IonHeader>
<img src={movie.cover_image} alt="movie cover" />
<IonBackButton
color="light"
className={styles.backButton}
text="&nbsp;&nbsp;Back to search"
icon={arrowBack}
/>
</IonHeader>
<IonContent fullscreen>
<IonGrid>
<IonRow>
<IonCol size="5">
<img src={movie.image} />
</IonCol>
<IonCol size="7" className={styles.movieInfo}>
<IonCardSubtitle>Title</IonCardSubtitle>
<IonCardTitle>{movie.title}</IonCardTitle>
<IonRow className="ion-justify-content-center ion-margin-top">
<IonCol size="6">
<IonCardSubtitle>Popularity</IonCardSubtitle>
<IonBadge className={styles.infoBadge}>
{movie.popularity ? movie.popularity : 'Unknown'}
</IonBadge>
</IonCol>
<IonCol size="6">
<IonCardSubtitle>Release Date</IonCardSubtitle>
<IonBadge className={styles.infoBadge}>
{movie.release_date ? parseDate(movie.release_date) : 'Unknown'}
</IonBadge>
</IonCol>
</IonRow>
<IonRow>
<IonCol size="12">
<IonCardSubtitle>Search for Youtube Trailer</IonCardSubtitle>
<a
href={`https://www.youtube.com/results?search_query=${movie.title} trailer`}
rel="noopener"
target="_new"
>
<IonButton>Search now</IonButton>
</a>
</IonCol>
</IonRow>
</IonCol>
</IonRow>
<IonRow className={styles.movieOverview}>
<IonCol size="12">
<IonCardSubtitle>Overview</IonCardSubtitle>
<p>
{movie.overview
? movie.overview
: `No overview to show unfortunately. Try doing a google search for '${movie.title}.`}
</p>
</IonCol>
</IonRow>
</IonGrid>
</IonContent>
</IonPage>
);
};
export default Movie;

View File

@@ -0,0 +1,94 @@
import { IonButton, IonCardSubtitle, IonCardTitle, IonContent, IonHeader, IonIcon, IonPage, IonToolbar } from '@ionic/react';
import styles from './Movies.module.scss';
// Algolia imports
import algoliasearch from 'algoliasearch/lite';
import { InstantSearch } from 'react-instantsearch-dom';
// Custom Algolia UI
import CustomSearchbox from '../components/CustomSearchbox';
import CustomInfiniteHits from '../components/CustomInfiniteHits';
import { useEffect } from 'react';
import { addOutline } from 'ionicons/icons';
const searchClient = algoliasearch('QZKBD6VPU7', 'db21b77f5f3bf4d4cbde385b7f33c60d');
const Movies = props => {
// PWA functionality for a custom add to homescreen
// This allows us to display a custom button based on service worker
useEffect(() => {
const buttInstall = document.getElementById('buttInstall');
window.addEventListener('beforeinstallprompt', (event) => {
console.log('👍', 'beforeinstallprompt', event);
// Save the event so it can be triggered later.
window.deferredPrompt = event;
buttInstall.classList.toggle('hidden', false);
});
window.addEventListener('appinstalled', (event) => {
console.log('👍', 'appinstalled', event);
// Clear the deferredPrompt so it can be garbage collected
window.deferredPrompt = null;
});
}, []);
const addToHomeScreen = async () => {
const buttInstall = document.getElementById('buttInstall');
console.log('👍', 'buttInstall-clicked');
const promptEvent = window.deferredPrompt;
if (!promptEvent) {
// The deferred prompt isn't available.
return;
}
// Show the install prompt.
promptEvent.prompt();
// Log the result
const result = await promptEvent.userChoice;
console.log('👍', 'userChoice', result);
// Reset the deferred prompt variable, since
// prompt() can only be called once.
window.deferredPrompt = null;
// Hide the install button.
buttInstall.classList.toggle('hidden', true);
}
return (
<IonPage className={ styles.page }>
<InstantSearch searchClient={ searchClient } indexName="dev_movies">
<IonHeader>
<IonToolbar>
<div className={ styles.searchContainer }>
<IonCardTitle>Movie List</IonCardTitle>
<IonCardSubtitle>with algolia search</IonCardSubtitle>
<CustomSearchbox />
<IonButton id="buttInstall" color="success" fill="solid" size="small" className="hidden add-button" onClick={ () => addToHomeScreen() }>
<IonIcon icon={ addOutline } />&nbsp;&nbsp;Install App
</IonButton>
</div>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<CustomInfiniteHits />
</IonContent>
</InstantSearch>
</IonPage>
);
};
export default Movies;

View File

@@ -0,0 +1,133 @@
.page {
ion-header {
background-color: #373b5e;
}
ion-toolbar {
--border-style: none;
--background: #373b5e;
--color: white;
--min-height: 8rem;
--stripe: #373b5e;
--bg: #3e4368;
--background: transparent;
background: linear-gradient(135deg, var(--bg) 25%, transparent 25%) -50px 0,
linear-gradient(225deg, var(--bg) 25%, transparent 25%) -50px 0,
linear-gradient(315deg, var(--bg) 25%, transparent 25%),
linear-gradient(45deg, var(--bg) 25%, transparent 25%);
background-size: 100px 100px;
background-color: var(--stripe);
ion-button {
font-size: 1.2rem;
font-weight: 800;
}
.searchContainer {
display: flex;
flex-direction: column;
color: white;
align-content: center;
align-items: center;
ion-card-title,
ion-card-subtitle {
padding: 0;
margin: 0;
}
ion-card-title {
font-size: 1.5rem;
color: white;
}
ion-card-subtitle {
color: rgb(189, 189, 189);
}
}
}
ion-searchbar {
--border-radius: 10px;
--background: white;
--color: black;
padding: 0.5rem !important;
margin: 0 !important;
--background: #2a2d44;
--color: white;
--icon-color: white;
--clear-button-color: white;
}
ion-searchbar {
input{
font-size: 1rem !important;
}
}
ion-list {
background-color: #e7edfb;
}
}
.movie {
.movieInfo {
text-align: center;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-end;
img {
height: 75vmin;
}
h2 {
position: absolute;
z-index: 10;
background-color: red;
width: 95%;
background-color: rgba(44, 53, 105, 0.8);
color: white;
font-size: 4vmin;
padding: 1rem;
min-height: 4.5rem;
display: flex;
flex-direction: row;
justify-content: center;
align-content: center;
align-items: center;
max-height: 4.5rem;
margin-bottom: 0;
text-overflow: ellipsis;
}
}
}
ion-button {
--background: #5a55ca;
--background-focused: #6f6bbb;
--background-activated: #6f6bbb;
--padding-top: 1rem;
--padding-bottom: 1rem;
--padding-start: 0.75rem;
--padding-end: 0.75rem;
margin-top: -0.2rem;
}

View File

@@ -0,0 +1,104 @@
import {
IonButton,
IonCardSubtitle,
IonCardTitle,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonToolbar,
} from '@ionic/react';
import styles from './Movies.module.scss';
// Algolia imports
import algoliasearch from 'algoliasearch/lite';
import { InstantSearch } from 'react-instantsearch-dom';
// Custom Algolia UI
import CustomSearchbox from '../components/CustomSearchbox';
import CustomInfiniteHits from '../components/CustomInfiniteHits';
import { useEffect } from 'react';
import { addOutline } from 'ionicons/icons';
const searchClient = algoliasearch('QZKBD6VPU7', 'db21b77f5f3bf4d4cbde385b7f33c60d');
const Movies = (props: any): React.JSX.Element => {
// PWA functionality for a custom add to homescreen
// This allows us to display a custom button based on service worker
useEffect(() => {
const buttInstall = document.getElementById('buttInstall');
window.addEventListener('beforeinstallprompt', (event) => {
console.log('👍', 'beforeinstallprompt', event);
// Save the event so it can be triggered later.
window.deferredPrompt = event;
buttInstall.classList.toggle('hidden', false);
});
window.addEventListener('appinstalled', (event) => {
console.log('👍', 'appinstalled', event);
// Clear the deferredPrompt so it can be garbage collected
window.deferredPrompt = null;
});
}, []);
const addToHomeScreen = async () => {
const buttInstall = document.getElementById('buttInstall');
console.log('👍', 'buttInstall-clicked');
const promptEvent = window.deferredPrompt;
if (!promptEvent) {
// The deferred prompt isn't available.
return;
}
// Show the install prompt.
promptEvent.prompt();
// Log the result
const result = await promptEvent.userChoice;
console.log('👍', 'userChoice', result);
// Reset the deferred prompt variable, since
// prompt() can only be called once.
window.deferredPrompt = null;
// Hide the install button.
buttInstall.classList.toggle('hidden', true);
};
return (
<IonPage className={styles.page}>
<InstantSearch searchClient={searchClient} indexName="dev_movies">
<IonHeader>
<IonToolbar>
<div className={styles.searchContainer}>
<IonCardTitle>Movie List</IonCardTitle>
<IonCardSubtitle>with algolia search</IonCardSubtitle>
<CustomSearchbox />
<IonButton
id="buttInstall"
color="success"
fill="solid"
size="small"
className="hidden add-button"
onClick={() => addToHomeScreen()}
>
<IonIcon icon={addOutline} />
&nbsp;&nbsp;Install App
</IonButton>
</div>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<CustomInfiniteHits />
</IonContent>
</InstantSearch>
</IonPage>
);
};
export default Movies;

View File

@@ -1,103 +0,0 @@
#about-page {
ion-toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
--background: transparent;
--color: white;
}
ion-toolbar ion-back-button,
ion-toolbar ion-button,
ion-toolbar ion-menu-button {
--color: white;
}
.about-header {
position: relative;
width: 100%;
height: 30%;
}
.about-header .about-image {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
opacity: 0;
transition: opacity 500ms ease-in-out;
}
.about-header .madison {
background-image: url('/assets/WeatherDemo/img/about/madison.jpg');
}
.about-header .austin {
background-image: url('/assets/WeatherDemo/img/about/austin.jpg');
}
.about-header .chicago {
background-image: url('/assets/WeatherDemo/img/about/chicago.jpg');
}
.about-header .seattle {
background-image: url('/assets/WeatherDemo/img/about/seattle.jpg');
}
.about-info {
position: relative;
margin-top: -10px;
border-radius: 10px;
background: var(--ion-background-color, #fff);
z-index: 2; // display rounded border above header image
}
.about-info h3 {
margin-top: 0;
}
.about-info ion-list {
padding-top: 0;
}
.about-info p {
line-height: 130%;
color: var(--ion-color-dark);
}
.about-info ion-icon {
margin-inline-end: 32px;
}
/*
* iOS Only
*/
.ios .about-info {
--ion-padding: 19px;
}
.ios .about-info h3 {
font-weight: 700;
}
}
#date-input-popover {
--offset-y: -var(--ion-safe-area-bottom);
--max-width: 90%;
--width: 336px;
}

View File

@@ -0,0 +1,24 @@
.container {
text-align: center;
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.container strong {
font-size: 20px;
line-height: 26px;
}
.container p {
font-size: 16px;
line-height: 22px;
color: #8c8c8c;
margin: 0;
}
.container a {
text-decoration: none;
}

View File

@@ -0,0 +1,21 @@
import './ExploreContainer.css';
const ExploreContainer = (): React.JSX.Element => {
return (
<div className="container">
<strong>Ready to create an app?</strong>
<p>
Start with Ionic{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://ionicframework.com/docs/components"
>
UI Components
</a>
</p>
</div>
);
};
export default ExploreContainer;

View File

@@ -1,38 +1,27 @@
import { IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs } from '@ionic/react';
import { IonRouterOutlet, IonTabs } from '@ionic/react';
import { cloudOutline, searchOutline } from 'ionicons/icons';
import { Route, Redirect } from 'react-router';
import Tab1 from './AppPages/Tab1';
import Tab2 from './AppPages/Tab2';
// import Tab1 from './AppPages/Tab1';
// import Tab2 from './AppPages/Tab2';
import './style.scss';
import './theme/variables.scss';
import Home from './pages/Home';
import Add from './pages/Add';
function DemoReactNotes() {
return (
<IonTabs>
<IonTabs className="demo-react-notes">
<IonRouterOutlet>
<Route exact path="/demo-react-notes/tab1">
<Tab1 />
<Route exact path="/demo-react-notes/home">
<Home />
</Route>
<Route exact path="/demo-react-notes/tab2">
<Tab2 />
<Route exact path="/demo-react-notes/add">
<Add />
</Route>
<Redirect exact path="/demo-react-notes" to="/demo-react-notes/tab1" />
<Redirect exact path="/demo-react-notes" to="/demo-react-notes/home" />
</IonRouterOutlet>
{/* */}
<IonTabBar slot="bottom">
<IonTabButton tab="tab1" href="/demo-react-notes/tab1">
<IonIcon icon={cloudOutline} />
<IonLabel>Dashboard</IonLabel>
</IonTabButton>
<IonTabButton tab="tab2" href="/demo-react-notes/tab2">
<IonIcon icon={searchOutline} />
<IonLabel>Search</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
);
}

View File

@@ -0,0 +1,29 @@
.title {
margin-top: 0.35rem;
}
.customInput {
border-radius: 22px !important;
--padding-bottom: 1rem;
--padding-top: 1rem;
box-shadow: 0 0.7px 0.9px rgba(0, 0, 0, 0.101),
0 0.9px 2.5px rgba(0, 0, 0, 0.145),
0 2.5px 6px rgba(0, 0, 0, 0.189),
0 8px 20px rgba(0, 0, 0, 0.29);
}
.customInput {
margin-bottom: 1rem !important;
}
.saveButton {
--border-radius: 12px !important;
--padding-top: 1.75rem !important;
--padding-bottom: 1.75rem !important;
}

View File

@@ -0,0 +1,115 @@
import {
IonBackButton,
IonButton,
IonButtons,
IonCol,
IonContent,
IonGrid,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonPage,
IonRow,
IonSelect,
IonSelectOption,
IonTextarea,
IonTitle,
IonToolbar,
useIonRouter,
} from '@ionic/react';
import styles from './Add.module.scss';
import { checkmarkOutline } from 'ionicons/icons';
import { getCategories, getNotes } from '../store/Selectors';
import { CategoryStore, NoteStore } from '../store';
import { addNote } from '../store/NoteStore';
import { useState } from 'react';
const Add = (): React.JSX.Element => {
const categories = CategoryStore.useState(getCategories);
const notes = NoteStore.useState(getNotes);
const [noteCategory, setNoteCategory] = useState<number | false>(false);
const [noteContent, setNoteContent] = useState<string>('');
const router = useIonRouter();
const add = () => {
const note = {
id: notes.length + 1,
category_id: noteCategory,
note: noteContent,
complete: false,
};
addNote(note);
router.goBack();
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton />
</IonButtons>
<IonTitle className={styles.title}>Add note</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonGrid className="ion-padding-top">
<IonRow>
<IonCol size="12" className="ion-padding-start">
<h1 className={styles.mainTitle}>Add a note</h1>
</IonCol>
</IonRow>
<IonRow>
<IonCol size="12">
<IonItem lines="none" className={styles.customInput}>
<IonLabel position="floating">Category</IonLabel>
<IonSelect
placeholder="Select..."
value={noteCategory}
onIonChange={(e) => setNoteCategory(e.target.value)}
>
{categories.map((category) => {
return (
<IonSelectOption value={category.id} key={category.id}>
{category.name}
</IonSelectOption>
);
})}
</IonSelect>
</IonItem>
</IonCol>
</IonRow>
<IonRow>
<IonCol size="12">
<IonItem lines="none" className={styles.customInput}>
<IonLabel position="floating">Note</IonLabel>
<IonTextarea
value={noteContent}
onIonChange={(e) => setNoteContent(e.target.value)}
placeholder="Enter note text here..."
/>
</IonItem>
</IonCol>
</IonRow>
<IonRow>
<IonCol size="12">
<IonButton expand="block" className={styles.saveButton} onClick={add}>
<IonIcon icon={checkmarkOutline} />
Save note
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
</IonContent>
</IonPage>
);
};
export default Add;

View File

@@ -0,0 +1,130 @@
.heading {
color: var(--lighter-blue-color);
font-size: 1rem;
}
.mainTitle {
font-size: 2rem;
font-weight: 700;
}
.slideHeader {
margin: 0 !important;
padding: 0;
padding: 1.5rem !important;
padding-top: 0 !important;
}
.slideCount {
margin: 0 !important;
padding: 0 !important;
padding-left: 1.5rem !important;
padding-top: 1rem !important;
}
.slideCount h6 {
color: var(--light-blue-color);
}
.slideHeader h4 {
color: white;
margin: 0 !important;
padding: 0 !important;
text-align: left;
font-size: 1.75rem;
}
.categorySlider {
margin-top: -1.6rem;
ion-slide {
width: 60%;
margin-right: 30px;
}
ion-col {
padding-left: 0;
ion-card {
width: 100%;
border-radius: 22px;
// box-shadow: 0px 4px 12px 2px rgba(0, 0, 0, 0.16);
box-shadow: 0 0.7px 0.9px rgba(0, 0, 0, 0.101),
0 1.9px 2.5px rgba(0, 0, 0, 0.145),
0 4.5px 6px rgba(0, 0, 0, 0.189),
0 15px 20px rgba(0, 0, 0, 0.29);
ion-card-header {
text-align: left !important;
}
ion-card-content {
padding: 0;
padding: 0.5rem;
}
}
}
.lastUsed {
display: flex;
flex-direction: row;
align-content: flex-start;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: white;
}
.categoryColor {
border-bottom: 3px solid var(--pink-color);
margin-left: 1rem;
margin-right: 1rem;
margin-bottom: 0.5rem;
margin-top: -1rem;
}
}
.recentNotes {
padding-left: 0.5rem;
padding-right: 0.5rem;
max-height: 18.8rem;
overflow: scroll;
ion-item {
--padding-start: 2rem;
--padding-end: 2rem;
--border-style: none;
--border-radius: 22px;
--min-height: 4rem;
h4 {
padding-left: 1rem;
margin-top: 0.6rem;
font-size: 1.2rem;
font-weight: 400;
}
}
}
.bottomContainer {
margin-top: -1rem;
}

View File

@@ -0,0 +1,166 @@
import {
IonButton,
IonButtons,
IonCard,
IonCardContent,
IonCardHeader,
IonCardSubtitle,
IonCheckbox,
IonCol,
IonContent,
IonFab,
IonFabButton,
IonGrid,
IonHeader,
IonIcon,
IonItem,
IonPage,
IonRow,
// IonSlide,
// IonSlides,
IonToolbar,
} from '@ionic/react';
import styles from './Home.module.scss';
import { addOutline, menuOutline, notificationsOutline, searchOutline } from 'ionicons/icons';
import { getCategories, getNotes } from '../store/Selectors';
import { CategoryStore, NoteStore } from '../store';
import { markNote } from '../store/NoteStore';
const Home = (): React.JSX.Element => {
return <>TODO: need update IonSlide</>;
const categories = CategoryStore.useState(getCategories);
const notes = NoteStore.useState(getNotes);
const getNoteStyle = (categoryID, isComplete = false) => {
const categoryColor = categories.filter((category) => category.id === categoryID)[0].color;
return {
'--background': categoryColor,
'--background-checked': categoryColor,
'--border-style': 'none',
opacity: isComplete ? '0.6' : '1',
};
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonButton>
<IonIcon icon={menuOutline} />
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton>
<IonIcon icon={searchOutline} />
</IonButton>
<IonButton>
<IonIcon icon={notificationsOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonGrid>
<IonRow>
<IonCol size="12" className="ion-padding-start">
<h1 className={styles.mainTitle}>Hello, Alan!</h1>
</IonCol>
</IonRow>
<IonRow>
<IonCol size="12" className="ion-padding-start ion-padding-top">
<IonCardSubtitle className={styles.heading}>Categories</IonCardSubtitle>
</IonCol>
</IonRow>
</IonGrid>
<IonSlides
id="slider"
options={{ slidesPerView: 'auto', zoom: true, grabCursor: true }}
className={`${styles.categorySlider} ion-padding-bottom`}
>
{categories.map((category, index) => {
const noteCount = notes.filter((n) => n.category_id === category.id).length;
return (
<IonSlide key={`categorySlide_${index}`}>
<IonCol className="ion-text-left">
<IonCard>
<IonCardHeader className="ion-no-padding">
<div className={styles.slideCount}>
<h6>
{noteCount} {noteCount === 1 ? 'note' : 'notes'}{' '}
</h6>
</div>
<div className={styles.slideHeader}>
<h4>{category.name}</h4>
</div>
</IonCardHeader>
<IonCardContent>
<div
className={styles.categoryColor}
style={{ borderBottom: `2px solid ${category.color}` }}
></div>
</IonCardContent>
</IonCard>
</IonCol>
</IonSlide>
);
})}
</IonSlides>
<IonGrid className={styles.bottomContainer}>
<IonRow>
<IonCol size="12" className="ion-padding-start">
<IonCardSubtitle className={styles.heading}>Recent Notes</IonCardSubtitle>
</IonCol>
</IonRow>
<div className={styles.recentNotes}>
{notes.map((note, index) => {
return (
<IonRow
key={`note_${index}`}
className="animate__animated animate__faster"
id={`noteRow_${note.id}`}
>
<IonCol size="12">
<IonItem>
<IonCheckbox
checked={note.complete}
style={getNoteStyle(note.category_id, note.complete)}
onClick={() => markNote(note.id)}
/>
<h4
style={
note.complete ? { textDecoration: 'line-through', opacity: '0.6' } : {}
}
>
{note.note}
</h4>
</IonItem>
</IonCol>
</IonRow>
);
})}
</div>
</IonGrid>
<IonFab vertical="bottom" horizontal="end" slot="fixed" className="ion-padding">
<IonFabButton routerLink="/add">
<IonIcon icon={addOutline} />
</IonFabButton>
</IonFab>
</IonContent>
</IonPage>
);
};
export default Home;

View File

@@ -0,0 +1,33 @@
import { Store } from 'pullstate';
interface Category {
id: number;
name: string;
count: string;
color: string;
}
const CategoryStore = new Store({
categories: [
{
id: 1,
name: 'Business',
count: '34',
color: '#60b660',
},
{
id: 2,
name: 'Personal',
count: '12',
color: '#1D68DF',
},
{
id: 3,
name: 'Leisure',
count: '23',
color: '#EB06FF',
},
] as Category[],
});
export default CategoryStore;

View File

@@ -0,0 +1,58 @@
import { Store } from 'pullstate';
interface Note {
id: number;
category_id: number;
note: string;
complete: boolean;
}
const NoteStore = new Store({
notes: [
{
id: 1,
category_id: 1,
note: 'Daily meeting with team',
complete: false,
},
{
id: 2,
category_id: 2,
note: 'Pay monthly rent',
complete: true,
},
{
id: 3,
category_id: 3,
note: 'Workout in the gym',
complete: false,
},
{
id: 4,
category_id: 1,
note: 'Make progress on project',
complete: false,
},
] as Note[],
});
export const markNote = (noteID: number) => {
const noteIndex = NoteStore.currentState.notes.findIndex((n) => n.id === noteID);
NoteStore.update((s) => {
s.notes[noteIndex].complete = !s.notes[noteIndex].complete;
});
document.getElementById(`noteRow_${noteID}`).classList.add('animate__pulse');
setTimeout(() => {
document.getElementById(`noteRow_${noteID}`).classList.remove('animate__pulse');
}, 500);
};
export const addNote = (note: Note) => {
NoteStore.update((s) => {
s.notes = [note, ...s.notes];
});
};
export default NoteStore;

View File

@@ -0,0 +1,16 @@
import { createSelector } from 'reselect';
import { Category, Note } from './NoteStore';
interface State {
categories: Category[];
notes: Note[];
}
const getState = (state: State) => state;
// General getters
export const getCategories = createSelector(getState, (state) => state.categories);
export const getNotes = createSelector(getState, (state) => state.notes);
// More specific getters
// export const getCoffee = (id: number) => createSelector(getState, state => state.coffees.filter(c => parseInt(c.id) === parseInt(id))[0]);

View File

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

View File

@@ -1,103 +0,0 @@
#about-page {
ion-toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
--background: transparent;
--color: white;
}
ion-toolbar ion-back-button,
ion-toolbar ion-button,
ion-toolbar ion-menu-button {
--color: white;
}
.about-header {
position: relative;
width: 100%;
height: 30%;
}
.about-header .about-image {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
opacity: 0;
transition: opacity 500ms ease-in-out;
}
.about-header .madison {
background-image: url('/assets/WeatherDemo/img/about/madison.jpg');
}
.about-header .austin {
background-image: url('/assets/WeatherDemo/img/about/austin.jpg');
}
.about-header .chicago {
background-image: url('/assets/WeatherDemo/img/about/chicago.jpg');
}
.about-header .seattle {
background-image: url('/assets/WeatherDemo/img/about/seattle.jpg');
}
.about-info {
position: relative;
margin-top: -10px;
border-radius: 10px;
background: var(--ion-background-color, #fff);
z-index: 2; // display rounded border above header image
}
.about-info h3 {
margin-top: 0;
}
.about-info ion-list {
padding-top: 0;
}
.about-info p {
line-height: 130%;
color: var(--ion-color-dark);
}
.about-info ion-icon {
margin-inline-end: 32px;
}
/*
* iOS Only
*/
.ios .about-info {
--ion-padding: 19px;
}
.ios .about-info h3 {
font-weight: 700;
}
}
#date-input-popover {
--offset-y: -var(--ion-safe-area-bottom);
--max-width: 90%;
--width: 336px;
}