new features
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export interface INotificationBellProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
// Rounded hover for ghost icon button
|
||||
:global(.rt-IconButton) {
|
||||
border-radius: variables.$radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 9999px;
|
||||
background-color: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
border: 1.5px solid variables.$bg-default;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import type { INotificationBellProps } from "./NotificationBell.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Bell } from "lucide-react"
|
||||
import { FunctionComponent, useCallback, useRef, useState } from "react"
|
||||
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import { Button } from "@shared/ui"
|
||||
|
||||
import { NotificationPopup } from "../NotificationPopup"
|
||||
|
||||
import styles from "./NotificationBell.module.scss"
|
||||
|
||||
export const NotificationBell: FunctionComponent<INotificationBellProps> =
|
||||
(): JSX.Element => {
|
||||
const unreadCount = useAppSelector(
|
||||
(state) => state.notifications.unreadCount,
|
||||
)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<Button variant="icon" size="lg" onClick={toggle}>
|
||||
<Bell size={22} />
|
||||
</Button>
|
||||
{unreadCount > 0 && (
|
||||
<span className={styles.badge}>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{isOpen && (
|
||||
<NotificationPopup
|
||||
onClose={close}
|
||||
anchorRef={rootRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NotificationBell } from "./NotificationBell"
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { RefObject } from "react"
|
||||
|
||||
export interface INotificationPopupProps {
|
||||
onClose: () => void
|
||||
anchorRef: RefObject<HTMLDivElement | null>
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-height: 480px;
|
||||
background-color: variables.$bg-surface;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
box-shadow: variables.$shadow-lg;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-body-14(600);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.readAllBtn {
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 500;
|
||||
color: variables.$purple-500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: variables.$purple-700;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: variables.$bg-hover;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
.itemUnread {
|
||||
border-left: 3px solid variables.$purple-500;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.itemMessage {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-secondary;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-tertiary;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.statusRunning {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.statusDone {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.statusFailed {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: variables.$border-subtle;
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background-color: variables.$purple-500;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import type { INotificationPopupProps } from "./NotificationPopup.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import Cookies from "js-cookie"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FunctionComponent, useCallback, useEffect, useRef } from "react"
|
||||
import { useDispatch } from "react-redux"
|
||||
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import { formatRelativeTime } from "@shared/lib/dates"
|
||||
import { API_URL } from "@shared/lib/constants"
|
||||
import {
|
||||
markAllRead,
|
||||
markRead,
|
||||
NotificationItem,
|
||||
} from "@shared/store/notifications"
|
||||
|
||||
const apiBase = API_URL || "http://localhost:8000"
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = Cookies.get("access_token")
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
import styles from "./NotificationPopup.module.scss"
|
||||
|
||||
const JOB_TYPE_LABELS: Record<string, string> = {
|
||||
MEDIA_PROBE: "Анализ медиа",
|
||||
SILENCE_REMOVE: "Удаление тишины",
|
||||
MEDIA_CONVERT: "Конвертация",
|
||||
TRANSCRIPTION_GENERATE: "Транскрипция",
|
||||
CAPTIONS_GENERATE: "Генерация субтитров",
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Ожидание",
|
||||
RUNNING: "Выполняется",
|
||||
DONE: "Завершено",
|
||||
FAILED: "Ошибка",
|
||||
}
|
||||
|
||||
function getStatusClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case "RUNNING":
|
||||
return styles.statusRunning
|
||||
case "DONE":
|
||||
return styles.statusDone
|
||||
case "FAILED":
|
||||
return styles.statusFailed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
||||
onClose,
|
||||
anchorRef,
|
||||
}): JSX.Element => {
|
||||
const items = useAppSelector((state) => state.notifications.items)
|
||||
const dispatch = useDispatch()
|
||||
const router = useRouter()
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(e.target as Node) &&
|
||||
anchorRef.current &&
|
||||
!anchorRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
dispatch(markAllRead())
|
||||
fetch(`${apiBase}/api/notifications/read-all/`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
}).catch(() => {})
|
||||
}, [dispatch])
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: NotificationItem) => {
|
||||
if (item.notification_id && !item.is_read) {
|
||||
dispatch(markRead(item.notification_id))
|
||||
fetch(`${apiBase}/api/notifications/${item.notification_id}/read/`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (item.project_id) {
|
||||
router.push(`/projects/${item.project_id}`)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[dispatch, router, onClose],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={popupRef}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>Уведомления</span>
|
||||
<button
|
||||
className={styles.readAllBtn}
|
||||
onClick={handleMarkAllRead}
|
||||
type="button"
|
||||
>
|
||||
Прочитать все
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>Нет уведомлений</div>
|
||||
) : (
|
||||
items.map((item, idx) => (
|
||||
<div
|
||||
key={item.notification_id || `${item.job_id}-${idx}`}
|
||||
className={cs(styles.item, {
|
||||
[styles.itemUnread]: !item.is_read,
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.itemTitle}>
|
||||
<span>
|
||||
{item.job_type
|
||||
? (JOB_TYPE_LABELS[item.job_type] ||
|
||||
item.title)
|
||||
: item.title}
|
||||
</span>
|
||||
{item.status && (
|
||||
<span
|
||||
className={cs(
|
||||
styles.statusBadge,
|
||||
getStatusClass(item.status),
|
||||
)}
|
||||
>
|
||||
{STATUS_LABELS[item.status] ||
|
||||
item.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.message && (
|
||||
<div className={styles.itemMessage}>
|
||||
{item.message}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "RUNNING" &&
|
||||
item.progress_pct != null && (
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{
|
||||
width: `${item.progress_pct}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.itemMeta}>
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NotificationPopup } from "./NotificationPopup"
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NotificationBell } from "./NotificationBell"
|
||||
export { NotificationPopup } from "./NotificationPopup"
|
||||
Reference in New Issue
Block a user