Update fetching and filtering capabilities by adding category support in GetArticles methods across multiple files. Update NewsStore to manage selected category state and integrate category filtering in article loading. Improve feed fetching with abort signal handling in fetchFeed function. Update UI components to reflect category selection and enhance user experience with new feed health management features.
This commit is contained in:
@@ -29,14 +29,9 @@ Web News follows a "zero-knowledge" philosophy:
|
||||
|
||||
- [ ] Reading time
|
||||
- [ ] UI/UX Cleanup
|
||||
- [ ] Add feed fetching timeout and button to remove if failed 3 times
|
||||
- [ ] Use Go Mobile, remove Java RSS plugin.
|
||||
- [ ] Dont show loading screen if not initial load (eg. reloading tab)
|
||||
- [ ] Fix feeds double image (Feed image and article image at top)
|
||||
- [ ] Export article(s)
|
||||
- [ ] Export/Import OPML on add feed modal
|
||||
- [ ] Favicons for feeds and caching
|
||||
- [ ]
|
||||
- [ ] Favicon fetcher and caching
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
|
||||
return a.db.SaveArticles(articles)
|
||||
}
|
||||
|
||||
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) {
|
||||
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit)
|
||||
func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
|
||||
a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
|
||||
if a.db == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return a.db.GetArticles(feedId, offset, limit)
|
||||
return a.db.GetArticles(feedId, offset, limit, categoryId)
|
||||
}
|
||||
|
||||
func (a *App) SearchArticles(query string, limit int) (string, error) {
|
||||
|
||||
@@ -52,6 +52,7 @@ export default [
|
||||
FileReader: 'readonly',
|
||||
performance: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
DOMParser: 'readonly',
|
||||
Element: 'readonly',
|
||||
Node: 'readonly',
|
||||
|
||||
@@ -322,11 +322,18 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error) {
|
||||
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if feedId != "" {
|
||||
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset)
|
||||
} else if categoryId != "" {
|
||||
rows, err = s.db.Query(`
|
||||
SELECT a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read, a.saved, a.imageUrl, a.readAt
|
||||
FROM articles a
|
||||
JOIN feeds f ON a.feedId = f.id
|
||||
WHERE f.categoryId = ?
|
||||
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`, categoryId, limit, offset)
|
||||
} else {
|
||||
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset)
|
||||
}
|
||||
@@ -488,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
|
||||
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
|
||||
FROM articles
|
||||
WHERE read = 1 AND readAt > ?
|
||||
GROUP BY date
|
||||
@@ -505,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
||||
if err := rows.Scan(&date, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
// Convert date string back to timestamp for frontend
|
||||
t, _ := time.Parse("2006-01-02", date)
|
||||
// Convert local date string back to local midnight timestamp for frontend
|
||||
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
history = append(history, map[string]any{
|
||||
"date": t.UnixMilli(),
|
||||
"count": count,
|
||||
|
||||
@@ -353,8 +353,16 @@
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-text-secondary hover:bg-bg-secondary transition-colors text-left min-w-0"
|
||||
onclick={() => toggleCategory(cat.id)}
|
||||
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors text-left min-w-0 {newsStore.selectedCategoryId ===
|
||||
cat.id
|
||||
? 'bg-accent-blue/10 text-accent-blue font-semibold'
|
||||
: 'text-text-secondary hover:bg-bg-secondary'}"
|
||||
onclick={() => {
|
||||
newsStore.selectCategory(cat.id);
|
||||
toggleCategory(cat.id);
|
||||
newsStore.readingArticle = null;
|
||||
if (!isManageMode) newsStore.showSidebar = false;
|
||||
}}
|
||||
title={cat.name}
|
||||
>
|
||||
{#if expandedCategories[cat.id]}
|
||||
|
||||
@@ -87,7 +87,12 @@ export interface IDB {
|
||||
saveCategory(category: Category): Promise<void>;
|
||||
saveCategories(categories: Category[]): Promise<void>;
|
||||
deleteCategory(id: string): Promise<void>;
|
||||
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>;
|
||||
getArticles(
|
||||
feedId?: string,
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
categoryId?: string
|
||||
): Promise<Article[]>;
|
||||
saveArticles(articles: Article[]): Promise<void>;
|
||||
searchArticles(query: string, limit?: number): Promise<Article[]>;
|
||||
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
|
||||
@@ -249,14 +254,42 @@ class IndexedDBImpl implements IDB {
|
||||
});
|
||||
}
|
||||
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
||||
async getArticles(
|
||||
feedId?: string,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
categoryId?: string
|
||||
): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const transaction = db.transaction(['articles', 'feeds'], 'readonly');
|
||||
const store = transaction.objectStore('articles');
|
||||
let request: IDBRequest<any[]>;
|
||||
if (feedId) request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
|
||||
else request = store.index('pubDate').getAll();
|
||||
|
||||
if (feedId) {
|
||||
request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
|
||||
} else if (categoryId) {
|
||||
// For IndexedDB, we need to get all feeds in category first
|
||||
const feedStore = transaction.objectStore('feeds');
|
||||
const feedsRequest = feedStore.getAll();
|
||||
feedsRequest.onsuccess = () => {
|
||||
const feeds = feedsRequest.result as Feed[];
|
||||
const catFeedIds = new Set(
|
||||
feeds.filter((f) => f.categoryId === categoryId).map((f) => f.id)
|
||||
);
|
||||
const allArticlesRequest = store.getAll();
|
||||
allArticlesRequest.onsuccess = () => {
|
||||
const articles = allArticlesRequest.result as Article[];
|
||||
const filtered = articles.filter((a) => catFeedIds.has(a.feedId));
|
||||
filtered.sort((a, b) => b.pubDate - a.pubDate);
|
||||
resolve(filtered.slice(offset, offset + limit));
|
||||
};
|
||||
};
|
||||
return;
|
||||
} else {
|
||||
request = store.index('pubDate').getAll();
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
const articles = request.result as Article[];
|
||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||
@@ -316,7 +349,8 @@ class IndexedDBImpl implements IDB {
|
||||
if (cursor) {
|
||||
const article = cursor.value as Article;
|
||||
if (article.readAt) {
|
||||
const date = new Date(article.readAt).toISOString().split('T')[0];
|
||||
const d = new Date(article.readAt);
|
||||
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
history[date] = (history[date] || 0) + 1;
|
||||
}
|
||||
cursor.continue();
|
||||
@@ -605,7 +639,12 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
await db.run('DELETE FROM categories WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
||||
async getArticles(
|
||||
feedId?: string,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
categoryId?: string
|
||||
): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
let res;
|
||||
if (feedId) {
|
||||
@@ -613,6 +652,14 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
|
||||
[feedId, limit, offset]
|
||||
);
|
||||
} else if (categoryId) {
|
||||
res = await db.query(
|
||||
`SELECT a.* FROM articles a
|
||||
JOIN feeds f ON a.feedId = f.id
|
||||
WHERE f.categoryId = ?
|
||||
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`,
|
||||
[categoryId, limit, offset]
|
||||
);
|
||||
} else {
|
||||
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
|
||||
limit,
|
||||
@@ -662,17 +709,21 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const res = await db.query(
|
||||
`
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
|
||||
FROM articles
|
||||
WHERE read = 1 AND readAt > ?
|
||||
GROUP BY date
|
||||
ORDER BY date DESC`,
|
||||
[cutoff]
|
||||
);
|
||||
return (res.values || []).map((row) => ({
|
||||
date: new Date(row.date).getTime(),
|
||||
count: row.count,
|
||||
}));
|
||||
return (res.values || []).map((row) => {
|
||||
const [y, m, d] = row.date.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d).getTime();
|
||||
return {
|
||||
date,
|
||||
count: row.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
@@ -839,8 +890,15 @@ class WailsDBImpl implements IDB {
|
||||
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
|
||||
);
|
||||
}
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
||||
return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit));
|
||||
async getArticles(
|
||||
feedId?: string,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
categoryId?: string
|
||||
): Promise<Article[]> {
|
||||
return JSON.parse(
|
||||
await this.call('GetArticles', feedId || '', offset, limit, categoryId || '')
|
||||
);
|
||||
}
|
||||
async saveArticles(articles: Article[]): Promise<void> {
|
||||
await this.call('SaveArticles', JSON.stringify(articles));
|
||||
@@ -983,8 +1041,8 @@ class LazyDBWrapper implements IDB {
|
||||
deleteCategory(id: string) {
|
||||
return this.getImpl().deleteCategory(id);
|
||||
}
|
||||
getArticles(feedId?: string, offset?: number, limit?: number) {
|
||||
return this.getImpl().getArticles(feedId, offset, limit);
|
||||
getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
|
||||
return this.getImpl().getArticles(feedId, offset, limit, categoryId);
|
||||
}
|
||||
saveArticles(articles: Article[]) {
|
||||
return this.getImpl().saveArticles(articles);
|
||||
|
||||
@@ -5,13 +5,19 @@ import { registerPlugin } from '@capacitor/core';
|
||||
const RSS = registerPlugin<any>('RSS');
|
||||
|
||||
export async function fetchFeed(
|
||||
feedUrl: string
|
||||
feedUrl: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
|
||||
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
|
||||
if (newsStore.isCapacitor) {
|
||||
try {
|
||||
// Capacitor plugin might not support signal directly, but we can check it
|
||||
if (signal?.aborted) throw new Error('Aborted');
|
||||
|
||||
const data = await RSS.fetchFeed({ url: feedUrl });
|
||||
|
||||
if (signal?.aborted) throw new Error('Aborted');
|
||||
|
||||
const articles: Article[] = data.articles.map((item: any) => ({
|
||||
...item,
|
||||
description: stripHtml(item.description || '').substring(0, 200),
|
||||
@@ -25,6 +31,7 @@ export async function fetchFeed(
|
||||
articles,
|
||||
};
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Aborted') throw e;
|
||||
console.warn('Native RSS fetch failed, falling back to API proxy:', e);
|
||||
// Show actual error in toast if it's not a "not implemented" error
|
||||
if (e.message && !e.message.includes('not implemented')) {
|
||||
@@ -38,7 +45,10 @@ export async function fetchFeed(
|
||||
if (newsStore.settings.authToken) {
|
||||
headers['X-Account-Number'] = newsStore.settings.authToken;
|
||||
}
|
||||
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, { headers });
|
||||
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, {
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
newsStore.logout();
|
||||
throw new Error('Unauthorized');
|
||||
@@ -93,9 +103,10 @@ function stripHtml(html: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function refreshAllFeeds() {
|
||||
export async function refreshAllFeeds(signal?: AbortSignal) {
|
||||
const feeds = await db.getFeeds();
|
||||
for (const feed of feeds) {
|
||||
if (signal?.aborted) throw new Error('Aborted');
|
||||
if (!feed.enabled) continue;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -103,7 +114,7 @@ export async function refreshAllFeeds() {
|
||||
|
||||
if (shouldFetch) {
|
||||
try {
|
||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
|
||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
...updatedFeed,
|
||||
@@ -112,6 +123,7 @@ export async function refreshAllFeeds() {
|
||||
});
|
||||
await db.saveArticles(articles);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
|
||||
console.error(`Failed to refresh feed ${feed.id}:`, e);
|
||||
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
|
||||
await db.saveFeed({
|
||||
@@ -124,13 +136,13 @@ export async function refreshAllFeeds() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshFeed(feedId: string) {
|
||||
export async function refreshFeed(feedId: string, signal?: AbortSignal) {
|
||||
const feeds = await db.getFeeds();
|
||||
const feed = feeds.find((f) => f.id === feedId);
|
||||
if (!feed) return;
|
||||
|
||||
try {
|
||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
|
||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
...updatedFeed,
|
||||
@@ -139,6 +151,7 @@ export async function refreshFeed(feedId: string) {
|
||||
});
|
||||
await db.saveArticles(articles);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
|
||||
console.error(`Failed to refresh feed ${feed.id}:`, e);
|
||||
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
|
||||
await db.saveFeed({
|
||||
|
||||
@@ -39,6 +39,7 @@ class NewsStore {
|
||||
isInitialLoading = $state(false);
|
||||
showSidebar = $state(false);
|
||||
selectedFeedId = $state<string | null>(null);
|
||||
selectedCategoryId = $state<string | null>(null);
|
||||
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
|
||||
readingArticle = $state<any | null>(null);
|
||||
searchQuery = $state('');
|
||||
@@ -46,6 +47,7 @@ class NewsStore {
|
||||
isSelectMode = $state(false);
|
||||
selectedArticleIds = $state(new Set<string>());
|
||||
private limit = 20;
|
||||
private refreshController: AbortController | null = null;
|
||||
|
||||
// Connection status
|
||||
isOnline = $state(true);
|
||||
@@ -308,7 +310,12 @@ class NewsStore {
|
||||
} else if (this.currentView === 'following') {
|
||||
articles = await db.getArticles(undefined, 0, this.limit);
|
||||
} else {
|
||||
articles = await db.getArticles(this.selectedFeedId || undefined, 0, this.limit * 2);
|
||||
articles = await db.getArticles(
|
||||
this.selectedFeedId || undefined,
|
||||
0,
|
||||
this.limit * 2,
|
||||
this.selectedCategoryId || undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +464,12 @@ class NewsStore {
|
||||
if (this.currentView === 'saved') {
|
||||
this.hasMore = false;
|
||||
} else {
|
||||
more = await db.getArticles(this.selectedFeedId || undefined, offset, this.limit);
|
||||
more = await db.getArticles(
|
||||
this.selectedFeedId || undefined,
|
||||
offset,
|
||||
this.limit,
|
||||
this.selectedCategoryId || undefined
|
||||
);
|
||||
}
|
||||
|
||||
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
|
||||
@@ -482,11 +494,20 @@ class NewsStore {
|
||||
async selectView(view: 'all' | 'saved' | 'following') {
|
||||
this.currentView = view;
|
||||
this.selectedFeedId = null;
|
||||
this.selectedCategoryId = null;
|
||||
await this.loadArticles();
|
||||
}
|
||||
|
||||
async selectFeed(feedId: string | null) {
|
||||
this.selectedFeedId = feedId;
|
||||
this.selectedCategoryId = null;
|
||||
this.currentView = 'all';
|
||||
await this.loadArticles();
|
||||
}
|
||||
|
||||
async selectCategory(categoryId: string | null) {
|
||||
this.selectedCategoryId = categoryId;
|
||||
this.selectedFeedId = null;
|
||||
this.currentView = 'all';
|
||||
await this.loadArticles();
|
||||
}
|
||||
@@ -557,10 +578,20 @@ class NewsStore {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.loading && this.refreshController) {
|
||||
this.refreshController.abort();
|
||||
this.refreshController = null;
|
||||
this.loading = false;
|
||||
toast.info('Refresh cancelled');
|
||||
return;
|
||||
}
|
||||
if (this.loading || !this.isAuthenticated) return;
|
||||
|
||||
this.loading = true;
|
||||
this.refreshController = new AbortController();
|
||||
|
||||
try {
|
||||
await refreshAllFeeds();
|
||||
await refreshAllFeeds(this.refreshController.signal);
|
||||
await this.loadArticles();
|
||||
this.feeds = (await db.getFeeds()) || [];
|
||||
this.categories = (await db.getCategories()) || [];
|
||||
@@ -577,7 +608,11 @@ class NewsStore {
|
||||
}
|
||||
|
||||
toast.success('Feeds refreshed');
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') {
|
||||
console.log('Refresh aborted');
|
||||
return;
|
||||
}
|
||||
console.error('Refresh failed:', e);
|
||||
if (e instanceof Error && e.message.includes('401')) {
|
||||
this.logout();
|
||||
@@ -586,22 +621,37 @@ class NewsStore {
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.refreshController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshFeed(feedId: string) {
|
||||
if (this.loading && this.refreshController) {
|
||||
this.refreshController.abort();
|
||||
this.refreshController = null;
|
||||
this.loading = false;
|
||||
toast.info('Refresh cancelled');
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.refreshController = new AbortController();
|
||||
|
||||
try {
|
||||
await refreshFeed(feedId);
|
||||
await refreshFeed(feedId, this.refreshController.signal);
|
||||
this.feeds = await db.getFeeds();
|
||||
await this.loadArticles();
|
||||
toast.success('Feed refreshed');
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') {
|
||||
console.log('Refresh aborted');
|
||||
return;
|
||||
}
|
||||
console.error('Feed refresh failed:', e);
|
||||
toast.error('Failed to refresh feed');
|
||||
this.feeds = await db.getFeeds();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.refreshController = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +724,27 @@ class NewsStore {
|
||||
toast.success('Feed removed');
|
||||
}
|
||||
|
||||
async purgeProblematicFeeds(threshold = 5) {
|
||||
const problematic = this.feeds.filter((f) => f.consecutiveErrors >= threshold);
|
||||
if (problematic.length === 0) {
|
||||
toast.info('No problematic feeds found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to remove ${problematic.length} feeds that have failed ${threshold}+ times?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
for (const feed of problematic) {
|
||||
await db.deleteFeed(feed.id);
|
||||
}
|
||||
this.feeds = this.feeds.filter((f) => f.consecutiveErrors < threshold);
|
||||
toast.success(`Removed ${problematic.length} problematic feeds`);
|
||||
}
|
||||
|
||||
async updateFeed(feed: Feed, oldId?: string) {
|
||||
const plainFeed = $state.snapshot(feed);
|
||||
if (oldId && oldId !== feed.id) {
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
let fontSize = $state(newsStore.settings.fontSize);
|
||||
let lineHeight = $state(newsStore.settings.lineHeight);
|
||||
let contentPurgeDays = $state(newsStore.settings.contentPurgeDays);
|
||||
let muteFilters = $state<string[]>([]);
|
||||
let errorThreshold = $state(5);
|
||||
|
||||
$effect(() => {
|
||||
theme = newsStore.settings.theme;
|
||||
@@ -51,6 +53,7 @@
|
||||
fontSize = newsStore.settings.fontSize;
|
||||
lineHeight = newsStore.settings.lineHeight;
|
||||
contentPurgeDays = newsStore.settings.contentPurgeDays;
|
||||
muteFilters = [...newsStore.settings.muteFilters];
|
||||
});
|
||||
|
||||
async function handleSaveSettings() {
|
||||
@@ -66,7 +69,8 @@
|
||||
fontSize,
|
||||
lineHeight,
|
||||
contentPurgeDays,
|
||||
// muteFilters and shortcuts are already updated in newsStore.settings via UI binding
|
||||
muteFilters: [...muteFilters],
|
||||
// shortcuts are already updated in newsStore.settings via UI binding
|
||||
};
|
||||
|
||||
newsStore.settings = newSettings;
|
||||
@@ -709,6 +713,43 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feed Health -->
|
||||
<section id="feed-health" class="space-y-4 w-full min-w-0">
|
||||
<h3
|
||||
class="text-sm font-semibold text-text-secondary uppercase tracking-wider flex items-center gap-2"
|
||||
>
|
||||
<Zap size={16} />
|
||||
Feed Health
|
||||
</h3>
|
||||
<div class="card p-4 sm:p-6 space-y-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-text-secondary"
|
||||
>Purge feeds with consecutive errors ≥</span
|
||||
>
|
||||
<span class="font-medium text-accent-blue">{errorThreshold} failures</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
bind:value={errorThreshold}
|
||||
class="w-full h-1.5 bg-bg-secondary rounded-lg appearance-none cursor-pointer accent-accent-blue"
|
||||
aria-label="Error threshold"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="w-full py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-xl text-xs font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
onclick={() => newsStore.purgeProblematicFeeds(errorThreshold)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Purge All Problematic Feeds
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mute Filters -->
|
||||
<section id="mute-filters" class="space-y-4 w-full min-w-0">
|
||||
<h3
|
||||
@@ -731,10 +772,7 @@
|
||||
class="flex-1 bg-bg-secondary border border-border-color rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-accent-blue/20"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && newFilter) {
|
||||
newsStore.settings.muteFilters = [
|
||||
...newsStore.settings.muteFilters,
|
||||
newFilter,
|
||||
];
|
||||
muteFilters = [...muteFilters, newFilter];
|
||||
newFilter = '';
|
||||
}
|
||||
}}
|
||||
@@ -743,10 +781,7 @@
|
||||
class="btn-primary px-4 py-2 rounded-xl whitespace-nowrap"
|
||||
onclick={() => {
|
||||
if (newFilter) {
|
||||
newsStore.settings.muteFilters = [
|
||||
...newsStore.settings.muteFilters,
|
||||
newFilter,
|
||||
];
|
||||
muteFilters = [...muteFilters, newFilter];
|
||||
newFilter = '';
|
||||
}
|
||||
}}
|
||||
@@ -757,7 +792,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 pt-2">
|
||||
{#each newsStore.settings.muteFilters as filter}
|
||||
{#each muteFilters as filter}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-bg-secondary border border-border-color text-xs font-medium"
|
||||
>
|
||||
@@ -765,8 +800,7 @@
|
||||
<button
|
||||
class="text-text-secondary hover:text-red-500 transition-colors"
|
||||
onclick={() =>
|
||||
(newsStore.settings.muteFilters =
|
||||
newsStore.settings.muteFilters.filter((f) => f !== filter))}
|
||||
(muteFilters = muteFilters.filter((f) => f !== filter))}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
@@ -835,12 +869,14 @@
|
||||
{#each Array(30)
|
||||
.fill(0)
|
||||
.map((_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (29 - i));
|
||||
const dStr = date.toISOString().split('T')[0];
|
||||
const entry = readingHistory.find((h) => new Date(h.date)
|
||||
.toISOString()
|
||||
.split('T')[0] === dStr);
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - (29 - i));
|
||||
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const entry = readingHistory.find((h) => {
|
||||
const hd = new Date(h.date);
|
||||
const hStr = `${hd.getFullYear()}-${String(hd.getMonth() + 1).padStart(2, '0')}-${String(hd.getDate()).padStart(2, '0')}`;
|
||||
return hStr === dStr;
|
||||
});
|
||||
return { date: dStr, count: entry?.count || 0 };
|
||||
}) as day}
|
||||
<div
|
||||
@@ -1117,6 +1153,11 @@
|
||||
>{feed?.id}</span
|
||||
>
|
||||
</div>
|
||||
{:else if newsStore.selectedCategoryId}
|
||||
{@const cat = newsStore.categories.find(
|
||||
(c) => c.id === newsStore.selectedCategoryId
|
||||
)}
|
||||
<span>{cat?.name}</span>
|
||||
{:else if newsStore.currentView === 'saved'}
|
||||
Saved stories
|
||||
{:else if newsStore.currentView === 'following'}
|
||||
|
||||
Reference in New Issue
Block a user