This commit is contained in:
Daniil
2026-04-07 13:42:23 +03:00
parent d648678c68
commit 46f34bdcac
59 changed files with 2708 additions and 1312 deletions
@@ -83,55 +83,51 @@
min-width: 0;
}
.itemTitle {
@include typography.font-body-14(600);
color: variables.$text-primary;
.itemHeader {
display: flex;
align-items: flex-start;
gap: 12px;
}
.itemHeadline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.itemMessage {
@include typography.font-caption-m;
color: variables.$text-secondary;
margin-top: 2px;
.itemTitle {
@include typography.font-body-14(600);
color: variables.$text-primary;
min-width: 0;
flex: 1;
}
.itemTitleText {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemMeta {
.itemStatusBadge {
flex-shrink: 0;
}
.itemTime {
@include typography.font-caption-m;
color: variables.$text-tertiary;
flex-shrink: 0;
}
.itemMessage {
@include typography.font-caption-m;
color: variables.$text-secondary;
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: 600;
line-height: 16px;
}
.statusRunning {
background-color: hsl(262, 50%, 94%);
color: hsl(262, 72%, 45%);
}
.statusDone {
background-color: hsl(150, 30%, 92%);
color: hsl(150, 50%, 30%);
}
.statusFailed {
background-color: hsl(0, 80%, 95%);
color: hsl(0, 65%, 40%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progressBar {
@@ -10,13 +10,16 @@ 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 { formatNotificationRelativeTime } from "@shared/lib/dates"
import { API_URL } from "@shared/lib/constants"
import {
markAllRead,
markRead,
NotificationItem,
} from "@shared/store/notifications"
import { Badge } from "@shared/ui"
import { getNotificationPresentation } from "./presentation"
const apiBase = API_URL || "http://localhost:8000"
@@ -27,34 +30,6 @@ function authHeaders(): HeadersInit {
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,
@@ -120,56 +95,59 @@ export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
{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}
items.map((item, idx) => {
const presentation = getNotificationPresentation(item)
return (
<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.itemHeader}>
<div className={styles.itemHeadline}>
<div className={styles.itemTitle}>
<span className={styles.itemTitleText}>
{presentation.title}
</span>
</div>
{presentation.statusText &&
presentation.statusVariant && (
<Badge
variant={presentation.statusVariant}
className={styles.itemStatusBadge}
>
{presentation.statusText}
</Badge>
)}
</div>
<span className={styles.itemTime}>
{formatNotificationRelativeTime(item.created_at)}
</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}%`,
}}
/>
{presentation.detailText && (
<div className={styles.itemMessage}>
{presentation.detailText}
</div>
)}
<div className={styles.itemMeta}>
{formatRelativeTime(item.created_at)}
{item.status === "RUNNING" &&
item.progress_pct != null && (
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{
width: `${item.progress_pct}%`,
}}
/>
</div>
)}
</div>
</div>
</div>
))
)
})
)}
</div>
</div>
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test"
import type { NotificationItem } from "@shared/store/notifications"
import { getNotificationPresentation } from "./presentation"
function buildNotification(
overrides: Partial<NotificationItem> = {},
): NotificationItem {
return {
event: "task_update",
notification_id: "notification-1",
job_id: "job-1",
project_id: "project-1",
job_type: "TRANSCRIPTION_GENERATE",
status: "DONE",
progress_pct: 100,
message: "Завершено",
title: "Задача завершена",
created_at: "2026-04-05T12:00:00.000Z",
is_read: false,
...overrides,
}
}
describe("getNotificationPresentation", () => {
test("uses the task type as the notification title and moves status into secondary text", () => {
expect(getNotificationPresentation(buildNotification())).toEqual({
title: "Транскрипция",
statusText: "Завершено",
statusVariant: "success",
detailText: null,
})
})
test("maps silence apply notifications to the operation title instead of backend status title", () => {
expect(
getNotificationPresentation(
buildNotification({
job_type: "SILENCE_APPLY",
title: "Задача завершена",
message: "Завершено",
}),
),
).toEqual({
title: "Применение вырезок",
statusText: "Завершено",
statusVariant: "success",
detailText: null,
})
})
test("keeps detailed progress text when it carries more information than the status", () => {
expect(
getNotificationPresentation(
buildNotification({
status: "RUNNING",
message: "Транскрибирование (whisper)",
}),
),
).toEqual({
title: "Транскрипция",
statusText: "Выполняется",
statusVariant: "info",
detailText: "Транскрибирование (whisper)",
})
})
test("maps failed notifications to a danger badge", () => {
expect(
getNotificationPresentation(
buildNotification({
status: "FAILED",
message: "Ошибка",
}),
),
).toEqual({
title: "Транскрипция",
statusText: "Ошибка",
statusVariant: "danger",
detailText: null,
})
})
})
@@ -0,0 +1,61 @@
import type { NotificationItem } from "@shared/store/notifications"
import type { BadgeVariant } from "@shared/ui/Badge/Badge.d"
const JOB_TYPE_LABELS: Record<string, string> = {
MEDIA_PROBE: "Анализ медиа",
SILENCE_REMOVE: "Удаление тишины",
SILENCE_DETECT: "Анализ тишины",
SILENCE_APPLY: "Применение вырезок",
MEDIA_CONVERT: "Конвертация",
TRANSCRIPTION_GENERATE: "Транскрипция",
CAPTIONS_GENERATE: "Генерация субтитров",
FRAME_EXTRACT: "Извлечение кадров",
}
const STATUS_LABELS: Record<string, string> = {
PENDING: "Ожидание",
RUNNING: "Выполняется",
DONE: "Завершено",
FAILED: "Ошибка",
}
const STATUS_VARIANTS: Record<string, BadgeVariant> = {
PENDING: "secondary",
RUNNING: "info",
DONE: "success",
FAILED: "danger",
}
export interface NotificationPresentation {
title: string
statusText: string | null
statusVariant: BadgeVariant | null
detailText: string | null
}
function normalizeText(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
export function getNotificationPresentation(
item: NotificationItem,
): NotificationPresentation {
const statusText = item.status ? (STATUS_LABELS[item.status] ?? item.status) : null
const statusVariant = item.status ? (STATUS_VARIANTS[item.status] ?? null) : null
const rawTitle = normalizeText(item.title)
const rawMessage = normalizeText(item.message)
const title = item.job_type
? (JOB_TYPE_LABELS[item.job_type] ?? rawTitle ?? "Уведомление")
: (rawTitle ?? "Уведомление")
return {
title,
statusText,
statusVariant,
detailText:
rawMessage && rawMessage !== statusText && rawMessage !== rawTitle
? rawMessage
: null,
}
}