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.
This commit is contained in:
@@ -1,7 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
|
||||
import AnimatedStatusRing from '$lib/components/app/AnimatedStatusRing.svelte';
|
||||
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
|
||||
import TagBadge from '$lib/components/app/TagBadge.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte.js';
|
||||
import { favorites } from '$lib/stores/favorites.svelte.js';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface AppLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
icon: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface AppTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
interface AppData {
|
||||
id: string;
|
||||
@@ -11,6 +30,8 @@
|
||||
iconType: string;
|
||||
description: string | null;
|
||||
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||
links?: AppLink[];
|
||||
tags?: AppTag[];
|
||||
}
|
||||
|
||||
interface StatusPoint {
|
||||
@@ -20,15 +41,23 @@
|
||||
|
||||
interface Props {
|
||||
app: AppData;
|
||||
cardSize?: 'compact' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
let { app }: Props = $props();
|
||||
let { app, cardSize = 'medium' }: Props = $props();
|
||||
|
||||
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
|
||||
|
||||
let historyData: StatusPoint[] = $state([]);
|
||||
let uptimePercent: number | null = $state(null);
|
||||
let historyLoading = $state(true);
|
||||
let linksExpanded = $state(false);
|
||||
let showContextMenu = $state(false);
|
||||
let contextMenuPos = $state({ x: 0, y: 0 });
|
||||
|
||||
const latestStatus = $derived(app.statuses[0]?.status ?? 'unknown');
|
||||
const hasLinks = $derived(Array.isArray(app.links) && app.links.length > 0);
|
||||
const hasTags = $derived(Array.isArray(app.tags) && app.tags.length > 0);
|
||||
|
||||
const iconSrc = $derived.by(() => {
|
||||
if (!app.icon) return null;
|
||||
@@ -61,48 +90,299 @@
|
||||
historyLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
contextMenuPos = { x: e.clientX, y: e.clientY };
|
||||
showContextMenu = true;
|
||||
}
|
||||
|
||||
function handleWindowClick() {
|
||||
showContextMenu = false;
|
||||
}
|
||||
|
||||
function toggleFavorite() {
|
||||
showContextMenu = false;
|
||||
if (favorites.isFavorite(app.id)) {
|
||||
favorites.remove(app.id);
|
||||
} else {
|
||||
favorites.add(app.id);
|
||||
}
|
||||
}
|
||||
|
||||
function recordClick() {
|
||||
fetch('/api/recent-apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appId: app.id })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function toggleLinks(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linksExpanded = !linksExpanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card-hover group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if app.iconType === 'emoji' && app.icon}
|
||||
<span class="text-2xl">{app.icon}</span>
|
||||
{:else if iconSrc}
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt="{app.name} icon"
|
||||
class="h-8 w-8 object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-lg font-bold text-muted-foreground">
|
||||
{app.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
{#if cardSize === 'compact'}
|
||||
<!-- Compact: icon + name only, inline layout -->
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card-hover group flex items-center gap-2 rounded-lg {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
|
||||
oncontextmenu={handleContextMenu}
|
||||
onclick={recordClick}
|
||||
>
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if app.iconType === 'emoji' && app.icon}
|
||||
<span class="text-base">{app.icon}</span>
|
||||
{:else if iconSrc}
|
||||
<img src={iconSrc} alt="{app.name} icon" class="h-5 w-5 object-contain" />
|
||||
{:else}
|
||||
<span class="text-xs font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<AnimatedStatusRing status={latestStatus} size={32} animated />
|
||||
</div>
|
||||
<span class="truncate text-xs font-medium text-foreground transition-colors group-hover:text-primary">
|
||||
{app.name}
|
||||
</span>
|
||||
{#if hasLinks}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleLinks}
|
||||
class="ml-auto flex-shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
title="Show links"
|
||||
>
|
||||
<svg class="h-3 w-3 transition-transform" class:rotate-90={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Name -->
|
||||
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
|
||||
{app.name}
|
||||
</span>
|
||||
|
||||
<!-- Status -->
|
||||
<AppHealthBadge status={latestStatus} />
|
||||
|
||||
<!-- Sparkline -->
|
||||
{#if historyLoading}
|
||||
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
|
||||
{:else if historyData.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
|
||||
{/if}
|
||||
<!-- Expanded links for compact -->
|
||||
{#if linksExpanded && hasLinks}
|
||||
<div transition:slide={{ duration: 200 }} class="ml-10 space-y-0.5 pb-1">
|
||||
{#each app.links ?? [] as link (link.id)}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg class="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else if cardSize === 'large'}
|
||||
<!-- Large: icon + name + description + sparkline + tags + links -->
|
||||
<div
|
||||
class="card-hover group rounded-xl {cardStyleClass} p-5 transition-colors hover:border-primary/50"
|
||||
oncontextmenu={handleContextMenu}
|
||||
>
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex flex-col items-center gap-3 text-center"
|
||||
onclick={recordClick}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if app.iconType === 'emoji' && app.icon}
|
||||
<span class="text-3xl">{app.icon}</span>
|
||||
{:else if iconSrc}
|
||||
<img src={iconSrc} alt="{app.name} icon" class="h-10 w-10 object-contain" />
|
||||
{:else}
|
||||
<span class="text-xl font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<AnimatedStatusRing status={latestStatus} size={64} animated />
|
||||
</div>
|
||||
|
||||
<span class="w-full truncate text-base font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||
{app.name}
|
||||
</span>
|
||||
|
||||
{#if app.description}
|
||||
<p class="line-clamp-2 w-full text-xs text-muted-foreground">{app.description}</p>
|
||||
{/if}
|
||||
|
||||
<AppHealthBadge status={latestStatus} />
|
||||
|
||||
{#if historyLoading}
|
||||
<div class="h-5 w-24 animate-pulse rounded bg-muted"></div>
|
||||
{:else if historyData.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if hasTags}
|
||||
<div class="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{#each app.tags ?? [] as tag (tag.id)}
|
||||
<TagBadge name={tag.name} color={tag.color} size="sm" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Expandable Links -->
|
||||
{#if hasLinks}
|
||||
<div class="mt-2 border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleLinks}
|
||||
class="flex w-full items-center justify-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg class="h-3 w-3 transition-transform" class:rotate-180={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
{linksExpanded ? 'Hide' : `${app.links?.length ?? 0} more`} links
|
||||
</button>
|
||||
|
||||
{#if linksExpanded}
|
||||
<div transition:slide={{ duration: 200 }} class="mt-1.5 space-y-1">
|
||||
{#each app.links ?? [] as link (link.id)}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Medium (default): icon + name + status + sparkline on hover + links -->
|
||||
<div
|
||||
class="card-hover group rounded-xl {cardStyleClass} p-4 transition-colors hover:border-primary/50"
|
||||
oncontextmenu={handleContextMenu}
|
||||
>
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex flex-col items-center gap-2 text-center"
|
||||
onclick={recordClick}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if app.iconType === 'emoji' && app.icon}
|
||||
<span class="text-2xl">{app.icon}</span>
|
||||
{:else if iconSrc}
|
||||
<img src={iconSrc} alt="{app.name} icon" class="h-8 w-8 object-contain" />
|
||||
{:else}
|
||||
<span class="text-lg font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<AnimatedStatusRing status={latestStatus} size={48} animated />
|
||||
</div>
|
||||
|
||||
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
|
||||
{app.name}
|
||||
</span>
|
||||
|
||||
<AppHealthBadge status={latestStatus} />
|
||||
|
||||
{#if historyLoading}
|
||||
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
|
||||
{:else if historyData.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Expandable Links -->
|
||||
{#if hasLinks}
|
||||
<div class="mt-2 border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleLinks}
|
||||
class="flex w-full items-center justify-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg class="h-3 w-3 transition-transform" class:rotate-180={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
{linksExpanded ? 'Hide' : `${app.links?.length ?? 0} more`}
|
||||
</button>
|
||||
|
||||
{#if linksExpanded}
|
||||
<div transition:slide={{ duration: 200 }} class="mt-1 space-y-0.5">
|
||||
{#each app.links ?? [] as link (link.id)}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<svg class="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Context Menu -->
|
||||
{#if showContextMenu}
|
||||
<div
|
||||
class="fixed z-50 rounded-lg border border-border bg-popover p-1 shadow-lg"
|
||||
style="left: {contextMenuPos.x}px; top: {contextMenuPos.y}px"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
onclick={toggleFavorite}
|
||||
>
|
||||
{#if favorites.isFavorite(app.id)}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
Remove from favorites
|
||||
{:else}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
Add to favorites
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Calendar, MapPin, Clock } from 'lucide-svelte';
|
||||
import type { CalendarWidgetConfig } from '$lib/types/widget.js';
|
||||
|
||||
interface Props {
|
||||
config: CalendarWidgetConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
interface CalendarEvent {
|
||||
summary: string;
|
||||
start: string;
|
||||
end: string;
|
||||
location?: string;
|
||||
calendarLabel?: string;
|
||||
calendarColor?: string;
|
||||
}
|
||||
|
||||
let events: CalendarEvent[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
|
||||
function groupLabel(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
/* eslint-disable svelte/prefer-svelte-reactivity */
|
||||
const today = new Date();
|
||||
const tomorrow = new Date();
|
||||
/* eslint-enable svelte/prefer-svelte-reactivity */
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) return 'Today';
|
||||
if (date.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatTimeRange(start: string, end: string): string {
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
// Check if all-day (midnight to midnight or close to it)
|
||||
if (s.getHours() === 0 && s.getMinutes() === 0 && e.getHours() === 0 && e.getMinutes() === 0) {
|
||||
return 'All day';
|
||||
}
|
||||
const fmt = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
return `${fmt.format(s)} - ${fmt.format(e)}`;
|
||||
}
|
||||
|
||||
interface GroupedEvents {
|
||||
label: string;
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
const grouped = $derived.by((): GroupedEvents[] => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const groups: Map<string, CalendarEvent[]> = new Map();
|
||||
for (const evt of events) {
|
||||
const key = new Date(evt.start).toDateString();
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.push(evt);
|
||||
} else {
|
||||
groups.set(key, [evt]);
|
||||
}
|
||||
}
|
||||
const result: GroupedEvents[] = [];
|
||||
for (const [, evts] of groups) {
|
||||
result.push({
|
||||
label: groupLabel(evts[0].start),
|
||||
events: evts
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
async function fetchEvents() {
|
||||
error = false;
|
||||
try {
|
||||
const res = await fetch('/api/widgets/calendar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
icalUrls: config.icalUrls,
|
||||
daysAhead: config.daysAhead ?? 7
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
events = json.data;
|
||||
}
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
} catch {
|
||||
error = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchEvents();
|
||||
});
|
||||
|
||||
// Refresh every 30 minutes
|
||||
$effect(() => {
|
||||
const interval = setInterval(fetchEvents, 30 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</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">
|
||||
<Calendar class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">Calendar</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each [1, 2, 3] as _n (_n)}
|
||||
<div class="space-y-1">
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-4 w-3/4 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-3 w-1/3 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 events</span>
|
||||
</div>
|
||||
{:else if events.length === 0}
|
||||
<div class="flex flex-1 items-center justify-center text-center">
|
||||
<div>
|
||||
<Calendar class="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||
<span class="text-xs text-muted-foreground">No upcoming events</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 space-y-3 overflow-y-auto">
|
||||
{#each grouped as group (group.label)}
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
{#each group.events as evt (evt.summary + evt.start)}
|
||||
<div class="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/50">
|
||||
<!-- Color dot -->
|
||||
<span
|
||||
class="mt-1.5 inline-block h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style="background-color: {evt.calendarColor || 'hsl(var(--primary))'}"
|
||||
></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-tight text-foreground">{evt.summary}</p>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
||||
<span class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock class="h-3 w-3" />
|
||||
{formatTimeRange(evt.start, evt.end)}
|
||||
</span>
|
||||
{#if evt.location}
|
||||
<span class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin class="h-3 w-3" />
|
||||
<span class="truncate">{evt.location}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Maximize2, X, AlertCircle } from 'lucide-svelte';
|
||||
import type { CameraWidgetConfig } from '$lib/types/widget.js';
|
||||
|
||||
interface Props {
|
||||
config: CameraWidgetConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
let imgSrc = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
let fullscreen = $state(false);
|
||||
let videoEl: HTMLVideoElement | null = $state(null);
|
||||
|
||||
const streamType = $derived(config.type ?? 'image');
|
||||
const refreshMs = $derived((config.refreshInterval ?? 10) * 1000);
|
||||
const aspectRatio = $derived(config.aspectRatio ?? '16/9');
|
||||
|
||||
// For snapshot mode, fetch through our proxy
|
||||
function buildProxyUrl(): string {
|
||||
const params = new URLSearchParams({ streamUrl: config.streamUrl });
|
||||
return `/api/widgets/camera?${params}&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
async function fetchSnapshot() {
|
||||
error = false;
|
||||
try {
|
||||
const url = buildProxyUrl();
|
||||
// Pre-validate the response
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
if (imgSrc) URL.revokeObjectURL(imgSrc);
|
||||
imgSrc = URL.createObjectURL(blob);
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
} catch {
|
||||
error = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (streamType === 'image') {
|
||||
fetchSnapshot();
|
||||
} else if (streamType === 'mjpeg') {
|
||||
// MJPEG streams directly via img src
|
||||
imgSrc = config.streamUrl;
|
||||
loading = false;
|
||||
} else if (streamType === 'hls') {
|
||||
loading = false;
|
||||
// HLS setup via $effect below
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (imgSrc && streamType === 'image') {
|
||||
URL.revokeObjectURL(imgSrc);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Auto-refresh for snapshot mode
|
||||
$effect(() => {
|
||||
if (streamType !== 'image') return;
|
||||
const interval = setInterval(fetchSnapshot, refreshMs);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
// HLS.js lazy loading
|
||||
$effect(() => {
|
||||
if (streamType !== 'hls' || !videoEl) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let hls: any = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { default: Hls } = await import('hls.js');
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(config.streamUrl);
|
||||
hls.attachMedia(videoEl!);
|
||||
} else if (videoEl!.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoEl!.src = config.streamUrl;
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
} catch {
|
||||
// HLS.js not available — try native
|
||||
if (videoEl!.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoEl!.src = config.streamUrl;
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function handleImgError() {
|
||||
error = true;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleImgLoad() {
|
||||
loading = false;
|
||||
error = false;
|
||||
}
|
||||
|
||||
function openFullscreen() {
|
||||
fullscreen = true;
|
||||
}
|
||||
|
||||
function closeFullscreen() {
|
||||
fullscreen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card overflow-hidden">
|
||||
<!-- Stream view -->
|
||||
<div
|
||||
class="relative w-full bg-black"
|
||||
style="aspect-ratio: {aspectRatio}"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-muted/80">
|
||||
<AlertCircle class="h-8 w-8 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">Stream unavailable</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if streamType === 'hls'}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
class="h-full w-full object-contain"
|
||||
></video>
|
||||
{:else}
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt="Camera stream"
|
||||
class="h-full w-full object-contain {loading ? 'opacity-0' : 'opacity-100'} transition-opacity"
|
||||
onload={handleImgLoad}
|
||||
onerror={handleImgError}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Fullscreen button overlay -->
|
||||
{#if !error}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openFullscreen}
|
||||
class="absolute bottom-2 right-2 rounded-md bg-black/50 p-1.5 text-white/80 transition-colors hover:bg-black/70 hover:text-white"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen modal -->
|
||||
{#if fullscreen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
onclick={closeFullscreen}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeFullscreen()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeFullscreen}
|
||||
class="absolute right-4 top-4 rounded-md p-2 text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
<X class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="max-h-[90vh] max-w-[90vw]" onclick={(e) => e.stopPropagation()}>
|
||||
{#if streamType === 'hls'}
|
||||
<video
|
||||
src={config.streamUrl}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
controls
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
></video>
|
||||
{:else}
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt="Camera stream fullscreen"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Cloud, Sun, CloudRain, CloudSnow, CloudLightning, CloudDrizzle, Wind, Thermometer } from 'lucide-svelte';
|
||||
import type { ClockWeatherWidgetConfig } from '$lib/types/widget.js';
|
||||
|
||||
interface Props {
|
||||
config: ClockWeatherWidgetConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
let now = $state(new Date());
|
||||
let weatherData: { temp: number; condition: string; location?: string } | null = $state(null);
|
||||
let weatherError = $state(false);
|
||||
let weatherLoading = $state(false);
|
||||
|
||||
const clockStyle = $derived(config.clockStyle ?? 'digital');
|
||||
const showWeather = $derived(config.showWeather ?? false);
|
||||
|
||||
const timeFormatter = $derived(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: clockStyle !== '24h',
|
||||
timeZone: config.timezone || undefined
|
||||
})
|
||||
);
|
||||
|
||||
const dateFormatter = $derived(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: config.timezone || undefined
|
||||
})
|
||||
);
|
||||
|
||||
const timeStr = $derived(timeFormatter.format(now));
|
||||
const dateStr = $derived(dateFormatter.format(now));
|
||||
|
||||
// Analog clock hand angles
|
||||
const hours = $derived.by(() => {
|
||||
const d = new Date(now.toLocaleString('en-US', { timeZone: config.timezone || undefined }));
|
||||
return d.getHours() % 12;
|
||||
});
|
||||
const minutes = $derived.by(() => {
|
||||
const d = new Date(now.toLocaleString('en-US', { timeZone: config.timezone || undefined }));
|
||||
return d.getMinutes();
|
||||
});
|
||||
const seconds = $derived.by(() => {
|
||||
const d = new Date(now.toLocaleString('en-US', { timeZone: config.timezone || undefined }));
|
||||
return d.getSeconds();
|
||||
});
|
||||
|
||||
const hourAngle = $derived((hours + minutes / 60) * 30);
|
||||
const minuteAngle = $derived((minutes + seconds / 60) * 6);
|
||||
const secondAngle = $derived(seconds * 6);
|
||||
|
||||
async function fetchWeather() {
|
||||
if (!showWeather || !config.latitude || !config.longitude) return;
|
||||
weatherLoading = true;
|
||||
weatherError = false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/widgets/weather?lat=${config.latitude}&lng=${config.longitude}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
weatherData = json.data;
|
||||
}
|
||||
} else {
|
||||
weatherError = true;
|
||||
}
|
||||
} catch {
|
||||
weatherError = true;
|
||||
} finally {
|
||||
weatherLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchWeather();
|
||||
});
|
||||
|
||||
// Tick clock every second
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = new Date();
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
// Refresh weather every 30 minutes
|
||||
$effect(() => {
|
||||
if (!showWeather) return;
|
||||
const interval = setInterval(fetchWeather, 30 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
function getWeatherIcon(condition: string) {
|
||||
const c = condition.toLowerCase();
|
||||
if (c.includes('rain')) return CloudRain;
|
||||
if (c.includes('drizzle')) return CloudDrizzle;
|
||||
if (c.includes('snow')) return CloudSnow;
|
||||
if (c.includes('thunder') || c.includes('lightning')) return CloudLightning;
|
||||
if (c.includes('wind')) return Wind;
|
||||
if (c.includes('cloud') || c.includes('overcast')) return Cloud;
|
||||
return Sun;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
|
||||
{#if clockStyle === 'analog'}
|
||||
<!-- Analog clock face -->
|
||||
<svg viewBox="0 0 100 100" class="h-32 w-32">
|
||||
<!-- Clock face -->
|
||||
<circle cx="50" cy="50" r="48" fill="none" stroke="currentColor" stroke-width="1.5" class="text-border" />
|
||||
<!-- Hour markers -->
|
||||
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
|
||||
{#each {length: 12} as _, i (i)}
|
||||
{@const angle = (i * 30 * Math.PI) / 180}
|
||||
{@const x1 = 50 + 42 * Math.sin(angle)}
|
||||
{@const y1 = 50 - 42 * Math.cos(angle)}
|
||||
{@const x2 = 50 + 46 * Math.sin(angle)}
|
||||
{@const y2 = 50 - 46 * Math.cos(angle)}
|
||||
<line {x1} {y1} {x2} {y2} stroke="currentColor" stroke-width={i % 3 === 0 ? '2' : '1'} class="text-foreground" />
|
||||
{/each}
|
||||
<!-- Hour hand -->
|
||||
<line
|
||||
x1="50" y1="50"
|
||||
x2={50 + 24 * Math.sin((hourAngle * Math.PI) / 180)}
|
||||
y2={50 - 24 * Math.cos((hourAngle * Math.PI) / 180)}
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="text-foreground"
|
||||
/>
|
||||
<!-- Minute hand -->
|
||||
<line
|
||||
x1="50" y1="50"
|
||||
x2={50 + 34 * Math.sin((minuteAngle * Math.PI) / 180)}
|
||||
y2={50 - 34 * Math.cos((minuteAngle * Math.PI) / 180)}
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" class="text-foreground"
|
||||
/>
|
||||
<!-- Second hand -->
|
||||
<line
|
||||
x1="50" y1="50"
|
||||
x2={50 + 38 * Math.sin((secondAngle * Math.PI) / 180)}
|
||||
y2={50 - 38 * Math.cos((secondAngle * Math.PI) / 180)}
|
||||
stroke="currentColor" stroke-width="0.8" stroke-linecap="round" class="text-primary"
|
||||
/>
|
||||
<!-- Center dot -->
|
||||
<circle cx="50" cy="50" r="2" fill="currentColor" class="text-primary" />
|
||||
</svg>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{dateStr}</p>
|
||||
{:else}
|
||||
<!-- Digital clock -->
|
||||
<p class="font-mono text-4xl font-bold tabular-nums text-foreground">{timeStr}</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{dateStr}</p>
|
||||
{#if config.timezone}
|
||||
<p class="mt-0.5 text-xs text-muted-foreground/70">{config.timezone.replace(/_/g, ' ')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Weather section -->
|
||||
{#if showWeather}
|
||||
<div class="mt-3 flex items-center gap-2 border-t border-border pt-3">
|
||||
{#if weatherLoading}
|
||||
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
|
||||
{:else if weatherError}
|
||||
<span class="text-xs text-muted-foreground">Weather unavailable</span>
|
||||
{:else if weatherData}
|
||||
{@const WeatherIcon = getWeatherIcon(weatherData.condition)}
|
||||
<WeatherIcon class="h-5 w-5 text-muted-foreground" />
|
||||
<span class="text-lg font-semibold text-foreground">{Math.round(weatherData.temp)}°</span>
|
||||
<span class="text-xs text-muted-foreground">{weatherData.condition}</span>
|
||||
{:else}
|
||||
<Thermometer class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">No weather data</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { ExternalLink, ChevronDown, Link } from 'lucide-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import type { LinkGroupWidgetConfig } from '$lib/types/widget.js';
|
||||
|
||||
interface Props {
|
||||
config: LinkGroupWidgetConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
let collapsed = $state(false);
|
||||
|
||||
const isCollapsible = $derived(config.collapsible ?? false);
|
||||
const links = $derived(config.links ?? []);
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<!-- Header -->
|
||||
{#if isCollapsible}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (collapsed = !collapsed)}
|
||||
class="mb-2 flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Link class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">Links</span>
|
||||
<span class="text-xs text-muted-foreground">({links.length})</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform {collapsed ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<Link class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">Links</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Links list -->
|
||||
{#if !collapsed}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
{#if links.length === 0}
|
||||
<p class="text-xs text-muted-foreground">No links configured</p>
|
||||
{:else}
|
||||
<div class="space-y-0.5">
|
||||
{#each links as link (link.url + link.label)}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-muted/50 hover:text-primary"
|
||||
>
|
||||
{#if link.icon}
|
||||
<span class="flex-shrink-0 text-base">{link.icon}</span>
|
||||
{:else}
|
||||
<ExternalLink class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">{link.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { Pencil, Eye } from 'lucide-svelte';
|
||||
import type { MarkdownWidgetConfig } from '$lib/types/widget.js';
|
||||
|
||||
interface Props {
|
||||
config: MarkdownWidgetConfig;
|
||||
widgetId?: string;
|
||||
}
|
||||
|
||||
let { config, widgetId }: Props = $props();
|
||||
|
||||
let editMode = $state(false);
|
||||
let editContent = $state(config.content ?? '');
|
||||
let saving = $state(false);
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
const renderedHtml = $derived.by(() => {
|
||||
const source = editMode ? editContent : (config.content ?? '');
|
||||
const raw = marked.parse(source, { async: false }) as string;
|
||||
return DOMPurify.sanitize(raw);
|
||||
});
|
||||
|
||||
async function saveContent() {
|
||||
if (!widgetId) return;
|
||||
saving = true;
|
||||
try {
|
||||
const newConfig = { ...config, content: editContent };
|
||||
await fetch(`/api/widgets/${widgetId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: JSON.stringify(newConfig) })
|
||||
});
|
||||
} catch {
|
||||
// Silently fail — user can retry
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (editMode) {
|
||||
saveContent();
|
||||
} else {
|
||||
editContent = config.content ?? '';
|
||||
}
|
||||
editMode = !editMode;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-end border-b border-border px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleEdit}
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
disabled={saving}
|
||||
>
|
||||
{#if editMode}
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
<span>{saving ? 'Saving...' : 'Preview'}</span>
|
||||
{:else}
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
<span>Edit</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if editMode}
|
||||
<!-- Split pane: editor + preview -->
|
||||
<div class="flex flex-1 divide-x divide-border overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<textarea
|
||||
bind:value={editContent}
|
||||
class="h-full w-full resize-none border-0 bg-background p-3 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
placeholder="Write markdown here..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
<div class="prose prose-sm prose-invert max-w-none text-foreground">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- View mode -->
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="prose prose-sm prose-invert max-w-none text-foreground">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-svelte';
|
||||
import type { MetricWidgetConfig } from '$lib/types/widget.js';
|
||||
|
||||
interface Props {
|
||||
config: MetricWidgetConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
let currentValue: number | null = $state(null);
|
||||
let trend: 'up' | 'down' | 'flat' | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
|
||||
const refreshMs = $derived((config.refreshInterval ?? 60) * 1000);
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (Math.abs(n) >= 1_000_000) {
|
||||
return (n / 1_000_000).toFixed(1) + 'M';
|
||||
}
|
||||
if (Math.abs(n) >= 1_000) {
|
||||
return (n / 1_000).toFixed(1) + 'K';
|
||||
}
|
||||
// Use locale formatting for smaller numbers
|
||||
return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function fetchMetric() {
|
||||
error = false;
|
||||
try {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const params = new URLSearchParams({ source: config.source });
|
||||
if (config.value) params.set('value', config.value);
|
||||
if (config.url) params.set('url', config.url);
|
||||
if (config.jsonPath) params.set('jsonPath', config.jsonPath);
|
||||
if (config.query) params.set('query', config.query);
|
||||
|
||||
const res = await fetch(`/api/widgets/metric?${params}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
currentValue = json.data.value;
|
||||
trend = json.data.trend ?? null;
|
||||
}
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
} catch {
|
||||
error = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchMetric();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(fetchMetric, refreshMs);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
const trendColor = $derived.by(() => {
|
||||
if (trend === 'up') return 'text-green-500';
|
||||
if (trend === 'down') return 'text-red-500';
|
||||
return 'text-muted-foreground';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="h-10 w-24 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<span class="text-xs text-muted-foreground">Failed to load metric</span>
|
||||
{:else if currentValue !== null}
|
||||
<!-- Trend arrow -->
|
||||
<div class="mb-1 {trendColor}">
|
||||
{#if trend === 'up'}
|
||||
<TrendingUp class="h-5 w-5" />
|
||||
{:else if trend === 'down'}
|
||||
<TrendingDown class="h-5 w-5" />
|
||||
{:else}
|
||||
<Minus class="h-5 w-5" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Big number -->
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-bold tabular-nums text-foreground">
|
||||
{formatNumber(currentValue)}
|
||||
</span>
|
||||
{#if config.unit}
|
||||
<span class="text-lg text-muted-foreground">{config.unit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<p class="mt-1 text-sm text-muted-foreground">{config.label}</p>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">No data</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,148 @@
|
||||
<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>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { SystemStatsWidgetConfig } from '$lib/types/widget.js';
|
||||
|
||||
interface Props {
|
||||
config: SystemStatsWidgetConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
interface MetricData {
|
||||
metric: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
let metrics: MetricData[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
|
||||
const refreshMs = $derived((config.refreshInterval ?? 30) * 1000);
|
||||
|
||||
function thresholdColor(value: number): string {
|
||||
if (value >= 85) return 'text-red-500';
|
||||
if (value >= 60) return 'text-yellow-500';
|
||||
return 'text-green-500';
|
||||
}
|
||||
|
||||
function thresholdStroke(value: number): string {
|
||||
if (value >= 85) return 'stroke-red-500';
|
||||
if (value >= 60) return 'stroke-yellow-500';
|
||||
return 'stroke-green-500';
|
||||
}
|
||||
|
||||
function thresholdTrack(_value: number): string {
|
||||
return 'stroke-muted';
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
error = false;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
sourceUrl: config.sourceUrl,
|
||||
sourceType: config.sourceType
|
||||
});
|
||||
const res = await fetch(`/api/widgets/system-stats?${params}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
// Filter to only configured metrics if specified
|
||||
const allMetrics: MetricData[] = json.data;
|
||||
metrics =
|
||||
config.metrics.length > 0
|
||||
? allMetrics.filter((m) =>
|
||||
config.metrics.some((cm) => m.metric.toLowerCase().includes(cm.toLowerCase()))
|
||||
)
|
||||
: allMetrics;
|
||||
}
|
||||
} else {
|
||||
error = true;
|
||||
}
|
||||
} catch {
|
||||
error = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchStats();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(fetchStats, refreshMs);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
// SVG donut chart constants
|
||||
const RADIUS = 36;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<span class="mb-3 text-sm font-medium text-foreground">System Stats</span>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex flex-1 items-center justify-center gap-4">
|
||||
{#each [1, 2, 3] as _n (_n)}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="h-20 w-20 animate-pulse rounded-full bg-muted"></div>
|
||||
<div class="h-3 w-12 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 stats</span>
|
||||
</div>
|
||||
{:else if metrics.length === 0}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<span class="text-xs text-muted-foreground">No metrics available</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-wrap items-center justify-center gap-4">
|
||||
{#each metrics as m (m.metric)}
|
||||
{@const pct = Math.min(100, Math.max(0, m.value))}
|
||||
{@const dashOffset = CIRCUMFERENCE - (pct / 100) * CIRCUMFERENCE}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="relative h-20 w-20">
|
||||
<svg viewBox="0 0 80 80" class="h-full w-full -rotate-90">
|
||||
<!-- Track -->
|
||||
<circle
|
||||
cx="40" cy="40" r={RADIUS}
|
||||
fill="none"
|
||||
stroke-width="6"
|
||||
class={thresholdTrack(pct)}
|
||||
/>
|
||||
<!-- Value arc -->
|
||||
<circle
|
||||
cx="40" cy="40" r={RADIUS}
|
||||
fill="none"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={CIRCUMFERENCE}
|
||||
stroke-dashoffset={dashOffset}
|
||||
class={thresholdStroke(pct)}
|
||||
style="transition: stroke-dashoffset 0.5s ease"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Center text -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-sm font-bold {thresholdColor(pct)}">
|
||||
{Math.round(pct)}{m.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground capitalize">{m.metric}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -35,12 +35,69 @@
|
||||
let statusLabel = $state('');
|
||||
let statusAppIds = $state<string[]>([]);
|
||||
|
||||
// Clock/Weather fields
|
||||
let clockTimezone = $state('');
|
||||
let clockStyle = $state<'digital' | 'analog' | '24h'>('digital');
|
||||
let clockShowWeather = $state(false);
|
||||
let clockLatitude = $state('');
|
||||
let clockLongitude = $state('');
|
||||
|
||||
// System Stats fields
|
||||
let sysStatsSourceUrl = $state('');
|
||||
let sysStatsSourceType = $state<'glances' | 'prometheus' | 'custom'>('custom');
|
||||
let sysStatsMetrics = $state<string[]>(['cpu', 'ram', 'disk']);
|
||||
let sysStatsRefreshInterval = $state(30);
|
||||
|
||||
// RSS fields
|
||||
let rssFeedUrl = $state('');
|
||||
let rssMaxItems = $state(10);
|
||||
let rssShowSummary = $state(true);
|
||||
|
||||
// Calendar fields
|
||||
let calendarUrls = $state<Array<{ url: string; color: string; label: string }>>([
|
||||
{ url: '', color: '#6366f1', label: '' }
|
||||
]);
|
||||
let calendarDaysAhead = $state(7);
|
||||
|
||||
// Markdown fields
|
||||
let markdownContent = $state('');
|
||||
|
||||
// Metric fields
|
||||
let metricLabel = $state('');
|
||||
let metricSource = $state<'static' | 'json' | 'prometheus'>('static');
|
||||
let metricValue = $state('');
|
||||
let metricUrl = $state('');
|
||||
let metricJsonPath = $state('');
|
||||
let metricQuery = $state('');
|
||||
let metricUnit = $state('');
|
||||
let metricRefreshInterval = $state(60);
|
||||
|
||||
// Link Group fields
|
||||
let linkGroupLinks = $state<Array<{ label: string; url: string; icon: string }>>([
|
||||
{ label: '', url: '', icon: '' }
|
||||
]);
|
||||
let linkGroupCollapsible = $state(false);
|
||||
|
||||
// Camera fields
|
||||
let cameraStreamUrl = $state('');
|
||||
let cameraType = $state<'image' | 'mjpeg' | 'hls'>('image');
|
||||
let cameraRefreshInterval = $state(10);
|
||||
let cameraAspectRatio = $state('16/9');
|
||||
|
||||
const widgetTypeItems: IconGridItem[] = [
|
||||
{ value: 'app', icon: '🖥️', label: 'App' },
|
||||
{ value: 'bookmark', icon: '🔖', label: 'Bookmark' },
|
||||
{ value: 'note', icon: '📝', label: 'Note' },
|
||||
{ value: 'embed', icon: '🧩', label: 'Embed' },
|
||||
{ value: 'status', icon: '📊', label: 'Status' }
|
||||
{ value: 'status', icon: '📊', label: 'Status' },
|
||||
{ value: 'clock', icon: '🕐', label: 'Clock' },
|
||||
{ value: 'system_stats', icon: '💻', label: 'System' },
|
||||
{ value: 'rss', icon: '📡', label: 'RSS' },
|
||||
{ value: 'calendar', icon: '📅', label: 'Calendar' },
|
||||
{ value: 'markdown', icon: '📄', label: 'Markdown' },
|
||||
{ value: 'metric', icon: '📈', label: 'Metric' },
|
||||
{ value: 'link_group', icon: '🔗', label: 'Links' },
|
||||
{ value: 'camera', icon: '📷', label: 'Camera' }
|
||||
];
|
||||
|
||||
const noteFormatItems: IconGridItem[] = [
|
||||
@@ -48,6 +105,18 @@
|
||||
{ value: 'text', icon: '📄', label: 'Plain Text' }
|
||||
];
|
||||
|
||||
const clockStyleItems: IconGridItem[] = [
|
||||
{ value: 'digital', icon: '🔢', label: 'Digital' },
|
||||
{ value: 'analog', icon: '🕐', label: 'Analog' },
|
||||
{ value: '24h', icon: '⏰', label: '24h' }
|
||||
];
|
||||
|
||||
const metricSourceItems: IconGridItem[] = [
|
||||
{ value: 'static', icon: '📌', label: 'Static' },
|
||||
{ value: 'json', icon: '🔗', label: 'JSON' },
|
||||
{ value: 'prometheus', icon: '📊', label: 'Prometheus' }
|
||||
];
|
||||
|
||||
const appPickerItems: EntityPickerItem[] = $derived(
|
||||
apps.map((app) => ({
|
||||
value: app.id,
|
||||
@@ -68,6 +137,35 @@
|
||||
embedHeight = 300;
|
||||
statusLabel = '';
|
||||
statusAppIds = [];
|
||||
clockTimezone = '';
|
||||
clockStyle = 'digital';
|
||||
clockShowWeather = false;
|
||||
clockLatitude = '';
|
||||
clockLongitude = '';
|
||||
sysStatsSourceUrl = '';
|
||||
sysStatsSourceType = 'custom';
|
||||
sysStatsMetrics = ['cpu', 'ram', 'disk'];
|
||||
sysStatsRefreshInterval = 30;
|
||||
rssFeedUrl = '';
|
||||
rssMaxItems = 10;
|
||||
rssShowSummary = true;
|
||||
calendarUrls = [{ url: '', color: '#6366f1', label: '' }];
|
||||
calendarDaysAhead = 7;
|
||||
markdownContent = '';
|
||||
metricLabel = '';
|
||||
metricSource = 'static';
|
||||
metricValue = '';
|
||||
metricUrl = '';
|
||||
metricJsonPath = '';
|
||||
metricQuery = '';
|
||||
metricUnit = '';
|
||||
metricRefreshInterval = 60;
|
||||
linkGroupLinks = [{ label: '', url: '', icon: '' }];
|
||||
linkGroupCollapsible = false;
|
||||
cameraStreamUrl = '';
|
||||
cameraType = 'image';
|
||||
cameraRefreshInterval = 10;
|
||||
cameraAspectRatio = '16/9';
|
||||
}
|
||||
|
||||
function handleSubmitWidget() {
|
||||
@@ -100,6 +198,73 @@
|
||||
widgetData.appIds = statusAppIds;
|
||||
if (statusLabel) widgetData.label = statusLabel;
|
||||
break;
|
||||
case 'clock':
|
||||
if (clockTimezone) widgetData.timezone = clockTimezone;
|
||||
widgetData.clockStyle = clockStyle;
|
||||
widgetData.showWeather = clockShowWeather;
|
||||
if (clockShowWeather && clockLatitude && clockLongitude) {
|
||||
widgetData.latitude = parseFloat(clockLatitude);
|
||||
widgetData.longitude = parseFloat(clockLongitude);
|
||||
}
|
||||
break;
|
||||
case 'system_stats':
|
||||
if (!sysStatsSourceUrl) return;
|
||||
widgetData.sourceUrl = sysStatsSourceUrl;
|
||||
widgetData.sourceType = sysStatsSourceType;
|
||||
widgetData.metrics = sysStatsMetrics;
|
||||
widgetData.refreshInterval = sysStatsRefreshInterval;
|
||||
break;
|
||||
case 'rss':
|
||||
if (!rssFeedUrl) return;
|
||||
widgetData.feedUrl = rssFeedUrl;
|
||||
widgetData.maxItems = rssMaxItems;
|
||||
widgetData.showSummary = rssShowSummary;
|
||||
break;
|
||||
case 'calendar': {
|
||||
const validUrls = calendarUrls.filter((c) => c.url.trim() !== '');
|
||||
if (validUrls.length === 0) return;
|
||||
widgetData.icalUrls = validUrls;
|
||||
widgetData.daysAhead = calendarDaysAhead;
|
||||
break;
|
||||
}
|
||||
case 'markdown':
|
||||
if (!markdownContent) return;
|
||||
widgetData.content = markdownContent;
|
||||
break;
|
||||
case 'metric':
|
||||
if (!metricLabel) return;
|
||||
widgetData.label = metricLabel;
|
||||
widgetData.source = metricSource;
|
||||
if (metricSource === 'static') widgetData.value = metricValue;
|
||||
if (metricSource === 'json') {
|
||||
widgetData.url = metricUrl;
|
||||
widgetData.jsonPath = metricJsonPath;
|
||||
}
|
||||
if (metricSource === 'prometheus') {
|
||||
widgetData.url = metricUrl;
|
||||
widgetData.query = metricQuery;
|
||||
}
|
||||
if (metricUnit) widgetData.unit = metricUnit;
|
||||
widgetData.refreshInterval = metricRefreshInterval;
|
||||
break;
|
||||
case 'link_group': {
|
||||
const validLinks = linkGroupLinks.filter((l) => l.label.trim() && l.url.trim());
|
||||
if (validLinks.length === 0) return;
|
||||
widgetData.links = validLinks.map((l) => ({
|
||||
label: l.label,
|
||||
url: l.url,
|
||||
...(l.icon ? { icon: l.icon } : {})
|
||||
}));
|
||||
widgetData.collapsible = linkGroupCollapsible;
|
||||
break;
|
||||
}
|
||||
case 'camera':
|
||||
if (!cameraStreamUrl) return;
|
||||
widgetData.streamUrl = cameraStreamUrl;
|
||||
widgetData.type = cameraType;
|
||||
widgetData.refreshInterval = cameraRefreshInterval;
|
||||
widgetData.aspectRatio = cameraAspectRatio;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -115,6 +280,34 @@
|
||||
statusAppIds = [...statusAppIds, appId];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSysStatsMetric(metric: string) {
|
||||
if (sysStatsMetrics.includes(metric)) {
|
||||
sysStatsMetrics = sysStatsMetrics.filter((m) => m !== metric);
|
||||
} else {
|
||||
sysStatsMetrics = [...sysStatsMetrics, metric];
|
||||
}
|
||||
}
|
||||
|
||||
function addCalendarUrl() {
|
||||
calendarUrls = [...calendarUrls, { url: '', color: '#6366f1', label: '' }];
|
||||
}
|
||||
|
||||
function removeCalendarUrl(index: number) {
|
||||
calendarUrls = calendarUrls.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function addLinkGroupLink() {
|
||||
linkGroupLinks = [...linkGroupLinks, { label: '', url: '', icon: '' }];
|
||||
}
|
||||
|
||||
function removeLinkGroupLink(index: number) {
|
||||
linkGroupLinks = linkGroupLinks.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
// Input CSS class for reuse
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
|
||||
</script>
|
||||
|
||||
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
||||
@@ -152,7 +345,7 @@
|
||||
type="url"
|
||||
bind:value={bookmarkUrl}
|
||||
placeholder="https://example.com"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -163,7 +356,7 @@
|
||||
type="text"
|
||||
bind:value={bookmarkLabel}
|
||||
placeholder="My Bookmark"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -174,7 +367,7 @@
|
||||
type="text"
|
||||
bind:value={bookmarkIcon}
|
||||
placeholder="e.g. an emoji or icon name"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -184,7 +377,7 @@
|
||||
type="text"
|
||||
bind:value={bookmarkDescription}
|
||||
placeholder="A short description"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,7 +398,7 @@
|
||||
bind:value={noteContent}
|
||||
rows="4"
|
||||
placeholder="Write your note here..."
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -219,7 +412,7 @@
|
||||
type="url"
|
||||
bind:value={embedUrl}
|
||||
placeholder="https://example.com/embed"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -231,7 +424,7 @@
|
||||
bind:value={embedHeight}
|
||||
min="100"
|
||||
max="2000"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,7 +437,7 @@
|
||||
type="text"
|
||||
bind:value={statusLabel}
|
||||
placeholder="e.g. Production Services"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -267,6 +460,463 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === NEW WIDGET TYPE FORMS === -->
|
||||
|
||||
{:else if selectedWidgetType === 'clock'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-foreground">Clock Style</label>
|
||||
<IconGrid
|
||||
items={clockStyleItems}
|
||||
bind:value={clockStyle}
|
||||
columns={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="clock-tz-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Timezone (optional)</label>
|
||||
<input
|
||||
id="clock-tz-{sectionId}"
|
||||
type="text"
|
||||
bind:value={clockTimezone}
|
||||
placeholder="e.g. America/New_York"
|
||||
class={inputClass}
|
||||
/>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={clockShowWeather}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
Show Weather
|
||||
</label>
|
||||
</div>
|
||||
{#if clockShowWeather}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="clock-lat-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Latitude</label>
|
||||
<input
|
||||
id="clock-lat-{sectionId}"
|
||||
type="text"
|
||||
bind:value={clockLatitude}
|
||||
placeholder="e.g. 40.7128"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="clock-lng-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Longitude</label>
|
||||
<input
|
||||
id="clock-lng-{sectionId}"
|
||||
type="text"
|
||||
bind:value={clockLongitude}
|
||||
placeholder="e.g. -74.0060"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedWidgetType === 'system_stats'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="sys-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source URL</label>
|
||||
<input
|
||||
id="sys-url-{sectionId}"
|
||||
type="url"
|
||||
bind:value={sysStatsSourceUrl}
|
||||
placeholder="https://your-server:61208/api/3"
|
||||
class={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
|
||||
<select
|
||||
id="sys-type-{sectionId}"
|
||||
bind:value={sysStatsSourceType}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="glances">Glances</option>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="custom">Custom JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-foreground">Metrics</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)}
|
||||
<label class="flex items-center gap-1.5 rounded-md border border-input px-2 py-1 text-sm text-foreground hover:bg-accent">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sysStatsMetrics.includes(metric)}
|
||||
onchange={() => toggleSysStatsMetric(metric)}
|
||||
class="h-3.5 w-3.5 rounded border-input accent-primary"
|
||||
/>
|
||||
<span class="capitalize">{metric}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Refresh Interval: {sysStatsRefreshInterval}s
|
||||
</label>
|
||||
<input
|
||||
id="sys-refresh-{sectionId}"
|
||||
type="range"
|
||||
bind:value={sysStatsRefreshInterval}
|
||||
min="5"
|
||||
max="300"
|
||||
step="5"
|
||||
class="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if selectedWidgetType === 'rss'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="rss-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Feed URL</label>
|
||||
<input
|
||||
id="rss-url-{sectionId}"
|
||||
type="url"
|
||||
bind:value={rssFeedUrl}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
class={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Max Items: {rssMaxItems}
|
||||
</label>
|
||||
<input
|
||||
id="rss-max-{sectionId}"
|
||||
type="range"
|
||||
bind:value={rssMaxItems}
|
||||
min="3"
|
||||
max="30"
|
||||
step="1"
|
||||
class="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={rssShowSummary}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
Show Summaries
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if selectedWidgetType === 'calendar'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-foreground">iCal URLs</span>
|
||||
<div class="space-y-2">
|
||||
{#each calendarUrls as _cal, i (i)}
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 space-y-1">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={calendarUrls[i].url}
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
class={inputClass}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={calendarUrls[i].label}
|
||||
placeholder="Label (optional)"
|
||||
class="{inputClass} flex-1"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
bind:value={calendarUrls[i].color}
|
||||
class="h-9 w-9 cursor-pointer rounded-lg border border-input bg-background"
|
||||
title="Calendar color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if calendarUrls.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeCalendarUrl(i)}
|
||||
class="mt-2 text-xs text-muted-foreground transition-colors hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={addCalendarUrl}
|
||||
class="mt-1 text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
+ Add calendar
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Days Ahead: {calendarDaysAhead}
|
||||
</label>
|
||||
<input
|
||||
id="cal-days-{sectionId}"
|
||||
type="range"
|
||||
bind:value={calendarDaysAhead}
|
||||
min="1"
|
||||
max="30"
|
||||
step="1"
|
||||
class="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if selectedWidgetType === 'markdown'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="md-content-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Markdown Content</label>
|
||||
<textarea
|
||||
id="md-content-{sectionId}"
|
||||
bind:value={markdownContent}
|
||||
rows="8"
|
||||
placeholder="# Hello World Write your markdown here..."
|
||||
class="{inputClass} font-mono"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if selectedWidgetType === 'metric'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="metric-label-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Label</label>
|
||||
<input
|
||||
id="metric-label-{sectionId}"
|
||||
type="text"
|
||||
bind:value={metricLabel}
|
||||
placeholder="e.g. Active Users"
|
||||
class={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
|
||||
<IconGrid
|
||||
items={metricSourceItems}
|
||||
bind:value={metricSource}
|
||||
columns={3}
|
||||
/>
|
||||
</div>
|
||||
{#if metricSource === 'static'}
|
||||
<div>
|
||||
<label for="metric-val-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Value</label>
|
||||
<input
|
||||
id="metric-val-{sectionId}"
|
||||
type="text"
|
||||
bind:value={metricValue}
|
||||
placeholder="e.g. 42"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
{:else if metricSource === 'json'}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="metric-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">JSON URL</label>
|
||||
<input
|
||||
id="metric-url-{sectionId}"
|
||||
type="url"
|
||||
bind:value={metricUrl}
|
||||
placeholder="https://api.example.com/stats"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="metric-path-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">JSON Path</label>
|
||||
<input
|
||||
id="metric-path-{sectionId}"
|
||||
type="text"
|
||||
bind:value={metricJsonPath}
|
||||
placeholder="e.g. data.count"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if metricSource === 'prometheus'}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="metric-prom-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Prometheus URL</label>
|
||||
<input
|
||||
id="metric-prom-url-{sectionId}"
|
||||
type="url"
|
||||
bind:value={metricUrl}
|
||||
placeholder="https://prometheus.example.com"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="metric-query-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">PromQL Query</label>
|
||||
<input
|
||||
id="metric-query-{sectionId}"
|
||||
type="text"
|
||||
bind:value={metricQuery}
|
||||
placeholder='e.g. sum(rate(http_requests_total[5m]))'
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="metric-unit-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Unit (optional)</label>
|
||||
<input
|
||||
id="metric-unit-{sectionId}"
|
||||
type="text"
|
||||
bind:value={metricUnit}
|
||||
placeholder="e.g. req/s, %, ms"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Refresh: {metricRefreshInterval}s
|
||||
</label>
|
||||
<input
|
||||
id="metric-refresh-{sectionId}"
|
||||
type="range"
|
||||
bind:value={metricRefreshInterval}
|
||||
min="10"
|
||||
max="600"
|
||||
step="10"
|
||||
class="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if selectedWidgetType === 'link_group'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-foreground">Links</span>
|
||||
<div class="space-y-2">
|
||||
{#each linkGroupLinks as _link, i (i)}
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 grid gap-2 sm:grid-cols-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={linkGroupLinks[i].label}
|
||||
placeholder="Label"
|
||||
class={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={linkGroupLinks[i].url}
|
||||
placeholder="https://..."
|
||||
class={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={linkGroupLinks[i].icon}
|
||||
placeholder="Icon (emoji)"
|
||||
class={inputClass}
|
||||
/>
|
||||
</div>
|
||||
{#if linkGroupLinks.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeLinkGroupLink(i)}
|
||||
class="mt-2 text-xs text-muted-foreground transition-colors hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={addLinkGroupLink}
|
||||
class="mt-1 text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
+ Add link
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={linkGroupCollapsible}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
Collapsible
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if selectedWidgetType === 'camera'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="cam-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream URL</label>
|
||||
<input
|
||||
id="cam-url-{sectionId}"
|
||||
type="url"
|
||||
bind:value={cameraStreamUrl}
|
||||
placeholder="https://camera.example.com/stream"
|
||||
class={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label>
|
||||
<select
|
||||
id="cam-type-{sectionId}"
|
||||
bind:value={cameraType}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="image">Snapshot (Image)</option>
|
||||
<option value="mjpeg">MJPEG Stream</option>
|
||||
<option value="hls">HLS Stream</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Refresh: {cameraRefreshInterval}s
|
||||
</label>
|
||||
<input
|
||||
id="cam-refresh-{sectionId}"
|
||||
type="range"
|
||||
bind:value={cameraRefreshInterval}
|
||||
min="1"
|
||||
max="120"
|
||||
step="1"
|
||||
class="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label>
|
||||
<select
|
||||
id="cam-ratio-{sectionId}"
|
||||
bind:value={cameraAspectRatio}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="16/9">16:9</option>
|
||||
<option value="4/3">4:3</option>
|
||||
<option value="1/1">1:1</option>
|
||||
<option value="21/9">21:9</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import WidgetRenderer from './WidgetRenderer.svelte';
|
||||
import WidgetContainer from './WidgetContainer.svelte';
|
||||
import type { CardSize } from '$lib/utils/constants.js';
|
||||
|
||||
interface AppData {
|
||||
id: string;
|
||||
@@ -25,23 +26,47 @@
|
||||
interface Props {
|
||||
widgets: WidgetData[];
|
||||
allApps?: AppData[];
|
||||
cardSize?: CardSize;
|
||||
}
|
||||
|
||||
let { widgets, allApps = [] }: Props = $props();
|
||||
let { widgets, allApps = [], cardSize = 'medium' }: Props = $props();
|
||||
|
||||
// Widgets that should span full width
|
||||
const fullWidthTypes = new Set(['note', 'embed', 'status']);
|
||||
const fullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']);
|
||||
|
||||
// Grid column classes based on card size
|
||||
const gridClass = $derived.by(() => {
|
||||
switch (cardSize) {
|
||||
case 'compact':
|
||||
return 'grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6';
|
||||
case 'large':
|
||||
return 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3';
|
||||
default:
|
||||
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4';
|
||||
}
|
||||
});
|
||||
|
||||
const fullWidthClass = $derived.by(() => {
|
||||
switch (cardSize) {
|
||||
case 'compact':
|
||||
return 'col-span-2 sm:col-span-3 md:col-span-4 lg:col-span-6';
|
||||
case 'large':
|
||||
return 'col-span-1 sm:col-span-2 lg:col-span-3';
|
||||
default:
|
||||
return 'col-span-2 sm:col-span-3 lg:col-span-4';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if widgets.length === 0}
|
||||
<p class="text-sm text-muted-foreground">{$t('widget.no_widgets')}</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div class={gridClass}>
|
||||
{#each widgets as widget (widget.id)}
|
||||
{@const isFullWidth = fullWidthTypes.has(widget.type)}
|
||||
<div class={isFullWidth ? 'col-span-2 sm:col-span-3 lg:col-span-4' : ''}>
|
||||
<div class={isFullWidth ? fullWidthClass : ''}>
|
||||
<WidgetContainer>
|
||||
<WidgetRenderer {widget} {allApps} />
|
||||
<WidgetRenderer {widget} {allApps} {cardSize} />
|
||||
</WidgetContainer>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
import NoteWidget from './NoteWidget.svelte';
|
||||
import EmbedWidget from './EmbedWidget.svelte';
|
||||
import StatusWidget from './StatusWidget.svelte';
|
||||
import ClockWeatherWidget from './ClockWeatherWidget.svelte';
|
||||
import SystemStatsWidget from './SystemStatsWidget.svelte';
|
||||
import RssFeedWidget from './RssFeedWidget.svelte';
|
||||
import CalendarWidget from './CalendarWidget.svelte';
|
||||
import MarkdownWidget from './MarkdownWidget.svelte';
|
||||
import MetricWidget from './MetricWidget.svelte';
|
||||
import LinkGroupWidget from './LinkGroupWidget.svelte';
|
||||
import CameraStreamWidget from './CameraStreamWidget.svelte';
|
||||
|
||||
interface AppData {
|
||||
id: string;
|
||||
@@ -25,12 +33,15 @@
|
||||
app: AppData | null;
|
||||
}
|
||||
|
||||
import type { CardSize } from '$lib/utils/constants.js';
|
||||
|
||||
interface Props {
|
||||
widget: WidgetData;
|
||||
allApps?: AppData[];
|
||||
cardSize?: CardSize;
|
||||
}
|
||||
|
||||
let { widget, allApps = [] }: Props = $props();
|
||||
let { widget, allApps = [], cardSize = 'medium' }: Props = $props();
|
||||
|
||||
const parsedConfig = $derived.by(() => {
|
||||
try {
|
||||
@@ -42,7 +53,7 @@
|
||||
</script>
|
||||
|
||||
{#if widget.type === 'app' && widget.app}
|
||||
<AppWidget app={widget.app} />
|
||||
<AppWidget app={widget.app} {cardSize} />
|
||||
{:else if widget.type === 'bookmark'}
|
||||
<BookmarkWidget config={parsedConfig} />
|
||||
{:else if widget.type === 'note'}
|
||||
@@ -51,6 +62,60 @@
|
||||
<EmbedWidget config={{ url: parsedConfig.url ?? '', height: parsedConfig.height ?? 300, sandbox: parsedConfig.sandbox }} />
|
||||
{:else if widget.type === 'status'}
|
||||
<StatusWidget config={{ appIds: parsedConfig.appIds ?? [], label: parsedConfig.label }} apps={allApps} />
|
||||
{:else if widget.type === 'clock'}
|
||||
<ClockWeatherWidget config={{
|
||||
timezone: parsedConfig.timezone,
|
||||
showWeather: parsedConfig.showWeather ?? false,
|
||||
latitude: parsedConfig.latitude,
|
||||
longitude: parsedConfig.longitude,
|
||||
clockStyle: parsedConfig.clockStyle ?? 'digital'
|
||||
}} />
|
||||
{:else if widget.type === 'system_stats'}
|
||||
<SystemStatsWidget config={{
|
||||
sourceUrl: parsedConfig.sourceUrl ?? '',
|
||||
sourceType: parsedConfig.sourceType ?? 'custom',
|
||||
metrics: parsedConfig.metrics ?? [],
|
||||
refreshInterval: parsedConfig.refreshInterval ?? 30
|
||||
}} />
|
||||
{:else if widget.type === 'rss'}
|
||||
<RssFeedWidget config={{
|
||||
feedUrl: parsedConfig.feedUrl ?? '',
|
||||
maxItems: parsedConfig.maxItems ?? 10,
|
||||
showSummary: parsedConfig.showSummary ?? true
|
||||
}} />
|
||||
{:else if widget.type === 'calendar'}
|
||||
<CalendarWidget config={{
|
||||
icalUrls: parsedConfig.icalUrls ?? [],
|
||||
daysAhead: parsedConfig.daysAhead ?? 7
|
||||
}} />
|
||||
{:else if widget.type === 'markdown'}
|
||||
<MarkdownWidget
|
||||
config={{ content: parsedConfig.content ?? '', syntaxTheme: parsedConfig.syntaxTheme }}
|
||||
widgetId={widget.id}
|
||||
/>
|
||||
{:else if widget.type === 'metric'}
|
||||
<MetricWidget config={{
|
||||
label: parsedConfig.label ?? 'Metric',
|
||||
source: parsedConfig.source ?? 'static',
|
||||
value: parsedConfig.value,
|
||||
url: parsedConfig.url,
|
||||
jsonPath: parsedConfig.jsonPath,
|
||||
query: parsedConfig.query,
|
||||
unit: parsedConfig.unit,
|
||||
refreshInterval: parsedConfig.refreshInterval ?? 60
|
||||
}} />
|
||||
{:else if widget.type === 'link_group'}
|
||||
<LinkGroupWidget config={{
|
||||
links: parsedConfig.links ?? [],
|
||||
collapsible: parsedConfig.collapsible ?? false
|
||||
}} />
|
||||
{:else if widget.type === 'camera'}
|
||||
<CameraStreamWidget config={{
|
||||
streamUrl: parsedConfig.streamUrl ?? '',
|
||||
type: parsedConfig.type ?? 'image',
|
||||
refreshInterval: parsedConfig.refreshInterval ?? 10,
|
||||
aspectRatio: parsedConfig.aspectRatio ?? '16/9'
|
||||
}} />
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
|
||||
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
|
||||
|
||||
Reference in New Issue
Block a user