Refactor Svelte components for improved formatting and consistency

- Cleaned up formatting in AddFeedModal.svelte, ArticleCard.svelte, Navbar.svelte, Sidebar.svelte, and Toasts.svelte by adding missing commas and adjusting indentation.
- Enhanced readability by ensuring consistent use of line breaks and spacing across components.
- Updated event handlers and bindings for better clarity and maintainability.
This commit is contained in:
2025-12-27 12:26:48 -06:00
parent 30c7a50240
commit c5176f9ed4
5 changed files with 477 additions and 299 deletions

View File

@@ -26,7 +26,7 @@
lastFetched: Date.now(),
fetchInterval: 30,
enabled: true,
consecutiveErrors: 0
consecutiveErrors: 0,
});
await db.saveArticles(articles);
await newsStore.refresh();
@@ -40,26 +40,37 @@
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<button
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
<button
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
onclick={() => onOpenChange(false)}
aria-label="Close modal"
></button>
<div class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10">
<div
class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10"
>
<div class="p-6 border-b border-border-color flex justify-between items-center">
<h2 class="text-xl font-bold">Add RSS Feed</h2>
<button class="text-text-secondary hover:text-text-primary" onclick={() => onOpenChange(false)}>
<button
class="text-text-secondary hover:text-text-primary"
onclick={() => onOpenChange(false)}
>
<X size={24} />
</button>
</div>
<form class="p-6 space-y-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<form
class="p-6 space-y-4"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="space-y-2">
<label for="url" class="text-sm font-medium text-text-secondary">Feed URL</label>
<input
<input
id="url"
type="url"
type="url"
bind:value={feedUrl}
placeholder="https://example.com/rss.xml"
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all"
@@ -69,7 +80,7 @@
<div class="space-y-2">
<label for="category" class="text-sm font-medium text-text-secondary">Category</label>
<select
<select
id="category"
bind:value={categoryId}
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all text-sm"
@@ -84,8 +95,8 @@
<p class="text-red-500 text-sm">{error}</p>
{/if}
<button
type="submit"
<button
type="submit"
class="w-full btn-primary flex items-center justify-center gap-2 py-3"
disabled={loading}
>
@@ -99,4 +110,3 @@
</form>
</div>
</div>

View File

