Compare commits

...

135 Commits

Author SHA1 Message Date
7b978cac43 Test update 2025-12-27 18:36:32 +00:00
b5241a2a42 Style update banner correctly 2025-12-27 18:29:23 +00:00
8bf33e2d45 style: Consolidate update banner styles and add theme support
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-27 18:15:22 +00:00
987c401dc4 chore: Update client version mismatch message 2025-12-27 18:15:18 +00:00
6facbd3397 chore: Log service worker update check 2025-12-27 18:13:07 +00:00
a680b4b446 feat: Check for service worker updates on feed load
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-27 18:01:45 +00:00
2757f701d0 feat: Implement in-app service worker update notification
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-27 17:52:45 +00:00
a1ebcc6d80 Change checkbox label to "Small websites" 2025-12-16 23:41:11 +00:00
82478c469d Add "similar" link for searching host name 2025-12-13 22:37:47 +00:00
77b429a742 Don't move backward dot tooltip 2025-12-08 23:54:41 +00:00
0c1c56b54a Fix: Prevent false positive single dollar math detection in text nodes
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-08 23:52:36 +00:00
73a139ef9a fix: Fix browser navigation tooltip alignment
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-08 23:42:39 +00:00
5796bc56b5 fix: Adjust default tooltip horizontal position 2025-12-08 23:42:36 +00:00
7c1910c919 fix: Improve nav dot tooltip visibility and positioning
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-08 23:37:10 +00:00
60a4df2efc feat: Add tooltips to browser navigation dots
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-08 23:33:52 +00:00
a712ca12da Bring single dollar sign math back, check for whitespace 2025-12-07 22:59:09 +00:00
8700bda104 Adjust checkbox styles 2025-12-07 22:53:12 +00:00
3160e02d41 Fix: Split border shorthand for dark mode color override
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-07 22:53:12 +00:00
cfa61c2066 fix: Improve dark mode checkbox checkmark visibility
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-07 22:53:12 +00:00
27faea1823 style: Refine checked checkbox border color in dark theme 2025-12-07 22:53:12 +00:00
df76e34c71 fix: Prevent price false positives in single dollar math detection
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-07 22:53:12 +00:00
5d014f50df fix: Remove single dollar sign math rendering due to false positives
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-05 17:24:40 +00:00
bcfdff1067 Fix dt dd tags margin 2025-12-05 00:59:02 +00:00
a888e38ae8 fix: Adjust comment metadata indentation in comments
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-05 00:49:13 +00:00
2bd51bb1cb fix: Refactor comments with DL/DD for text browser compatibility
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-05 00:45:00 +00:00
655346a7eb chore: Remove unused nojs div 2025-12-05 00:44:58 +00:00
125c1c5225 Fix buttons in color themes 2025-12-05 00:35:06 +00:00
5dd2069af5 Clear stories first on checkbox change 2025-12-04 23:12:30 +00:00
d68fc73af5 Don't setStories when existing list is empty 2025-12-04 22:57:26 +00:00
ff1297e507 Style checkbox 2025-12-04 22:55:23 +00:00
1d019f880b fix: Implement custom transparent checkbox for dark mode visibility
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 22:31:11 +00:00
23b56b26b1 style: Apply transparent background to checkboxes 2025-12-04 22:31:07 +00:00
b439199836 fix: Cancel pending story fetches on filter change to prevent UI jumps
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 22:24:28 +00:00
5736cde21a feat: Fetch smallweb stories iteratively until limit met
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 22:18:22 +00:00
ed8ad1b6f6 feat: Add domain exclusion to smallweb list loading 2025-12-04 22:18:19 +00:00
75779722c1 feat: Add smallweb filter checkbox and server-side filtering
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 22:09:11 +00:00
13df4a7831 Put the loading status down below 2025-12-04 21:10:20 +00:00
d511453418 fix: Detect and render inline math using single dollar delimiters
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 20:56:14 +00:00
5e7240e2d0 fix: Convert inline align environments to display math
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 20:50:48 +00:00
96719f9e6f chore: Adjust console.log placement in Article component 2025-12-04 20:45:21 +00:00
0d4e674f3d chore: Add debug log for math block detection 2025-12-04 20:42:55 +00:00
7ce94e80dd fix: Render LaTeX expressions that are entire element contents
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 20:35:51 +00:00
1729e5b2be Add latex packages 2025-12-04 20:31:40 +00:00
d04bc2fe05 feat: Add LaTeX math rendering support
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 20:29:13 +00:00
02d165b2b2 fix: Extend direct HTML rendering to math elements
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 20:24:08 +00:00
2d10abf9aa fix: Prevent React warnings for SVG attributes
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 20:23:08 +00:00
e8911dc1d1 Move logos into public directory 2025-12-04 19:54:56 +00:00
41c4d7619d Downgrade humanize 2025-12-04 19:53:13 +00:00
e36fe3a403 Freeze requirements 2025-12-04 19:51:42 +00:00
fbec869257 Don't locate css file on server 2025-12-04 19:49:19 +00:00
e9e3cb30a4 chore: Remove conditional CSS import and improve alt attributes 2025-12-04 19:29:04 +00:00
a5e762c36b feat: Display relative time on non-JS article info line
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 19:11:27 +00:00
bbcb01f8d1 style: Remove zero-width spaces from story info 2025-12-04 19:11:24 +00:00
df0e66ad08 feat: Render homepage feed server-side
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:42:14 +00:00
1fefc149e2 feat: Include QotNews header for non-JS users
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:38:19 +00:00
449cb13dbd feat: Add relative timestamps and permalinks to comments
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:35:43 +00:00
f206485124 fix: Widen comments container on story page
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:32:57 +00:00
b185ecfe81 refactor: Align non-JS comments page structure and style
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:29:41 +00:00
274b4065e2 style: Match non-JS article page styling and layout to JS version
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:26:06 +00:00
85b6fbabf3 feat: Link compiled CSS bundle for non-JS client
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:16:50 +00:00
32cbf47d95 feat: Add static rendering for article pages
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-04 18:01:00 +00:00
7c600dcfba Only wrap code in comments 2025-12-03 04:18:36 +00:00
92e70229fe fix: Refine code block detection to ignore inline <code>
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 03:57:08 +00:00
b749e58f62 fix: Refine code block detection to exclude inline code
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 03:55:18 +00:00
b1b2be6080 fix: Use textContent for code block conversion to prevent content loss
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 03:51:33 +00:00
5ebe87dbc2 refactor: Optimize nodes() calls and simplify function in Article
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 03:50:10 +00:00
a8a36b693e fix: Render void elements correctly and copy all attributes
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 03:12:51 +00:00
60eefb4b27 refactor: Implement recursive rendering to detect and convert code blocks
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 02:52:07 +00:00
8f5dae4bdc fix: Unwrap single-child wrapper elements in nodes function
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 02:46:20 +00:00
89a511efc0 chore: Add debug log to isCodeBlock function 2025-12-03 02:46:18 +00:00
504fe745ea fix: Relax isCodeBlock check for nested code elements
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 02:37:58 +00:00
762e8a9a2e refactor: Refactor nodes logic from useMemo to a regular function 2025-12-03 02:37:56 +00:00
6dc47f6672 refactor: Extract code block detection into isCodeBlock function
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 01:46:19 +00:00
da108f25d4 fix: Detect code blocks nested in pre tags for conversion
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 01:43:33 +00:00
a2303841ec fix: Show 'Convert Code to Paragraph' button for <code> elements
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 01:37:08 +00:00
0e7aedbc5e fix: Adjust spacing below comment text content
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 01:28:10 +00:00
ec7d395407 fix: Wrap text in <pre> blocks to prevent horizontal overflow
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 00:58:39 +00:00
fd5acd4861 refactor: Convert 'show more' div to semantic button
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 00:50:58 +00:00
b1d4fc2903 refactor: Convert collapser span to button for accessibility
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-03 00:48:22 +00:00
0f87d47536 refactor: Remove unnecessary useCallback from comment functions
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 23:53:40 +00:00
8472907730 Mark deleted / empty comments 2025-12-02 23:39:24 +00:00
482753e96a Add a copy button to the article title 2025-12-02 23:19:31 +00:00
169a84faa1 fix: Align article title and copy button, correct icon font
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 23:19:31 +00:00
6fa929fb1f style: Update copy link button font 2025-12-02 23:19:31 +00:00
5f02a95cf3 fix: Improve copy button icon display and alignment
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 23:19:31 +00:00
1789f88d4d style: Style copy button icon 2025-12-02 23:19:31 +00:00
f5eab47496 feat: Use icons for copy link button feedback
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 23:19:31 +00:00
985e596790 feat: Add button to copy article title and URL to clipboard
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 23:19:31 +00:00
30298928f3 Move static build directory to apiserver/ 2025-12-02 22:38:49 +00:00
8d7d692d9c refactor: Iterate through stories in order for prioritized updates
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 22:37:58 +00:00
bd85127613 fix: Unregister service worker
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 17:13:52 +00:00
4c9d5eede1 Revert ScrollToTop component back to class-based 2025-12-02 17:02:03 +00:00
bf3e6bbc28 Don't setStories every loop iteration 2025-12-02 16:52:32 +00:00
856c360d98 feat: Add loading progress indicator to Feed
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-12-02 01:20:27 +00:00
1ce55e6d1f feat: Add fetching stories placeholder 2025-12-02 01:20:25 +00:00
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
29 changed files with 6011 additions and 3863 deletions

