1c0a7cb850
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.
149 lines
4.1 KiB
Svelte
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>
|