Ver código fonte

feat: add notifications

Thomas Zhang 3 meses atrás
pai
commit
55e1ae2fd9

+ 3 - 0
src/lib/components/common/notifications/index.ts

@@ -0,0 +1,3 @@
+import NotificationList from './list.svelte';
+
+export { NotificationList };

+ 44 - 0
src/lib/components/common/notifications/item.svelte

@@ -0,0 +1,44 @@
+<script lang="ts">
+	import { Check, CircleX, Info, X } from '@lucide/svelte';
+
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { type Notification, notificationsStore } from '$lib/stores/notifications.svelte';
+
+	let { notification }: { notification: Notification } = $props();
+</script>
+
+<div
+	class="flex w-80 items-start rounded-lg border border-white/25 bg-white/10 p-2 backdrop-blur-md"
+>
+	<div class="shrink-0">
+		{#if notification.type === 'loading'}
+			<Spinner class="text-primary" />
+		{:else if notification.type === 'success'}
+			<Check class="text-green-400" size={20} strokeWidth={2} />
+		{:else if notification.type === 'error'}
+			<CircleX class="text-red-400" size={20} strokeWidth={2} />
+		{:else}
+			<Info class="text-blue-400" size={20} strokeWidth={2} />
+		{/if}
+	</div>
+
+	<div class="flex flex-1 flex-col gap-2 px-4">
+		<span class="flex-1 text-sm text-white/80">{notification.message}</span>
+		{#if notification.href && notification.type !== 'loading'}
+			<a
+				class="flex items-center text-xs font-bold text-primary/75 hover:text-primary"
+				href={notification.href}
+			>
+				View
+			</a>
+		{/if}
+	</div>
+
+	<button
+		class="shrink-0 opacity-25 transition-opacity hover:opacity-75"
+		aria-label="Dismiss notification"
+		onclick={() => notificationsStore.dismiss(notification.id)}
+	>
+		<X size={18} strokeWidth={2} />
+	</button>
+</div>

+ 20 - 0
src/lib/components/common/notifications/list.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import { cubicOut } from 'svelte/easing';
+	import { fly } from 'svelte/transition';
+
+	import { notificationsStore } from '$lib/stores/notifications.svelte';
+
+	import NotificationItem from './item.svelte';
+</script>
+
+<div class="pointer-events-none fixed top-4 right-4 z-50 flex flex-col gap-2">
+	{#each notificationsStore.items as notification (notification.id)}
+		<div
+			class="pointer-events-auto"
+			in:fly={{ x: 20, duration: 200, easing: cubicOut }}
+			out:fly={{ x: 20, duration: 150, easing: cubicOut }}
+		>
+			<NotificationItem {notification} />
+		</div>
+	{/each}
+</div>

+ 44 - 0
src/lib/stores/events.svelte.ts

@@ -0,0 +1,44 @@
+import type { ResourceEvent } from '$lib/types/events';
+
+type EventListener = (event: ResourceEvent) => void;
+
+class EventsStore {
+	#source: EventSource | undefined;
+	#subscribers = new Set<EventListener>();
+
+	/**
+	 * Opens an SSE connection for the given workspace.
+	 * Closes any existing connection first.
+	 */
+	connect(workspaceId: string) {
+		this.disconnect();
+		this.#source = new EventSource(`/api/workspaces/${workspaceId}/events`);
+		this.#source.onmessage = (e: MessageEvent<string>) => {
+			try {
+				this.#dispatch(JSON.parse(e.data) as ResourceEvent);
+			} catch {
+				// ignore malformed events
+			}
+		};
+	}
+
+	disconnect() {
+		this.#source?.close();
+		this.#source = undefined;
+	}
+
+	subscribe(callback: (event: ResourceEvent) => void) {
+		this.#subscribers.add(callback);
+		return () => this.#subscribers.delete(callback);
+	}
+
+	#dispatch(event: ResourceEvent) {
+		for (const subscriber of this.#subscribers) {
+			subscriber(event);
+		}
+	}
+}
+
+const eventsStore = new EventsStore();
+
+export { eventsStore };

+ 43 - 0
src/lib/stores/notifications.svelte.ts

@@ -0,0 +1,43 @@
+type NotificationType = 'success' | 'error' | 'info' | 'loading';
+
+type Notification = {
+	id: string;
+	type: NotificationType;
+	message: string;
+	href?: string;
+	durationMs?: number;
+};
+
+class NotificationsStore {
+	items = $state<Notification[]>([]);
+
+	push(n: Omit<Notification, 'id'>): string {
+		const item: Notification = { ...n, id: crypto.randomUUID() };
+		this.items = [...this.items, item];
+		const duration = n.durationMs ?? 4000;
+		if (duration > 0) setTimeout(() => this.dismiss(item.id), duration);
+		return item.id;
+	}
+
+	/** Replace fields on an existing notification by id. If type changes away from
+	 *  'loading', schedules auto-dismiss using the updated durationMs (default 4000). */
+	update(id: string, partial: Partial<Omit<Notification, 'id'>>) {
+		this.items = this.items.map((n) => (n.id === id ? { ...n, ...partial } : n));
+		if (partial.type && partial.type !== 'loading') {
+			const item = this.items.find((n) => n.id === id);
+			if (item) {
+				const duration = item.durationMs ?? 4000;
+				if (duration > 0) setTimeout(() => this.dismiss(id), duration);
+			}
+		}
+	}
+
+	dismiss(id: string) {
+		this.items = this.items.filter((n) => n.id !== id);
+	}
+}
+
+const notificationsStore = new NotificationsStore();
+
+export type { Notification, NotificationType };
+export { notificationsStore };

+ 11 - 0
src/routes/+layout.svelte

@@ -4,12 +4,14 @@
 	import { page } from '$app/state';
 
 	import logo from '$lib/assets/logo.svg';
+	import { NotificationList } from '$lib/components/common/notifications';
 	import * as Sidebar from '$lib/components/controls/sidebar';
 	import { Assistant } from '$lib/components/layout/assistant';
 	import { Sidebar as AppSidebar } from '$lib/components/layout/side';
 	import { TopBar } from '$lib/components/layout/top';
 	import { getAssistantContext, setAssistantContext } from '$lib/stores/assistant.svelte';
 	import { setBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { eventsStore } from '$lib/stores/events.svelte';
 
 	import type { LayoutData } from './$types';
 
@@ -22,6 +24,13 @@
 	setAssistantContext();
 
 	let assistant = getAssistantContext();
+
+	$effect(() => {
+		if (data.user && data.workspaceId) {
+			eventsStore.connect(data.workspaceId);
+			return () => eventsStore.disconnect();
+		}
+	});
 </script>
 
 <svelte:head>
@@ -78,6 +87,8 @@
 	{/if}
 </div>
 
+<NotificationList />
+
 <style>
 	.viewport {
 		scrollbar-gutter: stable;

+ 37 - 0
src/routes/api/workspaces/[workspaceId]/events/+server.ts

@@ -0,0 +1,37 @@
+import { server } from '$lib/server';
+import type { ResourceEvent } from '$lib/types/events';
+
+import type { RequestHandler } from './$types';
+
+const GET: RequestHandler = ({ params, locals }) => {
+	if (!locals.user) return new Response('Unauthorized', { status: 401 });
+
+	const resources = server.resources.getWorkspaceResources(params.workspaceId);
+	if (!resources) return new Response('Not Found', { status: 404 });
+
+	const encoder = new TextEncoder();
+	let unsubscribe: (() => void) | undefined;
+
+	const stream = new ReadableStream({
+		start: (controller) => {
+			unsubscribe = resources.subscribe((event: ResourceEvent) => {
+				try {
+					controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+				} catch {
+					unsubscribe?.();
+				}
+			});
+		},
+		cancel: () => unsubscribe?.(),
+	});
+
+	return new Response(stream, {
+		headers: {
+			'Content-Type': 'text/event-stream',
+			'Cache-Control': 'no-cache',
+			Connection: 'keep-alive',
+		},
+	});
+};
+
+export { GET };