@@ -12,14 +12,14 @@
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function getSource(feedId: string) {
const feed = newsStore.feeds.find(f => f.id === feedId);
const feed = newsStore.feeds.find((f) => f.id === feedId);
return feed?.title || new URL(feedId).hostname;
}
@@ -27,12 +27,12 @@
e.stopPropagation();
const encodedUrl = btoa(article.link);
const shareUrl = `${window.location.origin}/share?url=${encodedUrl}`;
try {
await navigator.clipboard.writeText(shareUrl);
copied = true;
toast.success('Share link copied to clipboard');
setTimeout(() => copied = false, 2000);
setTimeout(() => (copied = false), 2000);
} catch (err) {
console.error('Failed to copy share link:', err);
toast.error('Failed to copy share link');
@@ -71,26 +71,32 @@
}
</script>
<article class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read ? 'opacity-60' : ''} {newsStore.readingArticle?.url === article.link ? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5' : ''}">
<article
class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read
? 'opacity-60'
: ''} {newsStore.readingArticle?.url === article.link
? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5'
: ''}"
>
{#if newsStore.isSelectMode}
<div class="flex items-center pl-4 z-20">
<div class="relative w-5 h-5">
<input
type="checkbox"
<input
type="checkbox"
class="peer appearance-none w-5 h-5 rounded border-2 border-border-color checked:bg-accent-blue checked:border-accent-blue transition-all cursor-pointer"
checked={newsStore.selectedArticleIds.has(article.id)}
onchange={handleToggleSelect}
/>
<Check
size={14}
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity"
<Check
size={14}
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity"
strokeWidth={3}
/>
</div>
</div>
{/if}
<button
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
<button
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
onclick={() => {
if (newsStore.isSelectMode) {
const isSelected = newsStore.selectedArticleIds.has(article.id);
@@ -106,21 +112,25 @@
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto">{getSource(article.feedId)}</span>
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto"
>{getSource(article.feedId)}</span
>
<span class="text-text-secondary text-xs"></span>
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
</div>
<h3 class="text-lg font-bold leading-snug mb-2 group-hover:text-accent-blue transition-colors">
{article.title}
</h3>
<p class="text-text-secondary text-sm line-clamp-2 mb-4">
{article.description}
</p>
<div class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto">
<button
<div
class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
>
<button
class="flex items-center gap-1.5 px-3 py-1 rounded-full hover:bg-bg-secondary transition-colors text-xs font-semibold hover:text-accent-blue"
onclick={fetchFullText}
disabled={loadingFullText}
@@ -132,15 +142,17 @@
{/if}
Read
</button>
<button
class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved ? 'text-accent-blue' : 'hover:text-text-primary'}"
<button
class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved
? 'text-accent-blue'
: 'hover:text-text-primary'}"
title={article.saved ? 'Remove from saved' : 'Save for later'}
onclick={toggleSave}
>
<Bookmark size={18} fill={article.saved ? 'currentColor' : 'none'} />
</button>
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1"
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1"
title="Copy share link"
onclick={shareArticle}
>
@@ -150,10 +162,13 @@
<Share2 size={18} />
{/if}
</button>
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
title="Open in new tab"
onclick={(e) => { e.stopPropagation(); window.open(article.link, '_blank'); }}
onclick={(e) => {
e.stopPropagation();
window.open(article.link, '_blank');
}}
>
<MoreVertical size={18} />
</button>
@@ -161,8 +176,14 @@
</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
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

@@ -5,17 +5,35 @@
let { onAddFeed } = $props();
</script>
<header class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe">
<header
class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe"
>
<div class="flex items-center gap-2">
<button
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
<button
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
aria-label="Menu"
onclick={() => newsStore.showSidebar = !newsStore.showSidebar}
onclick={() => (newsStore.showSidebar = !newsStore.showSidebar)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line
x1="3"
y1="18"
x2="21"
y2="18"
></line></svg
>
</button>
<button
class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1"
<button
class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1"
onclick={() => {
newsStore.selectFeed(null);
newsStore.currentView = 'all';
@@ -24,7 +42,22 @@
aria-label="Web News Home"
>
<div class="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle
cx="5"
cy="19"
r="1"
></circle></svg
>
</div>
<h1 class="text-xl font-bold tracking-tight hidden sm:block">Web News</h1>
</button>
@@ -33,9 +66,9 @@
<div class="flex-1 max-w-2xl mx-4 hidden sm:block">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
<input
type="text"
placeholder="Search for topics, locations & sources"
<input
type="text"
placeholder="Search for topics, locations & sources"
class="w-full bg-bg-secondary border-none rounded-xl py-2.5 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all"
bind:value={newsStore.searchQuery}
oninput={() => newsStore.loadArticles()}
@@ -45,32 +78,42 @@
<div class="flex items-center gap-1 sm:gap-2">
{#if newsStore.ping !== null && !newsStore.isWails && !newsStore.isCapacitor}
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color">
<div class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200 ? 'bg-green-500' : newsStore.ping < 500 ? 'bg-yellow-500' : 'bg-red-500'}"></div>
<div
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color"
>
<div
class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200
? 'bg-green-500'
: newsStore.ping < 500
? 'bg-yellow-500'
: 'bg-red-500'}"
></div>
<span class="text-[10px] font-medium text-text-secondary">{newsStore.ping}ms</span>
</div>
{:else if !newsStore.isOnline}
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20">
<div
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20"
>
<div class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-[10px] font-medium text-red-500">Offline</span>
</div>
{/if}
<button
<button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={() => newsStore.refresh()}
title="Refresh feeds"
>
<RefreshCw size={20} class={newsStore.loading ? 'animate-spin text-accent-blue' : ''} />
</button>
<button
<button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={onAddFeed}
title="Add RSS Feed"
>
<Plus size={24} />
</button>
<button
<button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={() => newsStore.toggleTheme()}
title="Toggle theme"
@@ -83,4 +126,3 @@
</button>
</div>
</header>

View File

@@ -2,17 +2,35 @@
import { newsStore } from '$lib/store.svelte';
import { db } from '$lib/db';
import { exportToOPML, parseOPML } from '$lib/opml';
import { Home, Star, Bookmark, Hash, Settings as SettingsIcon, ChevronRight, ChevronDown, AlertCircle, Edit2, GripVertical, Plus, Trash2, Save, X, Download, Upload, GitBranch } from 'lucide-svelte';
import {
Home,
Star,
Bookmark,
Hash,
Settings as SettingsIcon,
ChevronRight,
ChevronDown,
AlertCircle,
Edit2,
GripVertical,
Plus,
Trash2,
Save,
X,
Download,
Upload,
GitBranch,
} from 'lucide-svelte';
import { toast } from '$lib/toast.svelte';
import { slide } from 'svelte/transition';
let { onOpenSettings } = $props();
let expandedCategories = $state<Record<string, boolean>>({});
$effect(() => {
// Expand new categories by default
newsStore.categories.forEach(cat => {
newsStore.categories.forEach((cat) => {
if (expandedCategories[cat.id] === undefined) {
expandedCategories[cat.id] = true;
}
@@ -38,7 +56,7 @@
function getFeedsForCategory(categoryId: string) {
return newsStore.feeds
.filter(f => f.categoryId === categoryId)
.filter((f) => f.categoryId === categoryId)
.sort((a, b) => a.order - b.order);
}
@@ -66,34 +84,34 @@
if (draggedCategoryId && targetType === 'category') {
const cats = [...newsStore.categories].sort((a, b) => a.order - b.order);
const fromIndex = cats.findIndex(c => c.id === draggedCategoryId);
const toIndex = cats.findIndex(c => c.id === targetId);
const fromIndex = cats.findIndex((c) => c.id === draggedCategoryId);
const toIndex = cats.findIndex((c) => c.id === targetId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
const [moved] = cats.splice(fromIndex, 1);
cats.splice(toIndex, 0, moved);
await newsStore.reorderCategories(cats.map(c => c.id));
await newsStore.reorderCategories(cats.map((c) => c.id));
}
} else if (draggedFeedId && targetType === 'feed') {
const sourceFeed = newsStore.feeds.find(f => f.id === draggedFeedId);
const targetFeed = newsStore.feeds.find(f => f.id === targetId);
const sourceFeed = newsStore.feeds.find((f) => f.id === draggedFeedId);
const targetFeed = newsStore.feeds.find((f) => f.id === targetId);
if (sourceFeed && targetFeed && sourceFeed.categoryId === targetFeed.categoryId) {
const catFeeds = newsStore.feeds
.filter(f => f.categoryId === sourceFeed.categoryId)
.filter((f) => f.categoryId === sourceFeed.categoryId)
.sort((a, b) => a.order - b.order);
const fromIndex = catFeeds.findIndex(f => f.id === draggedFeedId);
const toIndex = catFeeds.findIndex(f => f.id === targetId);
const fromIndex = catFeeds.findIndex((f) => f.id === draggedFeedId);
const toIndex = catFeeds.findIndex((f) => f.id === targetId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
const [moved] = catFeeds.splice(fromIndex, 1);
catFeeds.splice(toIndex, 0, moved);
await newsStore.reorderFeeds(catFeeds.map(f => f.id));
await newsStore.reorderFeeds(catFeeds.map((f) => f.id));
}
}
}
draggedCategoryId = null;
draggedFeedId = null;
}
@@ -111,7 +129,7 @@
async function saveCategory() {
if (!editingCategoryId) return;
const cat = newsStore.categories.find(c => c.id === editingCategoryId);
const cat = newsStore.categories.find((c) => c.id === editingCategoryId);
if (cat) {
await newsStore.updateCategory({ ...cat, name: editingCategoryName });
}
@@ -126,13 +144,16 @@
async function saveFeed() {
if (!editingFeedId) return;
const feed = newsStore.feeds.find(f => f.id === editingFeedId);
const feed = newsStore.feeds.find((f) => f.id === editingFeedId);
if (feed) {
await newsStore.updateFeed({
...feed,
title: editingFeedTitle,
id: editingFeedUrl
}, editingFeedId);
await newsStore.updateFeed(
{
...feed,
title: editingFeedTitle,
id: editingFeedUrl,
},
editingFeedId
);
}
editingFeedId = null;
}
@@ -145,14 +166,14 @@
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();
} catch (err) {
@@ -183,234 +204,315 @@
}
</script>
<aside
class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full"
<aside
class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar
? 'translate-x-0'
: '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full"
>
<div class="flex flex-col gap-6 py-6 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container">
<nav class="flex flex-col gap-1 px-2">
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'all' && newsStore.selectedFeedId === null ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => { newsStore.selectView('all'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
>
<Home size={20} />
<span>Top stories</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'following' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => { newsStore.selectView('following'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
>
<Star size={20} />
<span>Following</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'saved' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => { newsStore.selectView('saved'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
>
<Bookmark size={20} />
<span>Saved stories</span>
</button>
</nav>
<div class="px-6 py-2">
<div class="h-px bg-border-color w-full"></div>
</div>
<div class="flex-1 px-2 overflow-y-auto">
<div class="flex items-center justify-between px-4 mb-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Subscriptions</h3>
<button
class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode ? 'text-accent-blue' : ''}"
onclick={() => isManageMode = !isManageMode}
title="Manage feeds"
<div
class="flex flex-col gap-6 py-6 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container"
>
<nav class="flex flex-col gap-1 px-2">
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'all' && newsStore.selectedFeedId === null
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('all');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
{#if isManageMode}
<X size={14} />
{:else}
<Edit2 size={14} />
{/if}
<Home size={20} />
<span>Top stories</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'following'
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('following');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
<Star size={20} />
<span>Following</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'saved'
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('saved');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
<Bookmark size={20} />
<span>Saved stories</span>
</button>
</nav>
<div class="px-6 py-2">
<div class="h-px bg-border-color w-full"></div>
</div>
{#if isManageMode}
<div class="px-4 mb-4 space-y-3">
<div class="flex gap-1">
<input
type="text"
bind:value={newCategoryName}
placeholder="Add category..."
class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30"
onkeydown={(e) => e.key === 'Enter' && addCategory()}
/>
<button class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors" onclick={addCategory}>
<Plus size={14} />
</button>
</div>
<div class="flex gap-2">
<label class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer">
<Upload size={12} />
Import
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
<button
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
onclick={handleExport}
>
<Download size={12} />
Export
</button>
</div>
<div class="flex-1 px-2 overflow-y-auto">
<div class="flex items-center justify-between px-4 mb-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Subscriptions
</h3>
<button
class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode
? 'text-accent-blue'
: ''}"
onclick={() => (isManageMode = !isManageMode)}
title="Manage feeds"
>
{#if isManageMode}
<X size={14} />
{:else}
<Edit2 size={14} />
{/if}
</button>
</div>
{/if}
<div class="space-y-1" role="list">
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
{@const catFeeds = getFeedsForCategory(cat.id)}
{#if catFeeds.length > 0 || isManageMode}
<div
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'category', cat.id)}
ondragover={(e) => handleDragOver(e, cat.id)}
ondrop={(e) => handleDrop(e, 'category', cat.id)}
>
<div class="flex items-center group">
{#if isManageMode}
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={14} />
</div>
{/if}
{#if editingCategoryId === cat.id}
<div class="flex-1 flex items-center gap-1 px-2 py-1">
<input
type="text"
bind:value={editingCategoryName}
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
onkeydown={(e) => e.key === 'Enter' && saveCategory()}
/>
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button>
</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)}
title={cat.name}
>
{#if expandedCategories[cat.id]}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
<span class="truncate">{cat.name}</span>
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full">{catFeeds.length}</span>
</button>
{#if isManageMode}
<div class="px-4 mb-4 space-y-3">
<div class="flex gap-1">
<input
type="text"
bind:value={newCategoryName}
placeholder="Add category..."
class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30"
onkeydown={(e) => e.key === 'Enter' && addCategory()}
/>
<button
class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
onclick={addCategory}
>
<Plus size={14} />
</button>
</div>
<div class="flex gap-2">
<label
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer"
>
<Upload size={12} />
Import
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
<button
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
onclick={handleExport}
>
<Download size={12} />
Export
</button>
</div>
</div>
{/if}
<div class="space-y-1" role="list">
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
{@const catFeeds = getFeedsForCategory(cat.id)}
{#if catFeeds.length > 0 || isManageMode}
<div
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id
? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
: ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'category', cat.id)}
ondragover={(e) => handleDragOver(e, cat.id)}
ondrop={(e) => handleDrop(e, 'category', cat.id)}
>
<div class="flex items-center group">
{#if isManageMode}
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button>
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteCategory(cat.id)}><Trash2 size={12} /></button>
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={14} />
</div>
{/if}
{/if}
</div>
{#if expandedCategories[cat.id]}
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list">
{#each catFeeds as feed (feed.id)}
<div
class="flex items-center group rounded-xl transition-all {dragOverId === feed.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)}
ondragover={(e) => handleDragOver(e, feed.id)}
ondrop={(e) => handleDrop(e, 'feed', feed.id)}
{#if editingCategoryId === cat.id}
<div class="flex-1 flex items-center gap-1 px-2 py-1">
<input
type="text"
bind:value={editingCategoryName}
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
onkeydown={(e) => e.key === 'Enter' && saveCategory()}
/>
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button
>
</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)}
title={cat.name}
>
{#if isManageMode}
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={12} />
</div>
{/if}
{#if editingFeedId === feed.id}
<div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
<input
type="text"
bind:value={editingFeedTitle}
placeholder="Feed title"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
/>
<input
type="text"
bind:value={editingFeedUrl}
placeholder="Feed URL"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
/>
<div class="flex justify-end gap-1 mt-1">
<button class="p-1 text-red-500 hover:bg-red-500/10 rounded" onclick={() => editingFeedId = null}><X size={14} /></button>
<button class="p-1 text-green-500 hover:bg-green-500/10 rounded" onclick={saveFeed}><Save size={14} /></button>
</div>
</div>
{#if expandedCategories[cat.id]}
<ChevronDown size={16} />
{:else}
<button
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId === feed.id ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0"
onclick={() => { newsStore.selectFeed(feed.id); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
title={feed.title}
>
{#if feed.error}
<AlertCircle size={16} class="text-red-500 flex-shrink-0" />
{:else if feed.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{:else}
<Hash size={16} class="flex-shrink-0" />
{/if}
<span class="truncate {feed.error ? 'text-red-500' : ''}">{feed.title}</span>
</button>
<ChevronRight size={16} />
{/if}
<span class="truncate">{cat.name}</span>
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full"
>{catFeeds.length}</span
>
</button>
{#if isManageMode}
<div
class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button
>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteCategory(cat.id)}
><Trash2 size={12} /></button
>
</div>
{/if}
{/if}
</div>
{#if expandedCategories[cat.id]}
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list">
{#each catFeeds as feed (feed.id)}
<div
class="flex items-center group rounded-xl transition-all {dragOverId ===
feed.id
? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
: ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)}
ondragover={(e) => handleDragOver(e, feed.id)}
ondrop={(e) => handleDrop(e, 'feed', feed.id)}
>
{#if isManageMode}
<div class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5">
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditFeed(feed)} title="Edit feed"><Edit2 size={12} /></button>
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteFeed(feed.id)} title="Delete feed"><Trash2 size={12} /></button>
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={12} />
</div>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if editingFeedId === feed.id}
<div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
<input
type="text"
bind:value={editingFeedTitle}
placeholder="Feed title"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
/>
<input
type="text"
bind:value={editingFeedUrl}
placeholder="Feed URL"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
/>
<div class="flex justify-end gap-1 mt-1">
<button
class="p-1 text-red-500 hover:bg-red-500/10 rounded"
onclick={() => (editingFeedId = null)}><X size={14} /></button
>
<button
class="p-1 text-green-500 hover:bg-green-500/10 rounded"
onclick={saveFeed}><Save size={14} /></button
>
</div>
</div>
{:else}
<button
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId ===
feed.id
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0"
onclick={() => {
newsStore.selectFeed(feed.id);
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
title={feed.title}
>
{#if feed.error}
<AlertCircle size={16} class="text-red-500 flex-shrink-0" />
{:else if feed.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{:else}
<Hash size={16} class="flex-shrink-0" />
{/if}
<span class="truncate {feed.error ? 'text-red-500' : ''}"
>{feed.title}</span
>
</button>
{#if isManageMode}
<div
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => startEditFeed(feed)}
title="Edit feed"><Edit2 size={12} /></button
>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteFeed(feed.id)}
title="Delete feed"><Trash2 size={12} /></button
>
</div>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
{#if newsStore.feeds.length === 0}
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
{/if}
{/each}
{#if newsStore.feeds.length === 0}
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
{/if}
</div>
</div>
</div>
<div class="flex flex-col items-center pb-24 md:pb-6 space-y-4">
<div class="flex flex-col items-center space-y-1">
<a
href="https://git.quad4.io/Quad4-Software/webnews"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
<div class="flex flex-col items-center pb-24 md:pb-6 space-y-4">
<div class="flex flex-col items-center space-y-1">
<a
href="https://git.quad4.io/Quad4-Software/webnews"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
>
<GitBranch size={13} />
<span>v0.1.0</span>
</a>
<p class="text-[11px] text-text-secondary font-medium">
Created by <a
href="https://quad4.io"
target="_blank"
rel="noopener noreferrer"
class="hover:text-accent-blue transition-colors">Quad4</a
>
</p>
</div>
<button
class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]"
onclick={onOpenSettings}
>
<GitBranch size={13} />
<span>v0.1.0</span>
</a>
<p class="text-[11px] text-text-secondary font-medium">
Created by <a href="https://quad4.io" target="_blank" rel="noopener noreferrer" class="hover:text-accent-blue transition-colors">Quad4</a>
</p>
<SettingsIcon size={18} />
<span class="font-medium text-sm">Settings</span>
</button>
</div>
<button
class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]"
onclick={onOpenSettings}
>
<SettingsIcon size={18} />
<span class="font-medium text-sm">Settings</span>
</button>
</div>
</div>
</aside>

View File

@@ -7,27 +7,31 @@
info: Info,
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle
warning: AlertTriangle,
};
const colors = {
info: 'text-blue-500 bg-bg-secondary/95 border-blue-500/30',
success: 'text-green-500 bg-bg-secondary/95 border-green-500/30',
error: 'text-red-500 bg-bg-secondary/95 border-red-500/30',
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30'
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30',
};
</script>
<div class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4">
<div
class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4"
>
{#each toast.toasts as t (t.id)}
<div
class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[t.type]}"
<div
class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[
t.type
]}"
in:fly={{ y: 20, duration: 300 }}
out:fade={{ duration: 200 }}
>
<svelte:component this={icons[t.type]} size={20} class="flex-shrink-0" />
<p class="text-sm font-medium flex-1 leading-snug">{t.message}</p>
<button
<button
class="text-text-secondary hover:text-text-primary transition-colors p-1"
onclick={() => toast.remove(t.id)}
>
@@ -36,4 +40,3 @@
</div>
{/each}
</div>