417 lines
16 KiB
Svelte
417 lines
16 KiB
Svelte
<script lang="ts">
|
|
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 { 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 => {
|
|
if (expandedCategories[cat.id] === undefined) {
|
|
expandedCategories[cat.id] = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
let isManageMode = $state(false);
|
|
let editingCategoryId = $state<string | null>(null);
|
|
let editingCategoryName = $state('');
|
|
let editingFeedId = $state<string | null>(null);
|
|
let editingFeedTitle = $state('');
|
|
let editingFeedUrl = $state('');
|
|
let newCategoryName = $state('');
|
|
|
|
// Drag and drop state
|
|
let draggedCategoryId = $state<string | null>(null);
|
|
let draggedFeedId = $state<string | null>(null);
|
|
let dragOverId = $state<string | null>(null);
|
|
|
|
function toggleCategory(id: string) {
|
|
expandedCategories[id] = !expandedCategories[id];
|
|
}
|
|
|
|
function getFeedsForCategory(categoryId: string) {
|
|
return newsStore.feeds
|
|
.filter(f => f.categoryId === categoryId)
|
|
.sort((a, b) => a.order - b.order);
|
|
}
|
|
|
|
function handleDragStart(e: DragEvent, type: 'category' | 'feed', id: string) {
|
|
if (type === 'category') {
|
|
draggedCategoryId = id;
|
|
draggedFeedId = null;
|
|
} else {
|
|
draggedFeedId = id;
|
|
draggedCategoryId = null;
|
|
}
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}
|
|
}
|
|
|
|
function handleDragOver(e: DragEvent, id: string) {
|
|
e.preventDefault();
|
|
dragOverId = id;
|
|
}
|
|
|
|
async function handleDrop(e: DragEvent, targetType: 'category' | 'feed', targetId: string) {
|
|
e.preventDefault();
|
|
dragOverId = null;
|
|
|
|
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);
|
|
|
|
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));
|
|
}
|
|
} else if (draggedFeedId && targetType === 'feed') {
|
|
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)
|
|
.sort((a, b) => a.order - b.order);
|
|
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
|
|
draggedCategoryId = null;
|
|
draggedFeedId = null;
|
|
}
|
|
|
|
async function addCategory() {
|
|
if (!newCategoryName) return;
|
|
await newsStore.addCategory(newCategoryName);
|
|
newCategoryName = '';
|
|
}
|
|
|
|
function startEditCategory(cat: any) {
|
|
editingCategoryId = cat.id;
|
|
editingCategoryName = cat.name;
|
|
}
|
|
|
|
async function saveCategory() {
|
|
if (!editingCategoryId) return;
|
|
const cat = newsStore.categories.find(c => c.id === editingCategoryId);
|
|
if (cat) {
|
|
await newsStore.updateCategory({ ...cat, name: editingCategoryName });
|
|
}
|
|
editingCategoryId = null;
|
|
}
|
|
|
|
function startEditFeed(feed: any) {
|
|
editingFeedId = feed.id;
|
|
editingFeedTitle = feed.title;
|
|
editingFeedUrl = feed.id; // Currently ID is the URL
|
|
}
|
|
|
|
async function saveFeed() {
|
|
if (!editingFeedId) return;
|
|
const feed = newsStore.feeds.find(f => f.id === editingFeedId);
|
|
if (feed) {
|
|
await newsStore.updateFeed({
|
|
...feed,
|
|
title: editingFeedTitle,
|
|
id: editingFeedUrl
|
|
}, editingFeedId);
|
|
}
|
|
editingFeedId = null;
|
|
}
|
|
|
|
async function handleImport(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
const file = target.files?.[0];
|
|
if (!file) return;
|
|
|
|
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) {
|
|
console.error('Import failed:', err);
|
|
toast.error('Failed to import OPML');
|
|
} finally {
|
|
target.value = '';
|
|
}
|
|
}
|
|
|
|
async function handleExport() {
|
|
try {
|
|
const opml = exportToOPML(newsStore.feeds, newsStore.categories);
|
|
const blob = new Blob([opml], { type: 'text/xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'feeds.opml';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
toast.success('OPML exported');
|
|
} catch (err) {
|
|
console.error('Export failed:', err);
|
|
toast.error('Failed to export OPML');
|
|
}
|
|
}
|
|
</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"
|
|
>
|
|
<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"
|
|
>
|
|
{#if isManageMode}
|
|
<X size={14} />
|
|
{:else}
|
|
<Edit2 size={14} />
|
|
{/if}
|
|
</button>
|
|
</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>
|
|
{/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="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="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>
|
|
{: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}
|
|
</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"
|
|
>
|
|
<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}
|
|
>
|
|
<SettingsIcon size={18} />
|
|
<span class="font-medium text-sm">Settings</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|