new features

This commit is contained in:
Daniil
2026-02-27 23:34:17 +03:00
parent 42ce5fa0fe
commit 71b974903a
191 changed files with 11300 additions and 373 deletions
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
export { NotificationBell } from "./NotificationBell"
export { NotificationPopup } from "./NotificationPopup"