Skip to content

Commit b136295

Browse files
Add Admin Hub UI: notification bell, admin settings tab, special awards
- Wire NotificationFeedPanel into ExtensionBar toolbar with bell icon + unread badge - Add Admin Hub tab to SettingsPanel (visible only for admins) with three sub-sections: - Notifications: create/send with image upload, target groups/users, post to chat - Groups: create groups, manage members with user search autocomplete - Special Badges: create badges with image upload, award to users or groups - Add Special Awards section to UserProfilePanel (gold-accented, conditional) - Fix: export notificationReads from store, add computed import from vue - Fix: isRead() function in NotificationFeedPanel now works correctly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ac0bdfb commit b136295

File tree

5 files changed

+849
-45
lines changed

5 files changed

+849
-45
lines changed

src/components/ExtensionBar.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import ActivityFeedPanel from "components/ActivityFeedPanel.vue";
1515
import CellLibraryPanel from "components/CellLibraryPanel.vue";
1616
import ChatPanel from "components/ChatPanel.vue";
1717
import BatchProcessorPanel from "components/BatchProcessorPanel.vue";
18+
import NotificationFeedPanel from "components/NotificationFeedPanel.vue";
1819
1920
import {loginSession, useLoginStore, useVolumesStore, useUserStatsStore, useSegmentAnnotationStore, useHelpRequestStore, useProofreadingQueueStore, useProofreadingBackendStore, useUserPreferencesStore} from '../store';
2021
import {useTutorialStore} from '../store-pyr';
@@ -80,6 +81,7 @@ const showChat = ref(false);
8081
const showCellLibrary = ref(false);
8182
const cellLibraryInitialTab = ref<string | undefined>(undefined);
8283
const showBatchProcessor = ref(false);
84+
const showNotifications = ref(false);
8385
const cmdPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
8486
const backendStore = useProofreadingBackendStore();
8587
const { tutorialStep } = storeToRefs(useTutorialStore());
@@ -112,11 +114,12 @@ const toolbarDefs = computed<ToolbarIcon[]>(() => [
112114
{ id: 'batch', emoji: '📦', label: 'Batch Processor', action: () => { showBatchProcessor.value = !showBatchProcessor.value; } },
113115
{ id: 'help', emoji: '🔍', label: 'Second Opinion Requests', action: () => { cellLibraryInitialTab.value = 'help'; showCellLibrary.value = true; }, badge: () => helpStore.pending.length },
114116
{ id: 'feed', emoji: '📡', label: 'Activity Feed', action: () => { showFeed.value = true; } },
117+
{ id: 'notif', emoji: '🔔', label: 'Notifications', action: () => { showNotifications.value = !showNotifications.value; }, badge: () => backendStore.unreadNotificationCount },
115118
{ id: 'chat', emoji: '💬', label: 'Chat', action: () => { showChat.value = !showChat.value; } },
116119
{ id: 'settings', emoji: '⚙️', label: 'Profile Settings', action: () => { showSettings.value = true; } },
117120
]);
118121
119-
const DEFAULT_TOOLBAR_ORDER = ['split', 'merge', 'recap', 'leaderboard', 'cells', 'batch', 'help', 'feed', 'chat', 'settings'];
122+
const DEFAULT_TOOLBAR_ORDER = ['split', 'merge', 'recap', 'leaderboard', 'cells', 'batch', 'help', 'feed', 'notif', 'chat', 'settings'];
120123
121124
const visibleToolbar = computed(() => {
122125
const prefs = useUserPreferencesStore().prefs;
@@ -184,6 +187,7 @@ function activateTool(toolType: 'multicut' | 'merge') {
184187
<weekly-recap-panel v-if="showRecap" @hide="showRecap = false" />
185188
<leaderboard-panel v-if="showLeaderboard" @hide="showLeaderboard = false" />
186189
<settings-panel v-if="showSettings" @hide="showSettings = false" />
190+
<notification-feed-panel v-if="showNotifications" @hide="showNotifications = false" />
187191
<chat-panel v-if="showChat" @hide="showChat = false" />
188192
<div id="extensionBar">
189193
<div class="ng-extend-logo">
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
<script setup lang="ts">
2+
import { ref, onMounted, onUnmounted, computed } from 'vue';
3+
import { useProofreadingBackendStore } from '../store';
4+
5+
const emit = defineEmits({ hide: null });
6+
const backend = useProofreadingBackendStore();
7+
8+
onMounted(() => {
9+
backend.loadNotifications();
10+
backend.subscribeToNotifications();
11+
});
12+
13+
onUnmounted(() => {
14+
backend.unsubscribeFromNotifications();
15+
});
16+
17+
const lightboxUrl = ref<string | null>(null);
18+
19+
function relativeTime(iso: string): string {
20+
const diff = Date.now() - new Date(iso).getTime();
21+
const mins = Math.floor(diff / 60000);
22+
if (mins < 1) return 'just now';
23+
if (mins < 60) return `${mins}m ago`;
24+
const hrs = Math.floor(mins / 60);
25+
if (hrs < 24) return `${hrs}h ago`;
26+
const days = Math.floor(hrs / 24);
27+
return `${days}d ago`;
28+
}
29+
30+
function isRead(id: number): boolean {
31+
return (backend as any).notificationReads?.has?.(id) ?? false;
32+
}
33+
</script>
34+
35+
<template>
36+
<div class="nge-notif-panel">
37+
<div class="nge-notif-topbar">
38+
<span class="nge-notif-title">🔔 Notifications</span>
39+
<div class="nge-notif-topbar-actions">
40+
<button
41+
v-if="backend.unreadNotificationCount > 0"
42+
class="nge-notif-mark-all"
43+
@click="backend.markAllNotificationsRead()"
44+
>Mark all read</button>
45+
<button class="nge-notif-close" @click="emit('hide')">×</button>
46+
</div>
47+
</div>
48+
49+
<div class="nge-notif-list" v-if="backend.notifications.length > 0">
50+
<div
51+
v-for="notif in backend.notifications"
52+
:key="notif.id"
53+
class="nge-notif-card"
54+
:class="{ 'nge-notif-card--unread': !isRead(notif.id) }"
55+
@click="backend.markNotificationRead(notif.id)"
56+
>
57+
<div class="nge-notif-card-header">
58+
<span v-if="!isRead(notif.id)" class="nge-notif-unread-dot"></span>
59+
<div class="nge-notif-card-title">{{ notif.title }}</div>
60+
<span class="nge-notif-time">{{ relativeTime(notif.send_at) }}</span>
61+
</div>
62+
<div class="nge-notif-card-body">{{ notif.body }}</div>
63+
<div v-if="notif.thumbnail_url" class="nge-notif-card-image">
64+
<img
65+
:src="notif.thumbnail_url"
66+
class="nge-notif-thumb"
67+
@click.stop="lightboxUrl = notif.image_url || notif.thumbnail_url"
68+
/>
69+
</div>
70+
</div>
71+
</div>
72+
73+
<div class="nge-notif-empty" v-else>
74+
No notifications yet.
75+
</div>
76+
77+
<!-- Lightbox for full-size images -->
78+
<Teleport to="body">
79+
<div v-if="lightboxUrl" class="nge-notif-lightbox" @click="lightboxUrl = null">
80+
<img :src="lightboxUrl" class="nge-notif-lightbox-img" />
81+
</div>
82+
</Teleport>
83+
</div>
84+
</template>
85+
86+
<style scoped>
87+
.nge-notif-panel {
88+
position: fixed;
89+
top: 42px;
90+
right: 8px;
91+
width: 340px;
92+
max-height: 480px;
93+
background: rgba(14, 17, 23, 0.97);
94+
border: 1px solid rgba(74, 158, 255, 0.15);
95+
border-radius: 10px;
96+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
97+
z-index: 8000;
98+
display: flex;
99+
flex-direction: column;
100+
backdrop-filter: blur(8px);
101+
font-size: 0.85em;
102+
}
103+
104+
.nge-notif-topbar {
105+
display: flex;
106+
justify-content: space-between;
107+
align-items: center;
108+
padding: 10px 14px;
109+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
110+
flex-shrink: 0;
111+
}
112+
113+
.nge-notif-title {
114+
font-size: 1em;
115+
font-weight: 600;
116+
color: #e0e0e0;
117+
}
118+
119+
.nge-notif-topbar-actions { display: flex; align-items: center; gap: 8px; }
120+
121+
.nge-notif-mark-all {
122+
background: none;
123+
border: 1px solid rgba(74, 158, 255, 0.2);
124+
color: rgba(74, 158, 255, 0.7);
125+
font-size: 0.72em;
126+
padding: 2px 8px;
127+
border-radius: 8px;
128+
cursor: pointer;
129+
}
130+
.nge-notif-mark-all:hover { color: #4a9eff; border-color: rgba(74, 158, 255, 0.4); }
131+
132+
.nge-notif-close {
133+
background: none; border: none; color: #666; font-size: 1.2em;
134+
cursor: pointer; padding: 0 4px;
135+
}
136+
.nge-notif-close:hover { color: #fff; }
137+
138+
.nge-notif-list {
139+
overflow-y: auto;
140+
max-height: 420px;
141+
padding: 6px 0;
142+
scrollbar-width: thin;
143+
scrollbar-color: rgba(74, 158, 255, 0.15) transparent;
144+
}
145+
146+
.nge-notif-card {
147+
padding: 10px 14px;
148+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
149+
cursor: pointer;
150+
transition: background 0.15s;
151+
}
152+
.nge-notif-card:hover { background: rgba(74, 158, 255, 0.04); }
153+
.nge-notif-card--unread { background: rgba(74, 158, 255, 0.03); }
154+
155+
.nge-notif-unread-dot {
156+
width: 6px;
157+
height: 6px;
158+
border-radius: 50%;
159+
background: #4a9eff;
160+
flex-shrink: 0;
161+
margin-top: 4px;
162+
}
163+
164+
.nge-notif-card-header {
165+
display: flex;
166+
justify-content: space-between;
167+
align-items: baseline;
168+
gap: 8px;
169+
}
170+
171+
.nge-notif-card-title {
172+
font-weight: 600;
173+
color: #dde;
174+
font-size: 0.92em;
175+
flex: 1;
176+
}
177+
178+
.nge-notif-time {
179+
font-size: 0.72em;
180+
color: #556;
181+
flex-shrink: 0;
182+
}
183+
184+
.nge-notif-card-body {
185+
margin-top: 3px;
186+
font-size: 0.82em;
187+
color: #888;
188+
line-height: 1.4;
189+
}
190+
191+
.nge-notif-card-image { margin-top: 8px; }
192+
193+
.nge-notif-thumb {
194+
max-width: 100%;
195+
max-height: 120px;
196+
border-radius: 6px;
197+
cursor: zoom-in;
198+
object-fit: cover;
199+
}
200+
201+
.nge-notif-empty {
202+
padding: 32px 16px;
203+
text-align: center;
204+
color: #556;
205+
font-size: 0.82em;
206+
font-style: italic;
207+
}
208+
209+
/* Lightbox */
210+
.nge-notif-lightbox {
211+
position: fixed;
212+
inset: 0;
213+
z-index: 10001;
214+
background: rgba(0, 0, 0, 0.85);
215+
display: flex;
216+
align-items: center;
217+
justify-content: center;
218+
cursor: pointer;
219+
}
220+
.nge-notif-lightbox-img {
221+
max-width: 90vw;
222+
max-height: 90vh;
223+
object-fit: contain;
224+
border-radius: 8px;
225+
}
226+
</style>

0 commit comments

Comments
 (0)