Compare commits

...

40 Commits

Author SHA1 Message Date
6a329e3ba9 Misc fixes 2025-12-01 21:07:01 +00:00
3acaf230c4 fix: Improve submit error handling on API and refactor client with async/await
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 23:02:29 +00:00
7b84573dd8 fix: Improve error handling for non-JSON server responses in Submit
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 22:59:15 +00:00
7523426f15 feat: Display detailed submission errors to user
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 22:56:48 +00:00
b2ec85cfa5 feat: Display detailed, expandable connection error in Comments component
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 22:51:14 +00:00
8c201d5c2e fix: Conditionally render error details to avoid layout gap
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 22:45:58 +00:00
a21c84efc6 refactor: Improve article loading error and cache messages 2025-11-21 22:45:54 +00:00
15aa413584 fix: Prevent layout shift when error message appears
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 22:39:34 +00:00
e9ee231954 feat: Persist new stories and improve layout consistency 2025-11-21 22:39:32 +00:00
62d5915133 feat: Add detailed, expandable error messages to Article component
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 22:34:24 +00:00
61ec583882 feat: Show preload progress on fetch failure
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 00:59:14 +00:00
1443fdcc32 style: Improve error messages and loading text, add spacing to error details 2025-11-21 00:59:12 +00:00
f2310b6925 fix: Provide detailed error for story fetch failures
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 00:50:58 +00:00
aa80570da4 fix: Display network error on API fetch failure
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 00:49:14 +00:00
7d0e60f5f0 fix: Provide detailed error messages for network failures
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 00:45:59 +00:00
21b5d67052 feat: Show detailed connection errors in collapsible section 2025-11-21 00:41:57 +00:00
53468c8ccd feat: Add 10s timeout and early exit for story preloading on error
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-21 00:34:17 +00:00
6cfb4b317f feat: Immediately display stories on first load
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-20 23:02:59 +00:00
f08202d592 fix: Always fetch full story and update existing in feed
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-20 22:58:44 +00:00
5a7f55184d Begin stats API route 2025-11-20 22:25:26 +00:00
e84062394b Ignore aider files 2025-11-20 22:25:20 +00:00
e867d5d868 Add debug logging, debug add manual submissions to feed 2025-11-20 21:55:45 +00:00
845d87ec55 Logging 2025-11-19 19:17:38 +00:00
e18aaad741 fix: Batch story list updates and limit length
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-19 19:17:38 +00:00
02e86efb4f chore: Add console log for stories 2025-11-19 19:17:38 +00:00
b85d879ae7 fix: Fix infinite loop in Feed by removing stories from useEffect deps
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-19 19:17:38 +00:00
55bf75742e refactor: Refactor Feed story fetching for improved network resilience
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-19 19:17:38 +00:00
83cb6fc0ae chore: Disable story updates and preloading logic 2025-11-19 19:17:38 +00:00
667c2c5eaf refactor: Refactor dot components to functional 2025-11-19 19:17:38 +00:00
1df1c59d61 refactor: Refactor Submit component to use hooks 2025-11-19 19:17:38 +00:00
c4f2e7d595 refactor: Refactor Search component to use hooks 2025-11-19 19:17:38 +00:00
f61cfc09b0 refactor: Convert ScrollToTop to functional component with hooks 2025-11-19 19:17:38 +00:00
366e76e25d refactor: refactor Results component to functional component 2025-11-19 19:17:38 +00:00
6f1811c564 Update webclient dependencies 2025-11-19 19:17:38 +00:00
443115ac0f refactor: Refactor Feed component to functional with hooks 2025-11-19 19:17:38 +00:00
034c440e46 refactor: Convert Comments class to functional using hooks 2025-11-19 19:17:38 +00:00
26a6353ca5 refactor: Rename Article component to Comments 2025-11-19 19:17:38 +00:00
7ac4dfa01c refactor: Refactor Article component to use hooks 2025-11-19 19:17:38 +00:00
633429c976 refactor: Convert App class component to functional component 2025-11-19 19:17:38 +00:00
5cdbf6ef54 Ignore blank hackernews titles 2025-11-19 19:17:38 +00:00
14 changed files with 5392 additions and 3847 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.aider*

View File

@@ -146,6 +146,9 @@ def story(ref):
return False return False
if not s['title']:
return False
if s['score'] < 25 and s['num_comments'] < 10: if s['score'] < 25 and s['num_comments'] < 10:
logging.info('Score ({}) or num comments ({}) below threshold.'.format(s['score'], s['num_comments'])) logging.info('Score ({}) or num comments ({}) below threshold.'.format(s['score'], s['num_comments']))
return False return False

View File