1
.gitignore vendored Normal file
View File

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

0
apiserver/build/.gitkeep Normal file
View File

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

@@ -8,6 +8,7 @@ Flask==1.1.2
Flask-Cors==3.0.8 Flask-Cors==3.0.8
gevent==20.6.2 gevent==20.6.2
greenlet==0.4.16 greenlet==0.4.16
humanize==4.10.0
idna==2.10 idna==2.10
itsdangerous==1.1.0 itsdangerous==1.1.0
Jinja2==2.11.2 Jinja2==2.11.2

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
@@ -13,22 +14,46 @@ import json
import threading import threading
import traceback import traceback
import time import time
import datetime
import humanize
import urllib.request
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import settings 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
from flask_cors import CORS from flask_cors import CORS
smallweb_set = set()
def load_smallweb_list():
EXCLUDED = [
'github.com',
]
global smallweb_set
try:
url = 'https://raw.githubusercontent.com/kagisearch/smallweb/refs/heads/main/smallweb.txt'
with urllib.request.urlopen(url, timeout=10) as response:
urls = response.read().decode('utf-8').splitlines()
hosts = {urlparse(u).hostname for u in urls if u and urlparse(u).hostname}
smallweb_set = {h.replace('www.', '') for h in hosts if h not in EXCLUDED}
logging.info('Loaded {} smallweb domains.'.format(len(smallweb_set)))
except Exception as e:
logging.error('Failed to load smallweb list: {}'.format(e))
load_smallweb_list()
database.init() 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()
@@ -36,20 +61,73 @@ def new_id():
nid = gen_rand_id() nid = gen_rand_id()
return nid return nid
build_folder = '../webclient/build'
def fromnow(ts):
return humanize.naturaltime(datetime.datetime.fromtimestamp(ts))
build_folder = './build'
flask_app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='') flask_app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='')
flask_app.jinja_env.filters['fromnow'] = fromnow
cors = CORS(flask_app) cors = CORS(flask_app)
@flask_app.route('/api') @flask_app.route('/api')
def api(): def api():
skip = request.args.get('skip', 0) skip = request.args.get('skip', 0)
limit = request.args.get('limit', settings.FEED_LENGTH) limit = request.args.get('limit', settings.FEED_LENGTH)
stories = database.get_stories(limit, skip)
if request.args.get('smallweb') == 'true' and smallweb_set:
limit = int(limit)
skip = int(skip)
filtered_stories = []
current_skip = skip
while len(filtered_stories) < limit:
stories_batch = database.get_stories(limit, current_skip)
if not stories_batch:
break
for story_str in stories_batch:
story = json.loads(story_str)
story_url = story.get('url') or story.get('link') or ''
if not story_url:
continue
hostname = urlparse(story_url).hostname
if hostname:
hostname = hostname.replace('www.', '')
if hostname in smallweb_set:
filtered_stories.append(story_str)
if len(filtered_stories) == limit:
break
if len(filtered_stories) == limit:
break
current_skip += limit
stories = filtered_stories
else:
stories = database.get_stories(limit, skip)
# hacky nested json # hacky nested json
res = Response('{"stories":[' + ','.join(stories) + ']}') res = Response('{"stories":[' + ','.join(stories) + ']}')
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 +139,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 +174,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 +187,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>')
@@ -121,11 +217,18 @@ def story(sid):
@flask_app.route('/') @flask_app.route('/')
@flask_app.route('/search') @flask_app.route('/search')
def index(): def index():
stories_json = database.get_stories(settings.FEED_LENGTH, 0)
stories = [json.loads(s) for s in stories_json]
for s in stories:
url = urlparse(s.get('url') or s.get('link') or '').hostname or ''
s['hostname'] = url.replace('www.', '')
return render_template('index.html', return render_template('index.html',
title='QotNews', title='QotNews',
url='news.t0.vc', url='news.t0.vc',
description='Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode', description='Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode',
robots='index', robots='index',
stories=stories,
) )
@flask_app.route('/<sid>', strict_slashes=False) @flask_app.route('/<sid>', strict_slashes=False)
@@ -136,9 +239,9 @@ def static_story(sid):
except NotFound: except NotFound:
pass pass
story = database.get_story(sid) story_obj = database.get_story(sid)
if not story: return abort(404) if not story_obj: return abort(404)
story = json.loads(story.full_json) story = json.loads(story_obj.full_json)
score = story['score'] score = story['score']
num_comments = story['num_comments'] num_comments = story['num_comments']
@@ -147,7 +250,7 @@ def static_story(sid):
score, 's' if score != 1 else '', score, 's' if score != 1 else '',
num_comments, 's' if num_comments != 1 else '', num_comments, 's' if num_comments != 1 else '',
source) source)
url = urlparse(story['url']).hostname or urlparse(story['link']).hostname or '' url = urlparse(story.get('url') or story.get('link') or '').hostname or ''
url = url.replace('www.', '') url = url.replace('www.', '')
return render_template('index.html', return render_template('index.html',
@@ -155,12 +258,14 @@ def static_story(sid):
url=url, url=url,
description=description, description=description,
robots='noindex', robots='noindex',
story=story,
show_comments=request.path.endswith('/c'),
) )
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 +286,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 +301,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

