feat: Add QuoteInfoModal for displaying detailed quote information and integrate it with the Quote component

This commit is contained in:
alexsparkes
2026-02-07 14:53:35 +00:00
parent f891a16350
commit 4ad799da3b
8 changed files with 458 additions and 2 deletions

View File

@@ -1,9 +1,11 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import Modal from 'react-modal';
import { MdInfoOutline } from 'react-icons/md';
import { ShareModal } from 'components/Elements';
import { useQuoteState, useQuoteLoader, useQuoteActions } from './hooks';
import { AuthorInfo, AuthorInfoLegacy } from './components';
import QuoteInfoModal from './components/QuoteInfoModal';
import EventBus from 'utils/eventbus';
import { useFrequencyInterval } from '../../hooks/useFrequencyInterval';
@@ -14,7 +16,7 @@ import './scss/index.scss';
* Supports: API quotes, custom quotes, quote packs, and offline quotes
*/
export default function Quote() {
const { quoteData, uiState, updateQuote, toggleShareModal } = useQuoteState();
const { quoteData, uiState, updateQuote, toggleShareModal, toggleInfoModal } = useQuoteState();
const { getQuote } = useQuoteLoader(updateQuote);
const { copyQuote, toggleFavourite } = useQuoteActions(quoteData);
@@ -90,7 +92,7 @@ export default function Quote() {
<div className="quotediv" style={{ display, fontSize }}>
<Modal
closeTimeoutMS={300}
open={uiState.shareModal}
isOpen={uiState.shareModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
@@ -102,6 +104,17 @@ export default function Quote() {
/>
</Modal>
<Modal
closeTimeoutMS={300}
isOpen={uiState.infoModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
onRequestClose={() => toggleInfoModal(false)}
>
<QuoteInfoModal quoteData={quoteData} modalClose={() => toggleInfoModal(false)} />
</Modal>
<span className="quote" ref={quoteRef}>
{quoteData.quote}
</span>
@@ -115,6 +128,7 @@ export default function Quote() {
onCopy={copyQuote}
onFavourite={handleFavourite}
onShare={() => toggleShareModal(true)}
onInfo={() => toggleInfoModal(true)}
isFavourited={isFavourited}
/>
) : (
@@ -127,9 +141,15 @@ export default function Quote() {
onCopy={copyQuote}
onFavourite={handleFavourite}
onShare={() => toggleShareModal(true)}
onInfo={() => toggleInfoModal(true)}
isFavourited={isFavourited}
/>
))}
<div className="quote-info-text" onClick={() => toggleInfoModal(true)}>
<MdInfoOutline />
<span>About this quote</span>
</div>
</div>
);
}

View File

@@ -16,6 +16,7 @@ export default function AuthorInfo({
onCopy,
onFavourite,
onShare,
onInfo,
isFavourited,
}) {
const t = useT();

View File

@@ -10,6 +10,7 @@ export default function AuthorInfoLegacy({
onCopy,
onFavourite,
onShare,
onInfo,
isFavourited,
}) {
const t = useT();

View File

@@ -0,0 +1,184 @@
import { memo } from 'react';
import { useNavigate } from 'react-router';
import { MdClose, MdWarning, MdSource, MdPerson, MdCategory } from 'react-icons/md';
import { HiMiniArrowUpRight } from 'react-icons/hi2';
import { Tooltip, Button } from 'components/Elements';
import variables from 'config/variables';
/**
* QuoteInfoModal - displays information about the current quote
*/
function QuoteInfoModal({ modalClose, quoteData }) {
const navigate = useNavigate();
const getQuoteSource = () => {
const type = localStorage.getItem('quoteType') || 'quote_pack';
const offline = localStorage.getItem('offlineMode') === 'true';
if (offline) {
return variables.getMessage('widgets.quote.info.source_offline') || 'Offline Mode';
}
switch (type) {
case 'custom':
return variables.getMessage('widgets.quote.info.source_custom') || 'Custom Quote';
case 'quote_pack':
return (
quoteData.packName ||
variables.getMessage('widgets.quote.info.source_pack') ||
'Quote Pack'
);
default:
return variables.getMessage('widgets.quote.info.source_unknown') || 'Unknown';
}
};
const getQuoteType = () => {
const type = localStorage.getItem('quoteType') || 'quote_pack';
const offline = localStorage.getItem('offlineMode') === 'true';
if (offline) {
return variables.getMessage('widgets.quote.info.type_offline') || 'Offline';
}
switch (type) {
case 'custom':
return variables.getMessage('widgets.quote.info.type_custom') || 'Custom';
case 'quote_pack':
return variables.getMessage('widgets.quote.info.type_pack') || 'Quote Pack';
default:
return variables.getMessage('widgets.quote.info.type_unknown') || 'Unknown';
}
};
const hasWarning = quoteData.authorimg || quoteData.authorOccupation;
return (
<div className="quoteInfoModal">
<div className="shareHeader">
<h4>{variables.getMessage('widgets.quote.info.title') || 'Quote Information'}</h4>
<Tooltip title={variables.getMessage('modals.welcome.buttons.close')}>
<div className="close" onClick={modalClose}>
<MdClose />
</div>
</Tooltip>
</div>
<div className="quoteInfoContent">
{/* Warning about automatic data */}
{hasWarning && (
<div className="quoteInfoWarning">
<div className="warningIcon">
<MdWarning />
</div>
<div className="warningText">
<p>
{variables.getMessage('widgets.quote.info.warning') ||
'Author image and occupation are automatically fetched and may be incorrect.'}
</p>
</div>
</div>
)}
{/* Quote Information */}
<div className="quoteInfoSection">
<div className="quoteInfoRow">
<div className="quoteInfoIcon">
<MdCategory />
</div>
<div className="quoteInfoText">
<span className="quoteInfoLabel">
{variables.getMessage('widgets.quote.info.type') || 'Type'}
</span>
<span className="quoteInfoValue">{getQuoteType()}</span>
</div>
</div>
{quoteData.realAuthor && quoteData.realAuthor !== quoteData.author && (
<div className="quoteInfoRow">
<div className="quoteInfoIcon">
<MdPerson />
</div>
<div className="quoteInfoText">
<span className="quoteInfoLabel">
{variables.getMessage('widgets.quote.info.original_author') || 'Original Author'}
</span>
<span className="quoteInfoValue">{quoteData.realAuthor}</span>
</div>
</div>
)}
<div className="quoteInfoRow">
<div className="quoteInfoIcon">
<MdSource />
</div>
<div className="quoteInfoText">
<span className="quoteInfoLabel">
{variables.getMessage('widgets.quote.info.source') || 'Source'}
</span>
<span className="quoteInfoValue">
{quoteData.packId ? (
<span
className="quoteInfoLink"
onClick={() => navigate(`/discover/item/${quoteData.packId}`)}
>
{getQuoteSource()}
<HiMiniArrowUpRight />
</span>
) : (
getQuoteSource()
)}
</span>
</div>
</div>
{quoteData.authorOccupation && quoteData.authorOccupation !== 'Unknown' && (
<div className="quoteInfoRow">
<div className="quoteInfoIcon">
<MdPerson />
</div>
<div className="quoteInfoText">
<span className="quoteInfoLabel">
{variables.getMessage('widgets.quote.info.occupation') || 'Occupation'}
</span>
<span className="quoteInfoValue">{quoteData.authorOccupation}</span>
</div>
</div>
)}
{quoteData.authorlink && (
<div className="quoteInfoRow">
<div className="quoteInfoIcon">
<MdSource />
</div>
<div className="quoteInfoText">
<span className="quoteInfoLabel">
{variables.getMessage('widgets.quote.info.wikipedia') || 'Wikipedia'}
</span>
<span className="quoteInfoValue">
<a
href={quoteData.authorlink}
target="_blank"
rel="noopener noreferrer"
className="quoteInfoLink"
>
{variables.getMessage('widgets.quote.info.view_article') || 'View Article'}
<HiMiniArrowUpRight />
</a>
</span>
</div>
</div>
)}
</div>
<div className="quoteInfoFooter">
<Button
type="settings"
onClick={modalClose}
label={variables.getMessage('modals.welcome.buttons.close') || 'Close'}
/>
</div>
</div>
</div>
);
}
export default memo(QuoteInfoModal);

View File

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

View File

@@ -119,6 +119,9 @@ export function useQuoteLoader(updateQuote) {
quote: `"${selected.quote}"`,
author: selected.author || 'Unknown',
authorlink: getAuthorLink(selected.author),
packName: null,
packId: null,
realAuthor: null,
needsAuthorData: true,
noQuote: false,
};
@@ -130,6 +133,9 @@ export function useQuoteLoader(updateQuote) {
quote: '"' + quote.quote + '"',
author: quote.author,
authorlink: getAuthorLink(quote.author),
packName: null,
packId: null,
realAuthor: null,
needsAuthorData: false,
};
}
@@ -149,6 +155,7 @@ export function useQuoteLoader(updateQuote) {
...quote,
fallbackauthorimg: item.icon_url,
packName: item.display_name || item.name,
packId: item.id,
noAuthorImg: item.noAuthorImg || quote.noAuthorImg,
})),
);
@@ -159,6 +166,9 @@ export function useQuoteLoader(updateQuote) {
quote: '"' + quote.quote + '"',
author: quote.author,
authorlink: getAuthorLink(quote.author),
packName: null,
packId: null,
realAuthor: null,
needsAuthorData: false,
};
}
@@ -173,6 +183,8 @@ export function useQuoteLoader(updateQuote) {
realAuthor: hasAuthor ? data.author : null,
authorlink: hasAuthor ? getAuthorLink(data.author) : null,
fallbackauthorimg: data.fallbackauthorimg,
packName: data.packName,
packId: data.packId,
needsAuthorData: hasAuthor && !data.noAuthorImg,
};
}, [getAuthorLink]);

