Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 94 additions & 19 deletions ordo-editor/apps/studio/src/components/layout/AppLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,83 @@ function notifIcon(type: string) {
return 'info-circle';
}

// ── Bell dropdown: merge server-backed inbox + transient toasts ───────────────
// The badge counts persistent notifications (release reviews/approvals, polled
// as a count) plus transient session toasts. The dropdown must therefore render
// both — otherwise a release-review notification lights the badge but the
// dropdown opens to "no notifications".

function persistentNotifTitle(type: string, payload: Record<string, unknown>) {
const title = (payload.title as string) ?? '';
if (type === 'release_review_requested')
return t('notifications.types.releaseReviewRequested', { title });
if (type === 'release_approved') return t('notifications.types.releaseApproved', { title });
if (type === 'release_rejected') return t('notifications.types.releaseRejected', { title });
return type;
}

function persistentNotifIcon(type: string) {
if (type === 'release_approved') return 'check-circle';
if (type === 'release_rejected') return 'close-circle';
if (type === 'release_review_requested') return 'notification';
return 'info-circle';
}

interface BellItem {
id: string;
icon: string;
iconType: string;
title: string;
message?: string;
time: Date;
read: boolean;
onClick?: () => void;
}

const bellItems = computed<BellItem[]>(() => {
const persistent: BellItem[] = persistentNotifStore.notifications.map((n) => ({
id: `p:${n.id}`,
icon: persistentNotifIcon(n.type),
iconType:
n.type === 'release_approved' ? 'success' : n.type === 'release_rejected' ? 'error' : 'info',
title: persistentNotifTitle(n.type, n.payload),
time: new Date(n.created_at),
read: !!n.read_at,
onClick: () => {
const pid = n.payload.project_id as string | undefined;
if (!n.ref_id || !pid || !currentOrgId.value) return;
notifOpen.value = false;
if (!n.read_at) void persistentNotifStore.markRead(currentOrgId.value, n.id);
navigate(`/orgs/${currentOrgId.value}/projects/${pid}/releases`);
},
}));
const transient: BellItem[] = notifStore.notifications.map((n) => ({
id: `t:${n.id}`,
icon: notifIcon(n.type),
iconType: n.type,
title: n.title,
message: n.message,
time: n.timestamp,
read: n.read,
}));
return [...persistent, ...transient].sort((a, b) => b.time.getTime() - a.time.getTime());
});

const bellUnread = computed(() => persistentNotifStore.unreadCount + notifStore.unreadCount);

function toggleNotif() {
notifOpen.value = !notifOpen.value;
// Populate the server-backed list on open (polling only keeps the count).
if (notifOpen.value && currentOrgId.value) {
void persistentNotifStore.fetchNotifications(currentOrgId.value, false).catch(() => {});
}
}

async function handleBellMarkAllRead() {
notifStore.markAllRead();
if (currentOrgId.value) await persistentNotifStore.markAllRead(currentOrgId.value);
}

// ── Page info ────────────────────────────────────────────────────────────────

