diff --git a/src/App.svelte b/src/App.svelte index 39d1308..c2c7d54 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -16,6 +16,8 @@ let realtimeState = 'DISCONNECTED'; let realtimeSocket: WebSocket | undefined; let activityEvents: Array<{ event_id: string; event_type: string; occurred_at: string }> = []; + let notifications: Array<{ id: string; severity: string; title: string; message: string; seen_at: string | null; created_at: string }> = []; + let showNotifications = false; let showStreamEditor = false; let streamBusy = false; let streamError = ''; @@ -89,6 +91,7 @@ mustReplaceCredentials = result.must_replace_credentials; connectionState = 'CONNECTED'; await loadActivity(); + await loadNotifications(); await loadStreamInfo(); connectRealtime(); if (!mustReplaceCredentials) password = ''; @@ -136,6 +139,8 @@ authenticated = false; mustReplaceCredentials = false; oauthLink = ''; + notifications = []; + showNotifications = false; } function connectRealtime() { @@ -182,6 +187,28 @@ } } + async function loadNotifications() { + try { + const response = await fetch(`${serverAddress}/api/v1/admin/notifications`, { + headers: { authorization: `Bearer ${sessionToken}` }, + }); + if (!response.ok) return; + notifications = await response.json(); + } catch { + notifications = []; + } + } + + async function markNotificationSeen(notificationId: string) { + const response = await fetch(`${serverAddress}/api/v1/admin/notifications/${notificationId}/seen`, { + method: 'POST', + headers: { authorization: `Bearer ${sessionToken}` }, + }); + if (response.ok) { + notifications = notifications.map((notification) => notification.id === notificationId ? { ...notification, seen_at: new Date().toISOString() } : notification); + } + } + async function loadStreamInfo() { try { const response = await fetch(`${serverAddress}/api/v1/twitch/stream-info`, { @@ -319,7 +346,7 @@ {/each} -
{connectionState}{#if connectionNotice}{connectionNotice}{/if}
+
{connectionState}{#if connectionNotice}{connectionNotice}{/if}{#if authenticated}
{#if showNotifications}
NOTIFICATIONS
{#if notifications.length}{#each notifications as notification}
{notification.title}{notification.severity}
{notification.message}
{#if !notification.seen_at}{/if}
{/each}{:else}
No notifications.
{/if}
{/if}
{/if}
diff --git a/src/styles.css b/src/styles.css index 34eae5e..be214fc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -174,6 +174,70 @@ button:hover:not(:disabled) { color: var(--ed-dim); font-size: 18px; } +.notification-wrap { + position: relative; +} +.notification-button { + position: relative; +} +.notification-dot { + position: absolute; + top: 2px; + right: 2px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ed-warn); +} +.notification-popover { + position: absolute; + z-index: 5; + top: 34px; + right: 0; + width: 320px; + max-height: 420px; + overflow: auto; + border: 1px solid var(--ui-line); + background: var(--ed-panel); + box-shadow: 0 8px 24px rgb(0 0 0 / 35%); +} +.notification-heading, +.notification-title { + display: flex; + align-items: center; + justify-content: space-between; +} +.notification-heading { + padding: 9px 10px; + border-bottom: 1px solid var(--ui-divider); + color: var(--ed-fg); + font: 600 11px var(--font-data); + letter-spacing: 0.12em; +} +.notification-item { + padding: 10px; + border-bottom: 1px solid var(--ui-divider); + opacity: 0.65; +} +.notification-item.unseen { + opacity: 1; + background: var(--ui-surface); +} +.notification-title { + color: var(--ed-fg); + font-weight: 600; +} +.notification-message { + margin: 6px 0 9px; + color: var(--ed-dim); + font-size: 11px; + line-height: 1.4; +} +.notification-empty { + padding: 18px 10px; + color: var(--ed-dim); + font: 11px var(--font-data); +} .workspace { display: grid; grid-template-columns: 214px 1fr;