View File

@@ -11,11 +11,15 @@ export function useQuoteState() {
authorlink: null,
authorimg: null,
authorimglicense: null,
packName: null,
packId: null,
realAuthor: null,
noQuote: false,
});
const [uiState, setUiState] = useState({
shareModal: false,
infoModal: false,
});
const updateQuote = useCallback((newData) => {
@@ -32,10 +36,18 @@ export function useQuoteState() {
}));
}, []);
const toggleInfoModal = useCallback((isOpen) => {
setUiState((prev) => ({
...prev,
infoModal: isOpen,
}));
}, []);
return {
quoteData,
uiState,
updateQuote,
toggleShareModal,
toggleInfoModal,
};
}

View File

@@ -181,6 +181,231 @@ h1.quoteauthor {
}
}
.quote-info-text {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 0.75rem;
margin-top: -5px;
cursor: pointer;
transition: opacity 0.2s ease;
user-select: none;
align-self: center;
@include themed {
color: t($subColor);
}
svg {
font-size: 0.8rem;
}
&:hover {
opacity: 0.7;
}
}
.quote-info-button {
@include basicIconButton(11px, 1.3rem, ui);
}
// Quote Info Modal Styles
.quoteInfoModal {
@extend %tabText;
display: flex;
flex-flow: column;
gap: 15px;
padding: 15px;
width: 550px;
@include themed {
background: t($modal-secondaryColour);
}
.shareHeader {
display: flex;
justify-content: space-between;
align-items: center;
h4 {
margin: 0;
font-size: 1.25rem;
}
.close {
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: background 0.2s ease;
&:hover {
@include themed {
background: t($modal-sidebarActive);
}
}
svg {
font-size: 1.5rem;
}
}
}
}
.quoteInfoContent {
padding: 1rem 0;
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
.quoteInfoWarning {
display: flex;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
align-items: flex-start;
@include themed {
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.3);
}
.warningIcon {
flex-shrink: 0;
svg {
font-size: 1.5rem;
color: #ff9800;
}
}
.warningText {
flex: 1;
word-wrap: break-word;
p {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
@include themed {
color: t($color);
}
}
}
}
.quoteInfoSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.quoteInfoRow {
display: flex;
gap: 1rem;
align-items: flex-start;
padding: 0.75rem;
border-radius: 8px;
transition: background 0.2s ease;
&:hover {
@include themed {
background: t($modal-sidebarActive);
}
}
.quoteInfoIcon {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: 50%;
@include themed {
background: t($modal-sidebar);
}
svg {
font-size: 1.2rem;
@include themed {
color: t($color);
}
}
}
.quoteInfoText {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
.quoteInfoLabel {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
@include themed {
color: t($subColor);
}
}
.quoteInfoValue {
font-size: 0.95rem;
@include themed {
color: t($color);
}
.quoteInfoLink {
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
transition: opacity 0.2s ease;
cursor: pointer;
@include themed {
color: t($link);
}
svg {
font-size: 0.9rem;
transition: transform 0.2s ease;
}
&:hover {
opacity: 0.8;
text-decoration: underline;
svg {
transform: translateX(2px);
}
}
}
}
}
}
.quoteInfoFooter {
display: flex;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid;
@include themed {
border-color: t($modal-sidebarActive);
}
button {
@include modal-button(standard);
}
}
.deleteButton {
height: auto !important;