@@ -4,12 +4,14 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"katex": "^0.16.25",
"localforage": "^1.7.3", "localforage": "^1.7.3",
"moment": "^2.24.0", "moment": "^2.24.0",
"query-string": "^6.8.3", "query-string": "^6.8.3",
"react": "^16.9.0", "react": "^16.9.0",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-helmet": "^5.2.1", "react-helmet": "^5.2.1",
"react-latex-next": "^3.0.0",
"react-router-dom": "^5.0.1", "react-router-dom": "^5.0.1",
"react-router-hash-link": "^1.2.2", "react-router-hash-link": "^1.2.2",
"react-scripts": "3.1.1" "react-scripts": "3.1.1"

View File

@@ -35,29 +35,105 @@
overflow-y: scroll; overflow-y: scroll;
} }
body { body {
background: #000; background: #eeeeee;
}
.nojs {
color: white;
max-width: 32rem;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="nojs"> <div id="root">
<noscript> <div class="container menu">
You need to enable JavaScript to run this app because it's written in React. <p>
I was planning on writing a server-side version, but I've become distracted <a href="/">QotNews</a>
by other projects -- sorry! <br />
<br/> <span class="slogan">Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode.</span>
I originally wrote this for myself, and of course I whitelist JavaScript on </p>
all my own domains. </div>
<br/><br/> {% if story %}
Alternatively, try activex.news.t0.vc for an ActiveX™ version. <div class="{% if show_comments %}container{% else %}article-container{% endif %}">
</noscript> <div class="article">
<h1>{{ story.title }}</h1>
{% if show_comments %}
<div class="info">
<a href="/{{ story.id }}">View article</a>
</div>
{% else %}
<div class="info">
Source: <a class="source" href="{{ story.url or story.link }}">{{ url }}</a>
</div>
{% endif %}
<div class="info">
{{ story.score }} points
by <a href="{{ story.author_link }}">{{ story.author }}</a>
{{ story.date | fromnow }}
on <a href="{{ story.link }}">{{ story.source }}</a> |
<a href="/{{ story.id }}/c">
{{ story.num_comments }} comment{{ 's' if story.num_comments != 1 }}
</a>
</div>
{% if not show_comments and story.text %}
<div class="story-text">{{ story.text | safe }}</div>
{% elif show_comments %}
{% macro render_comment(comment, level) %}
<dt></dt>
<dd class="comment{% if level > 0 %} lined{% endif %}">
<div class="info">
<p>
{% if comment.author == story.author %}[OP] {% endif %}{{ comment.author or '[Deleted]' }} | <a href="#{{ comment.author }}{{ comment.date }}" id="{{ comment.author }}{{ comment.date }}">{{ comment.date | fromnow }}</a>
</p>
</div>
<div class="text">{{ (comment.text | safe) if comment.text else '<p>[Empty / deleted comment]</p>' }}</div>
{% if comment.comments %}
<dl>
{% for reply in comment.comments %}
{{ render_comment(reply, level + 1) }}
{% endfor %}
</dl>
{% endif %}
</dd>
{% endmacro %}
<dl class="comments">
{% for comment in story.comments %}{{ render_comment(comment, 0) }}{% endfor %}
</dl>
{% endif %}
</div>
<div class='dot toggleDot'>
<div class='button'>
<a href="/{{ story.id }}{{ '/c' if not show_comments else '' }}">
{{ '' if not show_comments else '' }}
</a>
</div>
</div>
</div>
{% elif stories %}
<div class="container">
{% for story in stories %}
<div class='item'>
<div class='title'>
<a class='link' href='/{{ story.id }}'>
<img class='source-logo' src='/logos/{{ story.source }}.png' alt='{{ story.source }}:' /> {{ story.title }}
</a>
<span class='source'>
(<a class='source' href='{{ story.url or story.link }}'>{{ story.hostname }}</a>)
</span>
</div>
<div class='info'>
{{ story.score }} points
by <a href="{{ story.author_link }}">{{ story.author }}</a>
{{ story.date | fromnow }}
on <a href="{{ story.link }}">{{ story.source }}</a> |
<a class="{{ 'hot' if story.num_comments > 99 else '' }}" href="/{{ story.id }}/c">
{{ story.num_comments }} comment{{ 's' if story.num_comments != 1 }}
</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div> </div>
<div id="root"></div>
<!-- <!--
This HTML file is a template. This HTML file is a template.
If you open it directly in the browser, you will see an empty page. If you open it directly in the browser, you will see an empty page.

View File

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 500 B

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,72 @@ 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);
const [waitingWorker, setWaitingWorker] = useState(null);
this.state = { const updateCache = useCallback((key, value) => {
theme: localStorage.getItem('theme') || '', cache.current[key] = value;
}; }, []);
this.cache = {}; const light = () => {
} setTheme('');
updateCache = (key, value) => {
this.cache[key] = value;
}
light() {
this.setState({ theme: '' });
localStorage.setItem('theme', ''); localStorage.setItem('theme', '');
} };
dark() { const dark = () => {
this.setState({ theme: 'dark' }); setTheme('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) { 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) => { 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,49 +90,62 @@ 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 ||
document.webkitFullscreenEnabled || document.webkitFullscreenEnabled ||
document.msFullscreenEnabled; document.msFullscreenEnabled;
return ( return (
<div className={theme}> <div className={theme}>
<Router> {waitingWorker &&
<div className='container menu'> <div className='update-banner'>
<p> Client version mismatch, please refresh:{' '}
<Link to='/'>QotNews</Link> <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>
<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} /> {fullScreenAvailable &&
<Route path='/(|search)' component={Submit} /> <Route path='/(|search)' render={() => !isFullScreen ?
{fullScreenAvailable && <button className='fullscreen' onClick={() => goFullScreen()}>Enter Fullscreen</button>
<Route path='/(|search)' render={() => !document.fullscreenElement ? :
<button className='fullscreen' onClick={() => this.goFullScreen()}>Enter Fullscreen</button> <button className='fullscreen' onClick={() => exitFullScreen()}>Exit Fullscreen</button>
: } />
<button className='fullscreen' onClick={() => this.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 />
<ScrollToTop /> <ScrollToTop />
</Router> </Router>
</div> </div>
); );
}
} }
export default App; export default App;

View File

@@ -1,113 +1,237 @@
import React from 'react'; import React, { useState, useEffect } 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, similarLink, infoLine, ToggleDot } from './utils.js';
import Latex from 'react-latex-next';
import 'katex/dist/katex.min.css';
class Article extends React.Component { const VOID_ELEMENTS = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
constructor(props) { const DANGEROUS_TAGS = ['svg', 'math'];
super(props);
const id = this.props.match ? this.props.match.params.id : 'CLOL'; const latexDelimiters = [
const cache = this.props.cache; { left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false }
];
if (id in cache) console.log('cache hit'); function Article({ cache }) {
const { id } = useParams();
this.state = { if (id in cache) console.log('cache hit');
story: cache[id] || false,
error: false,
pConv: [],
};
}
componentDidMount() { const [story, setStory] = useState(cache[id] || false);
const id = this.props.match ? this.props.match.params.id : 'CLOL'; const [error, setError] = useState('');
const [pConv, setPConv] = useState([]);
const [copyButtonText, setCopyButtonText] = useState('\ue92c');
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 copyLink = () => {
this.setState({ pConv: [...this.state.pConv, n]}); navigator.clipboard.writeText(`${story.title}:\n${window.location.href}`).then(() => {
} setCopyButtonText('\uea10');
setTimeout(() => setCopyButtonText('\ue92c'), 2000);
}, () => {
setCopyButtonText('\uea0f');
setTimeout(() => setCopyButtonText('\ue92c'), 2000);
});
};
render() { const pConvert = (n) => {
const id = this.props.match ? this.props.match.params.id : 'CLOL'; setPConv(prevPConv => [...prevPConv, n]);
const story = this.state.story; };
const error = this.state.error;
const pConv = this.state.pConv;
let nodes = null;
if (story.text) { const isCodeBlock = (v) => {
let div = document.createElement('div'); if (v.localName === 'pre') {
div.innerHTML = story.text; return true;
nodes = div.childNodes;
} }
return ( if (v.localName === 'code') {
<div className='article-container'> if (v.closest('p')) {
{error && <p>Connection error?</p>} return false;
{story ? }
<div className='article'> const parent = v.parentElement;
<Helmet> if (parent) {
<title>{story.title} | QotNews</title> const nonWhitespaceChildren = Array.from(parent.childNodes).filter(n => {
<meta name="robots" content="noindex" /> return n.nodeType !== Node.TEXT_NODE || n.textContent.trim() !== '';
</Helmet> });
if (nonWhitespaceChildren.length === 1 && nonWhitespaceChildren[0] === v) {
<h1>{story.title}</h1> return true;
<div className='info'>
Source: {sourceLink(story)}
</div>
{infoLine(story)}
{nodes ?
<div className='story-text'>
{Object.entries(nodes).map(([k, v]) =>
pConv.includes(k) ?
v.innerHTML.split('\n\n').map(x =>
<p dangerouslySetInnerHTML={{ __html: x }} />
)
:
(v.nodeName === '#text' ?
<p>{v.data}</p>
:
<>
<v.localName dangerouslySetInnerHTML={v.innerHTML ? { __html: v.innerHTML } : null} />
{v.localName == 'pre' && <button onClick={() => this.pConvert(k)}>Convert Code to Paragraph</button>}
</>
)
)}
</div>
:
<p>Problem getting article :(</p>
}
</div>
:
<p>loading...</p>
} }
<ToggleDot id={id} article={false} /> }
</div> }
); return false;
} };
const renderNodes = (nodes, keyPrefix = '') => {
return Array.from(nodes).map((v, k) => {
const key = `${keyPrefix}${k}`;
if (pConv.includes(key)) {
return (
<React.Fragment key={key}>
{v.textContent.split('\n\n').map((x, i) =>
<p key={i}>{x}</p>
)}
</React.Fragment>
);
}
if (v.nodeName === '#text') {
const text = v.data;
if (text.includes('\\[') || text.includes('\\(') || text.includes('$$') || /\$(?:[^$]*[^\s$])\$/.test(text)) {
return <Latex key={key} delimiters={latexDelimiters}>{text}</Latex>;
}
// Only wrap top-level text nodes in <p>
if (keyPrefix === '' && v.data.trim() !== '') {
return <p key={key}>{v.data}</p>;
}
return v.data;
}
if (v.nodeType !== Node.ELEMENT_NODE) {
return null;
}
if (DANGEROUS_TAGS.includes(v.localName)) {
return <span key={key} dangerouslySetInnerHTML={{ __html: v.outerHTML }} />;
}
const Tag = v.localName;
if (isCodeBlock(v)) {
return (
<React.Fragment key={key}>
<Tag dangerouslySetInnerHTML={{ __html: v.innerHTML }} />
<button onClick={() => pConvert(key)}>Convert Code to Paragraph</button>
</React.Fragment>
);
}
const textContent = v.textContent.trim();
const isMath = (textContent.startsWith('\\(') && textContent.endsWith('\\)')) ||
(textContent.startsWith('\\[') && textContent.endsWith('\\]')) ||
(textContent.startsWith('$$') && textContent.endsWith('$$')) ||
(textContent.startsWith('$') && textContent.endsWith('$') && textContent.indexOf('$') !== textContent.lastIndexOf('$') && !/\s/.test(textContent.charAt(textContent.length - 2)));
const props = { key: key };
if (v.hasAttributes()) {
for (const attr of v.attributes) {
const name = attr.name === 'class' ? 'className' : attr.name;
props[name] = attr.value;
}
}
if (isMath) {
let mathContent = v.textContent;
// align environment requires display math mode
if (mathContent.includes('\\begin{align')) {
const trimmed = mathContent.trim();
if (trimmed.startsWith('\\(')) {
// Replace \( and \) with \[ and \] to switch to display mode
const firstParen = mathContent.indexOf('\\(');
const lastParen = mathContent.lastIndexOf('\\)');
mathContent = mathContent.substring(0, firstParen) + '\\[' + mathContent.substring(firstParen + 2, lastParen) + '\\]' + mathContent.substring(lastParen + 2);
} else if (trimmed.startsWith('$') && !trimmed.startsWith('$$')) {
// Replace $ with $$
const firstDollar = mathContent.indexOf('$');
const lastDollar = mathContent.lastIndexOf('$');
if (firstDollar !== lastDollar) {
mathContent = mathContent.substring(0, firstDollar) + '$$' + mathContent.substring(firstDollar + 1, lastDollar) + '$$' + mathContent.substring(lastDollar + 1);
}
}
}
return <Tag {...props}><Latex delimiters={latexDelimiters}>{mathContent}</Latex></Tag>;
}
if (VOID_ELEMENTS.includes(Tag)) {
return <Tag {...props} />;
}
return (
<Tag {...props}>
{renderNodes(v.childNodes, `${key}-`)}
</Tag>
);
});
};
const nodes = (s) => {
if (s && s.text) {
let div = document.createElement('div');
div.innerHTML = s.text;
return div.childNodes;
}
return null;
};
const storyNodes = nodes(story);
return (
<div className='article-container'>
{error &&
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{story && <p>Loaded article from cache.</p>}
</details>
}
{story ?
<div className='article'>
<Helmet>
<title>{story.title} | QotNews</title>
<meta name="robots" content="noindex" />
</Helmet>
<h1>{story.title} <button className='copy-button' onClick={copyLink}>{copyButtonText}</button></h1>
<div className='info'>
Source: {sourceLink(story)} | {similarLink(story)}
</div>
{infoLine(story)}
{storyNodes ?
<div className='story-text'>
{renderNodes(storyNodes)}
</div>
:
<p>Problem getting article :(</p>
}
</div>
:
<p>Loading...</p>
}
<ToggleDot id={id} article={false} />
</div>
);
} }
export default Article; export default Article;

View File

@@ -1,83 +1,80 @@
import React from 'react'; import React, { useState, useEffect } 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);
const hash = window.location.hash.substring(1);
if (hash) {
document.getElementById(hash).scrollIntoView();
}
});
localForage.setItem(id, result.story); localForage.setItem(id, result.story);
const hash = window.location.hash.substring(1);
if (hash) {
setTimeout(() => {
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView();
}
}, 0);
}
}, },
(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 = (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 = (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 = (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,56 +85,56 @@ 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> <button className='collapser pointer' onClick={() => collapseComment(cid)}></button>
} }
</p> </p>
</div> </div>
<div className={collapsed ? 'text hidden' : 'text'} dangerouslySetInnerHTML={{ __html: c.text }} /> <div className={isCollapsed ? 'text hidden' : 'text'} dangerouslySetInnerHTML={{ __html: c.text || '<p>[Empty / deleted comment]</p>'}} />
{hidden && hasChildren ? {hidden && hasChildren ?
<div className='comment lined info pointer' onClick={() => this.expandComment(cid)}>[show {this.countComments(c)-1} more]</div> <button className='comment lined info pointer' onClick={() => expandComment(cid)}>[show {countComments(c)-1} more]</button>
: :
c.comments.map(i => this.displayComment(story, i, level + 1)) c.comments.map(i => displayComment(story, i, level + 1))
} }
</div> </div>
); );
} };
render() { return (
const id = this.props.match.params.id; <div className='container'>
const story = this.state.story; {error &&
const error = this.state.error; <details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{story && <p>Loaded comments from cache.</p>}
</details>
}
{story ?
<div className='article'>
<Helmet>
<title>{story.title} | QotNews</title>
<meta name="robots" content="noindex" />
</Helmet>
return ( <h1>{story.title}</h1>
<div className='container'>
{error && <p>Connection error?</p>}
{story ?
<div className='article'>
<Helmet>
<title>{story.title} | QotNews</title>
<meta name="robots" content="noindex" />
</Helmet>
<h1>{story.title}</h1> <div className='info'>
<Link to={'/' + story.id}>View article</Link>
<div className='info'>
<Link to={'/' + story.id}>View article</Link>
</div>
{infoLine(story)}
<div className='comments'>
{story.comments.map(c => this.displayComment(story, c, 0))}
</div>
</div> </div>
:
<p>loading...</p> {infoLine(story)}
}
<ToggleDot id={id} article={true} /> <div className='comments'>
</div> {story.comments.map(c => displayComment(story, c, 0))}
); </div>
} </div>
:
<p>loading...</p>
}
<ToggleDot id={id} article={true} />
</div>
);
} }
export default Article; export default Comments;

