Compare commits

..

209 Commits

Author SHA1 Message Date
tanner 6a329e3ba9 Misc fixes 2025-12-01 21:07:01 +00:00
tanner 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
tanner 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
tanner 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
tanner 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
tanner 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
tanner a21c84efc6 refactor: Improve article loading error and cache messages 2025-11-21 22:45:54 +00:00
tanner 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
tanner e9ee231954 feat: Persist new stories and improve layout consistency 2025-11-21 22:39:32 +00:00
tanner 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
tanner 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
tanner 1443fdcc32 style: Improve error messages and loading text, add spacing to error details 2025-11-21 00:59:12 +00:00
tanner 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
tanner 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
tanner 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
tanner 21b5d67052 feat: Show detailed connection errors in collapsible section 2025-11-21 00:41:57 +00:00
tanner 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
tanner 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
tanner 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
tanner 5a7f55184d Begin stats API route 2025-11-20 22:25:26 +00:00
tanner e84062394b Ignore aider files 2025-11-20 22:25:20 +00:00
tanner e867d5d868 Add debug logging, debug add manual submissions to feed 2025-11-20 21:55:45 +00:00
tanner 845d87ec55 Logging 2025-11-19 19:17:38 +00:00
tanner 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
tanner 02e86efb4f chore: Add console log for stories 2025-11-19 19:17:38 +00:00
tanner 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
tanner 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
tanner 83cb6fc0ae chore: Disable story updates and preloading logic 2025-11-19 19:17:38 +00:00
tanner 667c2c5eaf refactor: Refactor dot components to functional 2025-11-19 19:17:38 +00:00
tanner 1df1c59d61 refactor: Refactor Submit component to use hooks 2025-11-19 19:17:38 +00:00
tanner c4f2e7d595 refactor: Refactor Search component to use hooks 2025-11-19 19:17:38 +00:00
tanner f61cfc09b0 refactor: Convert ScrollToTop to functional component with hooks 2025-11-19 19:17:38 +00:00
tanner 366e76e25d refactor: refactor Results component to functional component 2025-11-19 19:17:38 +00:00
tanner 6f1811c564 Update webclient dependencies 2025-11-19 19:17:38 +00:00
tanner 443115ac0f refactor: Refactor Feed component to functional with hooks 2025-11-19 19:17:38 +00:00
tanner 034c440e46 refactor: Convert Comments class to functional using hooks 2025-11-19 19:17:38 +00:00
tanner 26a6353ca5 refactor: Rename Article component to Comments 2025-11-19 19:17:38 +00:00
tanner 7ac4dfa01c refactor: Refactor Article component to use hooks 2025-11-19 19:17:38 +00:00
tanner 633429c976 refactor: Convert App class component to functional component 2025-11-19 19:17:38 +00:00
tanner 5cdbf6ef54 Ignore blank hackernews titles 2025-11-19 19:17:38 +00:00
tanner f1a30d0af2 Skip "Removed by moderator" stories 2025-09-27 17:38:50 +00:00
tanner 9ec61ea5bc Ignore dead and political stories 2025-05-27 18:47:17 +00:00
tanner bdc7a6c10d Fix Better HN api content extraction 2025-02-01 22:39:13 +00:00
tanner 4858516b01 Add Better HN as an API backup 2025-02-01 21:42:06 +00:00
tanner f10e6063fc Bug fixes 2025-02-01 20:31:35 +00:00
tanner 249a616531 Alert on story update error 2024-03-16 20:41:24 +00:00
tanner ab92bd5441 Adjust score and comment thresholds 2024-03-08 03:08:18 +00:00
tanner 6b16a768a7 Fix deletion script 2024-03-08 03:08:03 +00:00
tanner 57de076fec Increase database timeout 2024-02-27 18:48:56 +00:00
tanner 074b898508 Fix lobsters comment parsing 2024-02-27 18:47:00 +00:00
tanner f049d194ab Move scripts into own folder 2024-02-27 18:32:29 +00:00
tanner c2b9a1cb7a Update readability 2024-02-27 18:32:19 +00:00
tanner 4435f49e17 Make "dark" theme grey, add "black" theme 2023-09-13 01:19:47 +00:00
tanner 494d89ac30 Disable lobsters 2023-09-13 01:02:15 +00:00
tanner e79fca6ecc Replace "indent_level" with "depth" in lobsters API
See:
https://github.com/lobsters/lobsters/commit/fe09e5aa31993e09ed4ad255bb4a359f1e8a2d62
2023-08-31 07:35:44 +00:00
tanner c65fb69092 Handle Lobsters comment parsing TypeErrors
Too lazy to debug this:

2023-08-29 12:56:35,111 - root - INFO - Updating lobsters story: yktkwr, index: 55
Traceback (most recent call last):
  File "src/gevent/greenlet.py", line 854, in gevent._gevent_cgreenlet.Greenlet.run
  File "/home/tanner/qotnews/apiserver/server.py", line 194, in feed_thread
    valid = feed.update_story(story)
  File "/home/tanner/qotnews/apiserver/feed.py", line 74, in update_story
    res = lobsters.story(story['ref'])
  File "/home/tanner/qotnews/apiserver/feeds/lobsters.py", line 103, in story
    s['comments'] = iter_comments(r['comments'])
  File "/home/tanner/qotnews/apiserver/feeds/lobsters.py", line 76, in iter_comments
    parent_stack = parent_stack[:indent-1]