@@ -1,7 +1,8 @@
import logging import os, logging
DEBUG = os.environ.get('DEBUG')
logging.basicConfig( logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO) level=logging.DEBUG if DEBUG else logging.INFO)
import gevent import gevent
from gevent import monkey from gevent import monkey
@@ -19,7 +20,7 @@ import settings
import database import database
import search import search
import feed import feed
from utils import gen_rand_id from utils import gen_rand_id, NUM_ID_CHARS
from flask import abort, Flask, request, render_template, stream_with_context, Response from flask import abort, Flask, request, render_template, stream_with_context, Response
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
@@ -29,6 +30,8 @@ database.init()
search.init() search.init()
news_index = 0 news_index = 0
ref_list = []
current_item = {}
def new_id(): def new_id():
nid = gen_rand_id() nid = gen_rand_id()
@@ -50,6 +53,20 @@ def api():
res.headers['content-type'] = 'application/json' res.headers['content-type'] = 'application/json'
return res return res
@flask_app.route('/api/stats', strict_slashes=False)
def apistats():
stats = {
'news_index': news_index,
'ref_list': ref_list,
'len_ref_list': len(ref_list),
'current_item': current_item,
'total_stories': database.count_stories(),
'id_space': 26**NUM_ID_CHARS,
}
return stats
@flask_app.route('/api/search', strict_slashes=False) @flask_app.route('/api/search', strict_slashes=False)
def apisearch(): def apisearch():
q = request.args.get('q', '') q = request.args.get('q', '')
@@ -61,10 +78,17 @@ def apisearch():
res.headers['content-type'] = 'application/json' res.headers['content-type'] = 'application/json'
return res return res
@flask_app.route('/api/submit', methods=['POST'], strict_slashes=False) @flask_app.route('/api/submit', methods=['POST'], strict_slashes=False)
def submit(): def submit():
try: try:
url = request.form['url'] url = request.form['url']
for prefix in ['http://', 'https://']:
if url.lower().startswith(prefix):
break
else: # for
url = 'http://' + url
nid = new_id() nid = new_id()
logging.info('Manual submission: ' + url) logging.info('Manual submission: ' + url)
@@ -89,6 +113,11 @@ def submit():
ref = url ref = url
existing = database.get_story_by_ref(ref) existing = database.get_story_by_ref(ref)
if existing and DEBUG:
ref = ref + '#' + str(time.time())
existing = False
if existing: if existing:
return {'nid': existing.sid} return {'nid': existing.sid}
else: else:
@@ -97,14 +126,20 @@ def submit():
if valid: if valid:
database.put_story(story) database.put_story(story)
search.put_story(story) search.put_story(story)
if DEBUG:
logging.info('Adding manual ref: {}, id: {}, source: {}'.format(ref, nid, source))
database.put_ref(ref, nid, source)
return {'nid': nid} return {'nid': nid}
else: else:
raise Exception('Invalid article') raise Exception('Invalid article')
except BaseException as e: except Exception as e:
logging.error('Problem with article submission: {} - {}'.format(e.__class__.__name__, str(e))) msg = 'Problem with article submission: {} - {}'.format(e.__class__.__name__, str(e))
logging.error(msg)
print(traceback.format_exc()) print(traceback.format_exc())
abort(400) return {'error': msg.split('\n')[0]}, 400
@flask_app.route('/api/<sid>') @flask_app.route('/api/<sid>')
@@ -160,7 +195,7 @@ def static_story(sid):
http_server = WSGIServer(('', 33842), flask_app) http_server = WSGIServer(('', 33842), flask_app)
def feed_thread(): def feed_thread():
global news_index global news_index, ref_list, current_item
try: try:
while True: while True:
@@ -181,13 +216,13 @@ def feed_thread():
# update current stories # update current stories
if news_index < len(ref_list): if news_index < len(ref_list):
item = ref_list[news_index] current_item = ref_list[news_index]
try: try:
story_json = database.get_story(item['sid']).full_json story_json = database.get_story(current_item['sid']).full_json
story = json.loads(story_json) story = json.loads(story_json)
except AttributeError: except AttributeError:
story = dict(id=item['sid'], ref=item['ref'], source=item['source']) story = dict(id=current_item['sid'], ref=current_item['ref'], source=current_item['source'])
logging.info('Updating {} story: {}, index: {}'.format(story['source'], story['ref'], news_index)) logging.info('Updating {} story: {}, index: {}'.format(story['source'], story['ref'], news_index))
@@ -196,8 +231,8 @@ def feed_thread():
database.put_story(story) database.put_story(story)
search.put_story(story) search.put_story(story)
else: else:
database.del_ref(item['ref']) database.del_ref(current_item['ref'])
logging.info('Removed ref {}'.format(item['ref'])) logging.info('Removed ref {}'.format(current_item['ref']))
else: else:
logging.info('Skipping index: ' + str(news_index)) logging.info('Skipping index: ' + str(news_index))

View File

