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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user