forked from tanner/qotnews
313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom';
|
|
import localForage from 'localforage';
|
|
import './Style-light.css';
|
|
import './Style-dark.css';
|
|
import './Style-black.css';
|
|
import './Style-red.css';
|
|
import './fonts/Fonts.css';
|
|
import { BackwardDot, ForwardDot } from './utils.js';
|
|
import Feed from './Feed.js';
|
|
import Article from './Article.js';
|
|
import Comments from './Comments.js';
|
|
import Search from './Search.js';
|
|
import Submit from './Submit.js';
|
|
import Results from './Results.js';
|
|
import ScrollToTop from './ScrollToTop.js';
|
|
|
|
function App() {
|
|
const [theme, setTheme] = useState(localStorage.getItem('theme') || '');
|
|
const cache = useRef({});
|
|
const [isFullScreen, setIsFullScreen] = useState(!!document.fullscreenElement);
|
|
const [waitingWorker, setWaitingWorker] = useState(null);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const defaultBodyFontSize = 1.0;
|
|
const [bodyFontSize, setBodyFontSize] = useState(Number(localStorage.getItem('bodyFontSize')) || defaultBodyFontSize);
|
|
const [bodyFont, setBodyFont] = useState(localStorage.getItem('bodyFont') || 'Sans Serif');
|
|
const [articleFont, setArticleFont] = useState(localStorage.getItem('articleFont') || 'Apparatus SIL');
|
|
const [filterSmallweb, setFilterSmallweb] = useState(() => localStorage.getItem('filterSmallweb') === 'true');
|
|
const [feedSources, setFeedSources] = useState(() => {
|
|
const saved = localStorage.getItem('feedSources');
|
|
return saved ? JSON.parse(saved) : {
|
|
hackernews: true,
|
|
reddit: true,
|
|
lobsters: true,
|
|
tildes: true,
|
|
};
|
|
});
|
|
|
|
const updateCache = useCallback((key, value) => {
|
|
cache.current[key] = value;
|
|
}, []);
|
|
|
|
const light = () => {
|
|
setTheme('');
|
|
localStorage.setItem('theme', '');
|
|
};
|
|
|
|
const dark = () => {
|
|
setTheme('dark');
|
|
localStorage.setItem('theme', 'dark');
|
|
};
|
|
|
|
const black = () => {
|
|
setTheme('black');
|
|
localStorage.setItem('theme', 'black');
|
|
};
|
|
|
|
const red = () => {
|
|
setTheme('red');
|
|
localStorage.setItem('theme', 'red');
|
|
};
|
|
|
|
const handleFilterChange = e => {
|
|
const isChecked = e.target.checked;
|
|
setFilterSmallweb(isChecked);
|
|
localStorage.setItem('filterSmallweb', isChecked);
|
|
};
|
|
|
|
const handleFeedSourceChange = (source) => {
|
|
setFeedSources(prevSources => {
|
|
const newSources = { ...prevSources, [source]: !prevSources[source] };
|
|
localStorage.setItem('feedSources', JSON.stringify(newSources));
|
|
return newSources;
|
|
});
|
|
};
|
|
|
|
const changeBodyFont = (font) => {
|
|
setBodyFont(font);
|
|
localStorage.setItem('bodyFont', font);
|
|
};
|
|
|
|
const changeArticleFont = (font) => {
|
|
setArticleFont(font);
|
|
localStorage.setItem('articleFont', font);
|
|
};
|
|
|
|
const changeBodyFontSize = (amount) => {
|
|
const newSize = bodyFontSize + amount;
|
|
setBodyFontSize(parseFloat(newSize.toFixed(2)));
|
|
localStorage.setItem('bodyFontSize', newSize.toFixed(2));
|
|
};
|
|
|
|
const resetBodyFontSize = () => {
|
|
setBodyFontSize(defaultBodyFontSize);
|
|
localStorage.removeItem('bodyFontSize');
|
|
};
|
|
|
|
const bodyFontSettingsChanged = bodyFontSize !== defaultBodyFontSize;
|
|
|
|
useEffect(() => {
|
|
const onSWUpdate = e => {
|
|
setWaitingWorker(e.detail.waiting);
|
|
};
|
|
window.addEventListener('swUpdate', onSWUpdate);
|
|
return () => window.removeEventListener('swUpdate', onSWUpdate);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (Object.keys(cache.current).length === 0) {
|
|
localForage.iterate((value, key) => {
|
|
updateCache(key, value);
|
|
}).then(() => {
|
|
console.log('loaded cache from localforage');
|
|
});
|
|
}
|
|
}, [updateCache]);
|
|
|
|
const goFullScreen = () => {
|
|
if ('wakeLock' in navigator) {
|
|
navigator.wakeLock.request('screen');
|
|
}
|
|
document.body.requestFullscreen({ navigationUI: 'hide' });
|
|
};
|
|
|
|
const exitFullScreen = () => {
|
|
document.exitFullscreen();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onFullScreenChange = () => setIsFullScreen(!!document.fullscreenElement);
|
|
document.addEventListener('fullscreenchange', onFullScreenChange);
|
|
return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (theme === 'dark') {
|
|
document.body.style.backgroundColor = '#1a1a1a';
|
|
} else if (theme === 'black') {
|
|
document.body.style.backgroundColor = '#000';
|
|
} else if (theme === 'red') {
|
|
document.body.style.backgroundColor = '#000';
|
|
} else {
|
|
document.body.style.backgroundColor = '#eeeeee';
|
|
}
|
|
}, [theme]);
|
|
|
|
useEffect(() => {
|
|
document.documentElement.style.fontSize = `${bodyFontSize}rem`;
|
|
}, [bodyFontSize]);
|
|
|
|
const fontMap = {
|
|
'Sans Serif': 'sans-serif',
|
|
'Serif': 'serif',
|
|
'Apparatus SIL': "'Apparatus SIL', sans-serif"
|
|
};
|
|
|
|
useEffect(() => {
|
|
document.body.style.fontFamily = fontMap[bodyFont];
|
|
}, [bodyFont]);
|
|
|
|
useEffect(() => {
|
|
const styleId = 'article-font-family-style';
|
|
let style = document.getElementById(styleId);
|
|
if (!style) {
|
|
style = document.createElement('style');
|
|
style.id = styleId;
|
|
document.head.appendChild(style);
|
|
}
|
|
style.innerHTML = `.story-text { font-family: ${fontMap[articleFont]} !important; }`;
|
|
}, [articleFont]);
|
|
|
|
|
|
const fullScreenAvailable = document.fullscreenEnabled ||
|
|
document.mozFullscreenEnabled ||
|
|
document.webkitFullscreenEnabled ||
|
|
document.msFullscreenEnabled;
|
|
|
|
return (
|
|
<div className={theme}>
|
|
{settingsOpen &&
|
|
<div className="modal-overlay" onClick={() => setSettingsOpen(false)}>
|
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
|
<h3>Settings</h3>
|
|
|
|
<div className="setting-group">
|
|
<h4>Theme</h4>
|
|
<button className={theme === '' ? 'active' : ''} onClick={() => { light() }}>Light</button>
|
|
<button className={theme === 'dark' ? 'active' : ''} onClick={() => { dark() }}>Dark</button>
|
|
<button className={theme === 'black' ? 'active' : ''} onClick={() => { black() }}>Black</button>
|
|
<button className={theme === 'red' ? 'active' : ''} onClick={() => { red() }}>Red</button>
|
|
{fullScreenAvailable &&
|
|
<div style={{ marginTop: '0.5rem' }}>
|
|
{!isFullScreen ?
|
|
<button onClick={() => goFullScreen()}>Enter Fullscreen</button>
|
|
:
|
|
<button onClick={() => exitFullScreen()}>Exit Fullscreen</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div className="setting-group">
|
|
<h4>Feed</h4>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="checkbox" id="filter-smallweb" checked={filterSmallweb} onChange={handleFilterChange} />
|
|
<label htmlFor="filter-smallweb">Small websites only</label>
|
|
</div>
|
|
<br/>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="checkbox" id="filter-hackernews" name="feed-source" checked={feedSources.hackernews} onChange={() => handleFeedSourceChange('hackernews')} />
|
|
<label htmlFor="filter-hackernews">Hacker News</label>
|
|
</div>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="checkbox" id="filter-reddit" name="feed-source" checked={feedSources.reddit} onChange={() => handleFeedSourceChange('reddit')} />
|
|
<label htmlFor="filter-reddit">Reddit</label>
|
|
</div>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="checkbox" id="filter-lobsters" name="feed-source" checked={feedSources.lobsters} onChange={() => handleFeedSourceChange('lobsters')} />
|
|
<label htmlFor="filter-lobsters">Lobsters</label>
|
|
</div>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="checkbox" id="filter-tildes" name="feed-source" checked={feedSources.tildes} onChange={() => handleFeedSourceChange('tildes')} />
|
|
<label htmlFor="filter-tildes">Tildes</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="setting-group">
|
|
<h4>Font Size</h4>
|
|
<button onClick={() => changeBodyFontSize(-0.05)}>-</button>
|
|
<span className="font-size-display">{bodyFontSize.toFixed(2)}</span>
|
|
<button onClick={() => changeBodyFontSize(0.05)}>+</button>
|
|
<button onClick={resetBodyFontSize} disabled={!bodyFontSettingsChanged}>Reset</button>
|
|
</div>
|
|
|
|
<div className="setting-group">
|
|
<h4>Body Font</h4>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="radio" id="body-sans-serif" name="body-font" value="Sans Serif" checked={bodyFont === 'Sans Serif'} onChange={() => changeBodyFont('Sans Serif')} />
|
|
<label htmlFor="body-sans-serif">Sans Serif *</label>
|
|
</div>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="radio" id="body-serif" name="body-font" value="Serif" checked={bodyFont === 'Serif'} onChange={() => changeBodyFont('Serif')} />
|
|
<label htmlFor="body-serif">Serif</label>
|
|
</div>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="radio" id="body-apparatus" name="body-font" value="Apparatus SIL" checked={bodyFont === 'Apparatus SIL'} onChange={() => changeBodyFont('Apparatus SIL')} />
|
|
<label htmlFor="body-apparatus">Apparatus SIL</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="setting-group">
|
|
<h4>Article Font</h4>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="radio" id="article-sans-serif" name="article-font" value="Sans Serif" checked={articleFont === 'Sans Serif'} onChange={() => changeArticleFont('Sans Serif')} />
|
|
<label htmlFor="article-sans-serif">Sans Serif</label>
|
|
</div>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="radio" id="article-serif" name="article-font" value="Serif" checked={articleFont === 'Serif'} onChange={() => changeArticleFont('Serif')} />
|
|
<label htmlFor="article-serif">Serif</label>
|
|
</div>
|
|
<div className="font-option">
|
|
<input className="checkbox" type="radio" id="article-apparatus" name="article-font" value="Apparatus SIL" checked={articleFont === 'Apparatus SIL'} onChange={() => changeArticleFont('Apparatus SIL')} />
|
|
<label htmlFor="article-apparatus">Apparatus SIL *</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
{waitingWorker &&
|
|
<div className='update-banner'>
|
|
Client version mismatch, please refresh:{' '}
|
|
<button onClick={() => {
|
|
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
window.location.reload();
|
|
});
|
|
}}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
}
|
|
<Router>
|
|
<div className='container menu'>
|
|
<p>
|
|
<Link to='/'>QotNews</Link>
|
|
|
|
<button className="settings-button" onClick={() => setSettingsOpen(true)}>Settings</button>
|
|
<br />
|
|
<span className='slogan'>Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode.</span>
|
|
</p>
|
|
<Route path='/(|search)' component={Search} />
|
|
<Route path='/(|search)' component={Submit} />
|
|
</div>
|
|
|
|
<Route path='/' exact render={(props) => <Feed {...props} updateCache={updateCache} filterSmallweb={filterSmallweb} feedSources={feedSources} />} />
|
|
<Switch>
|
|
<Route path='/search' component={Results} />
|
|
<Route path='/:id' exact render={(props) => <Article {...props} cache={cache.current} />} />
|
|
</Switch>
|
|
<Route path='/:id/c' exact render={(props) => <Comments {...props} cache={cache.current} />} />
|
|
|
|
<BackwardDot />
|
|
<ForwardDot />
|
|
|
|
<ScrollToTop />
|
|
</Router>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|