const pageInfo = computed(() => {
Expand Down Expand Up @@ -617,54 +694,48 @@ async function handleCreateOrg() {
class="topbar-pill topbar-pill--icon"
:class="{
'is-open': notifOpen,
'has-unread': persistentNotifStore.unreadCount > 0 || notifStore.unreadCount > 0,
'has-unread': bellUnread > 0,
}"
@click="notifOpen = !notifOpen"
@click="toggleNotif()"
>
<t-icon name="notification" size="16px" />
<span
v-if="persistentNotifStore.unreadCount > 0 || notifStore.unreadCount > 0"
class="notif-badge"
>
{{
persistentNotifStore.unreadCount + notifStore.unreadCount > 99
? '99+'
: persistentNotifStore.unreadCount + notifStore.unreadCount
}}
<span v-if="bellUnread > 0" class="notif-badge">
{{ bellUnread > 99 ? '99+' : bellUnread }}
</span>
</button>

<div v-show="notifOpen" class="topbar-dropdown topbar-dropdown--notif">
<div class="topbar-dropdown__header">
<span>{{ t('shell.notifications') }}</span>
<button
v-if="notifStore.unreadCount > 0"
v-if="bellUnread > 0"
class="notif-mark-read"
@click="notifStore.markAllRead()"
@click="handleBellMarkAllRead()"
>
{{ t('shell.markAllRead') }}
</button>
</div>

<div v-if="notifStore.notifications.length === 0" class="notif-empty">
<div v-if="bellItems.length === 0" class="notif-empty">
<t-icon name="notification" size="24px" style="opacity: 0.25" />
<span>{{ t('shell.noNotifications') }}</span>
</div>

<div v-else class="notif-list">
<div
v-for="n in notifStore.notifications"
v-for="n in bellItems"
:key="n.id"
class="notif-item"
:class="{ 'notif-item--unread': !n.read }"
:class="{ 'notif-item--unread': !n.read, 'notif-item--clickable': !!n.onClick }"
@click="n.onClick?.()"
>
<span class="notif-icon" :class="`notif-icon--${n.type}`">
<t-icon :name="notifIcon(n.type)" size="14px" />
<span class="notif-icon" :class="`notif-icon--${n.iconType}`">
<t-icon :name="n.icon" size="14px" />
</span>
<div class="notif-content">
<div class="notif-title">{{ n.title }}</div>
<div v-if="n.message" class="notif-message">{{ n.message }}</div>
<div class="notif-time">{{ formatNotifTime(n.timestamp) }}</div>
<div class="notif-time">{{ formatNotifTime(n.time) }}</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -1359,6 +1430,10 @@ async function handleCreateOrg() {
background: var(--ordo-bg-selected);
}

.notif-item--clickable {
cursor: pointer;
}

.notif-icon {
width: 26px;
height: 26px;
Expand Down
3 changes: 3 additions & 0 deletions ordo-editor/apps/studio/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export default {
emptyTitle: 'No execution data yet',
emptyHint:
'Once your connected engine runs traffic it will appear here. This needs the engine to reach the platform over NATS.',
errorTitle: "Couldn't load analytics",
},
integrate: {
title: 'Integrate',
Expand Down Expand Up @@ -1215,6 +1216,8 @@ export default {
title: 'Notifications',
markAllRead: 'Mark all read',
empty: 'No notifications',
error: 'Failed to load notifications',
retry: 'Retry',
unreadOnly: 'Unread only',
viewInbox: 'View inbox',
types: {
Expand Down
3 changes: 3 additions & 0 deletions ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export default {
ruleset: '规则集',
emptyTitle: '暂无执行数据',
emptyHint: '已连接的引擎开始有流量后,数据会显示在这里。需要引擎能通过 NATS 连到平台。',
errorTitle: '分析数据加载失败',
},
integrate: {
title: '接入',
Expand Down Expand Up @@ -1182,6 +1183,8 @@ export default {
title: '通知',
markAllRead: '全部标为已读',
empty: '暂无通知',
error: '通知加载失败',
retry: '重试',
unreadOnly: '仅显示未读',
viewInbox: '查看收件箱',
types: {
Expand Down
3 changes: 3 additions & 0 deletions ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export default {
ruleset: '規則集',
emptyTitle: '暫無執行資料',
emptyHint: '已連接的引擎開始有流量後,資料會顯示在這裡。需要引擎能透過 NATS 連到平台。',
errorTitle: '分析資料載入失敗',
},
integrate: {
title: '接入',
Expand Down Expand Up @@ -1182,6 +1183,8 @@ export default {
title: '通知',
markAllRead: '全部標為已讀',
empty: '暫無通知',
error: '通知載入失敗',
retry: '重試',
unreadOnly: '僅顯示未讀',
viewInbox: '查看收件箱',
types: {
Expand Down
21 changes: 10 additions & 11 deletions ordo-editor/apps/studio/src/stores/persistentNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,18 @@ export const usePersistentNotificationStore = defineStore('persistentNotificatio
}
}

// Surfaces errors to the caller (throws) so a failed load can render an error
// state instead of silently looking like an empty inbox. Background callers
// that don't care (e.g. the bell peek) should catch and ignore.
async function fetchNotifications(orgId: string, unreadOnly = false) {
if (!auth.token || !orgId) return;
try {
notifications.value = await notificationApi.list(auth.token, orgId, {
unread_only: unreadOnly,
limit: 50,
});
unreadCount.value = unreadOnly
? notifications.value.length
: notifications.value.filter((n) => !n.read_at).length;
} catch {
// ignore
}
notifications.value = await notificationApi.list(auth.token, orgId, {
unread_only: unreadOnly,
limit: 50,
});
unreadCount.value = unreadOnly
? notifications.value.length
: notifications.value.filter((n) => !n.read_at).length;
}

async function markRead(orgId: string, notifId: string) {
Expand Down
24 changes: 1 addition & 23 deletions ordo-editor/apps/studio/src/views/auth/LoginView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,7 @@ async function handleLogin() {
</div>

<div class="field">
<div class="field-label-row">
<label class="field-label">{{ t('auth.passwordLabel') }}</label>
<router-link to="/forgot-password" class="forgot-link">{{
t('auth.forgotPassword')
}}</router-link>
</div>
<label class="field-label">{{ t('auth.passwordLabel') }}</label>
<t-input
v-model="password"
:placeholder="t('auth.passwordPlaceholder')"
Expand Down Expand Up @@ -323,23 +318,6 @@ async function handleLogin() {
color: #374151;
}

.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}

.forgot-link {
font-size: 12px;
color: var(--ordo-accent);
text-decoration: none;
font-weight: 500;
}

.forgot-link:hover {
text-decoration: underline;
}

.submit-btn {
margin-top: 6px;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { usePersistentNotificationStore } from '@/stores/persistentNotifications';
import { useOrgStore } from '@/stores/org';

const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const orgStore = useOrgStore();
const store = usePersistentNotificationStore();

const orgId = computed(() => orgStore.currentOrg?.id ?? '');
// Prefer the URL's org so a deep link / hard reload loads the right inbox even
// before the shell has settled on a current org.
const orgId = computed(
() => (route.params.orgId as string | undefined) || orgStore.currentOrg?.id || ''
);
const unreadOnly = ref(false);
const loading = ref(false);
const error = ref<string | null>(null);

onMounted(async () => {
async function load() {
if (!orgId.value) return;
loading.value = true;
error.value = null;
try {
await store.fetchNotifications(orgId.value, false);
} catch (e) {
// Surface the failure instead of rendering it as an empty inbox.
error.value = e instanceof Error ? e.message : t('notifications.error');
} finally {
loading.value = false;
}
});
}

onMounted(load);

const filtered = computed(() =>
unreadOnly.value ? store.notifications.filter((n) => !n.read_at) : store.notifications
Expand Down Expand Up @@ -97,6 +109,14 @@ function navigateToRelease(n: { ref_id?: string; payload: Record<string, unknown
<t-loading />
</div>

<div v-else-if="error" class="notifications-empty">
<t-icon name="error-circle" size="48px" style="opacity: 0.3" />
<p>{{ error }}</p>
<t-button theme="default" variant="outline" size="small" @click="load">
{{ t('notifications.retry') }}
</t-button>
</div>

<div v-else-if="filtered.length === 0" class="notifications-empty">
<t-icon name="notification" size="48px" style="opacity: 0.2" />
<p>{{ t('notifications.empty') }}</p>
Expand Down
14 changes: 13 additions & 1 deletion ordo-editor/apps/studio/src/views/project/AnalyticsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,17 @@ onBeforeUnmount(() => {
</t-card>
</div>

<!-- Error state — a failed load must not masquerade as "no traffic yet" -->
<t-card v-if="!analytics.loading && analytics.error" class="empty" :bordered="true">
<p class="empty-title">{{ t('analytics.errorTitle') }}</p>
<p class="empty-hint">{{ analytics.error }}</p>
<t-button class="empty-action" theme="default" variant="outline" @click="reload">
{{ t('analytics.refresh') }}
</t-button>
</t-card>

<!-- Empty state -->
<t-card v-if="!analytics.loading && !hasData" class="empty" :bordered="true">
<t-card v-else-if="!analytics.loading && !hasData" class="empty" :bordered="true">
<p class="empty-title">{{ t('analytics.emptyTitle') }}</p>
<p class="empty-hint">{{ t('analytics.emptyHint') }}</p>
</t-card>
Expand Down Expand Up @@ -323,6 +332,9 @@ onBeforeUnmount(() => {
color: var(--td-text-color-secondary);
margin: 0;
}
.empty-action {
margin-top: 16px;
}
@media (max-width: 900px) {
.stat-row {
grid-template-columns: repeat(2, 1fr);
Expand Down
Loading