View File

@@ -1,85 +1,168 @@
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('');
const [loadingStatus, setLoadingStatus] = useState(null);
const [filterSmallweb, setFilterSmallweb] = useState(() => localStorage.getItem('filterSmallweb') === 'true');
this.state = { const handleFilterChange = e => {
stories: JSON.parse(localStorage.getItem('stories')) || false, const isChecked = e.target.checked;
error: false, setStories(false);
}; setFilterSmallweb(isChecked);
} localStorage.setItem('filterSmallweb', isChecked);
};
componentDidMount() { useEffect(() => {
fetch('/api') const controller = new AbortController();
.then(res => res.json())
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then(reg => {
if (reg) {
console.log('Checking for client update...');
reg.update();
}
});
}
fetch(filterSmallweb ? '/api?smallweb=true' : '/api', { signal: controller.signal })
.then(res => {
if (!res.ok) {
throw new Error(`Server responded with ${res.status} ${res.statusText}`);
}
return res.json();
})
.then( .then(
(result) => { async (result) => {
const updated = !this.state.stories || this.state.stories[0].id !== result.stories[0].id; const newApiStories = result.stories;
console.log('updated:', updated);
this.setState({ stories: result.stories }); const updated = !stories || !stories.length || stories[0].id !== newApiStories[0].id;
localStorage.setItem('stories', JSON.stringify(result.stories)); console.log('New stories available:', updated);
if (updated) { if (!updated) return;
localForage.clear();
result.stories.forEach((x, i) => { setLoadingStatus({ current: 0, total: newApiStories.length });
fetch('/api/' + x.id)
.then(res => res.json()) let currentStories = Array.isArray(stories) ? [...stories] : [];
.then(result => { let preloadedCount = 0;
localForage.setItem(x.id, result.story)
.then(console.log('preloaded', x.id, x.title)); for (const [index, newStory] of newApiStories.entries()) {
this.props.updateCache(x.id, result.story); if (controller.signal.aborted) {
}, error => {} break;
); }
}); try {
const storyFetchController = new AbortController();
const timeoutId = setTimeout(() => storyFetchController.abort(), 10000); // 10-second timeout
const storyRes = await fetch('/api/' + newStory.id, { signal: storyFetchController.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++;
setLoadingStatus({ current: preloadedCount, total: newApiStories.length });
const existingStoryIndex = currentStories.findIndex(s => s.id === newStory.id);
if (existingStoryIndex > -1) {
currentStories.splice(existingStoryIndex, 1);
}
currentStories.splice(index, 0, 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);
setLoadingStatus(null);
}, },
(error) => { (error) => {
this.setState({ error: true }); if (error.name === 'AbortError') {
console.log('Feed fetch aborted.');
return;
}
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);
} }
); );
}
render() { return () => controller.abort();
const stories = this.state.stories; }, [updateCache, filterSmallweb]);
const error = this.state.error;
return ( return (
<div className='container'> <div className='container'>
<Helmet> <Helmet>
<title>QotNews</title> <title>QotNews</title>
<meta name="robots" content="index" /> <meta name="robots" content="index" />
</Helmet> </Helmet>
{error && <p>Connection error?</p>}
{stories ?
<div>
{stories.map(x =>
<div className='item' key={x.id}>
<div className='title'>
<Link className='link' to={'/' + x.id}>
<img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title}
</Link>
<span className='source'> <div style={{marginBottom: '1rem'}}>
({sourceLink(x)}) <input type="checkbox" id="filter-smallweb" className="checkbox" checked={filterSmallweb} onChange={handleFilterChange} />
</span> <label htmlFor="filter-smallweb">Small websites</label>
</div>
{infoLine(x)}
</div>
)}
</div>
:
<p>loading...</p>
}
</div> </div>
);
} {error &&
<details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary>
<p>{error}</p>
{stories && <p>Loaded feed from cache.</p>}
</details>
}
{stories ?
<div>
{stories.map(x =>
<div className='item' key={x.id}>
<div className='title'>
<Link className='link' to={'/' + x.id}>
<img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title}
</Link>
<span className='source'>
({sourceLink(x)})
</span>
</div>
{infoLine(x)}
</div>
)}
</div>
:
<p>Loading...</p>
}
{loadingStatus && <p>Preloading stories {loadingStatus.current} / {loadingStatus.total}...</p>}
</div>
);
} }
export default Feed; export default Feed;

