rev 4
This commit is contained in:
@@ -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>
|
||||
Удалить пресет «{deleteTarget.name}»? Это
|
||||
действие нельзя отменить.
|
||||
Удалить пресет «{deleteTarget.name}»? Это действие
|
||||
нельзя отменить.
|
||||
</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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user