Files
qotnews/webclient/src/App.js
T

220 lines
7.1 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 = 16;
const defaultStoryFontSize = 1.2;
const [bodyFontSize, setBodyFontSize] = useState(Number(localStorage.getItem('bodyFontSize')) || defaultBodyFontSize);
const [storyFontSize, setStoryFontSize] = useState(Number(localStorage.getItem('storyFontSize')) || defaultStoryFontSize);
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 changeBodyFontSize = (amount) => {
const newSize = bodyFontSize + amount;
setBodyFontSize(newSize);
localStorage.setItem('bodyFontSize', newSize);
};
const changeStoryFontSize = (amount) => {
const newSize = storyFontSize + amount;
setStoryFontSize(parseFloat(newSize.toFixed(1)));
localStorage.setItem('storyFontSize', newSize.toFixed(1));
};
const resetFontSettings = () => {
setBodyFontSize(defaultBodyFontSize);
localStorage.removeItem('bodyFontSize');
setStoryFontSize(defaultStoryFontSize);
localStorage.removeItem('storyFontSize');
};
const fontSettingsChanged = bodyFontSize !== defaultBodyFontSize || storyFontSize !== defaultStoryFontSize;
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}px`;
}, [bodyFontSize]);
useEffect(() => {
const styleId = 'story-font-size-style';
let style = document.getElementById(styleId);
if (!style) {
style = document.createElement('style');
style.id = styleId;
document.head.appendChild(style);
}
style.innerHTML = `.story-text { font-size: ${storyFontSize}rem !important; }`;
}, [storyFontSize]);
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>
</div>
<div className="setting-group">
<h4>Body Font Size</h4>
<button onClick={() => changeBodyFontSize(-1)}>-</button>
<span className="font-size-display">{bodyFontSize}px</span>
<button onClick={() => changeBodyFontSize(1)}>+</button>
</div>
<div className="setting-group">
<h4>Story Font Size</h4>
<button onClick={() => changeStoryFontSize(-0.1)}>-</button>
<span className="font-size-display">{storyFontSize.toFixed(1)}rem</span>
<button onClick={() => changeStoryFontSize(0.1)}>+</button>
</div>
<button onClick={resetFontSettings} disabled={!fontSettingsChanged}>Reset Font Settings</button>
</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>
{fullScreenAvailable &&
<Route path='/(|search)' render={() => !isFullScreen ?
<button className='fullscreen' onClick={() => goFullScreen()}>Enter Fullscreen</button>
:
<button className='fullscreen' onClick={() => exitFullScreen()}>Exit Fullscreen</button>
} />
}
<Route path='/(|search)' component={Search} />
<Route path='/(|search)' component={Submit} />
</div>
<Route path='/' exact render={(props) => <Feed {...props} updateCache={updateCache} />} />
<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;