refactor: make quote like background

This commit is contained in:
David Ralph
2025-10-28 22:01:06 +00:00
parent 7ac848c9a0
commit 2eed0f7307
11 changed files with 569 additions and 501 deletions

View File

@@ -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: (
<Tooltip title={variables.getMessage('widgets.quote.share')}>
<button
onClick={() => this.setState({ shareModal: true })}
aria-label={variables.getMessage('widgets.quote.share')}
>
<MdIosShare className="copyButton" />
</button>
</Tooltip>
),
copy: (
<Tooltip title={variables.getMessage('widgets.quote.copy')}>
<button
onClick={() => this.copyQuote()}
aria-label={variables.getMessage('widgets.quote.copy')}
>
<MdContentCopy className="copyButton" />
</button>
</Tooltip>
),
unfavourited: (
<Tooltip title={variables.getMessage('widgets.quote.favourite')}>
<button
onClick={() => this.favourite()}
aria-label={variables.getMessage('widgets.quote.favourite')}
>
<MdStarBorder className="copyButton" />
</button>
</Tooltip>
),
favourited: (
<Tooltip title={variables.getMessage('widgets.quote.unfavourite')}>
<button
onClick={() => this.favourite()}
aria-label={variables.getMessage('widgets.quote.unfavourite')}
>
<MdStar className="copyButton" />
</button>
</Tooltip>
),
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 (
<div className="quotediv">
<Modal
closeTimeoutMS={300}
isOpen={uiState.shareModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
onRequestClose={() => toggleShareModal(false)}
>
<ShareModal
data={`${quoteData.quote} - ${quoteData.author}`}
modalClose={() => toggleShareModal(false)}
/>
</Modal>
render() {
if (this.state.noQuote === true) {
return <></>;
}
<span className="quote" ref={quoteRef}>
{quoteData.quote}
</span>
return (
<div className="quotediv" ref={this.quotediv}>
<Modal
closeTimeoutMS={300}
isOpen={this.state.shareModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
onRequestClose={() => this.setState({ shareModal: false })}
>
<ShareModal
data={`${this.state.quote} - ${this.state.author}`}
modalClose={() => this.setState({ shareModal: false })}
{settings.authorDetails && (
settings.isLegacyStyle ? (
<AuthorInfoLegacy
author={quoteData.author}
authorlink={quoteData.authorlink}
onCopy={copyQuote}
onFavourite={handleFavourite}
onShare={() => toggleShareModal(true)}
isFavourited={isFavourited}
/>
</Modal>
<span className="quote" ref={this.quote}>
{this.state.quote}
</span>
{localStorage.getItem('widgetStyle') === 'legacy' ? (
<>
{this.authorDetails && (
<>
<div>
<h1 className="quoteauthor" ref={this.quoteauthor}>
<a
href={this.state.authorlink}
className="quoteAuthorLink"
target="_blank"
rel="noopener noreferrer"
aria-label="Learn about the author of the quote."
>
{this.state.author}
</a>
</h1>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
{this.state.copy} {this.state.share} {this.state.favourited}
</div>
</>
)}
</>
) : (
<>
{this.authorDetails && (
<>
<div className="author-holder">
<div className="author">
{localStorage.getItem('authorImg') !== 'false' ? (
<div
className="author-img"
style={{ backgroundImage: `url(${this.state.authorimg})` }}
>
{this.state.authorimg === undefined || this.state.authorimg ? (
''
) : (
<MdPerson />
)}
</div>
) : null}
{this.state.author !== null ? (
<div className="author-content" ref={this.quoteauthor}>
<span className="title">{this.state.author}</span>
{this.state.authorOccupation !== 'Unknown' && (
<span className="subtitle">{this.state.authorOccupation}</span>
)}
<span className="author-license" title={this.state.authorimglicense}>
{this.state.authorimglicense &&
this.state.authorimglicense.substring(0, 40) +
(this.state.authorimglicense.length > 40 ? '…' : '')}
</span>
</div>
) : (
<div className="author-content whileLoading" ref={this.quoteauthor}>
{/* these are placeholders for skeleton and as such don't need translating */}
<span className="title pulse">loading</span>
<span className="subtitle pulse">loading</span>
</div>
)}
{(this.state.authorOccupation !== 'Unknown' &&
this.state.authorlink !== null) ||
this.state.copy ||
this.state.share ||
this.state.favourited ? (
<div className="quote-buttons">
{this.state.authorOccupation !== 'Unknown' &&
this.state.authorlink !== null ? (
<Tooltip title={variables.getMessage('widgets.quote.link_tooltip')}>
<a
href={this.state.authorlink}
className="quoteAuthorLink"
target="_blank"
rel="noopener noreferrer"
aria-label="Learn about the author of the quote."
>
<MdOpenInNew />
</a>{' '}
</Tooltip>
) : null}
{this.state.copy} {this.state.share} {this.state.favourited}
</div>
) : null}
</div>
</div>
</>
)}
</>
)}
</div>
);
}
<AuthorInfo
author={quoteData.author}
authorOccupation={quoteData.authorOccupation}
authorlink={quoteData.authorlink}
authorimg={quoteData.authorimg}
authorimglicense={quoteData.authorimglicense}
onCopy={copyQuote}
onFavourite={handleFavourite}
onShare={() => toggleShareModal(true)}
isFavourited={isFavourited}
/>
)
)}
</div>
);
}
export { Quote as default, Quote };
export { Quote };

View File

@@ -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 (
<div className="author-holder">
<div className="author">
{showAuthorImg && (
<div className="author-img" style={{ backgroundImage: `url(${authorimg})` }}>
{!authorimg && authorimg !== undefined && <MdPerson />}
</div>
)}
{author ? (
<div className="author-content">
<span className="title">{author}</span>
{authorOccupation && authorOccupation !== 'Unknown' && (
<span className="subtitle">{authorOccupation}</span>
)}
{authorimglicense && (
<span className="author-license" title={authorimglicense}>
{trimmedLicense}
</span>
)}
</div>
) : (
<div className="author-content whileLoading">
<span className="title pulse">loading</span>
<span className="subtitle pulse">loading</span>
</div>
)}
<div className="quote-buttons">
{hasLink && (
<Tooltip title={variables.getMessage('widgets.quote.link_tooltip')}>
<a
href={authorlink}
className="quoteAuthorLink"
target="_blank"
rel="noopener noreferrer"
aria-label="Learn about the author of the quote."
>
<MdOpenInNew />
</a>
</Tooltip>
)}
<QuoteButtons
onCopy={onCopy}
onFavourite={onFavourite}
onShare={onShare}
isFavourited={isFavourited}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import QuoteButtons from './QuoteButtons';
/**
* Author information component (legacy style)
*/
export default function AuthorInfoLegacy({
author,
authorlink,
onCopy,
onFavourite,
onShare,
isFavourited,
}) {
return (
<>
<div>
<h1 className="quoteauthor">
<a
href={authorlink}
className="quoteAuthorLink"
target="_blank"
rel="noopener noreferrer"
aria-label="Learn about the author of the quote."
>
{author}
</a>
</h1>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
<QuoteButtons
onCopy={onCopy}
onFavourite={onFavourite}
onShare={onShare}
isFavourited={isFavourited}
/>
</div>
</>
);
}

View File

@@ -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 && (
<Tooltip title={variables.getMessage('widgets.quote.copy')}>
<button
onClick={onCopy}
aria-label={variables.getMessage('widgets.quote.copy')}
>
<MdContentCopy className="copyButton" />
</button>
</Tooltip>
)}
{showShare && (
<Tooltip title={variables.getMessage('widgets.quote.share')}>
<button
onClick={onShare}
aria-label={variables.getMessage('widgets.quote.share')}
>
<MdIosShare className="copyButton" />
</button>
</Tooltip>
)}
{showFavourite && (
<Tooltip
title={
isFavourited
? variables.getMessage('widgets.quote.unfavourite')
: variables.getMessage('widgets.quote.favourite')
}
>
<button
onClick={onFavourite}
aria-label={
isFavourited
? variables.getMessage('widgets.quote.unfavourite')
: variables.getMessage('widgets.quote.favourite')
}
>
{isFavourited ? (
<MdStar className="copyButton" />
) : (
<MdStarBorder className="copyButton" />
)}
</button>
</Tooltip>
)}
</>
);
}

View File

@@ -0,0 +1,3 @@
export { default as QuoteButtons } from './QuoteButtons';
export { default as AuthorInfo } from './AuthorInfo';
export { default as AuthorInfoLegacy } from './AuthorInfoLegacy';

View File

@@ -0,0 +1,4 @@
export { useQuoteState } from './useQuoteState';
export { useQuoteLoader } from './useQuoteLoader';
export { useQuoteActions } from './useQuoteActions';
export { useQuoteEvents } from './useQuoteEvents';

View File

@@ -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,
};
}

View File

@@ -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]);
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}