@@ -16,8 +16,9 @@ def alert_tanner(message):
except BaseException as e: except BaseException as e:
logging.error('Problem alerting Tanner: ' + str(e)) logging.error('Problem alerting Tanner: ' + str(e))
NUM_ID_CHARS = 4
def gen_rand_id(): def gen_rand_id():
return ''.join(random.choice(string.ascii_uppercase) for _ in range(4)) return ''.join(random.choice(string.ascii_uppercase) for _ in range(NUM_ID_CHARS))
def render_md(md): def render_md(md):
if md: if md:

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'; import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom';
import localForage from 'localforage'; import localForage from 'localforage';
import './Style-light.css'; import './Style-light.css';
@@ -15,70 +15,63 @@ import Submit from './Submit.js';
import Results from './Results.js'; import Results from './Results.js';
import ScrollToTop from './ScrollToTop.js'; import ScrollToTop from './ScrollToTop.js';
class App extends React.Component { function App() {
constructor(props) { const [theme, setTheme] = useState(localStorage.getItem('theme') || '');
super(props); const cache = useRef({});
const [isFullScreen, setIsFullScreen] = useState(!!document.fullscreenElement);
this.state = { const updateCache = useCallback((key, value) => {
theme: localStorage.getItem('theme') || '', cache.current[key] = value;
}, []);
const light = () => {
setTheme('');
localStorage.setItem('theme', '');
}; };
this.cache = {}; const dark = () => {
} setTheme('dark');
updateCache = (key, value) => {
this.cache[key] = value;
}
light() {
this.setState({ theme: '' });
localStorage.setItem('theme', '');
}
dark() {
this.setState({ theme: 'dark' });
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', 'dark');
} };
black() { const black = () => {
this.setState({ theme: 'black' }); setTheme('black');
localStorage.setItem('theme', 'black'); localStorage.setItem('theme', 'black');
} };
red() { const red = () => {
this.setState({ theme: 'red' }); setTheme('red');
localStorage.setItem('theme', 'red'); localStorage.setItem('theme', 'red');
} };
componentDidMount() { useEffect(() => {
if (!this.cache.length) { if (Object.keys(cache.current).length === 0) {
localForage.iterate((value, key) => { localForage.iterate((value, key) => {
this.updateCache(key, value); updateCache(key, value);
}); }).then(() => {
console.log('loaded cache from localforage'); console.log('loaded cache from localforage');
});
} }
} }, [updateCache]);
goFullScreen() { const goFullScreen = () => {
if ('wakeLock' in navigator) { if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen'); navigator.wakeLock.request('screen');
} }
document.body.requestFullscreen({ navigationUI: 'hide' });
document.body.requestFullscreen({ navigationUI: 'hide' }).then(() => {
window.addEventListener('resize', () => this.forceUpdate());
this.forceUpdate();
});
}; };
exitFullScreen() { const exitFullScreen = () => {
document.exitFullscreen().then(() => { document.exitFullscreen();
this.forceUpdate();
});
}; };
render() { useEffect(() => {
const theme = this.state.theme; const onFullScreenChange = () => setIsFullScreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullScreenChange);
return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
}, []);
useEffect(() => {
if (theme === 'dark') { if (theme === 'dark') {
document.body.style.backgroundColor = '#1a1a1a'; document.body.style.backgroundColor = '#1a1a1a';
} else if (theme === 'black') { } else if (theme === 'black') {
@@ -88,6 +81,7 @@ class App extends React.Component {
} else { } else {
document.body.style.backgroundColor = '#eeeeee'; document.body.style.backgroundColor = '#eeeeee';
} }
}, [theme]);
const fullScreenAvailable = document.fullscreenEnabled || const fullScreenAvailable = document.fullscreenEnabled ||
document.mozFullscreenEnabled || document.mozFullscreenEnabled ||
@@ -101,27 +95,27 @@ class App extends React.Component {
<p> <p>
<Link to='/'>QotNews</Link> <Link to='/'>QotNews</Link>
<span className='theme'><a href='#' onClick={() => this.light()}>Light</a> - <a href='#' onClick={() => this.dark()}>Dark</a> - <a href='#' onClick={() => this.black()}>Black</a> - <a href='#' onClick={() => this.red()}>Red</a></span> <span className='theme'><a href='#' onClick={() => light()}>Light</a> - <a href='#' onClick={() => dark()}>Dark</a> - <a href='#' onClick={() => black()}>Black</a> - <a href='#' onClick={() => red()}>Red</a></span>
<br /> <br />
<span className='slogan'>Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode.</span> <span className='slogan'>Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode.</span>
</p> </p>
<Route path='/(|search)' component={Search} />
<Route path='/(|search)' component={Submit} />
{fullScreenAvailable && {fullScreenAvailable &&
<Route path='/(|search)' render={() => !document.fullscreenElement ? <Route path='/(|search)' render={() => !isFullScreen ?
<button className='fullscreen' onClick={() => this.goFullScreen()}>Enter Fullscreen</button> <button className='fullscreen' onClick={() => goFullScreen()}>Enter Fullscreen</button>
: :
<button className='fullscreen' onClick={() => this.exitFullScreen()}>Exit Fullscreen</button> <button className='fullscreen' onClick={() => exitFullScreen()}>Exit Fullscreen</button>
} /> } />
} }
<Route path='/(|search)' component={Search} />
<Route path='/(|search)' component={Submit} />
</div> </div>
<Route path='/' exact render={(props) => <Feed {...props} updateCache={this.updateCache} />} /> <Route path='/' exact render={(props) => <Feed {...props} updateCache={updateCache} />} />
<Switch> <Switch>
<Route path='/search' component={Results} /> <Route path='/search' component={Results} />
<Route path='/:id' exact render={(props) => <Article {...props} cache={this.cache} />} /> <Route path='/:id' exact render={(props) => <Article {...props} cache={cache.current} />} />
</Switch> </Switch>
<Route path='/:id/c' exact render={(props) => <Comments {...props} cache={this.cache} />} /> <Route path='/:id/c' exact render={(props) => <Comments {...props} cache={cache.current} />} />
<BackwardDot /> <BackwardDot />
<ForwardDot /> <ForwardDot />
@@ -131,6 +125,5 @@ class App extends React.Component {
</div> </div>
); );
} }
}
export default App; export default App;

