diff --git a/src/features/quote/Quote.jsx b/src/features/quote/Quote.jsx index df1752db..1ea68d6f 100644 --- a/src/features/quote/Quote.jsx +++ b/src/features/quote/Quote.jsx @@ -1,521 +1,109 @@ -import variables from 'config/variables'; -import { PureComponent, createRef } from 'react'; -import { - MdContentCopy, - MdStarBorder, - MdStar, - MdPerson, - MdOpenInNew, - MdIosShare, -} from 'react-icons/md'; - -import { toast } from 'react-toastify'; - -import { Tooltip } from 'components/Elements'; - +import { useEffect, useRef, useState, useMemo } from 'react'; import Modal from 'react-modal'; import { ShareModal } from 'components/Elements'; -import offline_quotes from './offline_quotes.json'; +import { useQuoteState, useQuoteLoader, useQuoteActions, useQuoteEvents } from './hooks'; +import { AuthorInfo, AuthorInfoLegacy } from './components'; -import EventBus from 'utils/eventbus'; +import './scss/index.scss'; -import './quote.scss'; +/** + * Quote component - Displays quotes from various sources + * Supports: API quotes, custom quotes, quote packs, and offline quotes + */ +export default function Quote() { + const { quoteData, uiState, updateQuote, toggleShareModal } = useQuoteState(); + const { getQuote } = useQuoteLoader(updateQuote); + const { copyQuote, toggleFavourite } = useQuoteActions(quoteData); -class Quote extends PureComponent { - buttons = { - share: ( - - - - ), - copy: ( - - - - ), - unfavourited: ( - - - - ), - favourited: ( - - - - ), + const quoteRef = useRef(null); + const [isFavourited, setIsFavourited] = useState(false); + + const settings = useMemo(() => ({ + authorDetails: localStorage.getItem('authorDetails') === 'true', + isLegacyStyle: localStorage.getItem('widgetStyle') === 'legacy', + isEnabled: localStorage.getItem('quote') !== 'false', + zoom: Number((localStorage.getItem('zoomQuote') || 100) / 100), + }), []); + + const setZoom = () => { + if (quoteRef.current) { + quoteRef.current.style.fontSize = `${0.8 * settings.zoom}em`; + } }; - constructor() { - super(); - this.state = { - quote: null, - author: null, - authorOccupation: null, - favourited: this.useFavourite(), - share: localStorage.getItem('quoteShareButton') === 'false' ? null : this.buttons.share, - copy: localStorage.getItem('copyButton') === 'false' ? null : this.buttons.copy, - quoteLanguage: '', - type: localStorage.getItem('quoteType') || 'api', - shareModal: false, - }; - this.quote = createRef(); - this.quotediv = createRef(); - this.quoteauthor = createRef(); - this.authorDetails = localStorage.getItem('authorDetails') === 'true'; - } + const handleFavourite = () => { + toggleFavourite(); + setIsFavourited(!isFavourited); + }; - useFavourite() { - if (localStorage.getItem('favouriteQuoteEnabled') === 'true') { - return localStorage.getItem('favouriteQuote') - ? this.buttons.favourited - : this.buttons.unfavourited; - } else { - return null; - } - } + useQuoteEvents(getQuote, setZoom); - doOffline() { - // Get a random quote from our local JSON - const quote = offline_quotes[Math.floor(Math.random() * offline_quotes.length)]; - - this.setState({ - quote: '"' + quote.quote + '"', - author: quote.author, - authorlink: this.getAuthorLink(quote.author), - authorimg: '', - }); - } - - getAuthorLink(author) { - return localStorage.getItem('authorLink') === 'false' || author === 'Unknown' - ? null - : `https://${variables.languagecode.split('_')[0]}.wikipedia.org/wiki/${author - .split(' ') - .join('_')}`; - } - - stripHTML(html) { - const tmpdoc = new DOMParser().parseFromString(html, 'text/html'); - return tmpdoc.body.textContent || ''; - } - - async getAuthorImg(author) { - if (localStorage.getItem('authorImg') === 'false') { - return { authorimg: null, authorimglicense: null }; - } - - const authorimgdata = await ( - await fetch( - `https://${ - variables.languagecode.split('_')[0] - }.wikipedia.org/w/api.php?action=query&titles=${author}&origin=*&prop=pageimages&format=json&pithumbsize=100`, - ) - ).json(); - - let authorimg, authorimglicense; - const authorPage = authorimgdata.query.pages[Object.keys(authorimgdata.query.pages)[0]]; - try { - authorimg = authorPage?.thumbnail?.source; - - const authorimglicensedata = await ( - await fetch( - `https://${ - variables.languagecode.split('_')[0] - }.wikipedia.org/w/api.php?action=query&prop=imageinfo&iiprop=extmetadata&titles=File:${ - authorPage.pageimage - }&origin=*&format=json`, - ) - ).json(); - - const authorImagePage = - authorimglicensedata.query.pages[Object.keys(authorimglicensedata.query.pages)[0]]; - const metadata = authorImagePage?.imageinfo?.[0]?.extmetadata; - const license = metadata?.LicenseShortName; - const photographer = this.stripHTML( - metadata?.Attribution?.value || metadata?.Artist?.value || '', - ) - .replace(/©\s/, '') - .replace(/ \(talk\)/, ''); // talk page link (if applicable) is only removed for English - - if (!photographer) { - authorimg = null; - authorimglicense = null; - } else if (license?.value === 'Public domain') { - authorimglicense = null; - } else { - if (photographer) { - authorimglicense = `© ${photographer}${license ? `. ${license.value}` : ''}`; - } else if (license) { - authorimglicense = license.value; - } - authorimglicense = authorimglicense.replace(/copyright\s/i, ''); - } - } catch (e) { - console.error(e); - authorimg = null; - authorimglicense = null; - } - - if (author === 'Unknown') { - authorimg = null; - authorimglicense = null; - } - - return { authorimg, authorimglicense }; - } - - async getQuote() { - const offline = localStorage.getItem('offlineMode') === 'true'; - - const favouriteQuote = localStorage.getItem('favouriteQuote'); - if (favouriteQuote) { - const author = favouriteQuote.split(' - ')[1]; - const authorimgdata = await this.getAuthorImg(author); - return this.setState({ - quote: favouriteQuote.split(' - ')[0], - author, - authorlink: this.getAuthorLink(author), - authorimg: authorimgdata.authorimg, - authorimglicense: authorimgdata.authorimglicense, - }); - } - - switch (this.state.type) { - case 'custom': { - let customQuote; - try { - customQuote = JSON.parse(localStorage.getItem('customQuote')); - } catch { - // move to new format - customQuote = [ - { - quote: localStorage.getItem('customQuote'), - author: localStorage.getItem('customQuoteAuthor'), - }, - ]; - localStorage.setItem('customQuote', JSON.stringify(customQuote)); - } - - // pick random - customQuote = customQuote - ? customQuote[Math.floor(Math.random() * customQuote.length)] - : null; - - if (customQuote !== undefined && customQuote !== null) { - return this.setState({ - quote: '"' + customQuote.quote + '"', - author: customQuote.author, - authorlink: this.getAuthorLink(customQuote.author), - authorimg: await this.getAuthorImg(customQuote.author), - noQuote: false, - }); - } else { - this.setState({ noQuote: true }); - } - break; - } - case 'quote_pack': { - if (offline) { - return this.doOffline(); - } - - const quotePack = []; - const installed = JSON.parse(localStorage.getItem('installed')); - installed.forEach((item) => { - if (item.type === 'quotes') { - const quotes = item.quotes.map((quote) => ({ - ...quote, - fallbackauthorimg: item.icon_url, - })); - quotePack.push(...quotes); - } - }); - - if (quotePack) { - const data = quotePack[Math.floor(Math.random() * quotePack.length)]; - return this.setState({ - quote: '"' + data.quote + '"', - author: data.author, - authorlink: this.getAuthorLink(data.author), - authorimg: data.fallbackauthorimg, - }); - } else { - this.doOffline(); - } - break; - } - case 'api': { - if (offline) { - return this.doOffline(); - } - - const getAPIQuoteData = async () => { - const quoteLanguage = localStorage.getItem('quoteLanguage'); - const data = await ( - await fetch(variables.constants.API_URL + '/quotes/random?language=' + quoteLanguage) - ).json(); - // If we hit the ratelimit, we fall back to local quotes - if (data.statusCode === 429) { - return null; - } - const authorimgdata = await this.getAuthorImg(data.author); - return { - quote: '"' + data.quote.replace(/\s+$/g, '') + '"', - author: data.author, - authorlink: this.getAuthorLink(data.author), - authorimg: authorimgdata.authorimg, - authorimglicense: authorimgdata.authorimglicense, - quoteLanguage: quoteLanguage, - authorOccupation: data.author_occupation, - }; - }; - - // First we try and get a quote from the API... - try { - const data = JSON.parse(localStorage.getItem('nextQuote')) || (await getAPIQuoteData()); - localStorage.setItem('nextQuote', null); - if (data) { - this.setState(data); - localStorage.setItem('currentQuote', JSON.stringify(data)); - localStorage.setItem('nextQuote', JSON.stringify(await getAPIQuoteData())); // pre-fetch data about the next quote - } else { - this.doOffline(); - } - } catch { - // ...and if that fails we load one locally - this.doOffline(); - } - break; - } - default: - break; - } - } - - copyQuote() { - variables.stats.postEvent('feature', 'Quote copied'); - navigator.clipboard.writeText(`${this.state.quote} - ${this.state.author}`); - toast(variables.getMessage('toasts.quote')); - } - - favourite() { - if (localStorage.getItem('favouriteQuote')) { - localStorage.removeItem('favouriteQuote'); - this.setState({ favourited: this.buttons.unfavourited }); - } else { - localStorage.setItem('favouriteQuote', this.state.quote + ' - ' + this.state.author); - this.setState({ favourited: this.buttons.favourited }); - } - - variables.stats.postEvent('feature', 'Quote favourite'); - } - - init() { - this.setZoom(); - - const quoteType = localStorage.getItem('quoteType'); - - if ( - this.state.type !== quoteType || - localStorage.getItem('quoteLanguage') !== this.state.quoteLanguage || - (quoteType === 'custom' && this.state.quote !== localStorage.getItem('customQuote')) || - (quoteType === 'custom' && this.state.author !== localStorage.getItem('customQuoteAuthor')) - ) { - this.getQuote(); - } - } - - setZoom() { - const zoomQuote = Number((localStorage.getItem('zoomQuote') || 100) / 100); - if (this.quote.current) { - this.quote.current.style.fontSize = `${0.8 * zoomQuote}em`; - } - if (this.quoteauthor.current) { - this.quoteauthor.current.style.fontSize = `${0.9 * zoomQuote}em`; - } - } - - componentDidMount() { - this.setZoom(); - - EventBus.on('refresh', (data) => { - if (data === 'quote') { - if (localStorage.getItem('quote') === 'false') { - return (this.quotediv.current.style.display = 'none'); - } - - this.quotediv.current.style.display = 'block'; - this.init(); - - // buttons hot reload - this.setState({ - favourited: this.useFavourite(), - share: localStorage.getItem('quoteShareButton') === 'false' ? null : this.buttons.share, - copy: localStorage.getItem('copyButton') === 'false' ? null : this.buttons.copy, - }); - } - - // uninstall quote pack reverts the quote to what you had previously - if (data === 'marketplacequoteuninstall') { - this.init(); - } - - if (data === 'quoterefresh') { - this.getQuote(); - } - }); - - if ( - localStorage.getItem('quotechange') === 'refresh' || - localStorage.getItem('quotechange') === null - ) { - this.setZoom(); - this.getQuote(); + useEffect(() => { + const shouldRefresh = localStorage.getItem('quotechange') === 'refresh' || + localStorage.getItem('quotechange') === null; + + if (shouldRefresh) { + setZoom(); + getQuote(); localStorage.setItem('quoteStartTime', Date.now()); } + }, [getQuote]); + + useEffect(() => { + setIsFavourited(!!localStorage.getItem('favouriteQuote')); + }, [quoteData.quote]); + + if (quoteData.noQuote || !settings.isEnabled) { + return null; } - componentWillUnmount() { - EventBus.off('refresh'); - } + return ( +
+ toggleShareModal(false)} + > + toggleShareModal(false)} + /> + - render() { - if (this.state.noQuote === true) { - return <>; - } + + {quoteData.quote} + - return ( -
- this.setState({ shareModal: false })} - > - this.setState({ shareModal: false })} + {settings.authorDetails && ( + settings.isLegacyStyle ? ( + toggleShareModal(true)} + isFavourited={isFavourited} /> - - - {this.state.quote} - - - {localStorage.getItem('widgetStyle') === 'legacy' ? ( - <> - {this.authorDetails && ( - <> -
-

- - {this.state.author} - -

-
-
- {this.state.copy} {this.state.share} {this.state.favourited} -
- - )} - ) : ( - <> - {this.authorDetails && ( - <> -
-
- {localStorage.getItem('authorImg') !== 'false' ? ( -
- {this.state.authorimg === undefined || this.state.authorimg ? ( - '' - ) : ( - - )} -
- ) : null} - {this.state.author !== null ? ( -
- {this.state.author} - {this.state.authorOccupation !== 'Unknown' && ( - {this.state.authorOccupation} - )} - - {this.state.authorimglicense && - this.state.authorimglicense.substring(0, 40) + - (this.state.authorimglicense.length > 40 ? '…' : '')} - -
- ) : ( -
- {/* these are placeholders for skeleton and as such don't need translating */} - loading - loading -
- )} - {(this.state.authorOccupation !== 'Unknown' && - this.state.authorlink !== null) || - this.state.copy || - this.state.share || - this.state.favourited ? ( -
- {this.state.authorOccupation !== 'Unknown' && - this.state.authorlink !== null ? ( - - - - {' '} - - ) : null} - {this.state.copy} {this.state.share} {this.state.favourited} -
- ) : null} -
-
- - )} - - )} -
- ); - } + toggleShareModal(true)} + isFavourited={isFavourited} + /> + ) + )} +
+ ); } -export { Quote as default, Quote }; +export { Quote }; diff --git a/src/features/quote/components/AuthorInfo.jsx b/src/features/quote/components/AuthorInfo.jsx new file mode 100644 index 00000000..115dcefc --- /dev/null +++ b/src/features/quote/components/AuthorInfo.jsx @@ -0,0 +1,77 @@ +import { MdPerson, MdOpenInNew } from 'react-icons/md'; +import { Tooltip } from 'components/Elements'; +import variables from 'config/variables'; +import QuoteButtons from './QuoteButtons'; + +/** + * Author information component (modern style) + */ +export default function AuthorInfo({ + author, + authorOccupation, + authorlink, + authorimg, + authorimglicense, + onCopy, + onFavourite, + onShare, + isFavourited, +}) { + const showAuthorImg = localStorage.getItem('authorImg') !== 'false'; + const hasLink = authorOccupation !== 'Unknown' && authorlink !== null; + const trimmedLicense = authorimglicense?.substring(0, 40) + + (authorimglicense?.length > 40 ? '…' : ''); + + return ( +
+
+ {showAuthorImg && ( +
+ {!authorimg && authorimg !== undefined && } +
+ )} + + {author ? ( +
+ {author} + {authorOccupation && authorOccupation !== 'Unknown' && ( + {authorOccupation} + )} + {authorimglicense && ( + + {trimmedLicense} + + )} +
+ ) : ( +
+ loading + loading +
+ )} + +
+ {hasLink && ( + + + + + + )} + +
+
+
+ ); +} diff --git a/src/features/quote/components/AuthorInfoLegacy.jsx b/src/features/quote/components/AuthorInfoLegacy.jsx new file mode 100644 index 00000000..4bea8cd7 --- /dev/null +++ b/src/features/quote/components/AuthorInfoLegacy.jsx @@ -0,0 +1,39 @@ +import QuoteButtons from './QuoteButtons'; + +/** + * Author information component (legacy style) + */ +export default function AuthorInfoLegacy({ + author, + authorlink, + onCopy, + onFavourite, + onShare, + isFavourited, +}) { + return ( + <> +
+

+ + {author} + +

+
+
+ +
+ + ); +} diff --git a/src/features/quote/components/QuoteButtons.jsx b/src/features/quote/components/QuoteButtons.jsx new file mode 100644 index 00000000..3c004024 --- /dev/null +++ b/src/features/quote/components/QuoteButtons.jsx @@ -0,0 +1,66 @@ +import { MdContentCopy, MdStarBorder, MdStar, MdIosShare } from 'react-icons/md'; +import { Tooltip } from 'components/Elements'; +import variables from 'config/variables'; + +/** + * Quote action buttons component + */ +export default function QuoteButtons({ + onCopy, + onFavourite, + onShare, + isFavourited, +}) { + const showCopy = localStorage.getItem('copyButton') !== 'false'; + const showShare = localStorage.getItem('quoteShareButton') !== 'false'; + const showFavourite = localStorage.getItem('favouriteQuoteEnabled') === 'true'; + + return ( + <> + {showCopy && ( + + + + )} + {showShare && ( + + + + )} + {showFavourite && ( + + + + )} + + ); +} diff --git a/src/features/quote/components/index.js b/src/features/quote/components/index.js new file mode 100644 index 00000000..b3ead78d --- /dev/null +++ b/src/features/quote/components/index.js @@ -0,0 +1,3 @@ +export { default as QuoteButtons } from './QuoteButtons'; +export { default as AuthorInfo } from './AuthorInfo'; +export { default as AuthorInfoLegacy } from './AuthorInfoLegacy'; diff --git a/src/features/quote/hooks/index.js b/src/features/quote/hooks/index.js new file mode 100644 index 00000000..c3a0b30a --- /dev/null +++ b/src/features/quote/hooks/index.js @@ -0,0 +1,4 @@ +export { useQuoteState } from './useQuoteState'; +export { useQuoteLoader } from './useQuoteLoader'; +export { useQuoteActions } from './useQuoteActions'; +export { useQuoteEvents } from './useQuoteEvents'; diff --git a/src/features/quote/hooks/useQuoteActions.js b/src/features/quote/hooks/useQuoteActions.js new file mode 100644 index 00000000..ea88ef60 --- /dev/null +++ b/src/features/quote/hooks/useQuoteActions.js @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { toast } from 'react-toastify'; +import variables from 'config/variables'; + +/** + * Custom hook for quote actions (copy, favourite, share) + */ +export function useQuoteActions(quoteData) { + const copyQuote = useCallback(() => { + variables.stats.postEvent('feature', 'Quote copied'); + navigator.clipboard.writeText(`${quoteData.quote} - ${quoteData.author}`); + toast(variables.getMessage('toasts.quote')); + }, [quoteData.quote, quoteData.author]); + + const toggleFavourite = useCallback(() => { + if (localStorage.getItem('favouriteQuote')) { + localStorage.removeItem('favouriteQuote'); + } else { + localStorage.setItem('favouriteQuote', quoteData.quote + ' - ' + quoteData.author); + } + variables.stats.postEvent('feature', 'Quote favourite'); + }, [quoteData.quote, quoteData.author]); + + return { + copyQuote, + toggleFavourite, + }; +} diff --git a/src/features/quote/hooks/useQuoteEvents.js b/src/features/quote/hooks/useQuoteEvents.js new file mode 100644 index 00000000..53000e33 --- /dev/null +++ b/src/features/quote/hooks/useQuoteEvents.js @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import EventBus from 'utils/eventbus'; + +/** + * Custom hook for handling quote-related events + */ +export function useQuoteEvents(getQuote, setZoom) { + useEffect(() => { + const handleRefresh = (data) => { + switch (data) { + case 'quote': + setZoom(); + break; + case 'marketplacequoteuninstall': + case 'quoterefresh': + getQuote(); + break; + } + }; + + EventBus.on('refresh', handleRefresh); + return () => EventBus.off('refresh', handleRefresh); + }, [getQuote, setZoom]); +} diff --git a/src/features/quote/hooks/useQuoteLoader.js b/src/features/quote/hooks/useQuoteLoader.js new file mode 100644 index 00000000..921bbcc2 --- /dev/null +++ b/src/features/quote/hooks/useQuoteLoader.js @@ -0,0 +1,197 @@ +import { useCallback } from 'react'; +import variables from 'config/variables'; +import offline_quotes from '../offline_quotes.json'; + +/** + * Custom hook for loading quote data from various sources + */ +export function useQuoteLoader(updateQuote) { + const getAuthorLink = useCallback((author) => { + return localStorage.getItem('authorLink') === 'false' || author === 'Unknown' + ? null + : `https://${variables.languagecode.split('_')[0]}.wikipedia.org/wiki/${author + .split(' ') + .join('_')}`; + }, []); + + const stripHTML = useCallback((html) => { + const tmpdoc = new DOMParser().parseFromString(html, 'text/html'); + return tmpdoc.body.textContent || ''; + }, []); + + const getAuthorImg = useCallback( + async (author) => { + if (localStorage.getItem('authorImg') === 'false' || author === 'Unknown') { + return { authorimg: null, authorimglicense: null }; + } + + try { + const lang = variables.languagecode.split('_')[0]; + const pageData = await fetch( + `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${author}&origin=*&prop=pageimages&format=json&pithumbsize=100` + ).then(res => res.json()); + + const authorPage = Object.values(pageData.query.pages)[0]; + const authorimg = authorPage?.thumbnail?.source; + + if (!authorimg) { + return { authorimg: null, authorimglicense: null }; + } + + const licenseData = await fetch( + `https://${lang}.wikipedia.org/w/api.php?action=query&prop=imageinfo&iiprop=extmetadata&titles=File:${authorPage.pageimage}&origin=*&format=json` + ).then(res => res.json()); + + const licensePage = Object.values(licenseData.query.pages)[0]; + const metadata = licensePage?.imageinfo?.[0]?.extmetadata; + const license = metadata?.LicenseShortName; + const photographer = stripHTML(metadata?.Attribution?.value || metadata?.Artist?.value || '') + .replace(/©\s/, '') + .replace(/ \(talk\)/, ''); + + if (!photographer) { + return { authorimg: null, authorimglicense: null }; + } + + if (license?.value === 'Public domain') { + return { authorimg, authorimglicense: null }; + } + + const authorimglicense = photographer + ? `© ${photographer}${license ? `. ${license.value}` : ''}`.replace(/copyright\s/i, '') + : license?.value || null; + + return { authorimg, authorimglicense }; + } catch (e) { + console.error(e); + return { authorimg: null, authorimglicense: null }; + } + }, + [stripHTML], + ); + + const doOffline = useCallback(() => { + const quote = offline_quotes[Math.floor(Math.random() * offline_quotes.length)]; + + updateQuote({ + quote: '"' + quote.quote + '"', + author: quote.author, + authorlink: getAuthorLink(quote.author), + authorimg: '', + }); + }, [updateQuote, getAuthorLink]); + + const getQuote = useCallback(async () => { + const offline = localStorage.getItem('offlineMode') === 'true'; + const type = localStorage.getItem('quoteType') || 'api'; + + // Check for favourite quote first + const favouriteQuote = localStorage.getItem('favouriteQuote'); + if (favouriteQuote) { + const [quote, author] = favouriteQuote.split(' - '); + const authorimgdata = await getAuthorImg(author); + return updateQuote({ + quote, + author, + authorlink: getAuthorLink(author), + ...authorimgdata, + }); + } + + switch (type) { + case 'custom': { + let customQuote; + try { + customQuote = JSON.parse(localStorage.getItem('customQuote')); + } catch { + // Migrate old format + customQuote = [{ + quote: localStorage.getItem('customQuote'), + author: localStorage.getItem('customQuoteAuthor'), + }]; + localStorage.setItem('customQuote', JSON.stringify(customQuote)); + } + + if (!customQuote || customQuote.length === 0) { + return updateQuote({ noQuote: true }); + } + + const selected = customQuote[Math.floor(Math.random() * customQuote.length)]; + const authorimgdata = await getAuthorImg(selected.author); + + return updateQuote({ + quote: `"${selected.quote}"`, + author: selected.author, + authorlink: getAuthorLink(selected.author), + ...authorimgdata, + noQuote: false, + }); + } + + case 'quote_pack': { + if (offline) return doOffline(); + + const installed = JSON.parse(localStorage.getItem('installed') || '[]'); + const quotePack = installed + .filter(item => item.type === 'quotes') + .flatMap(item => item.quotes.map(quote => ({ + ...quote, + fallbackauthorimg: item.icon_url, + }))); + + if (quotePack.length === 0) return doOffline(); + + const data = quotePack[Math.floor(Math.random() * quotePack.length)]; + return updateQuote({ + quote: `"${data.quote}"`, + author: data.author, + authorlink: getAuthorLink(data.author), + authorimg: data.fallbackauthorimg, + }); + } + + case 'api': { + if (offline) return doOffline(); + + const fetchAPIQuote = async () => { + const quoteLanguage = localStorage.getItem('quoteLanguage'); + const response = await fetch( + `${variables.constants.API_URL}/quotes/random?language=${quoteLanguage}` + ).then(res => res.json()); + + if (response.statusCode === 429) return null; + + const authorimgdata = await getAuthorImg(response.author); + return { + quote: `"${response.quote.replace(/\s+$/g, '')}"`, + author: response.author, + authorlink: getAuthorLink(response.author), + ...authorimgdata, + quoteLanguage, + authorOccupation: response.author_occupation, + }; + }; + + try { + const data = JSON.parse(localStorage.getItem('nextQuote')) || await fetchAPIQuote(); + localStorage.setItem('nextQuote', null); + + if (data) { + updateQuote(data); + localStorage.setItem('currentQuote', JSON.stringify(data)); + localStorage.setItem('nextQuote', JSON.stringify(await fetchAPIQuote())); + } else { + doOffline(); + } + } catch { + doOffline(); + } + break; + } + } + }, [updateQuote, getAuthorLink, getAuthorImg, doOffline]); + + return { + getQuote, + }; +} diff --git a/src/features/quote/hooks/useQuoteState.js b/src/features/quote/hooks/useQuoteState.js new file mode 100644 index 00000000..8ec8de22 --- /dev/null +++ b/src/features/quote/hooks/useQuoteState.js @@ -0,0 +1,42 @@ +import { useState, useCallback } from 'react'; + +/** + * Custom hook for managing quote state + */ +export function useQuoteState() { + const [quoteData, setQuoteData] = useState({ + quote: null, + author: null, + authorOccupation: null, + authorlink: null, + authorimg: null, + authorimglicense: null, + quoteLanguage: '', + noQuote: false, + }); + + const [uiState, setUiState] = useState({ + shareModal: false, + }); + + const updateQuote = useCallback((newData) => { + setQuoteData((prev) => ({ + ...prev, + ...newData, + })); + }, []); + + const toggleShareModal = useCallback((isOpen) => { + setUiState((prev) => ({ + ...prev, + shareModal: isOpen, + })); + }, []); + + return { + quoteData, + uiState, + updateQuote, + toggleShareModal, + }; +} diff --git a/src/features/quote/quote.scss b/src/features/quote/scss/index.scss similarity index 100% rename from src/features/quote/quote.scss rename to src/features/quote/scss/index.scss