View File

@@ -1,95 +1,73 @@
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) { return (
if (this.props.location.search !== prevProps.location.search) { <div className='container'>
this.performSearch(); <Helmet>
} <title>Search Results | QotNews</title>
} </Helmet>
{error && <p>Connection error?</p>}
{stories ?
<>
<p>Search results:</p>
<div className='comment lined'>
{stories.length ?
stories.map(x =>
<div className='item' key={x.id}>
<div className='title'>
<Link className='link' to={'/' + x.id}>
<img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title}
</Link>
render() { <span className='source'>
const stories = this.state.stories; ({sourceLink(x)})
const error = this.state.error; </span>
return (
<div className='container'>
<Helmet>
<title>Search Results | QotNews</title>
</Helmet>
{error && <p>Connection error?</p>}
{stories ?
<>
<p>Search results:</p>
<div className='comment lined'>
{stories.length ?
stories.map(x =>
<div className='item' key={x.id}>
<div className='title'>
<Link className='link' to={'/' + x.id}>
<img className='source-logo' src={logos[x.source]} alt='source logo' /> {x.title}
</Link>
<span className='source'>
({sourceLink(x)})
</span>
</div>
{infoLine(x)}
</div> </div>
)
: {infoLine(x)}
<p>none</p> </div>
} )
</div> :
</> <p>none</p>
: }
<p>loading...</p> </div>
} </>
</div> :
); <p>loading...</p>
} }
</div>
);
} }
export default Results; export default Results;

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() { return (
const search = this.state.search; <span className='search'>
<form onSubmit={searchAgain}>
return ( <input
<span className='search'> placeholder='Search...'
<form onSubmit={this.searchAgain}> value={search}
<input onChange={searchArticles}
placeholder='Search...' ref={inputRef}
value={search} />
onChange={this.searchArticles} </form>
ref={this.inputRef} </span>
/> );
</form>
</span>
);
}
} }
export default withRouter(Search); export default Search;

