Compare commits

...

10 Commits

Author SHA1 Message Date
Tanner f4ec74e63d Ignore praw.ini 2026-06-13 16:35:38 -06:00
tanner a958ca3614 refactor: Adapt Meilisearch integration to v1.29.0 API
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-06-13 11:58:08 -06:00
tanner 1caf4248d2 feat: Add MeiliSearch API key authentication 2026-06-13 11:58:08 -06:00
tanner 09ea2535e0 refactor: Extract settings modal to dedicated component
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 20:08:43 +00:00
tanner 6b6e36d880 feat: Add settings component 2026-01-06 20:08:40 +00:00
tanner cf789b1518 fix: Make update banner refresh button robust
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 20:03:59 +00:00
tanner fc4eb077f0 Close settings modal on theme change or fullscreen 2026-01-06 19:58:31 +00:00
tanner 3120207bd9 Switch noscript redirect query param
This will match the blacklist: [/^\/_/,/\/[^/?]+\.[^/]+$/], in
serviceWorker.js
2026-01-06 19:10:07 +00:00
tanner d7c35c18a7 fix: Hide visible template tags by moving noscript block
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 18:48:35 +00:00
tanner 0111263163 fix: Reload cached index.html when JavaScript is disabled
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 18:13:06 +00:00
5 changed files with 239 additions and 184 deletions
+1
View File
@@ -111,3 +111,4 @@ data.db.bak
data/archive/* data/archive/*
data/backup/* data/backup/*
qotnews.sqlite qotnews.sqlite
praw.ini
+11 -16
View File
@@ -10,6 +10,7 @@ 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))
@@ -28,24 +29,20 @@ 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_rankings(): def update_settings():
json = ['typo', 'words', 'proximity', 'date:desc', 'exactness'] json = {
return meili_api(requests.post, 'indexes/qotnews/settings/ranking-rules', json=json) 'rankingRules': ['typo', 'words', 'proximity', 'date:desc', 'exactness'],
'searchableAttributes': ['title', 'url', 'author'],
def update_attributes(): 'displayedAttributes': ['id', 'ref', 'source', 'author', 'author_link', 'score', 'date', 'title', 'link', 'url', 'num_comments'],
json = ['title', 'url', 'author'] }
r = meili_api(requests.post, 'indexes/qotnews/settings/searchable-attributes', json=json) return meili_api(requests.post, 'indexes/qotnews/settings', 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_rankings() update_settings()
update_attributes()
def put_story(story): def put_story(story):
if not SEARCH_ENABLED: return if not SEARCH_ENABLED: return
@@ -53,13 +50,11 @@ def put_story(story):
def search(q): def search(q):
if not SEARCH_ENABLED: return [] if not SEARCH_ENABLED: return []
params = dict(q=q, limit=settings.FEED_LENGTH) json = dict(q=q, limit=settings.FEED_LENGTH)
r = meili_api(requests.get, 'indexes/qotnews/search', params=params, parse_json=False) r = meili_api(requests.post, 'indexes/qotnews/search', json=json, parse_json=False)
return r return r
if __name__ == '__main__': if __name__ == '__main__':
init() init()
print(update_rankings())
print(search('facebook')) print(search('facebook'))
+7
View File
@@ -59,6 +59,13 @@
</script> </script>
<div id="root"> <div id="root">
<div class="static-content"> <div class="static-content">
{% if False %}
<noscript>
<meta http-equiv="refresh" content="0;url=?/no.script">
</noscript>
{% endif %}
<div class="container menu"> <div class="container menu">
<p> <p>
<a href="/">QotNews</a> <a href="/">QotNews</a>
+29 -168
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useLayoutEffect, 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,6 +14,7 @@ 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') || '');
@@ -40,63 +41,6 @@ function App() {
cache.current[key] = value; cache.current[key] = value;
}, []); }, []);
const light = () => {
setTheme('');
localStorage.setItem('theme', '');
};
const dark = () => {
setTheme('dark');
localStorage.setItem('theme', 'dark');
};
const black = () => {
setTheme('black');
localStorage.setItem('theme', 'black');
};
const red = () => {
setTheme('red');
localStorage.setItem('theme', 'red');
};
const handleFilterChange = e => {
const isChecked = e.target.checked;
setFilterSmallweb(isChecked);
localStorage.setItem('filterSmallweb', isChecked);
};
const handleFeedSourceChange = (source) => {
setFeedSources(prevSources => {
const newSources = { ...prevSources, [source]: !prevSources[source] };
localStorage.setItem('feedSources', JSON.stringify(newSources));
return newSources;
});
};
const changeBodyFont = (font) => {
setBodyFont(font);
localStorage.setItem('bodyFont', font);
};
const changeArticleFont = (font) => {
setArticleFont(font);
localStorage.setItem('articleFont', font);
};
const changeBodyFontSize = (amount) => {
const newSize = bodyFontSize + amount;
setBodyFontSize(parseFloat(newSize.toFixed(2)));
localStorage.setItem('bodyFontSize', newSize.toFixed(2));
};
const resetBodyFontSize = () => {
setBodyFontSize(defaultBodyFontSize);
localStorage.removeItem('bodyFontSize');
};
const bodyFontSettingsChanged = bodyFontSize !== defaultBodyFontSize;
useEffect(() => { useEffect(() => {
const onSWUpdate = e => { const onSWUpdate = e => {
setWaitingWorker(e.detail.waiting); setWaitingWorker(e.detail.waiting);
@@ -115,24 +59,13 @@ 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);
}, []); }, []);
useEffect(() => { useLayoutEffect(() => {
if (theme === 'dark') { if (theme === 'dark') {
document.body.style.backgroundColor = '#1a1a1a'; document.body.style.backgroundColor = '#1a1a1a';
} else if (theme === 'black') { } else if (theme === 'black') {
@@ -170,112 +103,40 @@ function App() {
}, [articleFont]); }, [articleFont]);
const fullScreenAvailable = document.fullscreenEnabled ||
document.mozFullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.msFullscreenEnabled;
return ( return (
<div className={theme}> <div className={theme}>
{settingsOpen && <Settings
<> settingsOpen={settingsOpen}
<div className="modal-overlay" onClick={() => setSettingsOpen(false)}></div> setSettingsOpen={setSettingsOpen}
theme={theme}
<div className="modal-content" onClick={e => e.stopPropagation()}> setTheme={setTheme}
<button className="close-modal-button" onClick={() => setSettingsOpen(false)}>&times;</button> isFullScreen={isFullScreen}
<h3>Settings</h3> filterSmallweb={filterSmallweb}
setFilterSmallweb={setFilterSmallweb}
<div className="setting-group"> feedSources={feedSources}
<h4>Theme</h4> setFeedSources={setFeedSources}
<button className={theme === '' ? 'active' : ''} onClick={() => { light() }}>Light</button> bodyFontSize={bodyFontSize}
<button className={theme === 'dark' ? 'active' : ''} onClick={() => { dark() }}>Dark</button> setBodyFontSize={setBodyFontSize}
<button className={theme === 'black' ? 'active' : ''} onClick={() => { black() }}>Black</button> defaultBodyFontSize={defaultBodyFontSize}
<button className={theme === 'red' ? 'active' : ''} onClick={() => { red() }}>Red</button> bodyFont={bodyFont}
{fullScreenAvailable && setBodyFont={setBodyFont}
<div style={{ marginTop: '0.5rem' }}> articleFont={articleFont}
{!isFullScreen ? setArticleFont={setArticleFont}
<button onClick={() => goFullScreen()}>Enter Fullscreen</button> />
:
<button onClick={() => exitFullScreen()}>Exit Fullscreen</button>
}
</div>
}
</div>
<div className="setting-group">
<h4>Feed</h4>
<div className="font-option 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>
</>
}
{waitingWorker && {waitingWorker &&
<div className='update-banner'> <div className='update-banner'>
Client version mismatch, please refresh:{' '} Client version mismatch, please refresh:{' '}
<button onClick={() => { <button onClick={() => {
waitingWorker.postMessage({ type: 'SKIP_WAITING' }); waitingWorker.postMessage({ type: 'SKIP_WAITING' });
navigator.serviceWorker.addEventListener('controllerchange', () => { const reload = () => window.location.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 Refresh
+191
View File
@@ -0,0 +1,191 @@
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;