feat(notifications): add real-time notification system
Add database tables for notifications and WebSocket tickets Implement REST API and WebSocket server for real-time delivery Add NotificationBell component with dropdown and unread badge Trigger alerts for comments, reactions, and AI moderation results
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type AppIcon
|
||||
} from '../icons';
|
||||
import type { AuthUser, Language } from '../services/api';
|
||||
import NotificationBell from './NotificationBell.vue';
|
||||
import PokeBallMark from './PokeBallMark.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
@@ -414,6 +415,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="currentUser">
|
||||
<NotificationBell :current-user="currentUser" />
|
||||
<RouterLink
|
||||
class="auth-user"
|
||||
to="/profile"
|
||||
|
||||
440
frontend/src/components/NotificationBell.vue
Normal file
440
frontend/src/components/NotificationBell.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
iconBell,
|
||||
iconCheck,
|
||||
iconComment,
|
||||
iconReactionFun,
|
||||
iconReactionHelpful,
|
||||
iconReactionLike,
|
||||
iconReactionThanks,
|
||||
iconReply,
|
||||
iconWarning
|
||||
} from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
notificationWebSocketUrl,
|
||||
type AuthUser,
|
||||
type LifeReactionType,
|
||||
type NotificationItem,
|
||||
type NotificationTargetType,
|
||||
type NotificationWsMessage
|
||||
} from '../services/api';
|
||||
import Skeleton from './Skeleton.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
currentUser: AuthUser | null;
|
||||
}>();
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const router = useRouter();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const notifications = ref<NotificationItem[]>([]);
|
||||
const unreadCount = ref(0);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const loadError = ref('');
|
||||
const busyId = ref<number | null>(null);
|
||||
const markingAll = ref(false);
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let stopped = false;
|
||||
|
||||
const notificationLimit = 12;
|
||||
const displayUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||
|
||||
const reactionOptions = [
|
||||
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
|
||||
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
|
||||
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
|
||||
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
||||
|
||||
function closeMenu() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (root.value && !root.value.contains(event.target as Node)) {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
open.value = !open.value;
|
||||
if (open.value && notifications.value.length === 0 && !loading.value) {
|
||||
void loadNotifications(true);
|
||||
}
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectNotifications() {
|
||||
stopped = true;
|
||||
clearReconnectTimer();
|
||||
socket?.close();
|
||||
socket = null;
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearReconnectTimer();
|
||||
if (stopped || !props.currentUser || !getAuthToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
void connectNotifications();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function upsertNotification(notification: NotificationItem) {
|
||||
notifications.value = [
|
||||
notification,
|
||||
...notifications.value.filter((item) => item.id !== notification.id)
|
||||
].slice(0, 40);
|
||||
}
|
||||
|
||||
function mergeNotifications(existing: NotificationItem[], incoming: NotificationItem[]) {
|
||||
const existingIds = new Set(existing.map((notification) => notification.id));
|
||||
return [...existing, ...incoming.filter((notification) => !existingIds.has(notification.id))];
|
||||
}
|
||||
|
||||
function isNotificationWsMessage(value: unknown): value is NotificationWsMessage {
|
||||
return typeof value === 'object' && value !== null && typeof (value as { type?: unknown }).type === 'string';
|
||||
}
|
||||
|
||||
async function connectNotifications() {
|
||||
if (!props.currentUser || !getAuthToken() || typeof WebSocket === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
stopped = false;
|
||||
clearReconnectTimer();
|
||||
socket?.close();
|
||||
socket = null;
|
||||
|
||||
try {
|
||||
const { ticket } = await api.notificationWsTicket();
|
||||
if (stopped || !props.currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSocket = new WebSocket(notificationWebSocketUrl(ticket));
|
||||
socket = nextSocket;
|
||||
nextSocket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(String(event.data)) as unknown;
|
||||
if (!isNotificationWsMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unreadCount.value = message.unreadCount;
|
||||
if (message.type === 'notifications.created') {
|
||||
upsertNotification(message.notification);
|
||||
}
|
||||
} catch {
|
||||
// Invalid socket payloads are ignored.
|
||||
}
|
||||
};
|
||||
nextSocket.onclose = () => {
|
||||
if (socket === nextSocket) {
|
||||
socket = null;
|
||||
}
|
||||
scheduleReconnect();
|
||||
};
|
||||
nextSocket.onerror = () => {
|
||||
nextSocket.close();
|
||||
};
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotifications(reset = false) {
|
||||
if (!props.currentUser || (!reset && (!hasMore.value || loadingMore.value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
nextCursor.value = null;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
loadError.value = '';
|
||||
|
||||
try {
|
||||
const page = await api.notifications({
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
limit: notificationLimit
|
||||
});
|
||||
notifications.value = reset ? page.items : mergeNotifications(notifications.value, page.items);
|
||||
unreadCount.value = page.unreadCount;
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore;
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceNotification(notification: NotificationItem | null) {
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.value = notifications.value.map((item) => (item.id === notification.id ? notification : item));
|
||||
}
|
||||
|
||||
async function markNotificationRead(notification: NotificationItem) {
|
||||
if (notification.readAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
busyId.value = notification.id;
|
||||
try {
|
||||
const result = await api.markNotificationRead(notification.id);
|
||||
unreadCount.value = result.unreadCount;
|
||||
replaceNotification(result.notification);
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function activateNotification(notification: NotificationItem) {
|
||||
try {
|
||||
await markNotificationRead(notification);
|
||||
} finally {
|
||||
closeMenu();
|
||||
await router.push(notification.target.path);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (unreadCount.value === 0 || markingAll.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
markingAll.value = true;
|
||||
try {
|
||||
const result = await api.markAllNotificationsRead();
|
||||
unreadCount.value = result.unreadCount;
|
||||
const now = new Date().toISOString();
|
||||
notifications.value = notifications.value.map((notification) => ({
|
||||
...notification,
|
||||
readAt: notification.readAt ?? now
|
||||
}));
|
||||
} finally {
|
||||
markingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reactionLabel(type: LifeReactionType | null) {
|
||||
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.reactionLike');
|
||||
}
|
||||
|
||||
function reactionIcon(type: LifeReactionType | null) {
|
||||
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
|
||||
}
|
||||
|
||||
function actorName(notification: NotificationItem) {
|
||||
return notification.actor?.displayName ?? t('notifications.systemActor');
|
||||
}
|
||||
|
||||
function targetLabel(type: NotificationTargetType) {
|
||||
const labels: Record<NotificationTargetType, string> = {
|
||||
'life-post': t('notifications.targetLifePost'),
|
||||
'life-comment': t('notifications.targetLifeComment'),
|
||||
'discussion-comment': t('notifications.targetDiscussionComment')
|
||||
};
|
||||
return labels[type];
|
||||
}
|
||||
|
||||
function notificationText(notification: NotificationItem) {
|
||||
if (notification.type === 'life_post_comment') {
|
||||
return t('notifications.lifePostComment', { actor: actorName(notification) });
|
||||
}
|
||||
if (notification.type === 'life_comment_reply') {
|
||||
return t('notifications.lifeCommentReply', { actor: actorName(notification) });
|
||||
}
|
||||
if (notification.type === 'discussion_comment_reply') {
|
||||
return t('notifications.discussionCommentReply', { actor: actorName(notification) });
|
||||
}
|
||||
if (notification.type === 'life_post_reaction') {
|
||||
return t('notifications.lifePostReaction', {
|
||||
actor: actorName(notification),
|
||||
reaction: reactionLabel(notification.reactionType)
|
||||
});
|
||||
}
|
||||
|
||||
const target = targetLabel(notification.target.type);
|
||||
if (notification.moderationStatus === 'approved') {
|
||||
return t('notifications.moderationApproved', { target });
|
||||
}
|
||||
if (notification.moderationStatus === 'rejected') {
|
||||
return t('notifications.moderationRejected', { target });
|
||||
}
|
||||
return t('notifications.moderationFailed', { target });
|
||||
}
|
||||
|
||||
function notificationIcon(notification: NotificationItem) {
|
||||
if (notification.type === 'life_post_comment') {
|
||||
return iconComment;
|
||||
}
|
||||
if (notification.type === 'life_comment_reply' || notification.type === 'discussion_comment_reply') {
|
||||
return iconReply;
|
||||
}
|
||||
if (notification.type === 'life_post_reaction') {
|
||||
return reactionIcon(notification.reactionType);
|
||||
}
|
||||
return notification.moderationStatus === 'approved' ? iconCheck : iconWarning;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.currentUser?.id ?? null,
|
||||
(userId) => {
|
||||
disconnectNotifications();
|
||||
notifications.value = [];
|
||||
unreadCount.value = 0;
|
||||
nextCursor.value = null;
|
||||
hasMore.value = false;
|
||||
loadError.value = '';
|
||||
|
||||
if (userId) {
|
||||
void loadNotifications(true);
|
||||
void connectNotifications();
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
} else {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectNotifications();
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="notification-menu">
|
||||
<button
|
||||
class="notification-menu__trigger"
|
||||
type="button"
|
||||
:aria-label="t('notifications.open')"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="menu"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span class="notification-menu__icon-wrap">
|
||||
<Icon :icon="iconBell" class="ui-icon notification-menu__icon" aria-hidden="true" />
|
||||
<span v-if="unreadCount > 0" class="notification-menu__badge">{{ displayUnreadCount }}</span>
|
||||
</span>
|
||||
<span class="notification-menu__label">{{ t('notifications.title') }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="notification-menu__dropdown" role="menu">
|
||||
<div class="notification-menu__header">
|
||||
<div>
|
||||
<h2>{{ t('notifications.title') }}</h2>
|
||||
<p>{{ t('notifications.unreadCount', { count: unreadCount }) }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="notification-menu__mark-all"
|
||||
type="button"
|
||||
:disabled="unreadCount === 0 || markingAll"
|
||||
@click="markAllRead"
|
||||
>
|
||||
{{ t('notifications.markAllRead') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="notification-list" aria-hidden="true">
|
||||
<article v-for="index in 4" :key="index" class="notification-item notification-item--skeleton">
|
||||
<Skeleton width="36px" height="36px" radius="999px" />
|
||||
<div class="notification-item__copy">
|
||||
<Skeleton width="85%" height="14px" />
|
||||
<Skeleton width="44%" height="12px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="notification-menu__empty">
|
||||
<Icon :icon="iconWarning" class="notification-menu__empty-icon" aria-hidden="true" />
|
||||
<p>{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length" class="notification-list">
|
||||
<article
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
:class="{ 'notification-item--unread': !notification.readAt }"
|
||||
>
|
||||
<button class="notification-item__main" type="button" role="menuitem" @click="activateNotification(notification)">
|
||||
<span class="notification-item__icon">
|
||||
<Icon :icon="notificationIcon(notification)" class="ui-icon" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="notification-item__copy">
|
||||
<strong>{{ notificationText(notification) }}</strong>
|
||||
<time :datetime="notification.createdAt">{{ formatDateTime(notification.createdAt) }}</time>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!notification.readAt"
|
||||
class="notification-item__read-button"
|
||||
type="button"
|
||||
:disabled="busyId === notification.id"
|
||||
:aria-label="t('notifications.markRead')"
|
||||
@click="markNotificationRead(notification)"
|
||||
>
|
||||
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-menu__empty">
|
||||
<Icon :icon="iconBell" class="notification-menu__empty-icon" aria-hidden="true" />
|
||||
<h3>{{ t('notifications.emptyTitle') }}</h3>
|
||||
<p>{{ t('notifications.emptyBody') }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasMore && !loading"
|
||||
class="notification-menu__load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="loadNotifications(false)"
|
||||
>
|
||||
{{ loadingMore ? t('common.loading') : t('notifications.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ export const iconAction: AppIcon = 'mdi:gesture-tap-button';
|
||||
export const iconArtifact: AppIcon = 'mdi:diamond-stone';
|
||||
export const iconAutomation: AppIcon = 'mdi:factory';
|
||||
export const iconBack: AppIcon = 'mdi:arrow-left';
|
||||
export const iconBell: AppIcon = 'mdi:bell-outline';
|
||||
export const iconCancel: AppIcon = 'mdi:close';
|
||||
export const iconCheck: AppIcon = 'mdi:check';
|
||||
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||
|
||||
@@ -339,6 +339,14 @@ export interface DataToolsBundle {
|
||||
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
||||
export type NotificationType =
|
||||
| 'life_post_comment'
|
||||
| 'life_comment_reply'
|
||||
| 'discussion_comment_reply'
|
||||
| 'life_post_reaction'
|
||||
| 'moderation_result';
|
||||
export type NotificationModerationStatus = Extract<AiModerationStatus, 'approved' | 'rejected' | 'failed'>;
|
||||
export type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
||||
|
||||
export interface LifePost {
|
||||
id: number;
|
||||
@@ -423,6 +431,56 @@ export interface LifeReactionUsersParams {
|
||||
reactionType?: LifeReactionType;
|
||||
}
|
||||
|
||||
export interface NotificationTarget {
|
||||
type: NotificationTargetType;
|
||||
id: number;
|
||||
path: string;
|
||||
lifePostId: number | null;
|
||||
lifeCommentId: number | null;
|
||||
discussionCommentId: number | null;
|
||||
entityType: DiscussionEntityType | null;
|
||||
entityId: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
actor: UserSummary | null;
|
||||
target: NotificationTarget;
|
||||
reactionType: LifeReactionType | null;
|
||||
moderationStatus: NotificationModerationStatus | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationsPage {
|
||||
items: NotificationItem[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface NotificationsParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface NotificationReadResponse {
|
||||
notification: NotificationItem | null;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface NotificationWsTicket {
|
||||
ticket: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export type NotificationWsMessage =
|
||||
| { type: 'notifications.connected'; unreadCount: number }
|
||||
| { type: 'notifications.created'; notification: NotificationItem; unreadCount: number }
|
||||
| { type: 'notifications.unread'; unreadCount: number };
|
||||
|
||||
export interface RecipeDetail extends Recipe {
|
||||
acquisition_methods: NamedEntity[];
|
||||
editHistory: EditHistoryEntry[];
|
||||
@@ -858,6 +916,15 @@ function requestHeaders(): HeadersInit {
|
||||
};
|
||||
}
|
||||
|
||||
export function notificationWebSocketUrl(ticket: string): string {
|
||||
const base = new URL(apiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
base.pathname = '/api/notifications/ws';
|
||||
base.search = '';
|
||||
base.searchParams.set('ticket', ticket);
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
async function getErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await response.json()) as { message?: unknown };
|
||||
@@ -993,6 +1060,17 @@ export const api = {
|
||||
changePassword: (payload: ChangePasswordPayload) =>
|
||||
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
|
||||
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
|
||||
notifications: (params: NotificationsParams = {}) =>
|
||||
getJson<NotificationsPage>(
|
||||
`/api/notifications${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
})}`
|
||||
),
|
||||
notificationWsTicket: () => sendJson<NotificationWsTicket>('/api/notifications/ws-ticket', 'POST', {}),
|
||||
markNotificationRead: (id: string | number) =>
|
||||
sendJson<NotificationReadResponse>(`/api/notifications/${id}/read`, 'POST', {}),
|
||||
markAllNotificationsRead: () => sendJson<{ unreadCount: number }>('/api/notifications/read-all', 'POST', {}),
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
|
||||
userLifePosts: (id: string | number, params: ProfileActivityParams = {}) =>
|
||||
|
||||
@@ -369,6 +369,287 @@ svg {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-sidebar .notification-menu__trigger {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.notification-menu__trigger {
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft);
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
border-color 0.14s ease,
|
||||
box-shadow 0.14s ease,
|
||||
color 0.14s ease;
|
||||
}
|
||||
|
||||
.notification-menu__trigger:hover,
|
||||
.notification-menu__trigger[aria-expanded="true"] {
|
||||
border-color: var(--pokemon-blue);
|
||||
background: rgba(255, 203, 5, 0.22);
|
||||
color: var(--pokemon-blue-deep);
|
||||
}
|
||||
|
||||
.notification-menu__trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--pokemon-blue);
|
||||
box-shadow: 0 0 0 4px rgba(42, 117, 187, 0.16);
|
||||
}
|
||||
|
||||
.notification-menu__icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.notification-menu__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.notification-menu__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-menu__badge {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: -11px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
border: 2px solid var(--surface);
|
||||
border-radius: 999px;
|
||||
background: var(--pokemon-red);
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-menu__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 62;
|
||||
width: min(370px, calc(100vw - 40px));
|
||||
max-height: min(560px, calc(100vh - 48px));
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.site-sidebar .notification-menu__dropdown {
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.notification-menu__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.notification-menu__header h2,
|
||||
.notification-menu__empty h3 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 950;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.notification-menu__header p,
|
||||
.notification-menu__empty p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.notification-menu__mark-all,
|
||||
.notification-menu__load-more,
|
||||
.notification-item__read-button {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: transparent;
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-menu__mark-all {
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-menu__mark-all:hover,
|
||||
.notification-menu__load-more:hover,
|
||||
.notification-item__read-button:hover {
|
||||
background: rgba(255, 203, 5, 0.24);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.notification-item--unread {
|
||||
background: rgba(42, 117, 187, 0.06);
|
||||
}
|
||||
|
||||
.notification-item--skeleton {
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.notification-item__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-item__main:hover {
|
||||
background: rgba(255, 203, 5, 0.16);
|
||||
}
|
||||
|
||||
.notification-item__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-soft);
|
||||
color: var(--pokemon-blue-deep);
|
||||
}
|
||||
|
||||
.notification-item--unread .notification-item__icon {
|
||||
border-color: var(--pokemon-blue);
|
||||
background: rgba(255, 203, 5, 0.28);
|
||||
}
|
||||
|
||||
.notification-item__icon .ui-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.notification-item__copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notification-item__copy strong {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-item__copy time {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.notification-item__read-button {
|
||||
width: 38px;
|
||||
min-height: 100%;
|
||||
border-left: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.notification-item__read-button .ui-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.notification-menu__empty {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 6px;
|
||||
padding: 28px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-menu__empty-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.notification-menu__load-more {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.site-sidebar .language-menu__trigger {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
@@ -579,6 +860,7 @@ svg {
|
||||
.app-shell--sidebar-collapsed .side-nav__label,
|
||||
.app-shell--sidebar-collapsed .auth-user__name,
|
||||
.app-shell--sidebar-collapsed .auth-actions__label,
|
||||
.app-shell--sidebar-collapsed .notification-menu__label,
|
||||
.app-shell--sidebar-collapsed .language-menu__glyph {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
@@ -590,6 +872,7 @@ svg {
|
||||
.app-shell--sidebar-collapsed .side-nav__link,
|
||||
.app-shell--sidebar-collapsed .auth-actions .ui-button,
|
||||
.app-shell--sidebar-collapsed .auth-user,
|
||||
.app-shell--sidebar-collapsed .site-sidebar .notification-menu__trigger,
|
||||
.app-shell--sidebar-collapsed .site-sidebar .language-menu__trigger {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
@@ -626,6 +909,11 @@ svg {
|
||||
bottom: 0;
|
||||
left: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.app-shell--sidebar-collapsed .site-sidebar .notification-menu__dropdown {
|
||||
bottom: 0;
|
||||
left: calc(100% + 8px);
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
|
||||
Reference in New Issue
Block a user