TypeError: unsupported operand type(s) for -: 'NoneType' and 'int'
2023-08-29T12:56:35Z <Greenlet at 0x7f92ad840ae0: feed_thread> failed with TypeError
2023-08-31 07:30:39 +00:00
tanner 632d028e4c Add Tildes group whitelist 2023-07-13 22:54:36 +00:00
tanner ea8e9e5a23 Increase again 2023-06-13 17:11:50 +00:00
tanner 2838ea9b41 Increase Tildes story score requirement 2023-06-11 01:01:31 +00:00
tanner f15d108971 Catch all possible Reddit API exceptions 2023-03-15 21:16:37 +00:00
tanner f777348af8 Fix darkmode fullscreen button color 2022-08-11 19:36:36 +00:00
tanner 486404a413 Fix fix-stories bug 2022-08-10 04:06:39 +00:00
tanner 7c9c07a4cf Hide fullscreen button if it's not available 2022-08-10 04:05:25 +00:00
tanner 08d02f6013 Add fullscreen mode 2022-08-08 23:21:49 +00:00
tanner 1b54342702 Add red theme 2022-08-08 20:14:57 +00:00
tanner 9e9571a3c0 Write fixed stories to database 2022-07-05 00:57:56 +00:00
tanner dc83a70887 Begin script to fix bad gzip text 2022-07-04 20:32:01 +00:00
tanner 2e2c9ae837 Move FEED_LENGTH to settings.py, use for search results 2022-07-04 19:08:24 +00:00
tanner 61021d8f91 Small UI changes 2022-07-04 19:08:24 +00:00
tanner e65047fead Add accept gzip header to readability server 2022-07-04 19:07:31 +00:00
tanner 8e775c189f Add test file 2022-07-04 05:56:06 +00:00
tanner 3d9274309a Fix requests text encoding slowness 2022-07-04 05:55:52 +00:00
tanner 7bdbbf10b2 Return search results directly from the server 2022-07-04 04:33:01 +00:00
tanner 6aa0f78536 Remove Article / Comments, etc thing after name 2022-07-04 04:33:01 +00:00
tanner bf3663bbec Remove hard-coded title 2022-06-30 00:12:22 +00:00
tanner e6589dc61c Adjust title 2022-06-30 00:05:15 +00:00
tanner 307e8349f3 Change header based on page 2022-06-30 00:00:30 +00:00
tanner 04cd56daa8 Add index / noindex to client 2022-06-29 23:30:39 +00:00
tanner c80769def6 Add noindex meta tag to stories 2022-06-29 23:20:53 +00:00
tanner ebd1ad2140 Increase database timeout 2022-06-24 20:50:27 +00:00
tanner 2cc7dd0d6d Update software 2022-05-31 04:24:12 +00:00
tanner 6e7cb86d2e Explain no javascript 2022-05-31 04:23:52 +00:00
tanner a25457254f Improve logging, sends tweets to nitter.net 2022-03-05 23:48:46 +00:00
tanner a693ea5342 Remove outline API 2022-03-05 22:05:29 +00:00
tanner 7386e1d8b0 Include option to disable readerserver 2022-03-05 22:04:25 +00:00
tanner f8e8597e3a Include option to disable search 2022-03-05 21:58:35 +00:00
tanner 55c282ee69 Fix search to work with low-RAM server 2022-03-05 21:33:07 +00:00
tanner 3f774a9e38 Improve logging 2021-09-06 00:21:05 +00:00
tanner dcedd4caa1 Add script to reindex search, abstract search API 2021-09-06 00:20:21 +00:00
tanner 7a131ebd03 Change the order by which content-type is grabbed 2021-01-30 06:36:02 +00:00
tanner 6f64401785 Add optional skip and limit to API route 2021-01-18 03:59:33 +00:00
tanner 3ff917e806 Remove colons from date string so Python 3.5 can parse 2020-12-15 23:19:50 +00:00
tanner c9fb9bd5df Add Lobsters to feed 2020-12-12 05:26:33 +00:00
tanner fd9c9c888d Update gitignore 2020-12-11 23:49:45 +00:00
tanner 42dcf15374 Increase sqlite lock timeout 2020-11-19 21:38:18 +00:00
tanner d8a0b77765 Blacklist sec.gov website 2020-11-19 21:37:59 +00:00
tanner 9a279d44b1 Add header to get content type 2020-11-03 20:27:43 +00:00
tanner e506804666 Clean code up 2020-11-03 03:45:56 +00:00
tanner ca78a6d7a9 Move feed and Praw config to settings.py 2020-11-02 02:26:54 +00:00
tanner 7acce407e9 Fix index.html indentation 2020-11-02 00:38:34 +00:00
tanner 5281672000 Fix noscript font color 2020-11-02 00:36:11 +00:00
tanner e59acefda9 Remove Whoosh 2020-11-02 00:22:40 +00:00
tanner cbc802b7e9 Try Hackernews API twice 2020-11-02 00:17:22 +00:00
tanner 4579dfce00 Improve logging 2020-11-02 00:13:43 +00:00
tanner 0d16bec6f6 Fix table width CSS 2020-11-01 00:47:18 +00:00
tanner feba8b7aa0 Make qotnews work with WaPo 2020-10-29 04:55:34 +00:00
tanner ee5105743d Upgrade readability 2020-10-29 01:24:13 +00:00
tanner 72802a6fcf Show exerpt of hidden comments 2020-10-27 00:41:36 +00:00
tanner 99d3a234f4 Fix bug with rendering text nodes 2020-10-26 21:58:36 +00:00
tanner f95df227f1 Add instructions to download search server 2020-10-26 21:58:36 +00:00
tanner b82095ca7a Add buttons to collapse / expand comments 2020-10-26 21:57:10 +00:00
tanner 992c1c1233 Monkeypatch earlier 2020-10-24 22:30:00 +00:00
tanner 88d2216627 Add a script to delete a story 2020-10-03 23:42:21 +00:00
tanner 6cf2f01b08 Adjust feeds 2020-10-03 23:41:57 +00:00
tanner 607573dd44 Add buttons to convert <pre> to <p> 2020-10-03 23:23:25 +00:00
tanner c554ecd890 Add a line on UI to make search results obvious 2020-08-14 03:58:11 +00:00
tanner 6576eb1bac Adjust content-type request timeout 2020-08-14 03:57:43 +00:00
tanner 472af76d1a Adjust port 2020-08-14 03:57:18 +00:00
tanner 4727d34eb6 Delete displayed-attributes when init search 2020-08-14 03:56:47 +00:00
tanner 0e086b60b8 Remove business subreddit from feed 2020-08-14 03:55:28 +00:00
tanner b46ce36c63 Update requirements 2020-07-08 05:24:32 +00:00
tanner 9a449bf3ca Remove extra logging 2020-07-08 02:36:40 +00:00
tanner 0bd9f05250 Fix crash when HN feed fails 2020-07-08 02:36:40 +00:00
tanner 9c116bde4a Remove document img and ignore r/technology 2020-07-08 02:36:40 +00:00
tanner ebedaef00b Tune search rankings and attributes 2020-07-08 02:36:40 +00:00
tanner d7f0643bd7 Add more logging 2020-07-08 02:36:40 +00:00
tanner eb1137299d Remove article numbers 2020-07-08 02:36:40 +00:00
tanner 72d4a68929 Remove pre-fetching image 2020-07-08 02:36:40 +00:00
tanner f1c846acd0 Remove get first image 2020-07-08 02:36:40 +00:00
tanner 850b30e353 Add requests timeouts and temporary logging 2020-07-08 02:36:40 +00:00
tanner d614ad0743 Integrate with external MeiliSearch server 2020-07-08 02:36:40 +00:00
tanner f46cafdc90 Integrate sqlite database with server 2020-07-08 02:36:40 +00:00
tanner 873dc44cb1 Update whoosh migration script 2020-07-08 02:36:40 +00:00
tanner 1fb9db3f4b Store ref list in database too 2020-07-08 02:36:40 +00:00
tanner b923908a45 Begin initial sqlite conversion 2020-07-08 02:36:40 +00:00
tanner dbdcfaa921 Check if cache is broken 2020-07-08 02:36:40 +00:00
tanner 8799b10525 Fall back to ref on manual submission title 2020-07-08 02:36:40 +00:00
tanner 6430fe5e9f Check content-type 2020-07-08 02:36:40 +00:00
tanner a4cf719cb8 Remove technology subreddit 2020-07-08 02:36:40 +00:00
tanner 595f469b4a Update tildes parser group tag 2020-07-08 02:36:40 +00:00
tanner b252c6a207 Make noscript background white 2020-06-22 20:52:51 +00:00
tanner 02b73a8b14 Fix cache load race condition bug 2020-01-28 04:20:48 +00:00
tanner 72f1043952 Remove preload of news source icons 2020-01-28 04:20:29 +00:00
tanner 7b31fcf690 Remove keys of uncached stories 2020-01-28 04:20:05 +00:00
tanner b3d2eeb67f Fix tildes deleted comment parser error 2020-01-28 04:19:26 +00:00
tanner 9078b567f0 Add del tag and sort tags 2020-01-04 23:37:41 +00:00
tanner ced20390eb Fix back/forward scroll jump issue 2020-01-04 23:36:24 +00:00
tanner 6cd41f0902 Add forward button, convert icons to font 2020-01-03 03:45:56 +00:00
tanner 746932ab96 Add style changes to prevent horizontal scrolling 2019-12-22 21:43:33 +00:00
tanner 2822974b6e Stop using archive.is on articles (hits CAPTCHAs) 2019-12-15 22:47:33 +00:00
tanner 8fd7fc158c Fix search result icons 2019-12-14 07:39:25 +00:00
tanner 17ef7e3a65 Whitelist more html tags 2019-12-14 07:39:10 +00:00
tanner 3363ccd47e Embed base64 logo directly in source to avoid load 2019-12-02 23:54:02 +00:00
tanner 2d80b19414 Grab comments on manually submitted links 2019-12-02 23:15:51 +00:00
tanner ebcbf1b624 Sanitize html 2019-12-01 22:18:41 +00:00
tanner e231cd5c31 Decrease feed cache length to 150 2019-12-01 22:18:14 +00:00
tanner 569e5b16ca Add logo for manual submissions 2019-11-14 08:38:11 +00:00
tanner db5097ac57 Drop articles more than two days old 2019-11-08 21:50:33 +00:00
tanner 2edb3ceba7 Allow manual submission of articles 2019-11-08 05:55:30 +00:00
tanner 38b5f2dbeb Move to gevent production http server 2019-11-08 02:37:57 +00:00
tanner 6826f731c7 Handle hostnames better 2019-11-07 22:10:08 +00:00
tanner bb693ba434 Add subreddit 2019-11-07 22:09:45 +00:00
tanner 632b0276c4 Abort previous search requests 2019-11-07 22:08:28 +00:00
tanner 4cf97304e4 Get rid of lint warnings 2019-10-22 07:31:59 +00:00
tanner 9e55f6e4ec Fix Tildes down for maintenance edge case 2019-10-22 05:01:30 +00:00
tanner edc4c439d7 Prefetch first images 2019-10-19 07:33:06 +00:00
tanner 187c6b8110 Cache articles in memory for speed 2019-10-18 21:26:22 +00:00
tanner 6764bf0d6d Add serviceworker, render logos directly 2019-10-18 05:09:49 +00:00
tanner dc588fee91 Fix underlines 2019-10-18 01:20:38 +00:00
tanner f8998b687e Fix crash from domain and ext check bug 2019-10-16 08:56:31 +00:00
tanner e4f81472fc Fix copy/paste error, switch to info logging 2019-10-16 05:26:47 +00:00
tanner f293f2b5f9 Begin README and add license 2019-10-15 16:40:55 -06:00
tanner 810e8c5ead Archive WSJ articles first, catch KeyboardInterrupt 2019-10-15 21:03:47 +00:00
tanner 9c4766a928 Stop using python keyword id for id 2019-10-15 20:36:20 +00:00
tanner 0f5b2a5ff9 Cache all articles in IndexedDB 2019-10-12 23:41:31 +00:00
tanner 7cb87b59fe Move archive to Whoosh and add search 2019-10-12 05:32:17 +00:00
tanner 45b75b420b Gitkeep archive directory 2019-10-10 21:55:21 +00:00
tanner f0721519e1 Serve client through apiserver, adding meta info 2019-10-10 21:54:29 +00:00
tanner 25a671f58e Set title on article and comment pages, add comment anchors 2019-10-10 21:52:28 +00:00
tanner 5fd4fdb21c Fix Tildes comments with unknown authors 2019-10-08 08:01:17 +00:00
tanner 19e9a80be1 Archive Bloomberg articles first 2019-10-08 08:00:50 +00:00
tanner 5caa4542d8 Gitkeep apiserver data directory 2019-10-08 07:59:30 +00:00
tanner 1ed2baded6 Add huge margin to bottom of body for better pagescroll 2019-09-24 18:40:22 +00:00
tanner c7734eb2bc Add site logos, keep displaying news on error 2019-09-24 08:23:14 +00:00
tanner 0053147226 Ignore certain files and domains, remove refs 2019-09-24 08:22:06 +00:00
tanner 0496fbba45 Ignore new Tildes posts and handle deleted ones 2019-09-24 08:21:26 +00:00
tanner 0a1ebaa8b8 Handle Reddit PRAW exceptions 2019-09-24 08:20:46 +00:00
tanner 2ede5ed6ff Filter out False comments 2019-08-30 06:23:14 +00:00
tanner 20a9d9d452 Settle on serif font, add scroll to top component 2019-08-30 06:22:26 +00:00
tanner 23cdbc9292 Render reddit markdown, poll tildes better, add utils 2019-08-28 04:13:02 +00:00
tanner 10d4ec863e Snip deeply nested comments 2019-08-26 01:37:50 +00:00
tanner fc8ce79e33 Try outline.com for reader mode first 2019-08-25 23:49:08 +00:00
tanner 8eca354a47 Add favicons to webclient 2019-08-25 23:48:24 +00:00
tanner b1275d9a27 Add a button to toggle between article and comments 2019-08-25 08:50:49 +00:00
tanner 9336760ed3 Add fonts, fix styling issues 2019-08-25 07:46:58 +00:00
tanner cf9e197e6c Fix tildes comments parsing bug 2019-08-25 07:46:22 +00:00
tanner 2b1a352917 Clear localstorage cache and add slogan 2019-08-25 01:25:28 +00:00
tanner 1b6c8fc6cb Add tildes to feeds 2019-08-25 00:36:26 +00:00
tanner a2509958da Add reddit to feeds 2019-08-24 21:37:43 +00:00
tanner 4450e93c65 Remove DOMPurify import 2019-08-24 08:49:53 +00:00
tanner d341d4422f Abstract api server feeds 2019-08-24 08:49:11 +00:00
tanner 82074eb8aa Stop running DOMPurify on reader server 2019-08-24 05:09:02 +00:00
tanner c1a81a4d8c Write news stories to disk 2019-08-24 05:07:16 +00:00
tanner dde6ac4566 Finish prototype web client 2019-08-24 05:04:51 +00:00
tanner 62d68da415 Finish prototype api server 2019-08-23 08:23:48 +00:00
tanner c04b5c27f2 Figure out .gitignores 2019-08-23 08:23:26 +00:00
tanner 771c3987ec Change reader server useragent and port 2019-08-23 08:21:25 +00:00
tanner c0607b3fb6 Prototype readability server 2019-08-20 21:49:06 -06:00
tanner a814411c12 Initial commit 2019-08-20 21:48:55 -06:00
31 changed files with 159 additions and 1157 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2019 Tanner (tanner.vc) Copyright (c) 2019 Tanner Collin
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -20,7 +20,7 @@ $ sudo apt install yarn
Clone this repo: Clone this repo:
```text ```text
$ git clone https://git.tanner.vc/tanner/qotnews.git $ git clone https://gogs.tannercollin.com/tanner/qotnews.git
$ cd qotnews $ cd qotnews
``` ```
-1
View File
@@ -111,4 +111,3 @@ data.db.bak
data/archive/* data/archive/*
data/backup/* data/backup/*
qotnews.sqlite qotnews.sqlite
praw.ini
View File
+1 -1
View File
@@ -16,7 +16,7 @@ from utils import clean
# cache the topic groups to prevent redirects # cache the topic groups to prevent redirects
group_lookup = {} group_lookup = {}
USER_AGENT = 'qotnews scraper (github:tanner37)' USER_AGENT = 'qotnews scraper (github:tannercollin)'
API_TOPSTORIES = lambda : 'https://tildes.net' API_TOPSTORIES = lambda : 'https://tildes.net'
API_ITEM = lambda x : 'https://tildes.net/shortener/{}'.format(x) API_ITEM = lambda x : 'https://tildes.net/shortener/{}'.format(x)
-1
View File
@@ -8,7 +8,6 @@ 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
+16 -11
View File
@@ -10,7 +10,6 @@ SEARCH_ENABLED = bool(settings.MEILI_URL)
def meili_api(method, route, json=None, params=None, parse_json=True): def meili_api(method, route, json=None, params=None, parse_json=True):
try: try:
headers = {'Authorization': 'Bearer ' + settings.MEILI_API_KEY}
r = method(settings.MEILI_URL + route, json=json, params=params, timeout=4) r = method(settings.MEILI_URL + route, json=json, params=params, timeout=4)
if r.status_code > 299: if r.status_code > 299:
raise Exception('Bad response code ' + str(r.status_code)) raise Exception('Bad response code ' + str(r.status_code))
@@ -29,20 +28,24 @@ def create_index():
json = dict(uid='qotnews', primaryKey='id') json = dict(uid='qotnews', primaryKey='id')
return meili_api(requests.post, 'indexes', json=json) return meili_api(requests.post, 'indexes', json=json)
def update_settings(): def update_rankings():
json = { json = ['typo', 'words', 'proximity', 'date:desc', 'exactness']
'rankingRules': ['typo', 'words', 'proximity', 'date:desc', 'exactness'], return meili_api(requests.post, 'indexes/qotnews/settings/ranking-rules', json=json)
'searchableAttributes': ['title', 'url', 'author'],
'displayedAttributes': ['id', 'ref', 'source', 'author', 'author_link', 'score', 'date', 'title', 'link', 'url', 'num_comments'], def update_attributes():
} json = ['title', 'url', 'author']
return meili_api(requests.post, 'indexes/qotnews/settings', json=json) r = meili_api(requests.post, 'indexes/qotnews/settings/searchable-attributes', json=json)
json = ['id', 'ref', 'source', 'author', 'author_link', 'score', 'date', 'title', 'link', 'url', 'num_comments']
r = meili_api(requests.post, 'indexes/qotnews/settings/displayed-attributes', json=json)
return r
def init(): def init():
if not SEARCH_ENABLED: if not SEARCH_ENABLED:
logging.info('Search is not enabled, skipping init.') logging.info('Search is not enabled, skipping init.')
return return
print(create_index()) print(create_index())
update_settings() update_rankings()
update_attributes()
def put_story(story): def put_story(story):
if not SEARCH_ENABLED: return if not SEARCH_ENABLED: return
@@ -50,11 +53,13 @@ def put_story(story):
def search(q): def search(q):
if not SEARCH_ENABLED: return [] if not SEARCH_ENABLED: return []
json = dict(q=q, limit=settings.FEED_LENGTH) params = dict(q=q, limit=settings.FEED_LENGTH)
r = meili_api(requests.post, 'indexes/qotnews/search', json=json, parse_json=False) r = meili_api(requests.get, 'indexes/qotnews/search', params=params, parse_json=False)
return r return r
if __name__ == '__main__': if __name__ == '__main__':
init() init()
print(update_rankings())
print(search('facebook')) print(search('facebook'))
+6 -84
View File
@@ -14,9 +14,6 @@ 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
@@ -29,25 +26,6 @@ from flask import abort, Flask, request, render_template, stream_with_context, R
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()
@@ -61,62 +39,15 @@ 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)
is_smallweb_filter = request.args.get('smallweb') == 'true' and smallweb_set stories = database.get_stories(limit, skip)
sources_filter = request.args.getlist('source')
if not is_smallweb_filter and not sources_filter:
stories = database.get_stories(limit, skip)
else:
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)
if is_smallweb_filter:
story_url = story.get('url') or story.get('link') or ''
if not story_url:
continue
hostname = urlparse(story_url).hostname
if not hostname or hostname.replace('www.', '') not in smallweb_set:
continue
if sources_filter:
if story.get('source') not in sources_filter:
continue
filtered_stories.append(story_str)
if len(filtered_stories) == limit:
break
if len(filtered_stories) == limit:
break
current_skip += limit
stories = filtered_stories
# 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'
@@ -225,18 +156,11 @@ 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)
@@ -247,9 +171,9 @@ def static_story(sid):
except NotFound: except NotFound:
pass pass
story_obj = database.get_story(sid) story = database.get_story(sid)
if not story_obj: return abort(404) if not story: return abort(404)
story = json.loads(story_obj.full_json) story = json.loads(story.full_json)
score = story['score'] score = story['score']
num_comments = story['num_comments'] num_comments = story['num_comments']
@@ -258,7 +182,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.get('url') or story.get('link') or '').hostname or '' url = urlparse(story['url']).hostname or urlparse(story['link']).hostname or ''
url = url.replace('www.', '') url = url.replace('www.', '')
return render_template('index.html', return render_template('index.html',
@@ -266,8 +190,6 @@ 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)
+1 -1
View File
@@ -12,7 +12,7 @@ def alert_tanner(message):
try: try:
logging.info('Alerting Tanner: ' + message) logging.info('Alerting Tanner: ' + message)
params = dict(qotnews=message) params = dict(qotnews=message)
requests.get('https://tbot.tanner.vc/message', params=params, timeout=4) requests.get('https://tbot.tannercollin.com/message', params=params, timeout=4)
except BaseException as e: except BaseException as e:
logging.error('Problem alerting Tanner: ' + str(e)) logging.error('Problem alerting Tanner: ' + str(e))

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 538 B

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 500 B

-2
View File
@@ -4,14 +4,12 @@
"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"
+17 -118
View File
@@ -30,135 +30,34 @@
--> -->
<title>{{ title }}</title> <title>{{ title }}</title>
<script>document.documentElement.className = 'js-enabled';</script>
<style> <style>
.js-enabled .static-content {
display: none;
}
html { html {
overflow-y: scroll; overflow-y: scroll;
} }
body { body {
background: #eeeeee; background: #000;
}
.nojs {
color: white;
max-width: 32rem;
} }
</style> </style>
</head> </head>
<body> <body>
<script> <div class="nojs">
(function() { <noscript>
try { You need to enable JavaScript to run this app because it's written in React.
var theme = localStorage.getItem('theme'); I was planning on writing a server-side version, but I've become distracted
if (theme === 'dark') { by other projects -- sorry!
document.body.style.backgroundColor = '#1a1a1a'; <br/>
} else if (theme === 'black' || theme === 'red') { I originally wrote this for myself, and of course I whitelist JavaScript on
document.body.style.backgroundColor = '#000'; all my own domains.
} <br/><br/>
} catch (e) {} Alternatively, try activex.news.t0.vc for an ActiveX™ version.
})(); </noscript>
</script>
<div id="root">
<div class="static-content">
{% if False %}
<noscript>
<meta http-equiv="refresh" content="0;url=?/no.script">
</noscript>
{% endif %}
<div class="container menu">
<p>
<a href="/">QotNews</a>
<br />
<span class="slogan">Hacker News, Reddit, Lobsters, and Tildes articles rendered in reader mode.</span>
</p>
</div>
{% if story %}
<div class="{% if show_comments %}container{% else %}article-container{% endif %}">
<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>
<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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 981 B

+45 -91
View File
@@ -1,4 +1,4 @@
import React, { useState, useLayoutEffect, useEffect, useRef, useCallback } 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';
@@ -14,40 +14,35 @@ import Search from './Search.js';
import Submit from './Submit.js'; 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';
import Settings from './Settings.js';
function App() { function App() {
const [theme, setTheme] = useState(localStorage.getItem('theme') || ''); const [theme, setTheme] = useState(localStorage.getItem('theme') || '');
const cache = useRef({}); const cache = useRef({});
const [isFullScreen, setIsFullScreen] = useState(!!document.fullscreenElement); const [isFullScreen, setIsFullScreen] = useState(!!document.fullscreenElement);
const [waitingWorker, setWaitingWorker] = useState(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const defaultBodyFontSize = 1.0;
const [bodyFontSize, setBodyFontSize] = useState(Number(localStorage.getItem('bodyFontSize')) || defaultBodyFontSize);
const [bodyFont, setBodyFont] = useState(localStorage.getItem('bodyFont') || 'Sans Serif');
const [articleFont, setArticleFont] = useState(localStorage.getItem('articleFont') || 'Apparatus SIL');
const [filterSmallweb, setFilterSmallweb] = useState(() => localStorage.getItem('filterSmallweb') === 'true');
const [feedSources, setFeedSources] = useState(() => {
const saved = localStorage.getItem('feedSources');
return saved ? JSON.parse(saved) : {
hackernews: true,
reddit: true,
lobsters: true,
tildes: true,
};
});
const updateCache = useCallback((key, value) => { const updateCache = useCallback((key, value) => {
cache.current[key] = value; cache.current[key] = value;
}, []); }, []);
useEffect(() => { const light = () => {
const onSWUpdate = e => { setTheme('');
setWaitingWorker(e.detail.waiting); localStorage.setItem('theme', '');
}; };
window.addEventListener('swUpdate', onSWUpdate);
return () => window.removeEventListener('swUpdate', onSWUpdate); const dark = () => {
}, []); setTheme('dark');
localStorage.setItem('theme', 'dark');
};
const black = () => {
setTheme('black');
localStorage.setItem('theme', 'black');
};
const red = () => {
setTheme('red');
localStorage.setItem('theme', 'red');
};
useEffect(() => { useEffect(() => {
if (Object.keys(cache.current).length === 0) { if (Object.keys(cache.current).length === 0) {
@@ -59,13 +54,24 @@ function App() {
} }
}, [updateCache]); }, [updateCache]);
const goFullScreen = () => {
if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen');
}
document.body.requestFullscreen({ navigationUI: 'hide' });
};
const exitFullScreen = () => {
document.exitFullscreen();
};
useEffect(() => { useEffect(() => {
const onFullScreenChange = () => setIsFullScreen(!!document.fullscreenElement); const onFullScreenChange = () => setIsFullScreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullScreenChange); document.addEventListener('fullscreenchange', onFullScreenChange);
return () => document.removeEventListener('fullscreenchange', onFullScreenChange); return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
}, []); }, []);
useLayoutEffect(() => { 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') {
@@ -77,86 +83,34 @@ function App() {
} }
}, [theme]); }, [theme]);
useEffect(() => { const fullScreenAvailable = document.fullscreenEnabled ||
document.documentElement.style.fontSize = `${bodyFontSize}rem`; document.mozFullscreenEnabled ||
}, [bodyFontSize]); document.webkitFullscreenEnabled ||
document.msFullscreenEnabled;
const fontMap = {
'Sans Serif': 'sans-serif',
'Serif': 'serif',
'Apparatus SIL': "'Apparatus SIL', sans-serif"
};
useEffect(() => {
document.body.style.fontFamily = fontMap[bodyFont];
}, [bodyFont]);
useEffect(() => {
const styleId = 'article-font-family-style';
let style = document.getElementById(styleId);
if (!style) {
style = document.createElement('style');
style.id = styleId;
document.head.appendChild(style);
}
style.innerHTML = `.story-text { font-family: ${fontMap[articleFont]} !important; }`;
}, [articleFont]);
return ( return (
<div className={theme}> <div className={theme}>
<Settings
settingsOpen={settingsOpen}
setSettingsOpen={setSettingsOpen}
theme={theme}
setTheme={setTheme}
isFullScreen={isFullScreen}
filterSmallweb={filterSmallweb}
setFilterSmallweb={setFilterSmallweb}
feedSources={feedSources}
setFeedSources={setFeedSources}
bodyFontSize={bodyFontSize}
setBodyFontSize={setBodyFontSize}
defaultBodyFontSize={defaultBodyFontSize}
bodyFont={bodyFont}
setBodyFont={setBodyFont}
articleFont={articleFont}
setArticleFont={setArticleFont}
/>
{waitingWorker &&
<div className='update-banner'>
Client version mismatch, please refresh:{' '}
<button onClick={() => {
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
const reload = () => window.location.reload();
navigator.serviceWorker.addEventListener('controllerchange', reload, { once: true });
// Fallback for when the controller has already changed (ie. in another tab)
navigator.serviceWorker.getRegistration().then(reg => {
if (!reg || !reg.waiting) {
reload();
}
});
}}>
Refresh
</button>
</div>
}
<Router> <Router>
<div className='container menu'> <div className='container menu'>
<p> <p>
<Link to='/'>QotNews</Link> <Link to='/'>QotNews</Link>
<button className="settings-button" onClick={() => setSettingsOpen(true)}>Settings</button> <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>
{fullScreenAvailable &&
<Route path='/(|search)' render={() => !isFullScreen ?
<button className='fullscreen' onClick={() => goFullScreen()}>Enter Fullscreen</button>
:
<button className='fullscreen' onClick={() => exitFullScreen()}>Exit Fullscreen</button>
} />
}
<Route path='/(|search)' component={Search} /> <Route path='/(|search)' component={Search} />
<Route path='/(|search)' component={Submit} /> <Route path='/(|search)' component={Submit} />
</div> </div>
<Route path='/' exact render={(props) => <Feed {...props} updateCache={updateCache} filterSmallweb={filterSmallweb} feedSources={feedSources} />} /> <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={cache.current} />} /> <Route path='/:id' exact render={(props) => <Article {...props} cache={cache.current} />} />
+26 -149
View File
@@ -1,20 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom'; 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, similarLink, infoLine, ToggleDot } from './utils.js'; import { sourceLink, infoLine, ToggleDot } from './utils.js';
import Latex from 'react-latex-next';
import 'katex/dist/katex.min.css';
const VOID_ELEMENTS = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
const DANGEROUS_TAGS = ['svg', 'math'];
const latexDelimiters = [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false }
];
function Article({ cache }) { function Article({ cache }) {
const { id } = useParams(); const { id } = useParams();
@@ -24,7 +12,6 @@ function Article({ cache }) {
const [story, setStory] = useState(cache[id] || false); const [story, setStory] = useState(cache[id] || false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [pConv, setPConv] = useState([]); const [pConv, setPConv] = useState([]);
const [copyButtonText, setCopyButtonText] = useState('\ue92c');
useEffect(() => { useEffect(() => {
localForage.getItem(id) localForage.getItem(id)
@@ -55,144 +42,18 @@ function Article({ cache }) {
); );
}, [id]); }, [id]);
const copyLink = () => {
navigator.clipboard.writeText(`${story.title}:\n${window.location.href}`).then(() => {
setCopyButtonText('\uea10');
setTimeout(() => setCopyButtonText('\ue92c'), 2000);
}, () => {
setCopyButtonText('\uea0f');
setTimeout(() => setCopyButtonText('\ue92c'), 2000);
});
};
const pConvert = (n) => { const pConvert = (n) => {
setPConv(prevPConv => [...prevPConv, n]); setPConv(prevPConv => [...prevPConv, n]);
}; };
const isCodeBlock = (v) => { const nodes = useMemo(() => {
if (v.localName === 'pre') { if (story && story.text) {
return true;
}
if (v.localName === 'code') {
if (v.closest('p')) {
return false;
}
const parent = v.parentElement;
if (parent) {
const nonWhitespaceChildren = Array.from(parent.childNodes).filter(n => {
return n.nodeType !== Node.TEXT_NODE || n.textContent.trim() !== '';
});
if (nonWhitespaceChildren.length === 1 && nonWhitespaceChildren[0] === v) {
return true;
}
}
}
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'); let div = document.createElement('div');
div.innerHTML = s.text; div.innerHTML = story.text;
return div.childNodes; return div.childNodes;
} }
return null; return null;
}; }, [story]);
const storyNodes = nodes(story);
return ( return (
<div className='article-container'> <div className='article-container'>
@@ -210,17 +71,33 @@ function Article({ cache }) {
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
</Helmet> </Helmet>
<h1>{story.title} <button className='copy-button' onClick={copyLink}>{copyButtonText}</button></h1> <h1>{story.title}</h1>
<div className='info'> <div className='info'>
Source: {sourceLink(story)} | {similarLink(story)} Source: {sourceLink(story)}
</div> </div>
{infoLine(story)} {infoLine(story)}
{storyNodes ? {nodes ?
<div className='story-text'> <div className='story-text'>
{renderNodes(storyNodes)} {Object.entries(nodes).map(([k, v]) =>
pConv.includes(k) ?
<React.Fragment key={k}>
{v.innerHTML.split('\n\n').map((x, i) =>
<p key={i} dangerouslySetInnerHTML={{ __html: x }} />
)}
</React.Fragment>
:
(v.nodeName === '#text' ?
<p key={k}>{v.data}</p>
:
<React.Fragment key={k}>
<v.localName dangerouslySetInnerHTML={v.innerHTML ? { __html: v.innerHTML } : null} />
{v.localName === 'pre' && <button onClick={() => pConvert(k)}>Convert Code to Paragraph</button>}
</React.Fragment>
)
)}
</div> </div>
: :
<p>Problem getting article :(</p> <p>Problem getting article :(</p>
+10 -10
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Link, useParams } 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';
@@ -58,17 +58,17 @@ function Comments({ cache }) {
); );
}, [id]); }, [id]);
const collapseComment = (cid) => { const collapseComment = useCallback((cid) => {
setCollapsed(prev => [...prev, cid]); setCollapsed(prev => [...prev, cid]);
setExpanded(prev => prev.filter(x => x !== cid)); setExpanded(prev => prev.filter(x => x !== cid));
}; }, []);
const expandComment = (cid) => { const expandComment = useCallback((cid) => {
setCollapsed(prev => prev.filter(x => x !== cid)); setCollapsed(prev => prev.filter(x => x !== cid));
setExpanded(prev => [...prev, cid]); setExpanded(prev => [...prev, cid]);
}; }, []);
const displayComment = (story, c, level) => { const displayComment = useCallback((story, c, level) => {
const cid = c.author+c.date; const cid = c.author+c.date;
const isCollapsed = collapsed.includes(cid); const isCollapsed = collapsed.includes(cid);
@@ -85,21 +85,21 @@ function Comments({ cache }) {
{' '} | <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 &&
<button className='collapser pointer' onClick={() => collapseComment(cid)}></button> <span className='collapser pointer' onClick={() => collapseComment(cid)}></span>
} }
</p> </p>
</div> </div>
<div className={isCollapsed ? 'text hidden' : 'text'} dangerouslySetInnerHTML={{ __html: c.text || '<p>[Empty / deleted comment]</p>'}} /> <div className={isCollapsed ? 'text hidden' : 'text'} dangerouslySetInnerHTML={{ __html: c.text }} />
{hidden && hasChildren ? {hidden && hasChildren ?
<button className='comment lined info pointer' onClick={() => expandComment(cid)}>[show {countComments(c)-1} more]</button> <div className='comment lined info pointer' onClick={() => expandComment(cid)}>[show {countComments(c)-1} more]</div>
: :
c.comments.map(i => displayComment(story, i, level + 1)) c.comments.map(i => displayComment(story, i, level + 1))
} }
</div> </div>
); );
}; }, [collapsed, expanded, collapseComment, expandComment]);
return ( return (
<div className='container'> <div className='container'>
+14 -60
View File
@@ -1,49 +1,15 @@
import React, { useState, useEffect, useRef } 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';
function Feed({ updateCache, filterSmallweb, feedSources }) { function Feed({ updateCache }) {
const [stories, setStories] = useState(() => JSON.parse(localStorage.getItem('stories')) || false); const [stories, setStories] = useState(() => JSON.parse(localStorage.getItem('stories')) || false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loadingStatus, setLoadingStatus] = useState(null);
const isInitialMount = useRef(true);
useEffect(() => { useEffect(() => {
if (isInitialMount.current) { fetch('/api')
isInitialMount.current = false;
} else {
setStories(false);
}
}, [filterSmallweb, feedSources]);
useEffect(() => {
const controller = new AbortController();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then(reg => {
if (reg) {
console.log('Checking for client update...');
reg.update();
}
});
}
const params = new URLSearchParams();
if (filterSmallweb) {
params.append('smallweb', 'true');
}
const allSources = Object.keys(feedSources);
const enabledSources = allSources.filter(key => feedSources[key]);
if (enabledSources.length > 0 && enabledSources.length < allSources.length) {
enabledSources.forEach(source => params.append('source', source));
}
const apiUrl = `/api?${params.toString()}`;
fetch(apiUrl, { signal: controller.signal })
.then(res => { .then(res => {
if (!res.ok) { if (!res.ok) {
throw new Error(`Server responded with ${res.status} ${res.statusText}`); throw new Error(`Server responded with ${res.status} ${res.statusText}`);
@@ -54,24 +20,24 @@ function Feed({ updateCache, filterSmallweb, feedSources }) {
async (result) => { async (result) => {
const newApiStories = result.stories; const newApiStories = result.stories;
const updated = !stories || stories.map(s => s.id).join() !== newApiStories.map(s => s.id).join(); const updated = !stories || !stories.length || stories[0].id !== newApiStories[0].id;
console.log('New stories available:', updated); console.log('New stories available:', updated);
if (!updated) return; if (!updated) return;
setLoadingStatus({ current: 0, total: newApiStories.length }); if (!stories || !stories.length) {
setStories(newApiStories);
localStorage.setItem('stories', JSON.stringify(newApiStories));
}
let currentStories = Array.isArray(stories) ? [...stories] : []; let currentStories = Array.isArray(stories) ? [...stories] : [];
let preloadedCount = 0; let preloadedCount = 0;
for (const [index, newStory] of newApiStories.entries()) { for (const newStory of [...newApiStories].reverse()) {
if (controller.signal.aborted) {
break;
}
try { try {
const storyFetchController = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => storyFetchController.abort(), 10000); // 10-second timeout const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout
const storyRes = await fetch('/api/' + newStory.id, { signal: storyFetchController.signal }); const storyRes = await fetch('/api/' + newStory.id, { signal: controller.signal });
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!storyRes.ok) { if (!storyRes.ok) {
@@ -84,13 +50,12 @@ function Feed({ updateCache, filterSmallweb, feedSources }) {
console.log('Preloaded story:', fullStory.id, fullStory.title); console.log('Preloaded story:', fullStory.id, fullStory.title);
updateCache(fullStory.id, fullStory); updateCache(fullStory.id, fullStory);
preloadedCount++; preloadedCount++;
setLoadingStatus({ current: preloadedCount, total: newApiStories.length });
const existingStoryIndex = currentStories.findIndex(s => s.id === newStory.id); const existingStoryIndex = currentStories.findIndex(s => s.id === newStory.id);
if (existingStoryIndex > -1) { if (existingStoryIndex > -1) {
currentStories.splice(existingStoryIndex, 1); currentStories.splice(existingStoryIndex, 1);
} }
currentStories.splice(index, 0, newStory); currentStories.unshift(newStory);
localStorage.setItem('stories', JSON.stringify(currentStories)); localStorage.setItem('stories', JSON.stringify(currentStories));
setStories(currentStories); setStories(currentStories);
@@ -117,20 +82,13 @@ function Feed({ updateCache, filterSmallweb, feedSources }) {
localStorage.setItem('stories', JSON.stringify(finalStories)); localStorage.setItem('stories', JSON.stringify(finalStories));
setStories(finalStories); setStories(finalStories);
setLoadingStatus(null);
}, },
(error) => { (error) => {
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()}.`; 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); setError(errorMessage);
} }
); );
}, [updateCache]);
return () => controller.abort();
}, [updateCache, filterSmallweb, feedSources]);
return ( return (
<div className='container'> <div className='container'>
@@ -138,7 +96,6 @@ function Feed({ updateCache, filterSmallweb, feedSources }) {
<title>QotNews</title> <title>QotNews</title>
<meta name="robots" content="index" /> <meta name="robots" content="index" />
</Helmet> </Helmet>
{error && {error &&
<details style={{marginBottom: '1rem'}}> <details style={{marginBottom: '1rem'}}>
<summary>Connection error? Click to expand.</summary> <summary>Connection error? Click to expand.</summary>
@@ -146,7 +103,6 @@ function Feed({ updateCache, filterSmallweb, feedSources }) {
{stories && <p>Loaded feed from cache.</p>} {stories && <p>Loaded feed from cache.</p>}
</details> </details>
} }
{stories ? {stories ?
<div> <div>
{stories.map(x => {stories.map(x =>
@@ -168,8 +124,6 @@ function Feed({ updateCache, filterSmallweb, feedSources }) {
: :
<p>Loading...</p> <p>Loading...</p>
} }
{loadingStatus && <p>Preloading stories {loadingStatus.current} / {loadingStatus.total}...</p>}
</div> </div>
); );
} }
+8 -14
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;
-191
View File
@@ -1,191 +0,0 @@
import React from 'react';
function Settings({
settingsOpen,
setSettingsOpen,
theme,
setTheme,
isFullScreen,
filterSmallweb,
setFilterSmallweb,
feedSources,
setFeedSources,
bodyFontSize,
setBodyFontSize,
defaultBodyFontSize,
bodyFont,
setBodyFont,
articleFont,
setArticleFont,
}) {
const light = () => {
setTheme('');
localStorage.setItem('theme', '');
};
const dark = () => {
setTheme('dark');
localStorage.setItem('theme', 'dark');
};
const black = () => {
setTheme('black');
localStorage.setItem('theme', 'black');
};
const red = () => {
setTheme('red');
localStorage.setItem('theme', 'red');
};
const handleFilterChange = e => {
const isChecked = e.target.checked;
setFilterSmallweb(isChecked);
localStorage.setItem('filterSmallweb', isChecked);
};
const handleFeedSourceChange = (source) => {
setFeedSources(prevSources => {
const newSources = { ...prevSources, [source]: !prevSources[source] };
localStorage.setItem('feedSources', JSON.stringify(newSources));
return newSources;
});
};
const changeBodyFont = (font) => {
setBodyFont(font);
localStorage.setItem('bodyFont', font);
};
const changeArticleFont = (font) => {
setArticleFont(font);
localStorage.setItem('articleFont', font);
};
const changeBodyFontSize = (amount) => {
const newSize = bodyFontSize + amount;
setBodyFontSize(parseFloat(newSize.toFixed(2)));
localStorage.setItem('bodyFontSize', newSize.toFixed(2));
};
const resetBodyFontSize = () => {
setBodyFontSize(defaultBodyFontSize);
localStorage.removeItem('bodyFontSize');
};
const bodyFontSettingsChanged = bodyFontSize !== defaultBodyFontSize;
const goFullScreen = () => {
if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen');
}
document.body.requestFullscreen({ navigationUI: 'hide' });
};
const exitFullScreen = () => {
document.exitFullscreen();
};
const fullScreenAvailable = document.fullscreenEnabled ||
document.mozFullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.msFullscreenEnabled;
if (!settingsOpen) {
return null;
}
return (
<>
<div className="modal-overlay" onClick={() => setSettingsOpen(false)}></div>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="close-modal-button" onClick={() => setSettingsOpen(false)}>&times;</button>
<h3>Settings</h3>
<div className="setting-group">
<h4>Theme</h4>
<button className={theme === '' ? 'active' : ''} onClick={() => { light(); setSettingsOpen(false); }}>Light</button>
<button className={theme === 'dark' ? 'active' : ''} onClick={() => { dark(); setSettingsOpen(false); }}>Dark</button>
<button className={theme === 'black' ? 'active' : ''} onClick={() => { black(); setSettingsOpen(false); }}>Black</button>
<button className={theme === 'red' ? 'active' : ''} onClick={() => { red(); setSettingsOpen(false); }}>Red</button>
{fullScreenAvailable &&
<div style={{ marginTop: '0.5rem' }}>
{!isFullScreen ?
<button onClick={() => { goFullScreen(); setSettingsOpen(false); }}>Enter Fullscreen</button>
:
<button onClick={() => { exitFullScreen(); setSettingsOpen(false); }}>Exit Fullscreen</button>
}
</div>
}
</div>
<div className="setting-group">
<h4>Feed</h4>
<div className="font-option gap">
<input className="checkbox" type="checkbox" id="filter-smallweb" checked={filterSmallweb} onChange={handleFilterChange} />
<label htmlFor="filter-smallweb">Small websites only</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-hackernews" name="feed-source" checked={feedSources.hackernews} onChange={() => handleFeedSourceChange('hackernews')} />
<label htmlFor="filter-hackernews">Hacker News</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-reddit" name="feed-source" checked={feedSources.reddit} onChange={() => handleFeedSourceChange('reddit')} />
<label htmlFor="filter-reddit">Reddit</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-lobsters" name="feed-source" checked={feedSources.lobsters} onChange={() => handleFeedSourceChange('lobsters')} />
<label htmlFor="filter-lobsters">Lobsters</label>
</div>
<div className="font-option">
<input className="checkbox" type="checkbox" id="filter-tildes" name="feed-source" checked={feedSources.tildes} onChange={() => handleFeedSourceChange('tildes')} />
<label htmlFor="filter-tildes">Tildes</label>
</div>
</div>
<div className="setting-group">
<h4>Font Size</h4>
<button onClick={() => changeBodyFontSize(-0.05)}>-</button>
<span className="font-size-display">{bodyFontSize.toFixed(2)}</span>
<button onClick={() => changeBodyFontSize(0.05)}>+</button>
<button onClick={resetBodyFontSize} disabled={!bodyFontSettingsChanged}>Reset</button>
</div>
<div className="setting-group">
<h4>Body Font</h4>
<div className="font-option">
<input className="checkbox" type="radio" id="body-sans-serif" name="body-font" value="Sans Serif" checked={bodyFont === 'Sans Serif'} onChange={() => changeBodyFont('Sans Serif')} />
<label htmlFor="body-sans-serif">Sans Serif *</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="body-serif" name="body-font" value="Serif" checked={bodyFont === 'Serif'} onChange={() => changeBodyFont('Serif')} />
<label htmlFor="body-serif">Serif</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="body-apparatus" name="body-font" value="Apparatus SIL" checked={bodyFont === 'Apparatus SIL'} onChange={() => changeBodyFont('Apparatus SIL')} />
<label htmlFor="body-apparatus">Apparatus SIL</label>
</div>
</div>
<div className="setting-group">
<h4>Article Font</h4>
<div className="font-option">
<input className="checkbox" type="radio" id="article-sans-serif" name="article-font" value="Sans Serif" checked={articleFont === 'Sans Serif'} onChange={() => changeArticleFont('Sans Serif')} />
<label htmlFor="article-sans-serif">Sans Serif</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="article-serif" name="article-font" value="Serif" checked={articleFont === 'Serif'} onChange={() => changeArticleFont('Serif')} />
<label htmlFor="article-serif">Serif</label>
</div>
<div className="font-option">
<input className="checkbox" type="radio" id="article-apparatus" name="article-font" value="Apparatus SIL" checked={articleFont === 'Apparatus SIL'} onChange={() => changeArticleFont('Apparatus SIL')} />
<label htmlFor="article-apparatus">Apparatus SIL *</label>
</div>
</div>
</div>
</>
);
}
export default Settings;
+2 -37
View File
@@ -11,9 +11,8 @@
border: 1px solid #828282; border: 1px solid #828282;
} }
.black .menu button, .black button {
.black .story-text button { background-color: #444444;
background-color: #222222;
border-color: #bbb; border-color: #bbb;
color: #ddd; color: #ddd;
} }
@@ -67,37 +66,3 @@
.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: #222222;
border-color: #bbb;
color: #ddd;
}
.black .modal-content {
background: #222;
border-color: #828282;
color: #ddd;
}
.black .modal-content button {
background-color: #222222;
border-color: #bbb;
}
.black .modal-content button.active {
background-color: #555;
}
+2 -37
View File
@@ -11,9 +11,8 @@
border: 1px solid #828282; border: 1px solid #828282;
} }
.dark .menu button, .dark button {
.dark .story-text button { background-color: #444444;
background-color: #222222;
border-color: #bbb; border-color: #bbb;
color: #ddd; color: #ddd;
} }
@@ -63,37 +62,3 @@
.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: #222222;
border-color: #bbb;
color: #ddd;
}
.dark .modal-content {
background: #222;
border-color: #828282;
color: #ddd;
}
.dark .modal-content button {
background-color: #222222;
border-color: #bbb;
}
.dark .modal-content button.active {
background-color: #555;
}
+3 -245
View File
@@ -1,7 +1,6 @@
body { body {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-size: 1rem; font: 1rem/1.3 sans-serif;
line-height: 1.3;
color: #000000; color: #000000;
word-break: break-word; word-break: break-word;
font-kerning: normal; font-kerning: normal;
@@ -44,36 +43,16 @@ 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;
@@ -86,7 +65,6 @@ pre {
.slogan { .slogan {
color: #828282; color: #828282;
margin-bottom: 0.5rem;
} }
.theme { .theme {
@@ -143,13 +121,6 @@ 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;
} }
@@ -200,20 +171,12 @@ span.source {
} }
.story-text { .story-text {
font-size: 1.2rem; font: 1.2rem/1.5 'Apparatus SIL', sans-serif;
line-height: 1.5;
margin-top: 1em; margin-top: 1em;
} }
.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 {
@@ -226,11 +189,6 @@ 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 {
@@ -250,31 +208,6 @@ 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;
} }
@@ -327,178 +260,3 @@ button.comment {
.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;
}
.settings-button {
float: right;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
z-index: 100;
}
.modal-content {
position: absolute;
top: 1rem;
right: 1rem;
background: #eee;
color: #000;
padding: 1rem;
border-radius: 4px;
z-index: 101;
min-width: 250px;
border: 1px solid #828282;
}
.modal-content h3, .modal-content h4 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.modal-content .setting-group {
margin-bottom: 1rem;
}
.modal-content button {
margin-right: 0.5rem;
padding: 0.25rem 0.75rem;
border: 1px solid #828282;
border-radius: 4px;
background-color: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.modal-content button:last-child {
margin-right: 0;
}
.modal-content button.active {
background-color: #ccc;
}
.modal-content button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-content .font-size-display {
display: inline-block;
width: 50px;
text-align: center;
margin: 0 0.25rem;
}
.modal-content .font-option {
margin-bottom: 0.25rem;
}
.modal-content .gap {
margin-bottom: 0.75rem;
}
.close-modal-button {
position: absolute;
top: 0.5rem;
right: 0.75rem;
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
padding: 0;
cursor: pointer;
color: inherit;
}
+2 -41
View File
@@ -20,9 +20,8 @@
background-color: #690000; background-color: #690000;
} }
.red .menu button, .red button {
.red .story-text button { background-color: #440000;
background-color: #220000;
border-color: #b00; border-color: #b00;
color: #b00; color: #b00;
} }
@@ -81,41 +80,3 @@
.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: #220000;
border-color: #b00;
color: #d00;
}
.red .modal-content {
background: #100;
border-color: #690000;
color: #b00;
}
.red .modal-content button {
background-color: #220000;
border-color: #b00;
}
.red .modal-content button.active {
background-color: #550000;
}
-5
View File
@@ -26,8 +26,3 @@
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.
+1 -7
View File
@@ -3,15 +3,9 @@ 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 }));
}
});
+3 -30
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -9,13 +9,6 @@ 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
@@ -39,8 +32,6 @@ export const ToggleDot = ({ id, article }) => (
); );
export const BackwardDot = () => { export const BackwardDot = () => {
const [showTooltip, setShowTooltip] = useState(false);
const goBackward = () => { const goBackward = () => {
localStorage.setItem('scrollLock', 'True'); localStorage.setItem('scrollLock', 'True');
window.history.back(); window.history.back();
@@ -51,25 +42,15 @@ export const BackwardDot = () => {
if (!document.fullscreenElement) return null; if (!document.fullscreenElement) return null;
return ( return (
<div <div className='dot backwardDot' onClick={goBackward}>
className={'dot backwardDot tooltip' + (showTooltip ? ' show-tooltip' : '')}
onClick={goBackward}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchStart={() => setShowTooltip(true)}
onTouchEnd={() => setShowTooltip(false)}
>
<div className='button'> <div className='button'>
</div> </div>
<span className="tooltiptext">Browser Back</span>
</div> </div>
); );
}; };
export const ForwardDot = () => { export const ForwardDot = () => {
const [showTooltip, setShowTooltip] = useState(false);
const goForward = () => { const goForward = () => {
localStorage.setItem('scrollLock', 'True'); localStorage.setItem('scrollLock', 'True');
window.history.forward(); window.history.forward();
@@ -79,18 +60,10 @@ export const ForwardDot = () => {
if (!isMobile) return null; if (!isMobile) return null;
return ( return (
<div <div className='dot forwardDot' onClick={goForward}>
className={'dot forwardDot tooltip' + (showTooltip ? ' show-tooltip' : '')}
onClick={goForward}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchStart={() => setShowTooltip(true)}
onTouchEnd={() => setShowTooltip(false)}
>
<div className='button'> <div className='button'>
</div> </div>
<span className="tooltiptext">Browser Forward</span>
</div> </div>
); );
}; };
-19
View File
@@ -3169,11 +3169,6 @@ commander@^2.11.0, commander@^2.20.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^8.3.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==
commander@~2.19.0: commander@~2.19.0:
version "2.19.0" version "2.19.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
@@ -6861,13 +6856,6 @@ jsx-ast-utils@^2.1.0, jsx-ast-utils@^2.2.1:
array-includes "^3.1.1" array-includes "^3.1.1"
object.assign "^4.1.0" object.assign "^4.1.0"
katex@^0.16.0, katex@^0.16.25:
version "0.16.25"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.25.tgz#61699984277e3bdb3e89e0e446b83cd0a57d87db"
integrity sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==
dependencies:
commander "^8.3.0"
killable@^1.0.0: killable@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
@@ -9131,13 +9119,6 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.4:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-latex-next@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-latex-next/-/react-latex-next-3.0.0.tgz#3e347a13b1e701439b5fa52f75201bc86166854e"
integrity sha512-x70f1b1G7TronVigsRgKHKYYVUNfZk/3bciFyYX1lYLQH2y3/TXku3+5Vap8MDbJhtopePSYBsYWS6jhzIdz+g==
dependencies:
katex "^0.16.0"
react-router-dom@^5.0.1: react-router-dom@^5.0.1:
version "5.3.4" version "5.3.4"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.3.4.tgz#2ed62ffd88cae6db134445f4a0c0ae8b91d2e5e6" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.3.4.tgz#2ed62ffd88cae6db134445f4a0c0ae8b91d2e5e6"