View File

@@ -11,7 +11,8 @@
border: 1px solid #828282; border: 1px solid #828282;
} }
.black button { .black .menu button,
.black .story-text button {
background-color: #444444; background-color: #444444;
border-color: #bbb; border-color: #bbb;
color: #ddd; color: #ddd;
@@ -66,3 +67,22 @@
.black .comment.lined { .black .comment.lined {
border-left: 1px solid #444444; border-left: 1px solid #444444;
} }
.black .checkbox:checked + label::after {
border-color: #eee;
}
.black .copy-button {
color: #828282;
}
.black .update-banner {
background-color: #333;
color: #ddd;
}
.black .update-banner button {
background-color: #444444;
border-color: #bbb;
color: #ddd;
}

View File

@@ -11,7 +11,8 @@
border: 1px solid #828282; border: 1px solid #828282;
} }
.dark button { .dark .menu button,
.dark .story-text button {
background-color: #444444; background-color: #444444;
border-color: #bbb; border-color: #bbb;
color: #ddd; color: #ddd;
@@ -62,3 +63,22 @@
.dark .comment.lined { .dark .comment.lined {
border-left: 1px solid #444444; border-left: 1px solid #444444;
} }
.dark .checkbox:checked + label::after {
border-color: #eee;
}
.dark .copy-button {
color: #828282;
}
.dark .update-banner {
background-color: #333;
color: #ddd;
}
.dark .update-banner button {
background-color: #444444;
border-color: #bbb;
color: #ddd;
}

View File

@@ -43,16 +43,36 @@ input {
border-radius: 4px; border-radius: 4px;
} }
.update-banner {
background-color: #ddd;
padding: 0.75rem;
text-align: center;
}
.update-banner button {
margin-left: 1rem;
padding: 0.25rem 0.75rem;
border: 1px solid #828282;
border-radius: 4px;
background-color: transparent;
cursor: pointer;
}
.fullscreen { .fullscreen {
margin: 0.25rem; margin: 0.25rem;
padding: 0.25rem; padding: 0.25rem;
} }
pre { pre {
overflow: auto; overflow: auto;
} }
.comments pre {
overflow: auto;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.container { .container {
margin: 1rem auto; margin: 1rem auto;
max-width: 64rem; max-width: 64rem;
@@ -121,6 +141,13 @@ span.source {
border-bottom: 1px solid #222222; border-bottom: 1px solid #222222;
} }
.article-title {
display: flex;
align-items: center;
margin-top: 0.67em;
margin-bottom: 0.67em;
}
.article h1 { .article h1 {
font-size: 1.6rem; font-size: 1.6rem;
} }
@@ -177,6 +204,13 @@ span.source {
.comments { .comments {
margin-left: -1.25rem; margin-left: -1.25rem;
margin-top: 0;
margin-bottom: 0;
padding: 0;
}
.comments dl, .comments dd {
margin: 0;
} }
.comment { .comment {
@@ -189,6 +223,11 @@ span.source {
.comment .text { .comment .text {
margin-top: -0.5rem; margin-top: -0.5rem;
margin-bottom: 1rem;
}
.comment .text > * {
margin-bottom: 0;
} }
.comment .text.hidden > p { .comment .text.hidden > p {
@@ -208,6 +247,31 @@ span.source {
padding-right: 1.5rem; padding-right: 1.5rem;
} }
button.collapser {
background: transparent;
border: none;
margin: 0;
padding-top: 0;
padding-bottom: 0;
font: inherit;
color: inherit;
}
button.comment {
background: transparent;
border-top: none;
border-right: none;
border-bottom: none;
margin: 0;
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
font: inherit;
color: inherit;
text-align: left;
width: 100%;
}
.comment .pointer { .comment .pointer {
cursor: pointer; cursor: pointer;
} }
@@ -260,3 +324,84 @@ span.source {
.search form { .search form {
display: inline; display: inline;
} }
.copy-button {
font: 1.5rem/1 'icomoon2';
color: #828282;
background: transparent;
border: none;
cursor: pointer;
vertical-align: middle;
}
.checkbox {
-webkit-appearance: none;
appearance: none;
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox + label {
position: relative;
cursor: pointer;
padding-left: 1.75rem;
user-select: none;
}
.checkbox + label::before {
content: '';
position: absolute;
left: 0;
top: 0.1em;
width: 1rem;
height: 1rem;
border: 1px solid #828282;
background-color: transparent;
border-radius: 3px;
}
.checkbox:checked + label::after {
content: "";
position: absolute;
left: 0.35rem;
top: 0.2em;
width: 0.3rem;
height: 0.6rem;
border-style: solid;
border-color: #000;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.tooltip .tooltiptext {
visibility: hidden;
width: 140px;
background-color: #555;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px 0;
position: absolute;
z-index: 1;
bottom: 110%;
left: 50%;
margin-left: -70px;
opacity: 0;
transition: opacity 0.2s;
font-size: 0.9rem;
line-height: 1.3;
}
.forwardDot .tooltiptext {
left: auto;
right: 0;
margin-left: 0;
}
.tooltip.show-tooltip .tooltiptext {
visibility: visible;
opacity: 1;
}

View File

@@ -20,7 +20,8 @@
background-color: #690000; background-color: #690000;
} }
.red button { .red .menu button,
.red .story-text button {
background-color: #440000; background-color: #440000;
border-color: #b00; border-color: #b00;
color: #b00; color: #b00;
@@ -80,3 +81,26 @@
.red .dot { .red .dot {
background-color: #440000; background-color: #440000;
} }
.red .checkbox + label::before {
border: 1px solid #690000;
}
.red .checkbox:checked + label::after {
border-color: #dd0000;
}
.red .copy-button {
color: #690000;
}
.red .update-banner {
background-color: #300;
color: #d00;
}
.red .update-banner button {
background-color: #440000;
border-color: #b00;
color: #d00;
}

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) => { if (res.ok) {
this.props.history.replace('/' + result.nid); const result = await res.json();
}, history.replace('/' + result.nid);
(error) => { } else {
this.setState({ progress: 'Error' }); 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()}`);
}
} }
render() { return (
const progress = this.state.progress; <span className='search'>
<form onSubmit={submitArticle}>
return ( <input
<span className='search'> placeholder='Submit URL'
<form onSubmit={this.submitArticle}> ref={inputRef}
<input />
placeholder='Submit URL' </form>
ref={this.inputRef} {progress && <p>{progress}</p>}
/> </span>
</form> );
{progress ? progress : ''}
</span>
);
}
} }
export default withRouter(Submit); export default Submit;

View File

@@ -26,3 +26,8 @@
font-family: 'Icomoon'; font-family: 'Icomoon';
src: url('icomoon.ttf') format('truetype'); src: url('icomoon.ttf') format('truetype');
} }
@font-face {
font-family: 'Icomoon2';
src: url('icomoon2.ttf') format('truetype');
}

Binary file not shown.

View File

@@ -3,9 +3,15 @@ import ReactDOM from 'react-dom';
import App from './App'; import App from './App';
import * as serviceWorker from './serviceWorker'; import * as serviceWorker from './serviceWorker';
// version 4
ReactDOM.render(<App />, document.getElementById('root')); ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change // If you want your app to work offline and load faster, you can change
// // unregister() to register() below. Note this comes with some pitfalls. // // unregister() to register() below. Note this comes with some pitfalls.
// // Learn more about service workers: https://bit.ly/CRA-PWA // // Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.register(); serviceWorker.register({
onUpdate: registration => {
window.dispatchEvent(new CustomEvent('swUpdate', { detail: registration }));
}
});

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import moment from 'moment'; import moment from 'moment';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -9,6 +9,13 @@ export const sourceLink = (story) => {
return (<a className='source' href={url}>{host}</a>); return (<a className='source' href={url}>{host}</a>);
}; };
export const similarLink = (story) => {
const url = story.url || story.link;
const urlObj = new URL(url);
const host = urlObj.hostname.replace(/^www\./, '');
return (<Link to={'/search?q="'+host+'"'} className='similar'>similar</Link>);
};
export const infoLine = (story) => export const infoLine = (story) =>
<div className='info'> <div className='info'>
{story.score} points {story.score} points
@@ -21,63 +28,72 @@ export const infoLine = (story) =>
</div> </div>
; ;
export class ToggleDot extends React.Component { export const ToggleDot = ({ id, article }) => (
render() { <div className='dot toggleDot'>
const id = this.props.id; <div className='button'>
const article = this.props.article; <Link to={'/' + id + (article ? '' : '/c')}>
{article ? '' : ''}
</Link>
</div>
</div>
);
return ( export const BackwardDot = () => {
<div className='dot toggleDot'> const [showTooltip, setShowTooltip] = useState(false);
<div className='button'>
<Link to={'/' + id + (article ? '' : '/c')}>
{article ? '' : ''}
</Link>
</div>
</div>
);
}
}
export class BackwardDot extends React.Component { const goBackward = () => {
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
<div className='button'> className={'dot backwardDot tooltip' + (showTooltip ? ' show-tooltip' : '')}
onClick={goBackward}
</div> onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchStart={() => setShowTooltip(true)}
onTouchEnd={() => setShowTooltip(false)}
>
<div className='button'>
</div> </div>
); <span className="tooltiptext">Browser Back</span>
} </div>
} );
};
export class ForwardDot extends React.Component { export const ForwardDot = () => {
goForward() { const [showTooltip, setShowTooltip] = useState(false);
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
<div className='button'> className={'dot forwardDot tooltip' + (showTooltip ? ' show-tooltip' : '')}
onClick={goForward}
</div> onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchStart={() => setShowTooltip(true)}
onTouchEnd={() => setShowTooltip(false)}
>
<div className='button'>
</div> </div>
); <span className="tooltiptext">Browser Forward</span>
} </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