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;