Files
web-app-launcher/src/lib/components/widget/RssFeedWidget.svelte
T
alexei.dolgolyov 1c0a7cb850 feat: Phases 4-7 — Full Feature Expansion (26 features)
Phase 4 — New Widget Types:
- Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown,
  Metric/Counter, Link Group, Camera/Stream widgets
- Backend services with caching for each data source
- Full creation form with dynamic config fields per type

Phase 5 — Visual & Styling Enhancements:
- Glassmorphism card style (solid/glass/outline)
- Board-level themes with per-board hue/saturation
- Animated SVG status rings replacing static dots
- Card size options (compact/medium/large)
- Custom CSS injection (admin + per-board, sanitized)
- Wallpaper backgrounds with blur/overlay/parallax

Phase 6 — Functional Features:
- Favorites bar with drag-and-drop reordering
- Recent apps tracking with privacy toggle
- Uptime dashboard page (/status, guest-accessible)
- Notifications system (Discord/Slack/Telegram/HTTP webhooks)
- App tags with filtering in board view
- Multi-URL app cards with expandable sub-links
- Personal API tokens with scoped permissions
- Audit log with retention and admin viewer

Phase 7 — Quality of Life:
- Onboarding wizard (5-step first-launch setup)
- App URL health preview with favicon/title detection
- Board templates (4 built-in + custom import/export)
- Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help)

212 files changed, 15641 insertions, 980 deletions.
Build, lint, type check, and 222 tests all pass.
2026-03-25 14:18:10 +03:00

149 lines
4.1 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { ExternalLink, ChevronDown, Rss } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { RssWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: RssWidgetConfig;
}
let { config }: Props = $props();
interface FeedItem {
title: string;
link: string;
pubDate: string;
summary: string;
}
let items: FeedItem[] = $state([]);
let loading = $state(true);
let error = $state(false);
let expandedIndex: number | null = $state(null);
const showSummary = $derived(config.showSummary ?? true);
function relativeTime(dateStr: string): string {
try {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDays = Math.floor(diffHr / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch {
return '';
}
}
async function fetchFeed() {
error = false;
try {
const params = new URLSearchParams({
feedUrl: config.feedUrl,
maxItems: String(config.maxItems ?? 10)
});
const res = await fetch(`/api/widgets/rss?${params}`);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
items = json.data;
}
} else {
error = true;
}
} catch {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
fetchFeed();
});
// Refresh every 15 minutes
$effect(() => {
const interval = setInterval(fetchFeed, 15 * 60 * 1000);
return () => clearInterval(interval);
});
function toggleExpand(index: number) {
expandedIndex = expandedIndex === index ? null : index;
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="mb-3 flex items-center gap-2">
<Rss class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">RSS Feed</span>
</div>
{#if loading}
<div class="space-y-3">
{#each [1, 2, 3, 4] as _n (_n)}
<div class="space-y-1">
<div class="h-4 w-3/4 animate-pulse rounded bg-muted"></div>
<div class="h-3 w-1/4 animate-pulse rounded bg-muted"></div>
</div>
{/each}
</div>
{:else if error}
<div class="flex flex-1 items-center justify-center">
<span class="text-xs text-muted-foreground">Failed to load feed</span>
</div>
{:else if items.length === 0}
<div class="flex flex-1 items-center justify-center">
<span class="text-xs text-muted-foreground">No feed items</span>
</div>
{:else}
<div class="flex-1 space-y-1 overflow-y-auto">
{#each items as item, i (item.link + i)}
<div class="rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/50">
<div class="flex items-start justify-between gap-2">
<button
type="button"
class="flex flex-1 items-start gap-1.5 text-left"
onclick={() => toggleExpand(i)}
>
{#if showSummary && item.summary}
<ChevronDown
class="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform {expandedIndex === i ? 'rotate-180' : ''}"
/>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight text-foreground line-clamp-2">{item.title}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{relativeTime(item.pubDate)}</p>
</div>
</button>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
class="mt-0.5 flex-shrink-0 text-muted-foreground transition-colors hover:text-primary"
title="Open in new tab"
>
<ExternalLink class="h-3.5 w-3.5" />
</a>
</div>
{#if showSummary && expandedIndex === i && item.summary}
<div transition:slide={{ duration: 200 }}>
<p class="mt-1.5 pl-5 text-xs leading-relaxed text-muted-foreground">
{item.summary}
</p>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>