Files
webnews/src/components/Sidebar.svelte

519 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-2 pt-6 pb-2 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-20 md:pb-4 space-y-2">
<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 class="flex flex-col items-center">
<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.2.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>
</div>
</div>
</aside>