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.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 (
+ <>
+
+
+
+
+ >
+ );
+}
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