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,
}
}
@@ -1,23 +1,168 @@
/* ── Entrance animations ── */
@keyframes fadeSlideDown {
from {
opacity: 0;
transform: translateY(-12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes sparkleFloat {
0%,
100% {
opacity: 0;
transform: scale(0.6) translateY(0);
}
20% {
opacity: 0.7;
transform: scale(1) translateY(-6px);
}
50% {
opacity: 0.4;
transform: scale(0.8) translateY(-12px);
}
80% {
opacity: 0.6;
transform: scale(1) translateY(-4px);
}
}
/* ── Root ── */
.root {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ── Sparkle particles ── */
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.sparkle {
position: absolute;
border-radius: 50%;
animation: sparkleFloat 3.5s ease-in-out infinite;
}
.sparkleGreen {
background: color-mix(in srgb, variables.$color-success 60%, transparent);
}
.sparklePurple {
background: color-mix(in srgb, variables.$purple-400 55%, transparent);
}
/* ── Content area (scrollable) ── */
.content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
flex: 1;
padding: 32px 24px 24px;
overflow-y: auto;
min-height: 0;
}
/* ── Success header ── */
.header {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
animation: fadeSlideDown 0.6s variables.$ease-out both;
}
.title {
font-size: 20px;
font-weight: 600;
color: var(--gray-12);
@include typography.font-header-l;
color: variables.$text-primary;
margin: 0;
}
.subtitle {
@include typography.font-body-14(400);
color: variables.$text-tertiary;
margin: 0;
}
/* ── Video player ── */
.playerContainer {
width: 100%;
max-width: 780px;
animation: fadeSlideUp 0.7s variables.$ease-out 0.15s both;
}
.playerWrapper {
border-radius: 12px;
position: relative;
border-radius: variables.$radius-md;
overflow: hidden;
background: #000;
max-height: 60vh;
box-shadow: variables.$shadow-lg;
aspect-ratio: 16 / 9;
:global([data-media-player]) {
width: 100% !important;
height: 100% !important;
}
:global(.vds-video-layout) {
width: 100%;
height: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.player {
@@ -30,29 +175,92 @@
align-items: center;
justify-content: center;
aspect-ratio: 16 / 9;
color: var(--gray-9);
color: variables.$text-tertiary;
@include typography.font-body-14(400);
}
.filename {
font-size: 13px;
color: var(--gray-9);
margin: 0;
/* ── File info bar ── */
.fileInfoContainer {
width: 100%;
max-width: 780px;
animation: fadeSlideUp 0.6s variables.$ease-out 0.35s both;
}
.fileInfoBar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: variables.$bg-default;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-sm;
box-shadow: variables.$shadow-sm;
}
.fileIcon {
color: variables.$text-tertiary;
flex-shrink: 0;
}
.fileName {
@include typography.font-body-14(500);
color: variables.$text-primary;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileSize {
@include typography.font-caption-m;
color: variables.$text-tertiary;
flex-shrink: 0;
}
/* ── Loading state ── */
.loading {
padding: 48px;
text-align: center;
color: var(--gray-9);
color: variables.$text-tertiary;
@include typography.font-body-14(400);
}
/* ── Footer ── */
.footer {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--gray-6);
align-items: center;
padding: 16px 24px;
border-top: 1px solid variables.$border-subtle;
background: variables.$bg-surface;
animation: fadeIn 0.5s variables.$ease-out 0.5s both;
}
.rightActions {
display: flex;
gap: 8px;
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.header,
.playerContainer,
.fileInfoContainer,
.footer {
animation: none;
}
.sparkle {
animation: none;
display: none;
}
}
@@ -12,17 +12,33 @@ import {
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import { Download, RefreshCw } from "lucide-react"
import {
Check,
CheckCircle,
Download,
FileVideo,
RefreshCw,
} from "lucide-react"
import { FunctionComponent, useMemo } from "react"
import cs from "classnames"
import api from "@shared/api"
import { useWizard } from "@shared/context/WizardContext"
import { Button } from "@shared/ui"
import { Badge, Button } from "@shared/ui"
import styles from "./CaptionResultStep.module.scss"
const SPARKLE_COUNT = 10
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} ГБ`
}
export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
className,
}): JSX.Element => {
@@ -73,20 +89,11 @@ export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
setCaptionedVideoPath(effectivePath)
}
const { data: fileRecord } = api.useQuery(
"get",
"/api/files/files/{file_id}/",
{ params: { path: { file_id: effectiveFileId ?? "" } } },
{ enabled: !!effectiveFileId },
)
const filePath = fileRecord?.path ?? effectivePath ?? ""
const { data: fileInfo, isLoading } = api.useQuery(
"get",
"/api/files/get_file/",
{ params: { query: { file_path: filePath } } },
{ enabled: !!filePath },
"/api/files/files/{file_id}/resolve/",
{ params: { path: { file_id: effectiveFileId ?? "" } } },
{ enabled: !!effectiveFileId },
)
const videoUrl = fileInfo?.file_url ?? ""
@@ -107,6 +114,19 @@ export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
markStepCompleted("caption-result")
}
const sparkles = useMemo(
() =>
Array.from({ length: SPARKLE_COUNT }, (_, i) => ({
id: i,
variant: i % 2 === 0 ? "green" : "purple",
left: `${10 + (i * 80) / SPARKLE_COUNT + Math.sin(i * 1.7) * 5}%`,
top: `${15 + Math.sin(i * 2.3) * 30 + 20}%`,
delay: `${(i * 0.3) % 2.5}s`,
size: 4 + (i % 3) * 2,
})),
[],
)
if (isLoading) {
return (
<div className={cs(styles.root, className)}>
@@ -117,39 +137,90 @@ export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
return (
<div className={cs(styles.root, className)} data-testid="CaptionResultStep">
<h2 className={styles.title}>Результат</h2>
{/* Sparkle particles */}
<div className={styles.sparkles} aria-hidden="true">
{sparkles.map((s) => (
<span
key={s.id}
className={cs(
styles.sparkle,
s.variant === "green"
? styles.sparkleGreen
: styles.sparklePurple,
)}
style={{
left: s.left,
top: s.top,
animationDelay: s.delay,
width: s.size,
height: s.size,
}}
/>
))}
</div>
<div className={styles.playerWrapper}>
{videoUrl ? (
<MediaPlayer
src={videoUrl}
crossOrigin=""
playsInline
className={styles.player}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
) : (
<div className={styles.placeholder}>Видео недоступно</div>
{/* Content area */}
<div className={styles.content}>
{/* Success header */}
<div className={styles.header}>
<Badge variant="success">
<CheckCircle size={14} />
Готово
</Badge>
<h2 className={styles.title}>Результат</h2>
<p className={styles.subtitle}>
Видео с субтитрами готово к скачиванию
</p>
</div>
{/* Video player */}
<div className={styles.playerContainer}>
<div className={styles.playerWrapper}>
{videoUrl ? (
<MediaPlayer
src={videoUrl}
crossOrigin=""
playsInline
className={styles.player}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
) : (
<div className={styles.placeholder}>Видео недоступно</div>
)}
</div>
</div>
{/* File info bar */}
{fileInfo?.filename && (
<div className={styles.fileInfoContainer}>
<div className={styles.fileInfoBar}>
<FileVideo size={18} className={styles.fileIcon} />
<span className={styles.fileName}>{fileInfo.filename}</span>
{typeof fileInfo.file_size === "number" && (
<span className={styles.fileSize}>
{formatFileSize(fileInfo.file_size)}
</span>
)}
</div>
</div>
)}
</div>
{fileInfo?.filename && (
<p className={styles.filename}>{fileInfo.filename}</p>
)}
{/* Footer */}
<div className={styles.footer}>
<Button variant="outline" onClick={handleRerender}>
<Button variant="ghost" onClick={handleRerender}>
<RefreshCw size={16} />
Перегенерировать
</Button>
<div className={styles.rightActions}>
<Button variant="outline" onClick={handleDownload}>
<Button variant="primary" onClick={handleDownload}>
<Download size={16} />
Скачать
</Button>
<Button variant="primary" onClick={handleFinish}>
<Button variant="outline" onClick={handleFinish}>
<Check size={16} />
Завершить
</Button>
</div>
@@ -0,0 +1,150 @@
.presetCard {
position: relative;
display: flex;
flex-direction: column;
background: variables.$bg-default;
border: 1.5px solid variables.$border-subtle;
border-radius: variables.$radius-md;
overflow: hidden;
cursor: pointer;
box-shadow: variables.$shadow-sm;
transition: border-color var(--duration-normal) var(--ease-out);
&:hover {
border-color: variables.$color-secondary;
}
}
.selected {
border-color: variables.$color-primary;
box-shadow: variables.$shadow-sm, 0 0 0 1px variables.$color-primary;
&:hover {
border-color: variables.$color-primary;
}
}
.previewArea {
position: relative;
background: #0c0a1a;
overflow: hidden;
}
.selectedIndicator {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: variables.$color-primary;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
color: white;
svg {
width: 12px;
height: 12px;
}
}
.cardFooter {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
}
.presetName {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: variables.$text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.systemBadge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 500;
line-height: 1;
color: variables.$color-primary;
background: variables.$purple-100;
padding: 3px 8px;
border-radius: variables.$radius-sm;
}
.styleChars {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: variables.$text-tertiary;
white-space: nowrap;
overflow: hidden;
}
.colorDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(128, 128, 128, 0.15);
}
.divider {
color: variables.$border-default;
font-size: 10px;
}
.cardActions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--duration-fast);
z-index: 3;
.presetCard:hover & {
opacity: 1;
}
.selected & {
display: none;
}
}
.iconButton {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: variables.$bg-surface;
color: variables.$text-secondary;
cursor: pointer;
transition: background var(--duration-fast), color var(--duration-fast);
&:hover {
background: variables.$bg-hover;
color: variables.$text-primary;
}
}
@include breakpoints.respond-to(breakpoints.$mobileMax) {
.styleChars {
display: none;
}
}
@@ -0,0 +1,145 @@
"use client"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { JSX } from "react"
import { Pencil, Trash2 } from "lucide-react"
import { FunctionComponent } from "react"
import cs from "classnames"
import { StylePreview } from "./StylePreview"
import styles from "./PresetCard.module.scss"
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
interface IPresetCardProps {
preset: CaptionPresetRead
isSelected: boolean
aspectRatio?: number
onSelect: () => void
onEdit: () => void
onDelete: () => void
}
// Helper to extract style characteristics
function getStyleCharacteristics(config: CaptionStyleConfig | undefined): {
fontFamily: string
accentColor: string | null
accentName: string | null
} {
const style = config
const fontFamily = style?.text?.font_family ?? "Inter"
const highlightColor = style?.text?.highlight_color
const textColor = style?.text?.text_color
const colorMap: Record<string, string> = {
"#FFD700": "Золотой",
"#00ffff": "Неоновый",
"#ffffff": "Белый",
"#ff006e": "Розовый",
"#cba6f7": "Пурпурный",
"#f38ba8": "Розовый",
"#a6e3a1": "Зеленый",
"#f9e2af": "Желтый",
"#89dceb": "Голубой",
}
const accentColor = highlightColor || textColor || null
const accentName = accentColor ? (colorMap[accentColor] ?? null) : null
return {
fontFamily,
accentColor,
accentName,
}
}
export const PresetCard: FunctionComponent<IPresetCardProps> = ({
preset,
isSelected,
aspectRatio = 16 / 9,
onSelect,
onEdit,
onDelete,
}): JSX.Element => {
const { fontFamily, accentColor, accentName } = getStyleCharacteristics(
preset.style_config,
)
return (
<div
className={cs(styles.presetCard, { [styles.selected]: isSelected })}
onClick={onSelect}
role="button"
tabIndex={0}
>
<div className={styles.previewArea}>
<StylePreview
config={preset.style_config}
size="small"
aspectRatio={aspectRatio}
/>
{isSelected && (
<div className={styles.selectedIndicator}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</div>
<div className={styles.cardFooter}>
<div className={styles.presetName}>
{preset.name}
{preset.is_system && (
<span className={styles.systemBadge}>Системный</span>
)}
</div>
<div className={styles.styleChars}>
{fontFamily}
{accentColor && accentName && (
<>
<span className={styles.divider}>·</span>
<span
className={styles.colorDot}
style={{ background: accentColor }}
/>
<span style={{ color: accentColor }}>{accentName}</span>
</>
)}
</div>
</div>
{!preset.is_system && (
<div className={styles.cardActions}>
<button
className={styles.iconButton}
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="Редактировать"
>
<Pencil size={14} />
</button>
<button
className={styles.iconButton}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="Удалить"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
)
}
@@ -1,122 +1,46 @@
.grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: flex-start;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
height: 100cqh;
box-sizing: border-box;
border: 2px solid var(--gray-6);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s ease;
background: var(--gray-2);
&:hover {
border-color: var(--gray-8);
}
}
.selected {
border-color: var(--accent-9);
&:hover {
border-color: var(--accent-10);
}
}
.cardFooter {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.cardName {
font-size: 13px;
font-weight: 500;
color: var(--gray-12);
}
.systemBadge {
font-size: 10px;
font-weight: 500;
color: var(--accent-11);
background: var(--accent-3);
padding: 2px 6px;
border-radius: 4px;
}
.cardActions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s ease;
.card:hover & {
opacity: 1;
}
}
.iconButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: var(--gray-3);
color: var(--gray-11);
cursor: pointer;
transition: background 0.1s ease;
&:hover {
background: var(--gray-5);
color: var(--gray-12);
}
}
.createCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 100cqh;
box-sizing: border-box;
aspect-ratio: 9 / 16;
border-style: dashed;
border-color: var(--gray-7);
background: variables.$bg-default;
border: 1.5px dashed variables.$border-default;
border-radius: variables.$radius-md;
cursor: pointer;
transition: border-color var(--duration-normal) var(--ease-out);
&:hover {
border-color: var(--accent-8);
border-color: variables.$color-secondary;
}
}
.createPreview {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.createIcon {
color: var(--gray-9);
color: variables.$text-tertiary;
}
.createLabel {
font-size: 13px;
color: var(--gray-9);
color: variables.$text-tertiary;
}
.loading {
padding: 48px;
text-align: center;
color: var(--gray-9);
color: variables.$text-tertiary;
}
.deleteActions {
@@ -125,3 +49,16 @@
gap: 8px;
margin-top: 16px;
}
@include breakpoints.respond-to(breakpoints.$tabletMax) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
}
@include breakpoints.respond-to(breakpoints.$mobileMax) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -3,19 +3,22 @@
import type { components } from "@shared/api/__generated__/openapi.types"
import type { JSX } from "react"
import cs from "classnames"
import { Pencil, Plus, Trash2 } from "lucide-react"
import { Plus } from "lucide-react"
import { FunctionComponent, useState } from "react"
import { useWizard } from "@shared/context/WizardContext"
import { Button, Modal } from "@shared/ui"
import { StylePreview } from "./StylePreview"
import { PresetCard } from "./PresetCard"
import { PresetCardSkeleton } from "./PresetCardSkeleton"
import { useDeletePreset, usePresetsQuery } from "./useCaptionPresets"
import { useVideoMetadata } from "./useVideoMetadata"
import styles from "./PresetGrid.module.scss"
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
const SKELETON_COUNT = 5
interface IPresetGridProps {
selectedPresetId: string | null
onSelect: (presetId: string) => void
@@ -23,65 +26,23 @@ interface IPresetGridProps {
onCreateNew: () => void
}
const PresetCard: FunctionComponent<{
preset: CaptionPresetRead
isSelected: boolean
onSelect: () => void
onEdit: () => void
onDelete: () => void
}> = ({ preset, isSelected, onSelect, onEdit, onDelete }) => (
<div
className={cs(styles.card, { [styles.selected]: isSelected })}
onClick={onSelect}
role="button"
tabIndex={0}
>
<StylePreview config={preset.style_config} size="small" />
<div className={styles.cardFooter}>
<span className={styles.cardName}>{preset.name}</span>
{preset.is_system && (
<span className={styles.systemBadge}>Системный</span>
)}
</div>
{!preset.is_system && (
<div className={styles.cardActions}>
<button
className={styles.iconButton}
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="Редактировать"
>
<Pencil size={14} />
</button>
<button
className={styles.iconButton}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="Удалить"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
)
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
selectedPresetId,
onSelect,
onEdit,
onCreateNew,
}): JSX.Element => {
const { data: presets, isLoading } = usePresetsQuery()
const { primaryFileId } = useWizard()
const { aspectRatio, isLoading: isMetadataLoading } =
useVideoMetadata(primaryFileId)
const { data: presets, isLoading: isPresetsLoading } = usePresetsQuery()
const deletePreset = useDeletePreset()
const [deleteTarget, setDeleteTarget] = useState<CaptionPresetRead | null>(
null,
)
const isLoading = isPresetsLoading
const handleConfirmDelete = () => {
if (!deleteTarget) return
deletePreset.mutate(
@@ -91,29 +52,39 @@ export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
}
if (isLoading) {
return <div className={styles.loading}>Загрузка пресетов...</div>
return (
<div className={styles.grid} data-testid="PresetGrid">
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
<PresetCardSkeleton key={i} aspectRatio={aspectRatio} />
))}
</div>
)
}
return (
<>
<div className={styles.grid}>
<div className={styles.grid} data-testid="PresetGrid">
{presets?.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id}
aspectRatio={aspectRatio}
onSelect={() => onSelect(preset.id)}
onEdit={() => onEdit(preset)}
onDelete={() => setDeleteTarget(preset)}
/>
))}
<div
className={cs(styles.card, styles.createCard)}
className={styles.createCard}
onClick={onCreateNew}
role="button"
tabIndex={0}
data-testid="PresetGrid-CreateCard"
>
<Plus size={32} className={styles.createIcon} />
<div className={styles.createPreview} style={{ aspectRatio }}>
<Plus size={32} className={styles.createIcon} />
</div>
<span className={styles.createLabel}>Создать пресет</span>
</div>
</div>
@@ -125,14 +96,11 @@ export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
title="Удаление пресета"
>
<p>
Удалить пресет &laquo;{deleteTarget.name}&raquo;? Это
действие нельзя отменить.
Удалить пресет &laquo;{deleteTarget.name}&raquo;? Это действие
нельзя отменить.
</p>
<div className={styles.deleteActions}>
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Отмена
</Button>
<Button
@@ -8,9 +8,7 @@
}
.small {
--preview-h: calc(100cqh - 38px);
height: var(--preview-h);
width: calc(var(--preview-h) * 9 / 16);
width: 100%;
}
.large {
@@ -18,7 +18,7 @@ interface IStylePreviewProps {
aspectRatio?: number
}
const SMALL_SCALE = 0.65
const SMALL_SCALE = 0.45
const buildContainerStyles = (
config: CaptionStyleConfig,
@@ -107,7 +107,7 @@ export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
<div
style={{
...buildContainerStyles(safeConfig, scale),
maxWidth: "100%",
maxWidth: `${safeConfig.layout?.max_width_pct ?? 90}%`,
boxSizing: "border-box",
}}
>
@@ -10,8 +10,14 @@ interface UseVideoMetadataResult {
const DEFAULT_ASPECT_RATIO = 16 / 9
export function useVideoMetadata(fileId: string | null): UseVideoMetadataResult {
const { data: mediaFile, isLoading, isError } = api.useQuery(
export function useVideoMetadata(
fileId: string | null,
): UseVideoMetadataResult {
const {
data: mediaFile,
isLoading,
isError,
} = api.useQuery(
"get",
"/api/media/mediafiles/{media_file_id}/",
{
@@ -23,7 +29,8 @@ export function useVideoMetadata(fileId: string | null): UseVideoMetadataResult
},
{
enabled: !!fileId,
}
retry: false,
},
)
const aspectRatio = useMemo(() => {
@@ -4,10 +4,10 @@ import type { IConvertMediaViewProps } from "./ConvertMediaView.d"
import type { JSX } from "react"
import { CheckCircle, FileVideo } from "lucide-react"
import { FunctionComponent, useCallback, useState } from "react"
import { FunctionComponent, useCallback, useEffect, useState } from "react"
import api from "@shared/api"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
import { Button } from "@shared/ui"
import styles from "./ConvertMediaView.module.scss"
@@ -37,24 +37,25 @@ export const ConvertMediaView: FunctionComponent<
const [jobId, setJobId] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const notification = useAppSelector((state) =>
jobId
? state.notifications.items.find((n) => n.job_id === jobId)
: null,
)
const resolvedProgress = useTaskProgressState({
jobId,
enabled: !!jobId && status === STATUS_CONVERTING,
defaultMessage: "Конвертация...",
})
const progressPct = notification?.progress_pct ?? 0
const notifStatus = notification?.status
const notifMessage = notification?.message
useEffect(() => {
if (status !== STATUS_CONVERTING) return
// Update status from notification
if (status === STATUS_CONVERTING && notifStatus === "DONE") {
setStatus(STATUS_DONE)
}
if (status === STATUS_CONVERTING && notifStatus === "FAILED") {
setStatus(STATUS_FAILED)
setErrorMessage(notifMessage ?? ERROR_CONVERT_FAILED)
}
if (resolvedProgress.status === "DONE") {
setStatus(STATUS_DONE)
return
}
if (resolvedProgress.status === "FAILED") {
setStatus(STATUS_FAILED)
setErrorMessage(resolvedProgress.errorMessage ?? ERROR_CONVERT_FAILED)
}
}, [resolvedProgress.errorMessage, resolvedProgress.status, status])
const { mutate, isPending } = api.useMutation(
"post",
@@ -103,16 +104,16 @@ export const ConvertMediaView: FunctionComponent<
<div className={styles.root} data-testid="ConvertMediaView">
<div className={styles.content}>
<FileVideo size={48} className={styles.icon} />
<p className={styles.message}>
{notifMessage ?? "Конвертация..."}
</p>
<p className={styles.message}>{resolvedProgress.message}</p>
<div className={styles.progressTrack}>
<div
className={styles.progressBar}
style={{ width: `${progressPct}%` }}
style={{ width: `${resolvedProgress.progressPct}%` }}
/>
</div>
<p className={styles.progressLabel}>{Math.round(progressPct)}%</p>
<p className={styles.progressLabel}>
{Math.round(resolvedProgress.progressPct)}%
</p>
</div>
</div>
)
@@ -72,6 +72,7 @@ export const FragmentsStep: FunctionComponent<IFragmentsStepProps> = ({
const {
projectId,
silenceJobId,
primaryFileId,
primaryFileKey,
startProcessingJob,
goBack,
@@ -106,9 +107,9 @@ export const FragmentsStep: FunctionComponent<IFragmentsStepProps> = ({
const { data: fileInfo } = api.useQuery(
"get",
"/api/files/get_file/",
{ params: { query: { file_path: fileKey } } },
{ enabled: !!fileKey },
"/api/files/files/{file_id}/resolve/",
{ params: { path: { file_id: primaryFileId ?? "" } } },
{ enabled: !!primaryFileId },
)
const videoUrl = fileInfo?.file_url ?? null
@@ -8,7 +8,7 @@ import { Info } from "lucide-react"
import { FunctionComponent } from "react"
import { useWizard } from "@shared/context/WizardContext"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
import { Button, CircularProgress } from "@shared/ui"
import {
@@ -51,20 +51,18 @@ export const ProcessingStep: FunctionComponent<IProcessingStepProps> = ({
goBack()
}
const notification = useAppSelector((state) =>
activeJobId
? state.notifications.items.find(
(n) => n.job_id === activeJobId,
)
: null,
)
const taskProgress = useTaskProgressState({
jobId: activeJobId,
enabled: !!activeJobId,
defaultMessage: "Подождите, идёт обработка...",
})
const progressPct = notification?.progress_pct ?? 0
const progressPct = taskProgress.progressPct
const statusLabel = activeJobType
? (JOB_TYPE_LABELS[activeJobType] ?? "ОБРАБОТКА")
: "ОБРАБОТКА"
const statusMessage = notification?.message ?? "Подождите, идёт обработка..."
const isFailed = notification?.status === "FAILED"
const statusMessage = taskProgress.message
const isFailed = taskProgress.status === "FAILED"
const handleCancel = () => {
if (!activeJobId || isCancelling) return
@@ -118,9 +116,10 @@ export const ProcessingStep: FunctionComponent<IProcessingStepProps> = ({
})}
>
{isFailed
? (notification?.message ?? "Произошла ошибка при обработке")
? (taskProgress.errorMessage ??
"Произошла ошибка при обработке")
: statusMessage}
</p>
</p>
<div className={styles.infoCard}>
<Info size={16} className={styles.infoIcon} />
@@ -89,7 +89,7 @@
top: 0;
left: 0;
height: 100%;
background: variables.$purple-400;
background: variables.$color-primary;
border-radius: 2px;
pointer-events: none;
}
@@ -100,7 +100,7 @@
width: 12px;
height: 12px;
border-radius: 50%;
background: variables.$purple-400;
background: variables.$color-primary;
transform: translate(-50%, -50%);
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
@@ -121,8 +121,8 @@
&:focus {
outline: none;
border-color: variables.$purple-400;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
border-color: variables.$color-primary;
box-shadow: var(--focus-ring);
}
}
@@ -95,11 +95,22 @@ export const SilenceResultModal: FunctionComponent<ISilenceResultModalProps> = (
const outputData = taskStatus?.output_data as Record<string, unknown> | null
const fileKey = (outputData?.file_key as string) ?? ""
const { data: project } = api.useQuery(
"get",
"/api/projects/{project_id}/",
{ params: { path: { project_id: projectId } } },
{ enabled: open },
)
const primaryFileId =
(project?.workspace_state as { wizard?: { primary_file_id?: string | null } } | null)
?.wizard?.primary_file_id ?? null
const { data: fileInfo } = api.useQuery(
"get",
"/api/files/get_file/",
{ params: { query: { file_path: fileKey } } },
{ enabled: open && !!fileKey },
"/api/files/files/{file_id}/resolve/",
{ params: { path: { file_id: primaryFileId ?? "" } } },
{ enabled: open && !!primaryFileId },
)
const videoUrl = fileInfo?.file_url ?? null
@@ -3,38 +3,118 @@
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
.mediaPlayer {
display: flex !important;
flex-direction: column !important;
flex: 1;
width: 100%;
max-width: 100%;
height: auto;
min-height: 0;
min-width: 0;
overflow: hidden;
// Reset vidstack player defaults
aspect-ratio: unset !important;
width: 100% !important;
height: auto !important;
}
.workspaceShell {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
max-width: 100%;
min-height: 0;
min-width: 0;
border: 1px solid variables.$border-default;
border-radius: 10px;
background: variables.$bg-default;
overflow: hidden;
}
.mainGrid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.95fr);
gap: 16px;
flex: 1;
padding: 16px 24px;
width: 100%;
max-width: 100%;
padding: 16px;
overflow: hidden;
min-height: 0;
min-width: 0;
align-self: stretch;
@media (max-width: 1120px) {
grid-template-columns: 1fr;
align-content: start;
overflow-y: auto;
}
}
.panel {
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
min-height: 0;
min-width: 0;
border: 1px solid variables.$border-default;
border-radius: 8px;
background: variables.$bg-default;
overflow: hidden;
@media (max-width: 1120px) {
min-height: auto;
}
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid variables.$border-default;
flex-shrink: 0;
background: variables.$bg-default;
}
.panelTitle {
margin: 0;
color: variables.$text-primary;
@include typography.font-body-16(600);
}
.editorPanel {
min-height: 0;
@media (max-width: 1120px) {
min-height: 420px;
}
}
.playerPanel {
min-height: 0;
@media (max-width: 1120px) {
min-height: 260px;
}
}
.playerColumn {
display: flex;
flex-direction: column;
flex: 1;
position: relative;
border-radius: variables.$radius-md;
width: 100%;
max-width: 100%;
overflow: hidden;
background: #000;
min-height: 0;
min-height: 280px;
min-width: 0;
:global([data-media-player]) {
width: 100% !important;
@@ -55,11 +135,15 @@
}
.editorColumn {
overflow-y: auto;
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
max-width: 100%;
overflow: hidden;
min-height: 0;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md;
background: variables.$bg-surface;
min-width: 0;
background: variables.$bg-default;
}
.placeholder {
@@ -67,28 +151,41 @@
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
color: variables.$text-tertiary;
@include typography.font-body-14(500);
}
.timelineWrapper {
border-top: 1px solid variables.$border-subtle;
padding: 0 24px;
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0 16px 16px;
align-self: stretch;
overflow: hidden;
}
.timeline {
width: 100%;
max-width: 100%;
min-width: 0;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-top: 1px solid variables.$border-subtle;
background: variables.$bg-surface;
width: 100%;
max-width: 100%;
padding: 0 16px 16px;
background: transparent;
align-self: stretch;
flex-shrink: 0;
min-width: 0;
@media (max-width: 720px) {
flex-direction: column-reverse;
align-items: stretch;
gap: 10px;
}
}
@@ -3,7 +3,11 @@
import type { ISubtitleRevisionStepProps } from "./SubtitleRevisionStep.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider } from "@vidstack/react"
import {
MediaPlayer,
MediaProvider,
type MediaPlayerInstance,
} from "@vidstack/react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
@@ -90,7 +94,7 @@ const SubtitleRevisionContent: FunctionComponent<{
markStepCompleted,
} = useWizard()
const { data: artifacts } = api.useQuery(
const { data: artifacts, isLoading: isArtifactsLoading } = api.useQuery(
"get",
"/api/media/artifacts/",
{},
@@ -108,6 +112,10 @@ const SubtitleRevisionContent: FunctionComponent<{
)
return match?.id ?? null
}, [contextArtifactId, artifacts, projectId])
const isArtifactResolving = !contextArtifactId && isArtifactsLoading
const isTranscriptionReady = Boolean(transcriptionArtifactId)
const isTranscriptionUnavailable =
!isTranscriptionReady && !isArtifactResolving
useEffect(() => {
if (
@@ -130,6 +138,7 @@ const SubtitleRevisionContent: FunctionComponent<{
"/api/tasks/frame-extract/",
)
const extractTriggeredRef = useRef(false)
const playerRef = useRef<MediaPlayerInstance | null>(null)
useEffect(() => {
if (!primaryFileKey || !projectId || extractTriggeredRef.current) return
@@ -144,6 +153,8 @@ const SubtitleRevisionContent: FunctionComponent<{
}, [primaryFileKey, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
const handleFinish = () => {
if (!isTranscriptionReady) return
markStepCompleted("subtitle-revision")
goToStep("caption-settings")
}
@@ -158,89 +169,110 @@ const SubtitleRevisionContent: FunctionComponent<{
transcriptionArtifactId={transcriptionArtifactId}
/>
<MediaPlayer
src={videoUrl ?? ""}
crossOrigin=""
playsInline
className={styles.mediaPlayer}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
aspectRatio: "unset",
width: "100%",
height: "auto",
minHeight: 0,
overflow: "hidden",
}}
>
{/* Main content: video + editor */}
<div className={styles.workspaceShell}>
<div className={styles.mainGrid}>
{/* Left column: video player */}
<div className={styles.playerColumn}>
{videoUrl ? (
<>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
disableTimeSlider
slots={{
timeSlider: null,
currentTime: null,
timeDivider: null,
endTime: null,
startDuration: null,
seekBackwardButton: null,
seekForwardButton: null,
captionButton: null,
settingsMenu: null,
pipButton: null,
airPlayButton: null,
googleCastButton: null,
downloadButton: null,
}}
/>
</>
) : (
<div className={styles.placeholder}>
Видео недоступно
</div>
)}
</div>
<section className={cs(styles.panel, styles.editorPanel)}>
<div className={styles.panelHeader}>
<h2 className={styles.panelTitle}>Текст</h2>
</div>
<div
className={styles.editorColumn}
aria-busy={isArtifactResolving}
>
{isArtifactResolving ? (
<div
className={styles.placeholder}
role="status"
aria-live="polite"
aria-atomic="true"
>
Загружаем субтитры...
</div>
) : transcriptionArtifactId ? (
<TranscriptionEditor artifactId={transcriptionArtifactId} />
) : (
<div
className={styles.placeholder}
role="status"
aria-live="polite"
aria-atomic="true"
>
Транскрипция не найдена
</div>
)}
</div>
</section>
{/* Right column: transcription editor */}
<div className={styles.editorColumn}>
{transcriptionArtifactId ? (
<TranscriptionEditor
artifactId={transcriptionArtifactId}
/>
) : (
<div className={styles.placeholder}>
Транскрипция не найдена
</div>
)}
</div>
<section className={cs(styles.panel, styles.playerPanel)}>
<div className={styles.panelHeader}>
<h2 className={styles.panelTitle}>Видео</h2>
</div>
<div className={styles.playerColumn}>
{videoUrl ? (
<MediaPlayer
ref={playerRef}
src={videoUrl}
crossOrigin=""
playsInline
className={styles.mediaPlayer}
>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
disableTimeSlider
slots={{
timeSlider: null,
currentTime: null,
timeDivider: null,
endTime: null,
startDuration: null,
seekBackwardButton: null,
seekForwardButton: null,
captionButton: null,
settingsMenu: null,
pipButton: null,
airPlayButton: null,
googleCastButton: null,
downloadButton: null,
}}
/>
</MediaPlayer>
) : (
<div className={styles.placeholder}>
Видео недоступно
</div>
)}
</div>
</section>
</div>
{/* Bottom: timeline */}
<div className={styles.timelineWrapper}>
<TimelinePanel
projectId={projectId}
audioUrl={videoUrl}
className={styles.timeline}
playerRef={playerRef}
/>
</div>
{/* Footer */}
<div className={styles.footer}>
<Button variant="outline" onClick={goBack}>
Отмена
</Button>
<Button variant="primary" onClick={handleFinish}>
Далее
<Button
variant="primary"
onClick={handleFinish}
loading={isArtifactResolving}
disabled={!isTranscriptionReady}
>
{isArtifactResolving
? "Проверяем..."
: isTranscriptionUnavailable
? "Субтитры не найдены"
: "Далее"}
</Button>
</div>
</MediaPlayer>
</div>
</div>
)
}
@@ -8,8 +8,8 @@
top: 4px;
bottom: 4px;
border-radius: variables.$radius-sm;
background: rgba(139, 92, 246, 0.3);
border: 1px solid rgba(139, 92, 246, 0.7);
background: color-mix(in srgb, variables.$color-primary 28%, transparent);
border: 1px solid color-mix(in srgb, variables.$color-primary 62%, transparent);
cursor: pointer;
user-select: none;
display: flex;
@@ -18,7 +18,7 @@
transition: background 0.1s;
&:hover {
background: rgba(139, 92, 246, 0.45);
background: color-mix(in srgb, variables.$color-primary 42%, transparent);
}
}
@@ -40,15 +40,15 @@
}
.active {
background: rgba(139, 92, 246, 0.6);
background: color-mix(in srgb, variables.$color-primary 56%, transparent);
&:hover {
background: rgba(139, 92, 246, 0.65);
background: color-mix(in srgb, variables.$color-primary 62%, transparent);
}
}
.resizing {
background: rgba(139, 92, 246, 0.5);
background: color-mix(in srgb, variables.$color-primary 48%, transparent);
z-index: 2;
}
@@ -75,7 +75,7 @@
z-index: 3;
&:hover {
background: rgba(139, 92, 246, 0.5);
background: color-mix(in srgb, variables.$color-primary 48%, transparent);
}
}
@@ -2,7 +2,9 @@
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: transparent;
}
.loader {
@@ -10,12 +12,27 @@
align-items: center;
justify-content: center;
flex: 1;
gap: 8px;
color: variables.$text-tertiary;
@include typography.font-caption-m;
}
.spinner {
animation: spin 1s linear infinite;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -28,126 +45,168 @@
font-size: 14px;
}
.header {
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
gap: 16px;
padding: 16px 18px 14px;
border-bottom: 1px solid variables.$border-default;
background: variables.$bg-default;
flex-shrink: 0;
@media (max-width: 720px) {
flex-direction: column;
align-items: stretch;
}
}
.title {
font-size: 14px;
font-weight: 600;
color: variables.$text-primary;
.toolbarMeta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.toolbarTitle {
margin: 0;
color: variables.$text-primary;
@include typography.font-body-16(600);
}
.saveButton {
display: inline-flex;
.toolbarSummary {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: variables.$radius-sm;
border: none;
background: variables.$color-primary;
color: variables.$color-white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
flex-wrap: wrap;
gap: 10px;
}
&:hover {
opacity: 0.9;
}
.segmentCount {
color: variables.$text-secondary;
@include typography.font-body-14(500);
}
&.disabled {
background: variables.$border-default;
color: variables.$text-tertiary;
cursor: default;
pointer-events: none;
}
.headerStatus {
margin: 0;
color: variables.$text-tertiary;
@include typography.font-caption-m;
}
.segmentsList {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
padding: 14px 18px 18px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.segment {
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md;
padding: 12px 16px;
background: variables.$bg-surface;
transition: all 0.3s ease;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
border: 1px solid variables.$border-default;
border-radius: 10px;
padding: 12px;
background: variables.$bg-default;
transition: border-color 0.15s ease, background 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: variables.$border-default;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04);
background: variables.$bg-surface;
}
&.highlight {
border-color: variables.$color-primary;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
box-shadow: var(--focus-ring);
}
@media (max-width: 720px) {
grid-template-columns: 1fr;
}
}
.segmentTimes {
.segmentNumber {
display: inline-flex;
align-items: flex-start;
justify-content: center;
padding-top: 7px;
color: variables.$text-tertiary;
@include typography.font-caption-m;
@include typography.font-numeric;
}
.segmentMain {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.segmentMetaRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
flex-wrap: wrap;
}
.timesGroup {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
gap: 8px;
flex: 1;
min-width: 0;
}
.actionsGroup {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.timeLabel {
.timeField {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 8px 10px;
border-radius: 8px;
background: variables.$bg-surface;
border: 1px solid variables.$border-default;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:focus-within {
background: variables.$bg-default;
border-color: variables.$color-primary;
box-shadow: var(--focus-ring);
}
}
.timeLabelText {
font-size: 11px;
color: variables.$text-tertiary;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: variables.$text-secondary;
white-space: nowrap;
@include typography.font-caption-m;
}
.timeInput {
width: 84px;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: variables.$radius-sm;
font-size: 12px;
width: 96px;
padding: 0;
border: none;
border-radius: 0;
font-family: monospace;
color: variables.$text-secondary;
background: variables.$bg-hover;
transition: all 0.2s ease;
text-align: center;
color: variables.$text-primary;
background: transparent;
transition: color 0.2s ease;
text-align: left;
@include typography.font-caption-m;
@include typography.font-numeric;
&:focus {
outline: none;
background: variables.$bg-surface;
border-color: variables.$color-primary;
color: variables.$text-primary;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1);
}
}
@@ -155,19 +214,23 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
background: transparent;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid variables.$border-default;
background: variables.$bg-default;
color: variables.$text-tertiary;
cursor: pointer;
border-radius: variables.$radius-sm;
transition: all 0.2s ease;
border-radius: 8px;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease;
}
.splitButton {
&:hover:not(:disabled) {
color: variables.$color-primary;
background: variables.$bg-hover;
background: variables.$bg-surface;
border-color: variables.$border-default;
}
&:disabled {
@@ -179,22 +242,23 @@
.removeButton {
&:hover {
color: variables.$color-danger;
background: rgba(239, 68, 68, 0.1);
background: variables.$bg-surface;
border-color: variables.$border-default;
}
}
.textArea {
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: variables.$radius-sm;
font-size: 14px;
line-height: 1.5;
min-height: 96px;
padding: 12px 14px;
border: 1px solid variables.$border-default;
border-radius: 8px;
color: variables.$text-primary;
background: variables.$bg-hover;
background: variables.$bg-surface;
resize: vertical;
font-family: inherit;
transition: all 0.2s ease;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
@include typography.font-body-14(400);
&:hover {
background: variables.$bg-hover;
@@ -202,29 +266,41 @@
&:focus {
outline: none;
background: variables.$bg-surface;
background: variables.$bg-default;
border-color: variables.$color-primary;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
box-shadow: var(--focus-ring);
}
&::placeholder {
color: variables.$text-tertiary;
}
}
.addButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
margin: 0 16px 12px;
border: 1px dashed variables.$border-default;
border-radius: variables.$radius-md;
background: none;
gap: 8px;
justify-content: center;
padding: 10px 12px;
border: 1px solid variables.$border-default;
border-radius: 8px;
background: variables.$bg-default;
color: variables.$text-secondary;
font-size: 13px;
@include typography.font-body-14(500);
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
background: variables.$bg-hover;
border-color: variables.$color-primary;
background: variables.$bg-surface;
border-color: variables.$border-default;
color: variables.$color-primary;
box-shadow: var(--shadow-sm);
}
@media (max-width: 720px) {
width: 100%;
}
}
@@ -10,6 +10,7 @@ import { FunctionComponent, useCallback, useEffect, useRef, useState } from "rea
import api from "@shared/api"
import { fetchClient } from "@shared/api"
import { useWorkspaceFiles } from "@shared/context/WorkspaceContext"
import { declOfNum } from "@shared/lib/russianNoun"
import {
type EditorSegment,
documentToSegments,
@@ -23,12 +24,18 @@ import styles from "./TranscriptionEditor.module.scss"
/* Component */
/* ------------------------------------------------------------------ */
type SegmentRow = {
id: string
segment: EditorSegment
}
export const TranscriptionEditor: FunctionComponent<
ITranscriptionEditorProps
> = ({ artifactId }): JSX.Element => {
const queryClient = useQueryClient()
const { selectedFile, setSelectedFile } = useWorkspaceFiles()
const segmentsListRef = useRef<HTMLDivElement>(null)
const rowIdRef = useRef(0)
const { data: transcription, isLoading } = api.useQuery(
"get",
@@ -36,22 +43,41 @@ export const TranscriptionEditor: FunctionComponent<
{ params: { path: { artifact_id: artifactId } } },
)
const [segments, setSegments] = useState<EditorSegment[]>([])
const [segmentRows, setSegmentRows] = useState<SegmentRow[]>([])
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [splittingIdx, setSplittingIdx] = useState<number | null>(null)
const [splittingRowId, setSplittingRowId] = useState<string | null>(null)
const visibleStatus = saving
? "Сохраняем изменения"
: dirty
? "Изменения будут сохранены автоматически"
: null
const createSegmentRow = useCallback(
(segment: EditorSegment): SegmentRow => ({
id: `segment-row-${rowIdRef.current++}`,
segment,
}),
[],
)
useEffect(() => {
if (transcription?.document) {
setSegments(documentToSegments(transcription.document))
rowIdRef.current = 0
setSegmentRows(
documentToSegments(transcription.document).map((segment) =>
createSegmentRow(segment),
),
)
setDirty(false)
setSplittingRowId(null)
}
}, [transcription])
}, [transcription, createSegmentRow])
// Scroll to segment when navigated from SubtitlesTrack
useEffect(() => {
if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return
if (segments.length === 0) return
if (segmentRows.length === 0) return
const targetIdx = selectedFile.scrollToSegmentIndex
const container = segmentsListRef.current
@@ -76,16 +102,16 @@ export const TranscriptionEditor: FunctionComponent<
source: selectedFile.source,
artifactType: selectedFile.artifactType,
})
}, [selectedFile?.scrollToSegmentIndex, segments.length])
}, [selectedFile?.scrollToSegmentIndex, segmentRows.length, setSelectedFile])
const updateSegment = useCallback(
(idx: number, field: keyof EditorSegment, value: string) => {
setSegments((prev) =>
prev.map((seg, i) => {
if (i !== idx) return seg
const updated = { ...seg, [field]: value }
(rowId: string, field: keyof EditorSegment, value: string) => {
setSegmentRows((prev) =>
prev.map((row) => {
if (row.id !== rowId) return row
const updated = { ...row.segment, [field]: value }
if (field === "text") updated.words = undefined
return updated
return { ...row, segment: updated }
}),
)
setDirty(true)
@@ -94,30 +120,38 @@ export const TranscriptionEditor: FunctionComponent<
)
const handleSplit = useCallback(
(idx: number, newSegments: EditorSegment[]) => {
setSegments((prev) => [
...prev.slice(0, idx),
...newSegments,
...prev.slice(idx + 1),
])
setSplittingIdx(null)
(rowId: string, newSegments: EditorSegment[]) => {
setSegmentRows((prev) => {
const targetIndex = prev.findIndex((row) => row.id === rowId)
if (targetIndex === -1) return prev
const nextRows = newSegments.map((segment) => createSegmentRow(segment))
return [
...prev.slice(0, targetIndex),
...nextRows,
...prev.slice(targetIndex + 1),
]
})
setSplittingRowId(null)
setDirty(true)
},
[],
[createSegmentRow],
)
const addSegment = useCallback(() => {
const lastEnd =
segments.length > 0 ? segments[segments.length - 1].endTime : "00:00.000"
setSegments((prev) => [
...prev,
{ startTime: lastEnd, endTime: lastEnd, text: "" },
])
setSegmentRows((prev) => {
const lastEnd =
prev.length > 0 ? prev[prev.length - 1].segment.endTime : "00:00.000"
return [
...prev,
createSegmentRow({ startTime: lastEnd, endTime: lastEnd, text: "" }),
]
})
setDirty(true)
}, [segments])
}, [createSegmentRow])
const removeSegment = useCallback((idx: number) => {
setSegments((prev) => prev.filter((_, i) => i !== idx))
const removeSegment = useCallback((rowId: string) => {
setSegmentRows((prev) => prev.filter((row) => row.id !== rowId))
setSplittingRowId((prev) => (prev === rowId ? null : prev))
setDirty(true)
}, [])
@@ -129,7 +163,11 @@ export const TranscriptionEditor: FunctionComponent<
"/api/transcribe/transcriptions/{transcription_id}/",
{
params: { path: { transcription_id: transcription.id } },
body: { document: segmentsToDocument(segments) },
body: {
document: segmentsToDocument(
segmentRows.map((row) => row.segment),
),
},
},
)
setDirty(false)
@@ -143,7 +181,7 @@ export const TranscriptionEditor: FunctionComponent<
} finally {
setSaving(false)
}
}, [transcription, segments, artifactId, queryClient])
}, [transcription, segmentRows, artifactId, queryClient])
// Auto-save when dirty (debounced)
useEffect(() => {
@@ -157,9 +195,14 @@ export const TranscriptionEditor: FunctionComponent<
/* Loading */
if (isLoading) {
return (
<div className={styles.root} data-testid="TranscriptionEditor">
<div className={styles.loader}>
<div
className={styles.root}
data-testid="TranscriptionEditor"
aria-busy="true"
>
<div className={styles.loader} role="status" aria-live="polite">
<LoaderCircle size={24} className={styles.spinner} />
<span className={styles.srOnly}>Загружаем транскрипцию</span>
</div>
</div>
)
@@ -175,89 +218,138 @@ export const TranscriptionEditor: FunctionComponent<
}
return (
<div className={styles.root} data-testid="TranscriptionEditor">
{/* Header */}
<div className={styles.header}>
<h3 className={styles.title}>Редактор транскрипции</h3>
<div
className={styles.root}
data-testid="TranscriptionEditor"
aria-busy={saving ? "true" : "false"}
>
<div className={styles.toolbar}>
<div className={styles.toolbarMeta}>
<p className={styles.toolbarTitle}>Сегменты</p>
<div className={styles.toolbarSummary}>
<span className={styles.segmentCount}>
{segmentRows.length}{" "}
{declOfNum(segmentRows.length, [
"сегмент",
"сегмента",
"сегментов",
])}
</span>
{visibleStatus ? (
<p className={styles.headerStatus}>{visibleStatus}</p>
) : null}
</div>
</div>
<button className={styles.addButton} onClick={addSegment} type="button">
<Plus size={16} />
<span>Добавить сегмент</span>
</button>
<span
className={styles.srOnly}
role="status"
aria-live="polite"
aria-atomic="true"
>
{visibleStatus ?? ""}
</span>
</div>
{/* Segments list */}
<div className={styles.segmentsList} ref={segmentsListRef}>
{segments.map((seg, idx) => (
<div key={idx} className={styles.segment} data-segment-index={idx}>
<div className={styles.segmentTimes}>
<div className={styles.timesGroup}>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Начало</span>
<input
className={styles.timeInput}
type="text"
value={seg.startTime}
onChange={(e) =>
updateSegment(idx, "startTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Конец</span>
<input
className={styles.timeInput}
type="text"
value={seg.endTime}
onChange={(e) =>
updateSegment(idx, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
{segmentRows.map((row, idx) => {
const textareaId = `${row.id}-text`
const splitDisabled = !row.segment.words || row.segment.words.length < 2
return (
<div
key={row.id}
className={styles.segment}
data-segment-index={idx}
>
<div className={styles.segmentNumber} aria-hidden="true">
{String(idx + 1).padStart(2, "0")}
</div>
<div className={styles.actionsGroup}>
<button
className={styles.splitButton}
onClick={() => setSplittingIdx(idx)}
title={
!seg.words || seg.words.length < 2
? "Нет данных о словах для разделения"
: "Разделить сегмент"
}
disabled={!seg.words || seg.words.length < 2}
>
<Scissors size={14} />
</button>
<button
className={styles.removeButton}
onClick={() => removeSegment(idx)}
title="Удалить сегмент"
>
<Trash2 size={14} />
</button>
<div className={styles.segmentMain}>
<div className={styles.segmentMetaRow}>
<div className={styles.timesGroup}>
<label className={styles.timeField}>
<span className={styles.timeLabelText}>Начало</span>
<input
className={styles.timeInput}
type="text"
value={row.segment.startTime}
onChange={(e) =>
updateSegment(row.id, "startTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
<label className={styles.timeField}>
<span className={styles.timeLabelText}>Конец</span>
<input
className={styles.timeInput}
type="text"
value={row.segment.endTime}
onChange={(e) =>
updateSegment(row.id, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
</div>
<div className={styles.actionsGroup}>
<button
className={styles.splitButton}
onClick={() => setSplittingRowId(row.id)}
aria-label={
splitDisabled
? `Разделить сегмент ${idx + 1}: недоступно без разбивки по словам`
: `Разделить сегмент ${idx + 1}`
}
title={
splitDisabled
? "Нет данных о словах для разделения"
: "Разделить сегмент"
}
disabled={splitDisabled}
type="button"
>
<Scissors size={14} />
</button>
<button
className={styles.removeButton}
onClick={() => removeSegment(row.id)}
aria-label={`Удалить сегмент ${idx + 1}`}
title="Удалить сегмент"
type="button"
>
<Trash2 size={14} />
</button>
</div>
</div>
<label className={styles.srOnly} htmlFor={textareaId}>
Текст сегмента {idx + 1}
</label>
{splittingRowId === row.id ? (
<SegmentSplitter
segment={row.segment}
onSplit={(newSegs) => handleSplit(row.id, newSegs)}
onCancel={() => setSplittingRowId(null)}
/>
) : (
<textarea
id={textareaId}
className={styles.textArea}
value={row.segment.text}
onChange={(e) => updateSegment(row.id, "text", e.target.value)}
rows={2}
placeholder="Текст сегмента..."
/>
)}
</div>
</div>
{splittingIdx === idx ? (
<SegmentSplitter
segment={seg}
onSplit={(newSegs) => handleSplit(idx, newSegs)}
onCancel={() => setSplittingIdx(null)}
/>
) : (
<textarea
className={styles.textArea}
value={seg.text}
onChange={(e) => updateSegment(idx, "text", e.target.value)}
rows={2}
placeholder="Текст сегмента..."
/>
)}
</div>
))}
)
})}
</div>
{/* Add segment */}
<button className={styles.addButton} onClick={addSegment}>
<Plus size={16} />
<span>Добавить сегмент</span>
</button>
</div>
)
}
@@ -32,7 +32,7 @@
.dropZoneActive {
border-color: variables.$color-primary;
background: rgba(var(--iris-a3), 0.08);
background: color-mix(in srgb, variables.$color-primary 10%, variables.$bg-surface);
}
.dropZoneUploading {
@@ -39,7 +39,7 @@ export const UploadStep: FunctionComponent<IUploadStepProps> = ({
`projects/${projectId}`,
setProgress,
)
setFileKey(result.file_path, result.file_url, result.filename ?? null)
setFileKey(result.file_path, result.file_id, result.filename ?? null)
markStepCompleted("upload")
goNext()
} catch {
+19 -17
View File
@@ -34,7 +34,7 @@ import cs from "classnames"
import api, { fetchClient } from "@shared/api"
import { useWizard } from "@shared/context/WizardContext"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
import { Badge, Button, CircularProgress } from "@shared/ui"
import { StaticLoader } from "@shared/ui/Loader"
@@ -135,28 +135,30 @@ export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
})
}, [convertMutation, primaryFileKey, projectId])
const convertNotification = useAppSelector((state) =>
convertJobId
? state.notifications.items.find((n) => n.job_id === convertJobId)
: null,
)
const convertProgressPct = convertNotification?.progress_pct ?? 0
const convertMessage = convertNotification?.message ?? "Конвертация видео..."
const {
progressPct: convertProgressPct,
message: convertMessage,
status: convertTaskStatus,
errorMessage: convertErrorMessage,
} = useTaskProgressState({
jobId: convertJobId,
enabled: !!convertJobId && convertStatus === "converting",
defaultMessage: "Конвертация видео...",
})
useEffect(() => {
if (!convertJobId || convertStatus !== "converting") return
if (convertNotification?.status === "DONE") {
if (convertTaskStatus === "DONE") {
fetchConvertedFileFromJob(convertJobId)
}
if (convertNotification?.status === "FAILED") {
if (convertTaskStatus === "FAILED") {
setActiveJob(null)
setConvertError(convertNotification?.message ?? "Ошибка конвертации")
setConvertError(convertErrorMessage ?? "Ошибка конвертации")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convertNotification, convertJobId, convertStatus])
}, [convertErrorMessage, convertJobId, convertStatus, convertTaskStatus])
const fetchConvertedFileFromJob = useCallback(
async (jobId: string) => {
@@ -165,13 +167,13 @@ export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
{ params: { path: { job_id: jobId } } },
)
const outputData = taskStatus?.output_data as {
file_id?: string
file_path?: string
file_url?: string
} | null
if (outputData?.file_path && outputData?.file_url) {
if (outputData?.file_id && outputData?.file_path) {
const convertedName = outputData.file_path.split("/").pop() ?? null
setFileKey(outputData.file_path, outputData.file_url, convertedName)
setFileKey(outputData.file_path, outputData.file_id, convertedName)
setActiveJob(null)
}
},
@@ -181,7 +183,7 @@ export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
/* ---- Handlers ---- */
const handleReplace = () => {
setFileKey("", "", null)
setFileKey(null, null, null)
goToStep("upload")
}