mirror of
https://github.com/mue/mue.git
synced 2026-07-05 15:41:18 +02:00
refactor: make quote like background
This commit is contained in:
@@ -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 };
|
||||
|
||||
77
src/features/quote/components/AuthorInfo.jsx
Normal file
77
src/features/quote/components/AuthorInfo.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/features/quote/components/AuthorInfoLegacy.jsx
Normal file
39
src/features/quote/components/AuthorInfoLegacy.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
src/features/quote/components/QuoteButtons.jsx
Normal file
66
src/features/quote/components/QuoteButtons.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
src/features/quote/components/index.js
Normal file
3
src/features/quote/components/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as QuoteButtons } from './QuoteButtons';
|
||||
export { default as AuthorInfo } from './AuthorInfo';
|
||||
export { default as AuthorInfoLegacy } from './AuthorInfoLegacy';
|
||||
4
src/features/quote/hooks/index.js
Normal file
4
src/features/quote/hooks/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useQuoteState } from './useQuoteState';
|
||||
export { useQuoteLoader } from './useQuoteLoader';
|
||||
export { useQuoteActions } from './useQuoteActions';
|
||||
export { useQuoteEvents } from './useQuoteEvents';
|
||||
28
src/features/quote/hooks/useQuoteActions.js
Normal file
28
src/features/quote/hooks/useQuoteActions.js
Normal 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,
|
||||
};
|
||||
}
|
||||
24
src/features/quote/hooks/useQuoteEvents.js
Normal file
24
src/features/quote/hooks/useQuoteEvents.js
Normal 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]);
|
||||
}
|
||||
197
src/features/quote/hooks/useQuoteLoader.js
Normal file
197
src/features/quote/hooks/useQuoteLoader.js
Normal 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,
|
||||
};
|
||||
}
|
||||
42
src/features/quote/hooks/useQuoteState.js
Normal file
42
src/features/quote/hooks/useQuoteState.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user