Add OPML import functionality in AddFeedModal component, including file handling and toast notifications. Update ArticleCard to display feed icons and enhance article source display. Modify NewsStore to track last articles update and improve article refresh logic. Update UI components for better user experience and consistency.

This commit is contained in:
2025-12-27 21:00:36 -06:00
parent 7e235cb9d1
commit 82da01ca45
7 changed files with 141 additions and 32 deletions

View File

@@ -43,6 +43,7 @@ require (
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect

View File

@@ -17,8 +17,8 @@ import (
"time"
"git.quad4.io/Go-Libs/RSS"
readability "github.com/go-shiori/go-readability"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
readability "github.com/go-shiori/go-readability"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
)

View File

@@ -330,7 +330,7 @@ func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error)
} 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)
}
if err != nil {
return "[]", err
}
@@ -377,7 +377,7 @@ func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
FROM articles
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
if err != nil {
return "[]", err
}

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { db } from '$lib/db';
import { fetchFeed } from '$lib/rss';
import { parseOPML } from '$lib/opml';
import { newsStore } from '$lib/store.svelte';
import { X, Loader2 } from 'lucide-svelte';
import { X, Loader2, Upload } from 'lucide-svelte';
import { toast } from '$lib/toast.svelte';
let { onOpenChange } = $props();
let feedUrl = $state('');
@@ -37,6 +39,36 @@
loading = false;
}
}
async function handleImport(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
loading = true;
error = '';
try {
const text = await file.text();
const { feeds, categories } = parseOPML(text);
if (categories.length > 0) {
await db.saveCategories(categories as any);
}
if (feeds.length > 0) {
await db.saveFeeds(feeds as any);
}
toast.success(`Imported ${feeds.length} feeds`);
await newsStore.init();
onOpenChange(false);
} catch (err) {
console.error('Import failed:', err);
error = 'Failed to import OPML';
} finally {
loading = false;
target.value = '';
}
}
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
@@ -107,6 +139,30 @@
Add Feed
{/if}
</button>
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border-color"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-bg-primary px-2 text-text-secondary">Or</span>
</div>
</div>
<label
class="w-full flex items-center justify-center gap-2 py-3 bg-bg-secondary border border-border-color rounded-xl text-sm font-semibold text-text-primary hover:bg-bg-primary transition-all cursor-pointer {loading
? 'opacity-50 pointer-events-none'
: ''}"
>
{#if loading}
<Loader2 size={18} class="animate-spin" />
Importing...
{:else}
<Upload size={18} />
Import OPML File
{/if}
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
</form>
</div>
</div>

View File

@@ -5,6 +5,7 @@
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
let { article }: { article: Article } = $props();
const feed = $derived(newsStore.feeds.find((f) => f.id === article.feedId));
let copied = $state(false);
let loadingFullText = $state(false);
@@ -18,9 +19,11 @@
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function getSource(feedId: string) {
const feed = newsStore.feeds.find((f) => f.id === feedId);
return feed?.title || new URL(feedId).hostname;
function getSourceTitle() {
return (
feed?.title ||
(article.feedId.startsWith('http') ? new URL(article.feedId).hostname : article.feedId)
);
}
async function shareArticle(e: MouseEvent) {
@@ -78,6 +81,30 @@
? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5'
: ''}"
>
<!-- Background Flavor Layer -->
{#if article.imageUrl || feed?.icon}
<div class="absolute inset-0 z-0 overflow-hidden rounded-2xl pointer-events-none">
{#if article.imageUrl && article.imageUrl !== feed?.icon}
<div
class="absolute right-0 top-0 bottom-0 w-full sm:w-3/4 overflow-hidden"
style="mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%);"
>
<img
src={article.imageUrl}
alt=""
class="w-full h-full object-cover opacity-[0.22] dark:opacity-[0.10] group-hover:opacity-[0.32] dark:group-hover:opacity-[0.18] transition-all duration-700 group-hover:scale-110 origin-right"
/>
</div>
{:else if feed?.icon}
<div
class="absolute inset-0 opacity-0 group-hover:opacity-[0.08] dark:group-hover:opacity-[0.05] transition-opacity duration-500"
>
<img src={feed.icon} alt="" class="w-full h-full object-cover blur-3xl scale-150" />
</div>
{/if}
</div>
{/if}
{#if newsStore.isSelectMode}
<div class="flex items-center pl-4 z-20">
<div class="relative w-5 h-5">
@@ -112,8 +139,11 @@
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
<div class="flex items-center gap-2 mb-2">
{#if feed?.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{/if}
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto"
>{getSource(article.feedId)}</span
>{getSourceTitle()}</span
>
<span class="text-text-secondary text-xs"></span>
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
@@ -174,16 +204,4 @@
</button>
</div>
</div>
{#if article.imageUrl}
<div
class="w-full sm:w-32 h-48 sm:h-32 flex-shrink-0 sm:m-4 rounded-xl overflow-hidden bg-bg-secondary border border-border-color relative z-10 pointer-events-none"
>
<img
src={article.imageUrl}
alt=""
class="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
{/if}
</article>

View File

@@ -51,6 +51,7 @@ class NewsStore {
isOnline = $state(true);
ping = $state<number | null>(null);
lastStatusCheck = $state<number>(Date.now());
lastArticlesUpdate = $state<number>(Date.now());
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
isAuthenticated = $state(false);
newlyRegisteredToken = $state<string | null>(null);
@@ -277,11 +278,20 @@ class NewsStore {
this.statusInterval = setInterval(() => this.checkStatus(), 30000);
}
window.addEventListener('online', () => this.checkStatus());
window.addEventListener('offline', () => {
this.isOnline = false;
this.ping = null;
});
if (typeof window !== 'undefined') {
window.addEventListener('online', () => this.checkStatus());
window.addEventListener('offline', () => {
this.isOnline = false;
this.ping = null;
});
// Auto-refresh when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && this.isAuthenticated) {
this.refresh();
}
});
}
}
async loadArticles() {
@@ -324,6 +334,7 @@ class NewsStore {
} else {
this.articles = articles;
}
this.lastArticlesUpdate = Date.now();
}
private rankArticles(articles: Article[]): Article[] {
@@ -633,6 +644,7 @@ class NewsStore {
art.read = true;
art.readAt = Date.now();
if (data.content) art.content = data.content;
data.feedId = art.feedId;
}
if (data.content) {
@@ -760,7 +772,9 @@ class NewsStore {
startAutoFetch() {
if (this.fetchInterval) clearInterval(this.fetchInterval);
this.fetchInterval = setInterval(() => {
this.refresh();
if (this.isAuthenticated) {
this.refresh();
}
}, this.settings.globalFetchInterval * 60000);
}
}

View File

@@ -1064,7 +1064,7 @@
</a>
</div>
{#if newsStore.readingArticle.image}
{#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img
src={newsStore.readingArticle.image}
alt=""
@@ -1077,6 +1077,14 @@
{newsStore.readingArticle.title}
</h1>
<div class="flex items-center gap-2 text-sm text-text-secondary">
{#if newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img
src={newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)
?.icon}
alt=""
class="w-4 h-4 rounded-sm"
/>
{/if}
<span class="font-medium text-accent-blue"
>{newsStore.readingArticle.siteName || ''}</span
>
@@ -1118,7 +1126,7 @@
{/if}
</h2>
<span class="text-xs text-text-secondary">
Updated {new Date(newsStore.lastStatusCheck).toLocaleTimeString([], {
Updated {new Date(newsStore.lastArticlesUpdate).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
@@ -1269,7 +1277,7 @@
</button>
</div>
{#if newsStore.readingArticle.image}
{#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img
src={newsStore.readingArticle.image}
alt=""
@@ -1281,9 +1289,21 @@
<h1 class="text-3xl font-bold leading-tight tracking-tight">
{newsStore.readingArticle.title}
</h1>
<p class="text-xs text-text-secondary">
{newsStore.readingArticle.byline || newsStore.readingArticle.siteName || ''}
</p>
<div class="flex items-center gap-2 text-xs text-text-secondary">
{#if newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
<img
src={newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)
?.icon}
alt=""
class="w-4 h-4 rounded-sm"
/>
{/if}
<span
>{newsStore.readingArticle.byline ||
newsStore.readingArticle.siteName ||
''}</span
>
</div>
</div>
<div class="prose prose-invert max-w-none text-text-secondary leading-relaxed pb-20">