feat: Add feed source filtering to settings and API

Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
2026-01-03 00:01:46 +00:00
parent 93903eedf9
commit ab15868397
3 changed files with 72 additions and 18 deletions

View File

@@ -76,8 +76,12 @@ cors = CORS(flask_app)
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
sources_filter = request.args.getlist('source')
if request.args.get('smallweb') == 'true' and smallweb_set: if not is_smallweb_filter and not sources_filter:
stories = database.get_stories(limit, skip)
else:
limit = int(limit) limit = int(limit)
skip = int(skip) skip = int(skip)
filtered_stories = [] filtered_stories = []
@@ -90,24 +94,28 @@ def api():
for story_str in stories_batch: for story_str in stories_batch:
story = json.loads(story_str) story = json.loads(story_str)
story_url = story.get('url') or story.get('link') or ''
if not story_url: if is_smallweb_filter:
continue story_url = story.get('url') or story.get('link') or ''
hostname = urlparse(story_url).hostname if not story_url:
if hostname: continue
hostname = hostname.replace('www.', '') hostname = urlparse(story_url).hostname
if hostname in smallweb_set: if not hostname or hostname.replace('www.', '') not in smallweb_set:
filtered_stories.append(story_str) continue
if len(filtered_stories) == limit:
break 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: if len(filtered_stories) == limit:
break break
current_skip += limit current_skip += limit
stories = filtered_stories stories = filtered_stories
else:
stories = database.get_stories(limit, skip)
# hacky nested json # hacky nested json
res = Response('{"stories":[' + ','.join(stories) + ']}') res = Response('{"stories":[' + ','.join(stories) + ']}')

View File

@@ -26,6 +26,15 @@ function App() {
const [bodyFont, setBodyFont] = useState(localStorage.getItem('bodyFont') || 'Sans Serif'); const [bodyFont, setBodyFont] = useState(localStorage.getItem('bodyFont') || 'Sans Serif');
const [articleFont, setArticleFont] = useState(localStorage.getItem('articleFont') || 'Apparatus SIL'); const [articleFont, setArticleFont] = useState(localStorage.getItem('articleFont') || 'Apparatus SIL');
const [filterSmallweb, setFilterSmallweb] = useState(() => localStorage.getItem('filterSmallweb') === 'true'); 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;
@@ -57,6 +66,14 @@ function App() {
localStorage.setItem('filterSmallweb', 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) => { const changeBodyFont = (font) => {
setBodyFont(font); setBodyFont(font);
localStorage.setItem('bodyFont', font); localStorage.setItem('bodyFont', font);
@@ -177,6 +194,22 @@ function App() {
<input className="checkbox" type="checkbox" id="filter-smallweb" checked={filterSmallweb} onChange={handleFilterChange} /> <input className="checkbox" type="checkbox" id="filter-smallweb" checked={filterSmallweb} onChange={handleFilterChange} />
<label htmlFor="filter-smallweb">Small websites</label> <label htmlFor="filter-smallweb">Small websites</label>
</div> </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>
<div className="setting-group"> <div className="setting-group">
<h4>Font Size</h4> <h4>Font Size</h4>
@@ -251,7 +284,7 @@ function App() {
<Route path='/(|search)' component={Submit} /> <Route path='/(|search)' component={Submit} />
</div> </div>
<Route path='/' exact render={(props) => <Feed {...props} updateCache={updateCache} filterSmallweb={filterSmallweb} />} /> <Route path='/' exact render={(props) => <Feed {...props} updateCache={updateCache} filterSmallweb={filterSmallweb} feedSources={feedSources} />} />
<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} />} />

View File

@@ -4,7 +4,7 @@ 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 }) { function Feed({ updateCache, filterSmallweb, feedSources }) {
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 [loadingStatus, setLoadingStatus] = useState(null);
@@ -16,7 +16,7 @@ function Feed({ updateCache, filterSmallweb }) {
} else { } else {
setStories(false); setStories(false);
} }
}, [filterSmallweb]); }, [filterSmallweb, feedSources]);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -30,7 +30,20 @@ function Feed({ updateCache, filterSmallweb }) {
}); });
} }
fetch(filterSmallweb ? '/api?smallweb=true' : '/api', { signal: controller.signal }) 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}`);
@@ -117,7 +130,7 @@ function Feed({ updateCache, filterSmallweb }) {
); );
return () => controller.abort(); return () => controller.abort();
}, [updateCache, filterSmallweb]); }, [updateCache, filterSmallweb, feedSources]);
return ( return (
<div className='container'> <div className='container'>