View File

@@ -1,69 +1,69 @@
import React from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import localForage from 'localforage'; import localForage from 'localforage';
import { sourceLink, infoLine, ToggleDot } from './utils.js'; import { sourceLink, infoLine, ToggleDot } from './utils.js';
class Article extends React.Component { function Article({ cache }) {
constructor(props) { const { id } = useParams();
super(props);
const id = this.props.match ? this.props.match.params.id : 'CLOL';
const cache = this.props.cache;
if (id in cache) console.log('cache hit'); if (id in cache) console.log('cache hit');
this.state = { const [story, setStory] = useState(cache[id] || false);
story: cache[id] || false, const [error, setError] = useState('');
error: false, const [pConv, setPConv] = useState([]);
pConv: [],
};
}
componentDidMount() {
const id = this.props.match ? this.props.match.params.id : 'CLOL';
useEffect(() => {
localForage.getItem(id) localForage.getItem(id)
.then( .then(
(value) => { (value) => {
if (value) { if (value) {
this.setState({ story: value }); setStory(value);
} }
} }
); );
fetch('/api/' + id) fetch('/api/' + id)
.then(res => res.json()) .then(res => {
if (!res.ok) {
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
return res.json();
})
.then( .then(
(result) => { (result) => {
this.setState({ story: result.story }); setStory(result.story);
localForage.setItem(id, result.story); localForage.setItem(id, result.story);
}, },
(error) => { (error) => {
this.setState({ error: true }); const errorMessage = `Failed to fetch new article content (ID: ${id}). Your connection may be down or the server might be experiencing issues. ${error.toString()}.`;
setError(errorMessage);
} }
); );
} }, [id]);
pConvert = (n) => { const pConvert = (n) => {
this.setState({ pConv: [...this.state.pConv, n]}); setPConv(prevPConv => [...prevPConv, n]);
} };
render() { const nodes = useMemo(() => {
const id = this.props.match ? this.props.match.params.id : 'CLOL'; if (story && story.text) {
const story = this.state.story;
const error = this.state.error;
const pConv = this.state.pConv;
let nodes = null;
if (story.text) {
let div = document.createElement('div'); let div = document.createElement('div');
div.innerHTML = story.text; div.innerHTML = story.text;
nodes = div.childNodes; return div.childNodes;
} }
return null;
}, [story]);
return ( return (
<div className='article-container'> <div className='article-container'>
{error && <p>Connection error?</p>} {error &&
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{story && <p>Loaded article from cache.</p>}
</details>
}
{story ? {story ?
<div className='article'> <div className='article'>
<Helmet> <Helmet>
@@ -83,17 +83,19 @@ class Article extends React.Component {
<div className='story-text'> <div className='story-text'>
{Object.entries(nodes).map(([k, v]) => {Object.entries(nodes).map(([k, v]) =>
pConv.includes(k) ? pConv.includes(k) ?
v.innerHTML.split('\n\n').map(x => <React.Fragment key={k}>
<p dangerouslySetInnerHTML={{ __html: x }} /> {v.innerHTML.split('\n\n').map((x, i) =>
) <p key={i} dangerouslySetInnerHTML={{ __html: x }} />
)}
</React.Fragment>
: :
(v.nodeName === '#text' ? (v.nodeName === '#text' ?
<p>{v.data}</p> <p key={k}>{v.data}</p>
: :
<> <React.Fragment key={k}>
<v.localName dangerouslySetInnerHTML={v.innerHTML ? { __html: v.innerHTML } : null} /> <v.localName dangerouslySetInnerHTML={v.innerHTML ? { __html: v.innerHTML } : null} />
{v.localName == 'pre' && <button onClick={() => this.pConvert(k)}>Convert Code to Paragraph</button>} {v.localName === 'pre' && <button onClick={() => pConvert(k)}>Convert Code to Paragraph</button>}
</> </React.Fragment>
) )
)} )}
</div> </div>
@@ -102,12 +104,11 @@ class Article extends React.Component {
} }
</div> </div>
: :
<p>loading...</p> <p>Loading...</p>
} }
<ToggleDot id={id} article={false} /> <ToggleDot id={id} article={false} />
</div> </div>
); );
} }
}
export default Article; export default Article;

View File

@@ -1,83 +1,80 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { HashLink } from 'react-router-hash-link'; import { HashLink } from 'react-router-hash-link';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import moment from 'moment'; import moment from 'moment';
import localForage from 'localforage'; import localForage from 'localforage';
import { infoLine, ToggleDot } from './utils.js'; import { infoLine, ToggleDot } from './utils.js';
class Article extends React.Component { function countComments(c) {
constructor(props) { return c.comments.reduce((sum, x) => sum + countComments(x), 1);
super(props); }
const id = this.props.match.params.id; function Comments({ cache }) {
const cache = this.props.cache; const { id } = useParams();
if (id in cache) console.log('cache hit'); if (id in cache) console.log('cache hit');
this.state = { const [story, setStory] = useState(cache[id] || false);
story: cache[id] || false, const [error, setError] = useState('');
error: false, const [collapsed, setCollapsed] = useState([]);
collapsed: [], const [expanded, setExpanded] = useState([]);
expanded: [],
};
}
componentDidMount() {
const id = this.props.match.params.id;
useEffect(() => {
localForage.getItem(id) localForage.getItem(id)
.then( .then(
(value) => { (value) => {
this.setState({ story: value }); if (value) {
setStory(value);
}
} }
); );
fetch('/api/' + id) fetch('/api/' + id)
.then(res => res.json()) .then(res => {
if (!res.ok) {
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
return res.json();
})
.then( .then(
(result) => { (result) => {
this.setState({ story: result.story }, () => { setStory(result.story);
localForage.setItem(id, result.story);
const hash = window.location.hash.substring(1); const hash = window.location.hash.substring(1);
if (hash) { if (hash) {
document.getElementById(hash).scrollIntoView(); setTimeout(() => {
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView();
}
}, 0);
} }
});
localForage.setItem(id, result.story);
}, },
(error) => { (error) => {
this.setState({ error: true }); const errorMessage = `Failed to fetch comments (ID: ${id}). Your connection may be down or the server might be experiencing issues. ${error.toString()}.`;
setError(errorMessage);
} }
); );
} }, [id]);
collapseComment(cid) { const collapseComment = useCallback((cid) => {
this.setState(prevState => ({ setCollapsed(prev => [...prev, cid]);
...prevState, setExpanded(prev => prev.filter(x => x !== cid));
collapsed: [...prevState.collapsed, cid], }, []);
expanded: prevState.expanded.filter(x => x !== cid),
}));
}
expandComment(cid) { const expandComment = useCallback((cid) => {
this.setState(prevState => ({ setCollapsed(prev => prev.filter(x => x !== cid));
...prevState, setExpanded(prev => [...prev, cid]);
collapsed: prevState.collapsed.filter(x => x !== cid), }, []);
expanded: [...prevState.expanded, cid],
}));
}
countComments(c) { const displayComment = useCallback((story, c, level) => {
return c.comments.reduce((sum, x) => sum + this.countComments(x), 1);
}
displayComment(story, c, level) {
const cid = c.author+c.date; const cid = c.author+c.date;
const collapsed = this.state.collapsed.includes(cid); const isCollapsed = collapsed.includes(cid);
const expanded = this.state.expanded.includes(cid); const isExpanded = expanded.includes(cid);
const hidden = collapsed || (level == 4 && !expanded); const hidden = isCollapsed || (level == 4 && !isExpanded);
const hasChildren = c.comments.length !== 0; const hasChildren = c.comments.length !== 0;
return ( return (
@@ -88,30 +85,31 @@ class Article extends React.Component {
{' '} | <HashLink to={'#'+cid} id={cid}>{moment.unix(c.date).fromNow()}</HashLink> {' '} | <HashLink to={'#'+cid} id={cid}>{moment.unix(c.date).fromNow()}</HashLink>
{hidden || hasChildren && {hidden || hasChildren &&
<span className='collapser pointer' onClick={() => this.collapseComment(cid)}></span> <span className='collapser pointer' onClick={() => collapseComment(cid)}></span>
} }
</p> </p>
</div> </div>
<div className={collapsed ? 'text hidden' : 'text'} dangerouslySetInnerHTML={{ __html: c.text }} /> <div className={isCollapsed ? 'text hidden' : 'text'} dangerouslySetInnerHTML={{ __html: c.text }} />
{hidden && hasChildren ? {hidden && hasChildren ?
<div className='comment lined info pointer' onClick={() => this.expandComment(cid)}>[show {this.countComments(c)-1} more]</div> <div className='comment lined info pointer' onClick={() => expandComment(cid)}>[show {countComments(c)-1} more]</div>
: :
c.comments.map(i => this.displayComment(story, i, level + 1)) c.comments.map(i => displayComment(story, i, level + 1))
} }
</div> </div>
); );
} }, [collapsed, expanded, collapseComment, expandComment]);
render() {
const id = this.props.match.params.id;
const story = this.state.story;
const error = this.state.error;
return ( return (
<div className='container'> <div className='container'>
{error && <p>Connection error?</p>} {error &&
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{story && <p>Loaded comments from cache.</p>}
</details>
}
{story ? {story ?
<div className='article'> <div className='article'>
<Helmet> <Helmet>
@@ -128,7 +126,7 @@ class Article extends React.Component {
{infoLine(story)} {infoLine(story)}
<div className='comments'> <div className='comments'>
{story.comments.map(c => this.displayComment(story, c, 0))} {story.comments.map(c => displayComment(story, c, 0))}
</div> </div>
</div> </div>
: :
@@ -138,6 +136,5 @@ class Article extends React.Component {
</div> </div>
); );
} }
}
export default Article; export default Comments;

View File

@@ -1,53 +1,94 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import localForage from 'localforage'; import localForage from 'localforage';
import { sourceLink, infoLine, logos } from './utils.js'; import { sourceLink, infoLine, logos } from './utils.js';
class Feed extends React.Component { function Feed({ updateCache }) {
constructor(props) { const [stories, setStories] = useState(() => JSON.parse(localStorage.getItem('stories')) || false);
super(props); const [error, setError] = useState('');
this.state = { useEffect(() => {
stories: JSON.parse(localStorage.getItem('stories')) || false,
error: false,
};
}
componentDidMount() {
fetch('/api') fetch('/api')
.then(res => res.json()) .then(res => {
.then( if (!res.ok) {
(result) => { throw new Error(`Server responded with ${res.status} ${res.statusText}`);
const updated = !this.state.stories || this.state.stories[0].id !== result.stories[0].id;
console.log('updated:', updated);
this.setState({ stories: result.stories });
localStorage.setItem('stories', JSON.stringify(result.stories));
if (updated) {
localForage.clear();
result.stories.forEach((x, i) => {
fetch('/api/' + x.id)
.then(res => res.json())
.then(result => {
localForage.setItem(x.id, result.story)
.then(console.log('preloaded', x.id, x.title));
this.props.updateCache(x.id, result.story);
}, error => {}
);
});
} }
return res.json();
})
.then(
async (result) => {
const newApiStories = result.stories;
const updated = !stories || !stories.length || stories[0].id !== newApiStories[0].id;
console.log('New stories available:', updated);
if (!updated) return;
if (!stories || !stories.length) {
setStories(newApiStories);
localStorage.setItem('stories', JSON.stringify(newApiStories));
}
let currentStories = Array.isArray(stories) ? [...stories] : [];
let preloadedCount = 0;
for (const newStory of [...newApiStories].reverse()) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout
const storyRes = await fetch('/api/' + newStory.id, { signal: controller.signal });
clearTimeout(timeoutId);
if (!storyRes.ok) {
throw new Error(`Server responded with ${storyRes.status} ${storyRes.statusText}`);
}
const storyResult = await storyRes.json();
const fullStory = storyResult.story;
await localForage.setItem(fullStory.id, fullStory);
console.log('Preloaded story:', fullStory.id, fullStory.title);
updateCache(fullStory.id, fullStory);
preloadedCount++;
const existingStoryIndex = currentStories.findIndex(s => s.id === newStory.id);
if (existingStoryIndex > -1) {
currentStories.splice(existingStoryIndex, 1);
}
currentStories.unshift(newStory);
localStorage.setItem('stories', JSON.stringify(currentStories));
setStories(currentStories);
} catch (error) {
let errorMessage;
if (error.name === 'AbortError') {
errorMessage = `The request to fetch story '${newStory.title}' (${newStory.id}) timed out after 10 seconds. Your connection may be unstable. (${preloadedCount} / ${newApiStories.length} stories preloaded)`;
console.log('Fetch timed out for story:', newStory.id);
} else {
errorMessage = `An error occurred while fetching story '${newStory.title}' (ID: ${newStory.id}): ${error.toString()}. (${preloadedCount} / ${newApiStories.length} stories preloaded)`;
console.log('Fetch failed for story:', newStory.id, error);
}
setError(errorMessage);
break;
}
}
const finalStories = currentStories.slice(0, newApiStories.length);
const removedStories = currentStories.slice(newApiStories.length);
for (const story of removedStories) {
console.log('Removed story:', story.id, story.title);
localForage.removeItem(story.id);
}
localStorage.setItem('stories', JSON.stringify(finalStories));
setStories(finalStories);
}, },
(error) => { (error) => {
this.setState({ error: true }); const errorMessage = `Failed to fetch the main story list from the API. Your connection may be down or the server might be experiencing issues. ${error.toString()}.`;
setError(errorMessage);
} }
); );
} }, [updateCache]);
render() {
const stories = this.state.stories;
const error = this.state.error;
return ( return (
<div className='container'> <div className='container'>
@@ -55,7 +96,13 @@ class Feed extends React.Component {
<title>QotNews</title> <title>QotNews</title>
<meta name="robots" content="index" /> <meta name="robots" content="index" />
</Helmet> </Helmet>
{error && <p>Connection error?</p>} {error &&
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{stories && <p>Loaded feed from cache.</p>}
</details>
}
{stories ? {stories ?
<div> <div>
{stories.map(x => {stories.map(x =>
@@ -75,11 +122,10 @@ class Feed extends React.Component {
)} )}
</div> </div>
: :
<p>loading...</p> <p>Loading...</p>
} }
</div> </div>
); );
} }
}
export default Feed; export default Feed;

View File

@@ -1,57 +1,36 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { sourceLink, infoLine, logos } from './utils.js'; import { sourceLink, infoLine, logos } from './utils.js';
import AbortController from 'abort-controller'; import AbortController from 'abort-controller';
class Results extends React.Component { function Results() {
constructor(props) { const [stories, setStories] = useState(false);
super(props); const [error, setError] = useState(false);
const location = useLocation();
this.state = { useEffect(() => {
stories: false, const controller = new AbortController();
error: false, const signal = controller.signal;
};
this.controller = null; const search = location.search;
}
performSearch = () => {
if (this.controller) {
this.controller.abort();
}
this.controller = new AbortController();
const signal = this.controller.signal;
const search = this.props.location.search;
fetch('/api/search' + search, { method: 'get', signal: signal }) fetch('/api/search' + search, { method: 'get', signal: signal })
.then(res => res.json()) .then(res => res.json())
.then( .then(
(result) => { (result) => {
this.setState({ stories: result.hits }); setStories(result.hits);
}, },
(error) => { (error) => {
if (error.message !== 'The operation was aborted. ') { if (error.message !== 'The operation was aborted. ') {
this.setState({ error: true }); setError(true);
} }
} }
); );
}
componentDidMount() { return () => {
this.performSearch(); controller.abort();
} };
}, [location.search]);
componentDidUpdate(prevProps) {
if (this.props.location.search !== prevProps.location.search) {
this.performSearch();
}
}
render() {
const stories = this.state.stories;
const error = this.state.error;
return ( return (
<div className='container'> <div className='container'>
@@ -90,6 +69,5 @@ class Results extends React.Component {
</div> </div>
); );
} }
}
export default Results; export default Results;

View File

@@ -1,14 +1,10 @@
import React from 'react'; import { useEffect } from 'react';
import { withRouter } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
class ScrollToTop extends React.Component { function ScrollToTop() {
componentDidUpdate(prevProps) { const { pathname } = useLocation();
//console.log(this.props.location.pathname, prevProps.location.pathname);
if (this.props.location.pathname === prevProps.location.pathname) {
return;
}
useEffect(() => {
if (localStorage.getItem('scrollLock') === 'True') { if (localStorage.getItem('scrollLock') === 'True') {
localStorage.setItem('scrollLock', 'False'); localStorage.setItem('scrollLock', 'False');
return; return;
@@ -16,11 +12,9 @@ class ScrollToTop extends React.Component {
window.scrollTo(0, 0); window.scrollTo(0, 0);
document.body.scrollTop = 0; document.body.scrollTop = 0;
} }, [pathname]);
render() {
return null; return null;
} }
}
export default withRouter(ScrollToTop); export default ScrollToTop;

View File

@@ -1,51 +1,46 @@
import React, { Component } from 'react'; import React, { useState, useRef } from 'react';
import { withRouter } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import queryString from 'query-string'; import queryString from 'query-string';
const getSearch = props => queryString.parse(props.location.search).q; const getSearch = location => queryString.parse(location.search).q || '';
class Search extends Component { function Search() {
constructor(props) { const history = useHistory();
super(props); const location = useLocation();
this.state = {search: getSearch(this.props)}; const [search, setSearch] = useState(getSearch(location));
this.inputRef = React.createRef(); const inputRef = useRef(null);
}
searchArticles = (event) => { const searchArticles = (event) => {
const search = event.target.value; const newSearch = event.target.value;
this.setState({search: search}); setSearch(newSearch);
if (search.length >= 3) { if (newSearch.length >= 3) {
const searchQuery = queryString.stringify({ 'q': search }); const searchQuery = queryString.stringify({ 'q': newSearch });
this.props.history.replace('/search?' + searchQuery); history.replace('/search?' + searchQuery);
} else { } else {
this.props.history.replace('/'); history.replace('/');
} }
} }
searchAgain = (event) => { const searchAgain = (event) => {
event.preventDefault(); event.preventDefault();
const searchString = queryString.stringify({ 'q': event.target[0].value }); const searchString = queryString.stringify({ 'q': event.target[0].value });
this.props.history.push('/search?' + searchString); history.push('/search?' + searchString);
this.inputRef.current.blur(); inputRef.current.blur();
} }
render() {
const search = this.state.search;
return ( return (
<span className='search'> <span className='search'>
<form onSubmit={this.searchAgain}> <form onSubmit={searchAgain}>
<input <input
placeholder='Search...' placeholder='Search...'
value={search} value={search}
onChange={this.searchArticles} onChange={searchArticles}
ref={this.inputRef} ref={inputRef}
/> />
</form> </form>
</span> </span>
); );
} }
}
export default withRouter(Search); export default Search;

View File

@@ -1,54 +1,53 @@
import React, { Component } from 'react'; import React, { useState, useRef } from 'react';
import { withRouter } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
class Submit extends Component { function Submit() {
constructor(props) { const [progress, setProgress] = useState(null);
super(props); const inputRef = useRef(null);
const history = useHistory();
this.state = { const submitArticle = async (event) => {
progress: null,
};
this.inputRef = React.createRef();
}
submitArticle = (event) => {
event.preventDefault(); event.preventDefault();
const url = event.target[0].value; const url = event.target[0].value;
this.inputRef.current.blur(); inputRef.current.blur();
this.setState({ progress: 'Submitting...' }); setProgress('Submitting...');
let data = new FormData(); let data = new FormData();
data.append('url', url); data.append('url', url);
fetch('/api/submit', { method: 'POST', body: data }) try {
.then(res => res.json()) const res = await fetch('/api/submit', { method: 'POST', body: data });
.then(
(result) => {
this.props.history.replace('/' + result.nid);
},
(error) => {
this.setState({ progress: 'Error' });
}
);
}
render() { if (res.ok) {
const progress = this.state.progress; const result = await res.json();
history.replace('/' + result.nid);
} else {
let errorData;
try {
errorData = await res.json();
} catch (jsonError) {
// Not a JSON error from our API, so it's a server issue
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
setProgress(errorData.error || 'An unknown error occurred.');
}
} catch (error) {
setProgress(`Error: ${error.toString()}`);
}
}
return ( return (
<span className='search'> <span className='search'>
<form onSubmit={this.submitArticle}> <form onSubmit={submitArticle}>
<input <input
placeholder='Submit URL' placeholder='Submit URL'
ref={this.inputRef} ref={inputRef}
/> />
</form> </form>
{progress ? progress : ''} {progress && <p>{progress}</p>}
</span> </span>
); );
} }
}
export default withRouter(Submit); export default Submit;

View File

@@ -21,12 +21,7 @@ export const infoLine = (story) =>
</div> </div>
; ;
export class ToggleDot extends React.Component { export const ToggleDot = ({ id, article }) => (
render() {
const id = this.props.id;
const article = this.props.article;
return (
<div className='dot toggleDot'> <div className='dot toggleDot'>
<div className='button'> <div className='button'>
<Link to={'/' + id + (article ? '' : '/c')}> <Link to={'/' + id + (article ? '' : '/c')}>
@@ -35,49 +30,43 @@ export class ToggleDot extends React.Component {
</div> </div>
</div> </div>
); );
}
}
export class BackwardDot extends React.Component { export const BackwardDot = () => {
goBackward() { const goBackward = () => {
localStorage.setItem('scrollLock', 'True'); localStorage.setItem('scrollLock', 'True');
window.history.back(); window.history.back();
} };
render() {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (!isMobile) return null; if (!isMobile) return null;
if (!document.fullscreenElement) return null; if (!document.fullscreenElement) return null;
return ( return (
<div className='dot backwardDot' onClick={this.goBackward}> <div className='dot backwardDot' onClick={goBackward}>
<div className='button'> <div className='button'>
</div> </div>
</div> </div>
); );
} };
}
export class ForwardDot extends React.Component { export const ForwardDot = () => {
goForward() { const goForward = () => {
localStorage.setItem('scrollLock', 'True'); localStorage.setItem('scrollLock', 'True');
window.history.forward(); window.history.forward();
} };
render() {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (!isMobile) return null; if (!isMobile) return null;
return ( return (
<div className='dot forwardDot' onClick={this.goForward}> <div className='dot forwardDot' onClick={goForward}>
<div className='button'> <div className='button'>
</div> </div>
</div> </div>
); );
} };
}
export const logos = { export const logos = {
hackernews: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH4wgeBhwhciGZUAAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAGCSURBVFjD7Za/S0JRFMc/+oSgLWjLH/2AIKEhC2opIp1amqw/INCo9lbHghCnKDdpN5OoIGhISSLwx2RCEYSjUWhWpO+9hicopCHh8w29Mx3u/XLv95z7Pedcg+y1VQEBbUw0ang5gGBEY9MJ6ARMbaH6HdBnBlmC+5PfsVYX9PTCSx4KyQ4RsI6DxwcYIGSFxF5znHkOtvZBECDoa4tAe0+QDMFDVvFd7ta4pU0QTAo2GeqwBqIHIEkwMAQzaz/3LfNgn1Qw0aAKIswdQzZVy8Jyk+g3lNTfpSEXUakKjgJQrYB5GKY9DRpZALsDxCqEAyqWYT4G6etaFlYaol8HowCZBOSvVO4DR374+gTLCEytgs0JYxPKWtivUh9otOcM3FzC7CI43fBWVKK/vYBCqkudMLIN7yUYHFXe/qMMkZ0utuLyE8ROwWBU6j5+BqXHLs+C+GHdP9/VYBhJ1bpfedXHsU5A5Q9JKxEWa+KT5T8fY5C9NlnXgE7g3xMQNbxf/AZyEGqvyYs/dQAAAABJRU5ErkJggg==', hackernews: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH4wgeBhwhciGZUAAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAGCSURBVFjD7Za/S0JRFMc/+oSgLWjLH/2AIKEhC2opIp1amqw/INCo9lbHghCnKDdpN5OoIGhISSLwx2RCEYSjUWhWpO+9hicopCHh8w29Mx3u/XLv95z7Pedcg+y1VQEBbUw0ang5gGBEY9MJ6ARMbaH6HdBnBlmC+5PfsVYX9PTCSx4KyQ4RsI6DxwcYIGSFxF5znHkOtvZBECDoa4tAe0+QDMFDVvFd7ta4pU0QTAo2GeqwBqIHIEkwMAQzaz/3LfNgn1Qw0aAKIswdQzZVy8Jyk+g3lNTfpSEXUakKjgJQrYB5GKY9DRpZALsDxCqEAyqWYT4G6etaFlYaol8HowCZBOSvVO4DR374+gTLCEytgs0JYxPKWtivUh9otOcM3FzC7CI43fBWVKK/vYBCqkudMLIN7yUYHFXe/qMMkZ0utuLyE8ROwWBU6j5+BqXHLs+C+GHdP9/VYBhJ1bpfedXHsU5A5Q9JKxEWa+KT5T8fY5C9NlnXgE7g3xMQNbxf/AZyEGqvyYs/dQAAAABJRU5ErkJggg==',

File diff suppressed because it is too large Load Diff