new features

This commit is contained in:
Daniil
2026-02-27 23:34:17 +03:00
parent 42ce5fa0fe
commit 71b974903a
191 changed files with 11300 additions and 373 deletions
@@ -1,33 +1,62 @@
@use "@shared/styles/variables" as *;
.drawer {
background: transparent;
background: transparent !important;
}
.root {
display: flex;
flex-direction: column;
min-width: 220px;
height: 100%;
padding: 16px 12px;
background: #ffffff;
border-radius: 12px 12px 0 0;
box-shadow: 0 10px 30px rgba(12, 18, 38, 0.08);
background: variables.$bg-default;
border-radius: 0 variables.$radius-lg variables.$radius-lg 0;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.header {
margin-bottom: 12px;
font-weight: 700;
font-size: 16px;
color: #0c1226;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid variables.$border-subtle;
}
.brand {
@include typography.font-body-16(600);
color: variables.$text-primary;
letter-spacing: -0.01em;
}
.closeButton {
@include mixins.flex-center;
width: 32px;
height: 32px;
border: none;
border-radius: variables.$radius-sm;
background: transparent;
color: variables.$text-secondary;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
svg {
width: 18px;
height: 18px;
}
}
.list {
list-style: none;
margin: 0;
padding: 0;
@include mixins.reset-list;
display: flex;
flex-direction: column;
gap: 6px;
gap: 2px;
padding: 8px;
flex: 1;
}
.item {
@@ -36,40 +65,44 @@
.link,
.button {
display: inline-flex;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 10px;
border-radius: variables.$radius-sm;
background: transparent;
color: #0c1226;
color: variables.$text-secondary;
text-decoration: none;
font-weight: 600;
font-size: 14px;
line-height: 1.4;
@include typography.font-body-14(500);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
transition: background-color 0.15s ease, color 0.15s ease;
.link:hover,
.button:hover,
.link:focus-visible,
.button:focus-visible {
outline: none;
background-color: #f4f6fb;
&:hover {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
&:focus-visible {
outline: 2px solid variables.$color-secondary;
outline-offset: -2px;
border-radius: variables.$radius-sm;
}
&.active {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
}
.icon {
display: inline-flex;
flex-shrink: 0;
width: 18px;
height: 18px;
color: #5a6473;
}
.label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@include mixins.text-ellipsis;
}
@@ -1,10 +1,12 @@
import type { JSX } from "react"
import { X } from "lucide-react"
import { FunctionComponent } from "react"
import Drawer from "react-modern-drawer"
import cs from "classnames"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { INavigationDrawerProps } from "./NavigationDrawer.d"
import styles from "./NavigationDrawer.module.scss"
@@ -20,6 +22,8 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
size = 280,
title,
}): JSX.Element => {
const pathname = usePathname()
return (
<Drawer
open={open}
@@ -27,14 +31,25 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
direction={position}
size={size}
className={cs(styles.drawer, className)}
aria-label="Navigation drawer"
aria-label="Навигация"
duration={200}
>
<nav className={styles.root} data-testid="NavigationDrawer">
{title ? <div className={styles.header}>{title}</div> : null}
<div className={styles.header}>
<span className={styles.brand}>{title ?? "Coffee Project"}</span>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Закрыть навигацию"
>
<X />
</button>
</div>
<ul className={styles.list}>
{buttons.map(({ label, icon: Icon, path, action }, index) => {
const key = `${label}-${path ?? index}`
const isActive = path ? pathname === path : false
const content = (
<>
@@ -53,7 +68,7 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
{path ? (
<Link
href={path}
className={styles.link}
className={cs(styles.link, isActive && styles.active)}
onClick={handleClick}
>
{content}
+3
View File
@@ -17,4 +17,7 @@ export interface IProjectCardProps {
*/
imageUrl?: string
onClick?: () => void
onEdit?: () => void
onRename?: () => void
onDelete?: () => void
}
@@ -5,19 +5,22 @@
height: 100%;
overflow: hidden;
position: relative;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
box-shadow 0.2s ease,
border-color 0.2s ease;
cursor: pointer;
background: variables.$bg-default;
}
.hero {
width: 100%;
height: 180px;
background-color: variables.$purple-50;
background-color: variables.$bg-surface;
position: relative;
overflow: hidden;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: center;
align-items: center;
@@ -35,24 +38,25 @@
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
svg {
width: 48px;
height: 48px;
width: 40px;
height: 40px;
color: variables.$purple-300;
opacity: 0.5;
opacity: 0.4;
}
}
}
.root:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
box-shadow: var(--shadow-lg);
border-color: transparent;
}
.content {
display: flex;
flex-direction: column;
padding: 16px;
gap: 8px;
padding: 14px 16px;
gap: 6px;
flex: 1;
}
@@ -90,8 +94,8 @@
}
.progressValue {
stroke: variables.$purple-400;
stroke: variables.$purple-400;
&.completed {
stroke: variables.$color-success;
}
@@ -135,7 +139,7 @@
.info {
@include mixins.flex-column;
gap: 8px;
gap: 4px;
flex: 1;
min-width: 0;
}
@@ -155,16 +159,16 @@
.date {
@include typography.font-caption-m;
color: variables.$text-secondary;
color: variables.$text-tertiary;
}
.status {
margin-top: auto;
display: flex;
align-items: center;
gap: 6px;
@include typography.font-body-s;
@include typography.font-caption-m;
font-weight: 500;
&.statusGenerated {
@@ -172,7 +176,7 @@
}
&.statusProcessing, &.statusRendering, &.statusUploading {
color: variables.$purple-500;
color: variables.$purple-400;
}
&.statusDraft {
@@ -185,28 +189,27 @@
}
.statusDot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: currentColor;
}
.menuTrigger {
margin-left: auto;
background-color: variables.$bg-surface;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
width: 32px;
height: 32px;
background-color: transparent;
border-radius: variables.$radius-sm;
width: 28px;
height: 28px;
@include mixins.flex-center;
color: variables.$text-primary;
color: variables.$text-secondary;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover,
&[data-state="open"] {
background-color: variables.$bg-default;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
button {
@@ -223,13 +226,15 @@
.statusBadge {
position: absolute;
top: 12px;
left: 12px;
padding: 6px 10px;
border-radius: 12px;
background: variables.$bg-canvas;
top: 10px;
left: 10px;
padding: 4px 10px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
@include typography.font-caption-m;
font-weight: 500;
color: variables.$text-primary;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
z-index: 2;
}
+9 -11
View File
@@ -7,8 +7,8 @@ import { Image as ImageIcon, MoreHorizontal } from "lucide-react"
import { FunctionComponent } from "react"
import cs from "classnames"
import moment from "moment"
import { formatRelativeTime } from "@shared/lib/dates"
import { Card } from "@shared/ui/Card"
import { CircularProgress } from "@shared/ui/CircularProgress"
import {
@@ -27,6 +27,9 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
currentAction,
imageUrl,
onClick,
onEdit,
onRename,
onDelete,
}): JSX.Element => {
const { name, updated_at, status } = project
@@ -38,7 +41,6 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
const shouldShowProgress = isProcessing
// Helper to determine status color/class
const getStatusClass = () => {
if (isCompleted) return styles.statusGenerated
if (isProcessing) return styles.statusProcessing
@@ -49,7 +51,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
const getStatusLabel = () => {
if (isCompleted) return "Завершено"
if (isProcessing) return "В процессе" // Or more specific state
if (isProcessing) return "В процессе"
if (isDraft) return "Черновик"
if (isFailed) return "Ошибка"
return status
@@ -112,19 +114,15 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
</button>
</DropdownTrigger>
<DropdownContent align="end">
<DropdownItem
onSelect={() => console.log("Edit", project.id)}
>
<DropdownItem onSelect={() => onEdit?.()}>
Изменить
</DropdownItem>
<DropdownItem
onSelect={() => console.log("Rename", project.id)}
>
<DropdownItem onSelect={() => onRename?.()}>
Переименовать
</DropdownItem>
<DropdownItem
className="text-red-500"
onSelect={() => console.log("Delete", project.id)}
onSelect={() => onDelete?.()}
>
Удалить
</DropdownItem>
@@ -133,7 +131,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
</div>
</div>
<span className={styles.date}>
Создано {moment(updated_at).fromNow()}
Создано {formatRelativeTime(updated_at)}
</span>
</div>
</div>
@@ -1,9 +1,9 @@
.root {
display: flex
display: flex;
}
.username {
@include typography.font-body-16(500);
@include typography.font-body-14(500);
color: variables.$text-primary;
user-select: none;
}
@@ -12,14 +12,21 @@
display: flex;
align-items: center;
gap: 8px;
padding: 8px ;
padding: 6px 12px 6px 6px;
border-radius: 24px;
cursor: pointer;
transition: background-color 0.15s ease;
flex-shrink: 0;
&:hover {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
background-color: variables.$bg-surface;
}
}
}
.item {
gap: 8px;
}
.themeItem {
gap: 8px;
}
+75 -4
View File
@@ -1,23 +1,60 @@
import type { JSX } from "react"
import Cookies from "js-cookie"
import { Monitor, Moon, Sun } from "lucide-react"
import { FunctionComponent } from "react"
import { useRouter } from "next/navigation"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@shared/lib/constants"
import { setThemePreference } from "@shared/store/appState"
import type { ThemePreference } from "@shared/store/appState/types"
import { resetUser } from "@shared/store/user"
import { Avatar } from "@shared/ui/Avatar"
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownRadioGroup,
DropdownRadioItem,
DropdownSeparator,
DropdownSub,
DropdownSubContent,
DropdownSubTrigger,
DropdownTrigger,
} from "@shared/ui/Dropdown"
import { userDropdownValues } from "./constants"
import { navItems } from "./constants"
import { IUserDropdownProps } from "./UserDropdown.d"
import styles from "./UserDropdown.module.scss"
const themeOptions: { value: ThemePreference; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Светлая", icon: Sun },
{ value: "dark", label: "Тёмная", icon: Moon },
{ value: "system", label: "Системная", icon: Monitor },
]
export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
user,
}): JSX.Element => {
const router = useRouter()
const dispatch = useAppDispatch()
const themePreference = useAppSelector(
(state) => state.appState.themePreference,
)
const handleLogout = () => {
Cookies.remove(ACCESS_TOKEN_COOKIE)
Cookies.remove(REFRESH_TOKEN_COOKIE)
dispatch(resetUser())
router.push("/login")
}
return (
<div className={styles.root} data-testid="UserDropdown">
<Dropdown>
@@ -28,15 +65,49 @@ export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
</div>
</DropdownTrigger>
<DropdownContent>
{userDropdownValues.map((item) => (
{navItems.map((item) => (
<DropdownItem
key={item.acton}
key={item.action}
className={styles.item}
onSelect={() => console.log(`${item.acton} selected`)}
onSelect={() => router.push(item.path)}
>
{item.label}
</DropdownItem>
))}
<DropdownSeparator />
<DropdownSub>
<DropdownSubTrigger className={styles.item}>
<Sun size={16} />
Тема
</DropdownSubTrigger>
<DropdownSubContent>
<DropdownRadioGroup
value={themePreference}
onValueChange={(v) =>
dispatch(setThemePreference(v as ThemePreference))
}
>
{themeOptions.map((opt) => (
<DropdownRadioItem
key={opt.value}
value={opt.value}
className={styles.themeItem}
>
<opt.icon size={16} />
{opt.label}
</DropdownRadioItem>
))}
</DropdownRadioGroup>
</DropdownSubContent>
</DropdownSub>
<DropdownSeparator />
<DropdownItem className={styles.item} onSelect={handleLogout}>
Выйти
</DropdownItem>
</DropdownContent>
</Dropdown>
</div>
+3 -7
View File
@@ -1,16 +1,12 @@
export const userDropdownValues = [
export const navItems = [
{
label: "Профиль",
acton: "profile",
action: "profile",
path: "/profile",
},
{
label: "Настройки",
acton: "settings",
action: "settings",
path: "/settings",
},
{
label: "Выйти",
acton: "logout",
},
]
View File
@@ -0,0 +1,3 @@
export interface INotificationBellProps {
className?: string
}
@@ -0,0 +1,29 @@
.root {
position: relative;
display: inline-flex;
// Rounded hover for ghost icon button
:global(.rt-IconButton) {
border-radius: variables.$radius-sm;
}
}
.badge {
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -50%);
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 9999px;
background-color: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
pointer-events: none;
border: 1.5px solid variables.$bg-default;
box-sizing: content-box;
}
@@ -0,0 +1,45 @@
"use client"
import type { INotificationBellProps } from "./NotificationBell.d"
import type { JSX } from "react"
import { Bell } from "lucide-react"
import { FunctionComponent, useCallback, useRef, useState } from "react"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button } from "@shared/ui"
import { NotificationPopup } from "../NotificationPopup"
import styles from "./NotificationBell.module.scss"
export const NotificationBell: FunctionComponent<INotificationBellProps> =
(): JSX.Element => {
const unreadCount = useAppSelector(
(state) => state.notifications.unreadCount,
)
const [isOpen, setIsOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
const close = useCallback(() => setIsOpen(false), [])
return (
<div className={styles.root} ref={rootRef}>
<Button variant="icon" size="lg" onClick={toggle}>
<Bell size={22} />
</Button>
{unreadCount > 0 && (
<span className={styles.badge}>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{isOpen && (
<NotificationPopup
onClose={close}
anchorRef={rootRef}
/>
)}
</div>
)
}
@@ -0,0 +1 @@
export { NotificationBell } from "./NotificationBell"
@@ -0,0 +1,6 @@
import type { RefObject } from "react"
export interface INotificationPopupProps {
onClose: () => void
anchorRef: RefObject<HTMLDivElement | null>
}
@@ -0,0 +1,152 @@
.overlay {
position: fixed;
inset: 0;
z-index: 99;
}
.root {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 360px;
max-height: 480px;
background-color: variables.$bg-surface;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: variables.$shadow-lg;
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid variables.$border-subtle;
}
.title {
@include typography.font-body-14(600);
color: variables.$text-primary;
}
.readAllBtn {
@include typography.font-caption-m;
font-weight: 500;
color: variables.$purple-500;
background: none;
border: none;
cursor: pointer;
padding: 0;
&:hover {
color: variables.$purple-700;
}
}
.list {
flex: 1;
overflow-y: auto;
}
.item {
display: flex;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: variables.$bg-hover;
}
&:not(:last-child) {
border-bottom: 1px solid variables.$border-subtle;
}
}
.itemUnread {
border-left: 3px solid variables.$purple-500;
}
.itemContent {
flex: 1;
min-width: 0;
}
.itemTitle {
@include typography.font-body-14(500);
color: variables.$text-primary;
display: flex;
align-items: center;
gap: 8px;
}
.itemMessage {
@include typography.font-caption-m;
color: variables.$text-secondary;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemMeta {
@include typography.font-caption-m;
color: variables.$text-tertiary;
margin-top: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
line-height: 16px;
}
.statusRunning {
background-color: #dbeafe;
color: #1d4ed8;
}
.statusDone {
background-color: #dcfce7;
color: #15803d;
}
.statusFailed {
background-color: #fee2e2;
color: #b91c1c;
}
.progressBar {
width: 100%;
height: 4px;
background-color: variables.$border-subtle;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.progressFill {
height: 100%;
background-color: variables.$purple-500;
border-radius: 2px;
transition: width 0.3s ease;
}
.empty {
padding: 32px 16px;
text-align: center;
@include typography.font-body-14(400);
color: variables.$text-tertiary;
}
@@ -0,0 +1,177 @@
"use client"
import type { INotificationPopupProps } from "./NotificationPopup.d"
import type { JSX } from "react"
import cs from "classnames"
import Cookies from "js-cookie"
import { useRouter } from "next/navigation"
import { FunctionComponent, useCallback, useEffect, useRef } from "react"
import { useDispatch } from "react-redux"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { formatRelativeTime } from "@shared/lib/dates"
import { API_URL } from "@shared/lib/constants"
import {
markAllRead,
markRead,
NotificationItem,
} from "@shared/store/notifications"
const apiBase = API_URL || "http://localhost:8000"
function authHeaders(): HeadersInit {
const token = Cookies.get("access_token")
return token ? { Authorization: `Bearer ${token}` } : {}
}
import styles from "./NotificationPopup.module.scss"
const JOB_TYPE_LABELS: Record<string, string> = {
MEDIA_PROBE: "Анализ медиа",
SILENCE_REMOVE: "Удаление тишины",
MEDIA_CONVERT: "Конвертация",
TRANSCRIPTION_GENERATE: "Транскрипция",
CAPTIONS_GENERATE: "Генерация субтитров",
}
const STATUS_LABELS: Record<string, string> = {
PENDING: "Ожидание",
RUNNING: "Выполняется",
DONE: "Завершено",
FAILED: "Ошибка",
}
function getStatusClass(status: string | null): string {
switch (status) {
case "RUNNING":
return styles.statusRunning
case "DONE":
return styles.statusDone
case "FAILED":
return styles.statusFailed
default:
return ""
}
}
export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
onClose,
anchorRef,
}): JSX.Element => {
const items = useAppSelector((state) => state.notifications.items)
const dispatch = useDispatch()
const router = useRouter()
const popupRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(e.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(e.target as Node)
) {
onClose()
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [onClose, anchorRef])
const handleMarkAllRead = useCallback(() => {
dispatch(markAllRead())
fetch(`${apiBase}/api/notifications/read-all/`, {
method: "POST",
headers: authHeaders(),
}).catch(() => {})
}, [dispatch])
const handleItemClick = useCallback(
(item: NotificationItem) => {
if (item.notification_id && !item.is_read) {
dispatch(markRead(item.notification_id))
fetch(`${apiBase}/api/notifications/${item.notification_id}/read/`, {
method: "POST",
headers: authHeaders(),
}).catch(() => {})
}
if (item.project_id) {
router.push(`/projects/${item.project_id}`)
onClose()
}
},
[dispatch, router, onClose],
)
return (
<div className={styles.root} ref={popupRef}>
<div className={styles.header}>
<span className={styles.title}>Уведомления</span>
<button
className={styles.readAllBtn}
onClick={handleMarkAllRead}
type="button"
>
Прочитать все
</button>
</div>
<div className={styles.list}>
{items.length === 0 ? (
<div className={styles.empty}>Нет уведомлений</div>
) : (
items.map((item, idx) => (
<div
key={item.notification_id || `${item.job_id}-${idx}`}
className={cs(styles.item, {
[styles.itemUnread]: !item.is_read,
})}
onClick={() => handleItemClick(item)}
>
<div className={styles.itemContent}>
<div className={styles.itemTitle}>
<span>
{item.job_type
? (JOB_TYPE_LABELS[item.job_type] ||
item.title)
: item.title}
</span>
{item.status && (
<span
className={cs(
styles.statusBadge,
getStatusClass(item.status),
)}
>
{STATUS_LABELS[item.status] ||
item.status}
</span>
)}
</div>
{item.message && (
<div className={styles.itemMessage}>
{item.message}
</div>
)}
{item.status === "RUNNING" &&
item.progress_pct != null && (
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{
width: `${item.progress_pct}%`,
}}
/>
</div>
)}
<div className={styles.itemMeta}>
{formatRelativeTime(item.created_at)}
</div>
</div>
</div>
))
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { NotificationPopup } from "./NotificationPopup"
+2
View File
@@ -0,0 +1,2 @@
export { NotificationBell } from "./NotificationBell"
export { NotificationPopup } from "./NotificationPopup"
+5
View File
@@ -0,0 +1,5 @@
export interface IAvatarUploadProps {
currentAvatarUrl: string | null
onAvatarChange: (url: string) => void
className?: string
}
@@ -0,0 +1,38 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatarButton {
all: unset;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.changeLink {
all: unset;
cursor: pointer;
color: var(--accent-9);
font-size: 14px;
&:hover {
text-decoration: underline;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.fileInput {
display: none;
}
@@ -0,0 +1,81 @@
"use client"
import type { IAvatarUploadProps } from "./AvatarUpload.d"
import type { JSX } from "react"
import cs from "classnames"
import { FunctionComponent, useRef, useState } from "react"
import { uploadFileWithProgress } from "@shared/api/uploadFile"
import { Avatar } from "@shared/ui/Avatar"
import styles from "./AvatarUpload.module.scss"
export const AvatarUpload: FunctionComponent<
IAvatarUploadProps
> = ({ currentAvatarUrl, onAvatarChange, className }): JSX.Element => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0]
if (!file) return
setUploadProgress(0)
try {
const result = await uploadFileWithProgress(
file,
"avatars",
setUploadProgress,
)
onAvatarChange(result.file_path)
} catch (err) {
console.error("Avatar upload failed:", err)
} finally {
setUploadProgress(null)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
return (
<div
className={cs(styles.root, className)}
data-testid="AvatarUpload"
>
<button
type="button"
className={styles.avatarButton}
onClick={handleClick}
disabled={uploadProgress !== null}
>
<Avatar
size="xlarge"
url={currentAvatarUrl || ""}
/>
</button>
<button
type="button"
className={styles.changeLink}
onClick={handleClick}
disabled={uploadProgress !== null}
>
{uploadProgress !== null
? `Загрузка ${uploadProgress}%`
: "Изменить фото"}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className={styles.fileInput}
onChange={handleFileChange}
/>
</div>
)
}
@@ -0,0 +1 @@
export * from "./AvatarUpload"
@@ -0,0 +1,3 @@
export interface IChangePasswordFormProps {
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,108 @@
"use client"
import type { IChangePasswordFormProps } from "./ChangePasswordForm.d"
import type { JSX } from "react"
import { FunctionComponent, useState } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { Alert, Button, Form, TextField } from "@shared/ui"
import styles from "./ChangePasswordForm.module.scss"
interface IPasswordFormData {
current_password: string
new_password: string
confirm_password: string
}
export const ChangePasswordForm: FunctionComponent<
IChangePasswordFormProps
> = ({ className }): JSX.Element => {
const [successMessage, setSuccessMessage] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { register, handleSubmit, reset, formState: { errors } } = useForm<IPasswordFormData>({
defaultValues: {
current_password: "",
new_password: "",
confirm_password: "",
},
})
const { mutate, isPending } = api.useMutation(
"post",
"/api/users/me/change-password/",
{
onSuccess: () => {
setErrorMessage(null)
setSuccessMessage(true)
reset()
setTimeout(() => setSuccessMessage(false), 3000)
},
onError: (error) => {
setSuccessMessage(false)
setErrorMessage(
(error as { detail?: string })?.detail || "Не удалось сменить пароль",
)
},
},
)
const onSubmit = (data: IPasswordFormData) => {
setErrorMessage(null)
mutate({
body: {
current_password: data.current_password,
new_password: data.new_password,
},
})
}
return (
<div className={className} data-testid="ChangePasswordForm">
<h3 className={styles.title}>Смена пароля</h3>
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="current_password"
label="Текущий пароль"
placeholder="Введите текущий пароль"
type="password"
{...register("current_password", { required: "Обязательное поле" })}
/>
<TextField
id="new_password"
label="Новый пароль"
placeholder="Введите новый пароль"
type="password"
{...register("new_password", { required: "Обязательное поле" })}
/>
<TextField
id="confirm_password"
label="Подтверждение пароля"
placeholder="Повторите новый пароль"
type="password"
error={!!errors.confirm_password}
{...register("confirm_password", {
required: "Обязательное поле",
validate: (value, formValues) =>
value === formValues.new_password || "Пароли не совпадают",
})}
/>
{errors.confirm_password && (
<Alert variant="danger">{errors.confirm_password.message}</Alert>
)}
</div>
{successMessage && (
<Alert variant="success">Пароль успешно изменён</Alert>
)}
{errorMessage && <Alert variant="danger">{errorMessage}</Alert>}
<Button type="submit" variant="primary" disabled={isPending}>
{isPending ? "Сохранение..." : "Сменить пароль"}
</Button>
</Form>
</div>
)
}
@@ -0,0 +1 @@
export * from "./ChangePasswordForm"
@@ -0,0 +1,6 @@
import type { components } from "@shared/api/__generated__/openapi.types"
export interface IEditProfileFormProps {
user: components["schemas"]["UserRead"]
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,102 @@
"use client"
import type { IEditProfileFormProps } from "./EditProfileForm.d"
import type { JSX } from "react"
import { FunctionComponent, useState } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { setUser } from "@shared/store/user"
import { Alert, Button, Form, TextField } from "@shared/ui"
import styles from "./EditProfileForm.module.scss"
interface IProfileFormData {
first_name: string
last_name: string
email: string
phone_number: string
}
export const EditProfileForm: FunctionComponent<
IEditProfileFormProps
> = ({ user, className }): JSX.Element => {
const dispatch = useAppDispatch()
const [successMessage, setSuccessMessage] = useState(false)
const { register, handleSubmit } = useForm<IProfileFormData>({
defaultValues: {
first_name: user.first_name || "",
last_name: user.last_name || "",
email: user.email || "",
phone_number: user.phone_number || "",
},
})
const { mutate, isPending } = api.useMutation(
"patch",
"/api/users/{user_id}/",
{
onSuccess: (data) => {
dispatch(setUser(data))
setSuccessMessage(true)
setTimeout(() => setSuccessMessage(false), 3000)
},
},
)
const onSubmit = (data: IProfileFormData) => {
mutate({
params: { path: { user_id: user.id } },
body: {
first_name: data.first_name || null,
last_name: data.last_name || null,
email: data.email || null,
phone_number: data.phone_number || null,
},
})
}
return (
<div className={className} data-testid="EditProfileForm">
<h3 className={styles.title}>Личная информация</h3>
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="first_name"
label="Имя"
placeholder="Ваше имя"
{...register("first_name")}
/>
<TextField
id="last_name"
label="Фамилия"
placeholder="Ваша фамилия"
{...register("last_name")}
/>
<TextField
id="email"
label="Email"
placeholder="Ваш email"
type="email"
{...register("email")}
/>
<TextField
id="phone_number"
label="Телефон"
placeholder="Ваш телефон"
{...register("phone_number")}
/>
</div>
{successMessage && (
<Alert variant="success">Данные успешно сохранены</Alert>
)}
<Button type="submit" variant="primary" disabled={isPending}>
{isPending ? "Сохранение..." : "Сохранить"}
</Button>
</Form>
</div>
)
}
@@ -0,0 +1 @@
export * from "./EditProfileForm"
+3
View File
@@ -0,0 +1,3 @@
export interface ILogoutButtonProps {
className?: string
}
@@ -0,0 +1,2 @@
.root {
}
@@ -0,0 +1,43 @@
"use client"
import type { ILogoutButtonProps } from "./LogoutButton.d"
import type { JSX } from "react"
import Cookies from "js-cookie"
import { FunctionComponent } from "react"
import { useRouter } from "next/navigation"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@shared/lib/constants"
import { resetUser } from "@shared/store/user"
import { Button } from "@shared/ui"
export const LogoutButton: FunctionComponent<
ILogoutButtonProps
> = ({ className }): JSX.Element => {
const router = useRouter()
const dispatch = useAppDispatch()
const handleLogout = () => {
Cookies.remove(ACCESS_TOKEN_COOKIE)
Cookies.remove(REFRESH_TOKEN_COOKIE)
dispatch(resetUser())
router.push("/login")
}
return (
<Button
variant="danger"
size="lg"
className={className}
onClick={handleLogout}
data-testid="LogoutButton"
>
Выйти из аккаунта
</Button>
)
}
@@ -0,0 +1 @@
export * from "./LogoutButton"
+4
View File
@@ -0,0 +1,4 @@
export { AvatarUpload } from "./AvatarUpload"
export { ChangePasswordForm } from "./ChangePasswordForm"
export { EditProfileForm } from "./EditProfileForm"
export { LogoutButton } from "./LogoutButton"
@@ -0,0 +1,6 @@
export interface IConvertMediaViewProps {
projectId: string
fileKey: string
fileName: string
mimeType: string
}
@@ -0,0 +1,69 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 300px;
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 360px;
text-align: center;
}
.icon {
color: variables.$text-tertiary;
}
.successIcon {
color: variables.$color-success;
}
.fileName {
font-size: 15px;
font-weight: 500;
color: variables.$text-primary;
word-break: break-all;
}
.message {
font-size: 14px;
color: variables.$text-secondary;
}
.hint {
font-size: 13px;
color: variables.$text-tertiary;
}
.error {
font-size: 13px;
color: variables.$color-danger;
}
.progressTrack {
width: 100%;
height: 6px;
border-radius: 3px;
background: variables.$bg-surface;
overflow: hidden;
}
.progressBar {
height: 100%;
border-radius: 3px;
background: variables.$color-primary;
transition: width 0.3s ease;
}
.progressLabel {
font-size: 13px;
color: variables.$text-tertiary;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,142 @@
"use client"
import type { IConvertMediaViewProps } from "./ConvertMediaView.d"
import type { JSX } from "react"
import { CheckCircle, FileVideo } from "lucide-react"
import { FunctionComponent, useCallback, useState } from "react"
import api from "@shared/api"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button } from "@shared/ui"
import styles from "./ConvertMediaView.module.scss"
const STATUS_IDLE = "idle"
const STATUS_CONVERTING = "converting"
const STATUS_DONE = "done"
const STATUS_FAILED = "failed"
type ConvertStatus =
| typeof STATUS_IDLE
| typeof STATUS_CONVERTING
| typeof STATUS_DONE
| typeof STATUS_FAILED
const ERROR_CONVERT_FAILED = "Не удалось запустить конвертацию"
function formatNameFromMime(mime: string): string {
const sub = mime.split("/")[1] ?? mime
return sub.toUpperCase()
}
export const ConvertMediaView: FunctionComponent<
IConvertMediaViewProps
> = ({ projectId, fileKey, fileName, mimeType }): JSX.Element => {
const [status, setStatus] = useState<ConvertStatus>(STATUS_IDLE)
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 progressPct = notification?.progress_pct ?? 0
const notifStatus = notification?.status
const notifMessage = notification?.message
// 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)
}
const { mutate, isPending } = api.useMutation(
"post",
"/api/tasks/media-convert/",
{
onSuccess: (data) => {
setJobId(data.job_id)
setStatus(STATUS_CONVERTING)
setErrorMessage(null)
},
onError: () => {
setErrorMessage(ERROR_CONVERT_FAILED)
},
},
)
const handleConvert = useCallback(() => {
mutate({
body: {
file_key: fileKey,
out_folder: "output_files",
output_format: "mp4",
project_id: projectId,
},
})
}, [mutate, fileKey, projectId])
const formatName = formatNameFromMime(mimeType)
if (status === STATUS_DONE) {
return (
<div className={styles.root} data-testid="ConvertMediaView">
<div className={styles.content}>
<CheckCircle size={48} className={styles.successIcon} />
<p className={styles.message}>Конвертация завершена</p>
<p className={styles.hint}>
Файл MP4 доступен в разделе «Артефакты»
</p>
</div>
</div>
)
}
if (status === STATUS_CONVERTING) {
return (
<div className={styles.root} data-testid="ConvertMediaView">
<div className={styles.content}>
<FileVideo size={48} className={styles.icon} />
<p className={styles.message}>
{notifMessage ?? "Конвертация..."}
</p>
<div className={styles.progressTrack}>
<div
className={styles.progressBar}
style={{ width: `${progressPct}%` }}
/>
</div>
<p className={styles.progressLabel}>{Math.round(progressPct)}%</p>
</div>
</div>
)
}
return (
<div className={styles.root} data-testid="ConvertMediaView">
<div className={styles.content}>
<FileVideo size={48} className={styles.icon} />
<p className={styles.fileName}>{fileName}</p>
<p className={styles.message}>
Формат {formatName} не поддерживается для воспроизведения
</p>
{errorMessage && (
<p className={styles.error}>{errorMessage}</p>
)}
<Button
variant="primary"
onClick={handleConvert}
disabled={isPending}
>
Конвертировать в MP4
</Button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { ConvertMediaView } from "./ConvertMediaView"
@@ -20,6 +20,6 @@
}
.selectLabel {
font-size: 14px;
font-weight: 500;
@include typography.font-body-14(500);
color: variables.$text-primary;
}
@@ -1,6 +1,5 @@
"use client"
import type { ProjectCreateBody } from "./useCreateProject"
import type { ICreateProjectModalProps } from "./CreateProjectModal.d"
import type { JSX } from "react"
@@ -12,27 +11,16 @@ import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
import { useCreateProject } from "./useCreateProject"
import styles from "./CreateProjectModal.module.scss"
type ProjectStatus = ProjectCreateBody["status"]
interface ICreateProjectFormData {
name: string
description?: string
language: string
folder?: string
status: ProjectStatus
}
const STATUS_OPTIONS: Array<{ value: ProjectStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "PROCESSING", label: "Processing" },
{ value: "DONE", label: "Done" },
{ value: "FAILED", label: "Failed" },
]
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "auto", label: "Auto" },
{ value: "ru", label: "Russian" },
{ value: "en", label: "English" },
{ value: "auto", label: "Авто" },
{ value: "ru", label: "Русский" },
{ value: "en", label: "Английский" },
]
export const CreateProjectModal: FunctionComponent<
@@ -43,9 +31,7 @@ export const CreateProjectModal: FunctionComponent<
defaultValues: {
name: "",
description: "",
folder: "",
language: "auto",
status: "DRAFT",
},
})
@@ -66,15 +52,12 @@ export const CreateProjectModal: FunctionComponent<
const onSubmit = (data: ICreateProjectFormData): void => {
const name = data.name.trim()
const description = data.description?.trim()
const folder = data.folder?.trim()
mutate({
body: {
name,
description: description?.length ? description : undefined,
folder: folder?.length ? folder : undefined,
language: data.language,
status: data.status,
},
})
}
@@ -109,13 +92,6 @@ export const CreateProjectModal: FunctionComponent<
{...register("description")}
/>
<TextField
id="project_folder"
label="Папка"
placeholder="Например: /projects/my-project (необязательно)"
{...register("folder")}
/>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Язык</div>
<Controller
@@ -136,33 +112,12 @@ export const CreateProjectModal: FunctionComponent<
)}
/>
</div>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Статус</div>
<Controller
name="status"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите статус"
>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="ghost"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
@@ -0,0 +1,9 @@
import type { Dialog } from "@radix-ui/themes"
import type { ComponentProps } from "react"
export interface IDeleteFileModalProps
extends Pick<ComponentProps<typeof Dialog.Root>, "open" | "onOpenChange"> {
fileName: string
onConfirm: () => void
isPending: boolean
}
@@ -0,0 +1,20 @@
.root {
min-width: 420px;
}
.message {
@include typography.font-body-14(400);
color: variables.$text-secondary;
}
.fileName {
@include typography.font-body-14(600);
color: variables.$text-primary;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@@ -0,0 +1,53 @@
"use client"
import type { IDeleteFileModalProps } from "./DeleteFileModal.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { Button, Modal } from "@shared/ui"
import styles from "./DeleteFileModal.module.scss"
export const DeleteFileModal: FunctionComponent<IDeleteFileModalProps> = ({
open,
onOpenChange,
fileName,
onConfirm,
isPending,
}): JSX.Element => {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удалить файл"
description="Это действие нельзя отменить"
>
<div className={styles.root} data-testid="DeleteFileModal">
<p className={styles.message}>
Вы уверены, что хотите удалить файл{" "}
<span className={styles.fileName}>{fileName}</span>?
</p>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button
type="button"
variant="danger"
disabled={isPending}
onClick={onConfirm}
>
Удалить
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { DeleteFileModal } from "./DeleteFileModal"
export type { IDeleteFileModalProps } from "./DeleteFileModal.d"
@@ -0,0 +1,48 @@
import api from "@shared/api"
interface IUseDeleteFileParams {
onSuccess?: () => void
onError?: (error: unknown) => void
}
export const useDeleteUserFile = ({
onSuccess,
onError,
}: IUseDeleteFileParams = {}) => {
return api.useMutation("delete", "/api/files/files/{file_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
export const useDeleteArtifact = ({
onSuccess,
onError,
}: IUseDeleteFileParams = {}) => {
return api.useMutation("delete", "/api/media/artifacts/{artifact_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
export const useDeleteMediaFile = ({
onSuccess,
onError,
}: IUseDeleteFileParams = {}) => {
return api.useMutation("delete", "/api/media/mediafiles/{media_file_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
@@ -0,0 +1,11 @@
import type { Dialog } from "@radix-ui/themes"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IDeleteProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
onDeleted?: () => void | Promise<void>
}
@@ -0,0 +1,20 @@
.root {
min-width: 420px;
}
.message {
@include typography.font-body-14(400);
color: variables.$text-secondary;
}
.projectName {
@include typography.font-body-14(600);
color: variables.$text-primary;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@@ -0,0 +1,66 @@
"use client"
import type { IDeleteProjectModalProps } from "./DeleteProjectModal.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { Button, Modal } from "@shared/ui"
import { useDeleteProject } from "./useDeleteProject"
import styles from "./DeleteProjectModal.module.scss"
export const DeleteProjectModal: FunctionComponent<
IDeleteProjectModalProps
> = ({ open, onOpenChange, project, onDeleted }): JSX.Element => {
const { mutate, isPending } = useDeleteProject({
onSuccess: async () => {
await onDeleted?.()
onOpenChange?.(false)
},
onError: (error) => {
console.error("Delete project failed:", error)
},
})
const handleDelete = (): void => {
mutate({
params: { path: { project_id: project.id } },
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удалить проект"
description="Это действие нельзя отменить"
>
<div className={styles.root} data-testid="DeleteProjectModal">
<p className={styles.message}>
Вы уверены, что хотите удалить проект{" "}
<span className={styles.projectName}>{project.name}</span>?
</p>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button
type="button"
variant="danger"
disabled={isPending}
onClick={handleDelete}
>
Удалить
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { DeleteProjectModal } from "./DeleteProjectModal"
export type { IDeleteProjectModalProps } from "./DeleteProjectModal.d"
@@ -0,0 +1,20 @@
import api from "@shared/api"
interface IUseDeleteProjectParams {
onSuccess?: () => void
onError?: (error: unknown) => void
}
export const useDeleteProject = ({
onSuccess,
onError,
}: IUseDeleteProjectParams = {}) => {
return api.useMutation("delete", "/api/projects/{project_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
@@ -0,0 +1,11 @@
import type { Dialog } from "@radix-ui/themes"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IEditProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
onUpdated?: () => void | Promise<void>
}
@@ -0,0 +1,25 @@
.root {
min-width: 520px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.selectField {
display: grid;
gap: 6px;
}
.selectLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
}
@@ -0,0 +1,144 @@
"use client"
import type { IEditProjectModalProps } from "./EditProjectModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
import { useUpdateProject } from "./useUpdateProject"
import styles from "./EditProjectModal.module.scss"
interface IEditProjectFormData {
name: string
description?: string
language: string
}
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "auto", label: "Авто" },
{ value: "ru", label: "Русский" },
{ value: "en", label: "Английский" },
]
export const EditProjectModal: FunctionComponent<IEditProjectModalProps> = ({
open,
onOpenChange,
project,
onUpdated,
}): JSX.Element => {
const { control, register, handleSubmit, reset, formState } =
useForm<IEditProjectFormData>({
defaultValues: {
name: project.name,
description: project.description ?? "",
language: project.language,
},
})
const { mutate, isPending } = useUpdateProject({
onSuccess: async () => {
await onUpdated?.()
onOpenChange?.(false)
},
onError: (error) => {
console.error("Update project failed:", error)
},
})
useEffect(() => {
if (open) {
reset({
name: project.name,
description: project.description ?? "",
language: project.language,
})
}
}, [open, project, reset])
const onSubmit = (data: IEditProjectFormData): void => {
const name = data.name.trim()
const description = data.description?.trim()
mutate({
params: { path: { project_id: project.id } },
body: {
name,
description: description?.length ? description : null,
language: data.language,
},
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Изменить проект"
description="Измените параметры проекта"
>
<div className={styles.root} data-testid="EditProjectModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="project_name"
label="Название"
placeholder="Например: Мой первый проект"
error={Boolean(formState.errors.name)}
undertitle={formState.errors.name?.message}
{...register("name", {
required: "Введите название проекта",
validate: (v) =>
v.trim().length > 0 || "Введите название проекта",
})}
/>
<TextField
id="project_description"
label="Описание"
placeholder="Коротко опишите проект (необязательно)"
{...register("description")}
/>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Язык</div>
<Controller
name="language"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите язык"
>
{LANGUAGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Сохранить
</Button>
</div>
</Form>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { EditProjectModal } from "./EditProjectModal"
export type { IEditProjectModalProps } from "./EditProjectModal.d"
@@ -0,0 +1,25 @@
import type { components } from "@shared/api/__generated__/openapi.types"
import api from "@shared/api"
export type ProjectUpdateBody = components["schemas"]["ProjectUpdate"]
export type ProjectRead = components["schemas"]["ProjectRead"]
interface IUseUpdateProjectParams {
onSuccess?: (project: ProjectRead) => void
onError?: (error: unknown) => void
}
export const useUpdateProject = ({
onSuccess,
onError,
}: IUseUpdateProjectParams = {}) => {
return api.useMutation("patch", "/api/projects/{project_id}/", {
onSuccess: (project) => {
onSuccess?.(project)
},
onError: (error) => {
onError?.(error)
},
})
}
@@ -0,0 +1,11 @@
import type { Dialog } from "@radix-ui/themes"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IRenameProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
onRenamed?: () => void | Promise<void>
}
@@ -0,0 +1,15 @@
.root {
min-width: 420px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@@ -0,0 +1,98 @@
"use client"
import type { IRenameProjectModalProps } from "./RenameProjectModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { Button, Form, Modal, TextField } from "@shared/ui"
import styles from "./RenameProjectModal.module.scss"
interface IRenameProjectFormData {
name: string
}
export const RenameProjectModal: FunctionComponent<
IRenameProjectModalProps
> = ({ open, onOpenChange, project, onRenamed }): JSX.Element => {
const { register, handleSubmit, reset, formState } =
useForm<IRenameProjectFormData>({
defaultValues: {
name: project.name,
},
})
const { mutate, isPending } = api.useMutation(
"patch",
"/api/projects/{project_id}/",
{
onSuccess: async () => {
await onRenamed?.()
onOpenChange?.(false)
},
onError: (error) => {
console.error("Rename project failed:", error)
},
},
)
useEffect(() => {
if (open) {
reset({ name: project.name })
}
}, [open, project, reset])
const onSubmit = (data: IRenameProjectFormData): void => {
const name = data.name.trim()
mutate({
params: { path: { project_id: project.id } },
body: { name },
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Переименовать проект"
description="Введите новое название проекта"
>
<div className={styles.root} data-testid="RenameProjectModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="project_name"
label="Название"
placeholder="Например: Мой первый проект"
error={Boolean(formState.errors.name)}
undertitle={formState.errors.name?.message}
{...register("name", {
required: "Введите название проекта",
validate: (v) =>
v.trim().length > 0 || "Введите название проекта",
})}
/>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Переименовать
</Button>
</div>
</Form>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { RenameProjectModal } from "./RenameProjectModal"
export type { IRenameProjectModalProps } from "./RenameProjectModal.d"
@@ -0,0 +1,19 @@
import type { WordData } from "@shared/lib/transcriptionDocument"
export interface SegmentEditData {
start: number
end: number
text: string
words?: WordData[]
}
export interface ISegmentEditModalProps {
open: boolean
onOpenChange: (open: boolean) => void
videoUrl?: string
segment: SegmentEditData
onSave: (text: string) => Promise<void>
onSplit?: (
newSegments: Array<{ start: number; end: number; text: string; words?: WordData[] }>,
) => Promise<void>
}
@@ -0,0 +1,80 @@
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.player {
width: 100%;
border-radius: variables.$radius-md;
overflow: hidden;
aspect-ratio: 16 / 9;
}
.playerWrapper {
position: relative;
width: 100%;
height: 100%;
}
.timeRange {
position: absolute;
bottom: 8px;
left: 8px;
padding: 2px 8px;
border-radius: variables.$radius-sm;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.textArea {
width: 100%;
min-height: 72px;
padding: 10px 12px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
background: variables.$bg-surface;
color: variables.$text-primary;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
resize: vertical;
&:focus {
outline: none;
border-color: variables.$purple-400;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
}
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.splitAction {
display: inline-flex;
align-items: center;
gap: 4px;
}
.actionsSpacer {
flex: 1;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,220 @@
"use client"
import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default"
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import { LoaderCircle, Scissors } from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Button, Modal } from "@shared/ui"
import {
type EditorSegment,
secondsToTimecode,
splitSegmentAtMarkers,
} from "@shared/lib/transcriptionDocument"
import { SegmentSplitter } from "@features/project/SegmentSplitter"
import styles from "./SegmentEditModal.module.scss"
const SegmentPlayer = ({
videoUrl,
start,
end,
}: {
videoUrl: string
start: number
end: number
}) => {
const currentTime = useMediaState("currentTime")
const playing = useMediaState("playing")
const hasPausedRef = useRef(false)
const playerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
hasPausedRef.current = false
}, [start, end])
useEffect(() => {
if (!playing) return
if (currentTime >= end && !hasPausedRef.current) {
hasPausedRef.current = true
const player = playerRef.current as HTMLElement & {
pause?: () => void
}
player?.pause?.()
}
}, [currentTime, end, playing])
return (
<div className={styles.playerWrapper}>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
slots={{
settingsMenu: null,
pipButton: null,
fullscreenButton: null,
airPlayButton: null,
googleCastButton: null,
}}
/>
<div className={styles.timeRange}>
{secondsToTimecode(start)} {secondsToTimecode(end)}
</div>
</div>
)
}
export const SegmentEditModal: FunctionComponent<
ISegmentEditModalProps
> = ({ open, onOpenChange, videoUrl, segment, onSave, onSplit }): JSX.Element => {
const [text, setText] = useState(segment.text)
const [saving, setSaving] = useState(false)
const [splitMode, setSplitMode] = useState(false)
const canSplit = !!onSplit && !!segment.words && segment.words.length >= 2
useEffect(() => {
if (open) {
setText(segment.text)
setSplitMode(false)
}
}, [open, segment.text])
const editorSegment: EditorSegment = useMemo(
() => ({
startTime: secondsToTimecode(segment.start),
endTime: secondsToTimecode(segment.end),
text: segment.text,
words: segment.words,
}),
[segment],
)
const handleSave = useCallback(async () => {
setSaving(true)
try {
await onSave(text)
onOpenChange(false)
} finally {
setSaving(false)
}
}, [text, onSave, onOpenChange])
const handleSplit = useCallback(
async (newSegments: EditorSegment[]) => {
if (!onSplit) return
setSaving(true)
try {
await onSplit(
newSegments.map((s) => ({
start: s.words?.[0]?.start ?? segment.start,
end: s.words?.[s.words.length - 1]?.end ?? segment.end,
text: s.text,
words: s.words,
})),
)
onOpenChange(false)
} finally {
setSaving(false)
}
},
[onSplit, onOpenChange, segment],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSave()
}
},
[handleSave],
)
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Редактировать субтитр"
>
<div className={styles.root} data-testid="SegmentEditModal">
{videoUrl && (
<MediaPlayer
src={videoUrl}
currentTime={segment.start}
className={styles.player}
autoPlay
>
<SegmentPlayer
videoUrl={videoUrl}
start={segment.start}
end={segment.end}
/>
</MediaPlayer>
)}
{splitMode ? (
<SegmentSplitter
segment={editorSegment}
onSplit={handleSplit}
onCancel={() => setSplitMode(false)}
/>
) : (
<>
<textarea
className={styles.textArea}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={3}
placeholder="Текст субтитра..."
autoFocus
/>
<div className={styles.actions}>
{canSplit && (
<Button
type="button"
variant="outline"
onClick={() => setSplitMode(true)}
className={styles.splitAction}
>
<Scissors size={14} />
Разделить
</Button>
)}
<div className={styles.actionsSpacer} />
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Отмена
</Button>
<Button
type="button"
variant="primary"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<LoaderCircle size={16} className={styles.spinner} />
) : null}
Сохранить
</Button>
</div>
</>
)}
</div>
</Modal>
)
}
@@ -0,0 +1 @@
export * from "./SegmentEditModal"
@@ -0,0 +1,7 @@
import type { EditorSegment } from "@shared/lib/transcriptionDocument"
export interface ISegmentSplitterProps {
segment: EditorSegment
onSplit: (newSegments: EditorSegment[]) => void
onCancel: () => void
}
@@ -0,0 +1,150 @@
.root {
display: flex;
flex-direction: column;
gap: 10px;
}
.wordsRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 0;
padding: 8px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
background: variables.$bg-default;
min-height: 40px;
}
.wordGroup {
display: inline-flex;
align-items: center;
}
.word {
display: inline-block;
padding: 2px 4px;
font-size: 13px;
line-height: 1.5;
color: variables.$text-primary;
border-radius: 2px;
user-select: none;
}
.gap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 22px;
padding: 0;
margin: 0 1px;
border: none;
background: none;
cursor: pointer;
border-radius: 2px;
position: relative;
flex-shrink: 0;
&::after {
content: "";
display: block;
width: 2px;
height: 100%;
background: transparent;
border-radius: 1px;
transition: background 0.15s;
}
&:hover::after {
background: variables.$color-primary;
opacity: 0.4;
}
&.active::after {
background: variables.$color-danger;
opacity: 1;
}
}
.preview {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px dashed variables.$border-default;
border-radius: variables.$radius-sm;
background: variables.$bg-surface;
}
.previewLabel {
font-size: 11px;
font-weight: 500;
color: variables.$text-tertiary;
margin-bottom: 2px;
}
.previewSegment {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
& + & {
border-top: 1px solid variables.$border-default;
}
}
.previewTime {
font-size: 11px;
font-family: monospace;
color: variables.$text-tertiary;
white-space: nowrap;
flex-shrink: 0;
}
.previewText {
font-size: 13px;
color: variables.$text-primary;
}
.actions {
display: flex;
gap: 8px;
}
.splitBtn {
padding: 4px 12px;
border: none;
border-radius: variables.$radius-sm;
background: variables.$color-primary;
color: variables.$color-white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
&:hover {
opacity: 0.9;
}
&:disabled {
background: variables.$border-default;
color: variables.$text-tertiary;
cursor: default;
}
}
.cancelBtn {
padding: 4px 12px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
background: none;
color: variables.$text-secondary;
font-size: 12px;
cursor: pointer;
&:hover {
background: variables.$bg-hover;
}
}
@@ -0,0 +1,93 @@
"use client"
import type { ISegmentSplitterProps } from "./SegmentSplitter.d"
import type { JSX } from "react"
import { FunctionComponent, useCallback, useMemo, useState } from "react"
import {
type EditorSegment,
splitSegmentAtMarkers,
} from "@shared/lib/transcriptionDocument"
import styles from "./SegmentSplitter.module.scss"
export const SegmentSplitter: FunctionComponent<ISegmentSplitterProps> = ({
segment,
onSplit,
onCancel,
}): JSX.Element => {
const [markers, setMarkers] = useState<Set<number>>(new Set())
const words = segment.words!
const toggleMarker = useCallback((idx: number) => {
setMarkers((prev) => {
const next = new Set(prev)
if (next.has(idx)) {
next.delete(idx)
} else {
next.add(idx)
}
return next
})
}, [])
const preview: EditorSegment[] = useMemo(() => {
if (markers.size === 0) return [segment]
return splitSegmentAtMarkers(segment, Array.from(markers))
}, [segment, markers])
const handleSplit = useCallback(() => {
if (markers.size === 0) return
onSplit(preview)
}, [markers, preview, onSplit])
return (
<div className={styles.root} data-testid="SegmentSplitter">
<div className={styles.wordsRow}>
{words.map((word, idx) => (
<span key={idx} className={styles.wordGroup}>
{idx > 0 && (
<button
className={`${styles.gap} ${markers.has(idx) ? styles.active : ""}`}
onClick={() => toggleMarker(idx)}
title="Разделить здесь"
type="button"
/>
)}
<span className={styles.word}>{word.text}</span>
</span>
))}
</div>
{markers.size > 0 && (
<div className={styles.preview}>
<span className={styles.previewLabel}>Результат:</span>
{preview.map((seg, idx) => (
<div key={idx} className={styles.previewSegment}>
<span className={styles.previewTime}>
{seg.startTime} {seg.endTime}
</span>
<span className={styles.previewText}>{seg.text}</span>
</div>
))}
</div>
)}
<div className={styles.actions}>
<button
className={styles.splitBtn}
onClick={handleSplit}
disabled={markers.size === 0}
type="button"
>
Разделить
</button>
<button className={styles.cancelBtn} onClick={onCancel} type="button">
Отмена
</button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from "./SegmentSplitter"
@@ -0,0 +1,13 @@
export interface ISilenceResultModalProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
jobId: string
fileKey: string
}
export interface CutRegion {
id: string
startMs: number
endMs: number
}
@@ -0,0 +1,234 @@
// Override Radix Dialog.Content max-width for this wide modal
:global(.rt-DialogContent):has([data-testid="SilenceResultModal"]) {
max-width: 80vw !important;
width: 80vw !important;
max-height: 90vh;
}
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.playerWrapper {
position: relative;
width: 100%;
height: 45vh;
border-radius: variables.$radius-md;
overflow: hidden;
background: #000;
// Force Vidstack player to fill the container exactly
: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;
}
}
.timelineSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.zoomControls {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: variables.$text-secondary;
}
.zoomButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-sm;
background: variables.$bg-default;
color: variables.$text-primary;
cursor: pointer;
font-size: 16px;
font-weight: 500;
user-select: none;
&:hover {
background: variables.$bg-hover;
}
}
.timelineContainer {
position: relative;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md;
background: variables.$bg-surface;
}
.timelineInner {
position: relative;
}
.rulerRow {
position: relative;
height: 24px;
border-bottom: 1px solid variables.$border-subtle;
}
.rulerCanvas {
position: absolute;
top: 0;
left: 0;
display: block;
height: 24px;
}
.framesRow {
position: relative;
height: 48px;
border-bottom: 1px solid variables.$border-subtle;
background: #111;
}
.framesCanvas {
position: absolute;
top: 0;
left: 0;
display: block;
height: 48px;
}
.waveformRow {
position: relative;
height: 48px;
border-bottom: 1px solid variables.$border-subtle;
}
.cutRegionsRow {
position: relative;
height: 32px;
}
.infoBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
font-size: 13px;
color: variables.$text-secondary;
}
.infoTotal {
font-variant-numeric: tabular-nums;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
// --- Cut region blocks ---
.cutRegion {
position: absolute;
top: 0;
height: 100%;
background: rgba(255, 152, 0, 0.3);
border: 1px solid rgba(255, 152, 0, 0.7);
border-radius: 2px;
cursor: grab;
user-select: none;
transition: background 0.1s ease;
&:hover {
background: rgba(255, 152, 0, 0.4);
}
}
.cutRegionActive {
background: rgba(255, 152, 0, 0.5);
cursor: grabbing;
}
.handleLeft,
.handleRight {
position: absolute;
top: 0;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 2;
}
.handleLeft {
left: -3px;
}
.handleRight {
right: -3px;
}
// --- Context menu ---
.contextMenu {
min-width: 160px;
padding: 4px;
background: variables.$bg-surface;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: variables.$shadow-md;
z-index: 100;
}
.contextMenuItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: variables.$radius-sm;
background: none;
color: variables.$text-primary;
font-size: 13px;
cursor: pointer;
text-align: left;
&:hover {
background: variables.$bg-hover;
}
}
.contextMenuDanger {
color: variables.$color-danger;
}
// --- Playhead ---
.playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: variables.$color-danger;
z-index: 10;
pointer-events: none;
}
@@ -0,0 +1,801 @@
"use client"
import type { CutRegion, ISilenceResultModalProps } from "./SilenceResultModal.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider } from "@vidstack/react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default"
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import cs from "classnames"
import { Plus, Trash2 } from "lucide-react"
import {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import WaveSurfer from "wavesurfer.js"
import api from "@shared/api"
import { useSegmentResize } from "@shared/hooks/useSegmentResize"
import { Button, Modal } from "@shared/ui"
import { useSubmitSilenceApply } from "./useSubmitSilenceApply"
import styles from "./SilenceResultModal.module.scss"
const MIN_REGION_MS = 100
const DEFAULT_NEW_REGION_MS = 1000
const DEFAULT_PPS = 10
const MIN_PPS = 2
const MAX_PPS = 200
const PPS_STEP = 2
const FRAMES_HEIGHT = 48
const WAVEFORM_HEIGHT = 48
const RULER_HEIGHT = 24
const MAX_EXTRACTED_FRAMES = 150
const CANVAS_OVERSCAN = 300
let regionIdCounter = 0
const nextRegionId = (): string => `region_${++regionIdCounter}`
const formatDuration = (ms: number): string => {
const totalSec = Math.floor(ms / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
if (min > 0) return `${min}м ${sec}с`
return `${sec}с`
}
function resolveWaveformColors(): { wave: string; progress: string } {
const root = getComputedStyle(document.documentElement)
return {
wave:
root.getPropertyValue("--waveform-wave").trim() ||
"hsl(297, 70%, 44%)",
progress:
root.getPropertyValue("--waveform-progress").trim() ||
"hsl(293, 100%, 34%)",
}
}
export const SilenceResultModal: FunctionComponent<ISilenceResultModalProps> = ({
open,
onOpenChange,
projectId,
jobId,
}): JSX.Element => {
const [cutRegions, setCutRegions] = useState<CutRegion[]>([])
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PPS)
const [durationMs, setDurationMs] = useState(0)
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
regionId: string | null
timeMs: number
} | null>(null)
const timelineRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<any>(null)
const waveformRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WaveSurfer | null>(null)
// --- Data loading ---
const { data: taskStatus } = api.useQuery(
"get",
"/api/tasks/status/{job_id}/",
{ params: { path: { job_id: jobId } } },
{ enabled: open && !!jobId },
)
const outputData = taskStatus?.output_data as Record<string, unknown> | null
const fileKey = (outputData?.file_key as string) ?? ""
const { data: fileInfo } = api.useQuery(
"get",
"/api/files/get_file/",
{ params: { query: { file_path: fileKey } } },
{ enabled: open && !!fileKey },
)
const videoUrl = fileInfo?.file_url ?? null
// Initialize cut regions from detection results
useEffect(() => {
if (!outputData) return
const segments = outputData.silent_segments as
| { start_ms: number; end_ms: number }[]
| undefined
const dur = outputData.duration_ms as number | undefined
if (segments && dur) {
setDurationMs(dur)
setCutRegions(
segments.map((s) => ({
id: nextRegionId(),
startMs: s.start_ms,
endMs: s.end_ms,
})),
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [outputData])
// --- Timeline calculations ---
const totalWidth = Math.max(1, (durationMs / 1000) * pixelsPerSecond)
const msToPixels = useCallback(
(ms: number) => (ms / 1000) * pixelsPerSecond,
[pixelsPerSecond],
)
const pixelsToMs = useCallback(
(px: number) => (px / pixelsPerSecond) * 1000,
[pixelsPerSecond],
)
// --- Total removed calculation ---
const totalRemovedMs = useMemo(
() => cutRegions.reduce((sum, r) => sum + (r.endMs - r.startMs), 0),
[cutRegions],
)
// --- Region mutations ---
const addRegion = useCallback(
(atMs: number) => {
const startMs = Math.max(0, atMs - DEFAULT_NEW_REGION_MS / 2)
const endMs = Math.min(durationMs, startMs + DEFAULT_NEW_REGION_MS)
setCutRegions((prev) =>
[...prev, { id: nextRegionId(), startMs, endMs }].sort(
(a, b) => a.startMs - b.startMs,
),
)
},
[durationMs],
)
const removeRegion = useCallback((regionId: string) => {
setCutRegions((prev) => prev.filter((r) => r.id !== regionId))
}, [])
// --- Resize handling ---
const { handlePointerDown: handleResizePointerDown } = useSegmentResize({
pixelsPerSecond,
onResize: (index, edge, deltaSec) => {
setCutRegions((prev) => {
const updated = [...prev]
const region = { ...updated[index] }
const deltaMs = deltaSec * 1000
if (edge === "left") {
region.startMs = Math.max(
0,
Math.min(region.endMs - MIN_REGION_MS, region.startMs + deltaMs),
)
} else {
region.endMs = Math.min(
durationMs,
Math.max(region.startMs + MIN_REGION_MS, region.endMs + deltaMs),
)
}
updated[index] = region
return updated
})
},
onResizeEnd: () => {},
})
// --- Drag-to-move handling ---
const handleRegionDragStart = useCallback(
(e: React.PointerEvent, index: number) => {
e.stopPropagation()
const startX = e.clientX
const region = cutRegions[index]
const regionDuration = region.endMs - region.startMs
const onMove = (moveE: PointerEvent) => {
const dx = moveE.clientX - startX
const deltaMs = pixelsToMs(dx)
let newStart = region.startMs + deltaMs
newStart = Math.max(0, Math.min(durationMs - regionDuration, newStart))
setCutRegions((prev) => {
const updated = [...prev]
updated[index] = {
...updated[index],
startMs: Math.round(newStart),
endMs: Math.round(newStart + regionDuration),
}
return updated
})
}
const onUp = () => {
document.removeEventListener("pointermove", onMove)
document.removeEventListener("pointerup", onUp)
}
document.addEventListener("pointermove", onMove)
document.addEventListener("pointerup", onUp)
},
[cutRegions, durationMs, pixelsToMs],
)
// --- Context menu ---
const handleContextMenu = useCallback(
(e: React.MouseEvent, regionId: string | null) => {
e.preventDefault()
e.stopPropagation()
const rect = timelineRef.current?.getBoundingClientRect()
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
const x = e.clientX - (rect?.left ?? 0) + scrollLeft
const timeMs = pixelsToMs(x)
setContextMenu({
x: e.clientX,
y: e.clientY,
regionId,
timeMs,
})
},
[pixelsToMs],
)
// Close context menu on click anywhere
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
document.addEventListener("click", close)
return () => document.removeEventListener("click", close)
}, [contextMenu])
// --- Timeline click to seek ---
const handleTimelineClick = useCallback(
(e: React.MouseEvent) => {
const rect = timelineRef.current?.getBoundingClientRect()
if (!rect) return
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
const x = e.clientX - rect.left + scrollLeft
const timeMs = pixelsToMs(x)
const timeSec = timeMs / 1000
if (playerRef.current) {
playerRef.current.currentTime = timeSec
}
},
[pixelsToMs],
)
// --- Canvas drawing functions (stable refs, called from animation loop) ---
const rulerRef = useRef<HTMLCanvasElement>(null)
const drawRuler = useCallback(() => {
const container = timelineRef.current
const canvas = rulerRef.current
if (!container || !canvas || !durationMs) return
const sl = container.scrollLeft
const vw = container.clientWidth
if (!vw) return
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
const offset = Math.max(
0,
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
)
const dpr = window.devicePixelRatio || 1
canvas.width = canvasW * dpr
canvas.height = RULER_HEIGHT * dpr
canvas.style.width = `${canvasW}px`
canvas.style.height = `${RULER_HEIGHT}px`
canvas.style.transform = `translateX(${offset}px)`
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, canvasW, RULER_HEIGHT)
const rootStyles = getComputedStyle(document.documentElement)
const textColor =
rootStyles.getPropertyValue("--text-secondary").trim() || "#888"
const lineColor =
rootStyles.getPropertyValue("--border-subtle").trim() || "#444"
ctx.strokeStyle = lineColor
ctx.fillStyle = textColor
ctx.font = "10px monospace"
ctx.textAlign = "center"
const totalSec = durationMs / 1000
let tickInterval = 1
if (pixelsPerSecond < 5) tickInterval = 30
else if (pixelsPerSecond < 10) tickInterval = 15
else if (pixelsPerSecond < 20) tickInterval = 10
else if (pixelsPerSecond < 50) tickInterval = 5
else if (pixelsPerSecond < 150) tickInterval = 1
else tickInterval = 0.5
const majorMultiple = tickInterval >= 1 ? 5 : 1
const startSec =
Math.floor(offset / pixelsPerSecond / tickInterval) * tickInterval
const endSec = Math.min(
totalSec,
(offset + canvasW) / pixelsPerSecond,
)
for (let sec = startSec; sec <= endSec; sec += tickInterval) {
const x = sec * pixelsPerSecond - offset
if (x < -20 || x > canvasW + 20) continue
const isMajor =
Math.round(sec / tickInterval) % majorMultiple === 0
ctx.beginPath()
ctx.moveTo(x, isMajor ? 0 : 14)
ctx.lineTo(x, RULER_HEIGHT)
ctx.stroke()
if (isMajor) {
const min = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
const label = `${min}:${s.toString().padStart(2, "0")}`
const labelW = ctx.measureText(label).width
const tx = Math.max(labelW / 2, x)
ctx.fillText(label, tx, 10)
}
}
}, [durationMs, pixelsPerSecond, totalWidth])
// --- WaveSurfer ---
useEffect(() => {
if (!open || !videoUrl || !waveformRef.current || !durationMs) return
const durationSec = durationMs / 1000
const colors = resolveWaveformColors()
const ws = WaveSurfer.create({
container: waveformRef.current,
url: videoUrl,
duration: durationSec,
height: WAVEFORM_HEIGHT,
waveColor: colors.wave,
progressColor: colors.progress,
cursorWidth: 0,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
interact: false,
minPxPerSec: pixelsPerSecond,
hideScrollbar: true,
fillParent: false,
autoCenter: false,
autoScroll: false,
dragToSeek: false,
mediaControls: false,
backend: "MediaElement",
})
ws.setVolume(0)
wsRef.current = ws
return () => {
ws.destroy()
wsRef.current = null
}
// Only recreate when URL or open state changes, not on every PPS change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, videoUrl, durationMs])
// Update WaveSurfer zoom when PPS changes
useEffect(() => {
const ws = wsRef.current
if (!ws) return
try {
ws.zoom(pixelsPerSecond)
} catch {
// WaveSurfer might not be ready yet
}
}, [pixelsPerSecond])
// --- Video frames extraction ---
const framesCanvasRef = useRef<HTMLCanvasElement>(null)
const framesCacheRef = useRef<{ timeSec: number; bitmap: ImageBitmap }[]>([])
const [framesReady, setFramesReady] = useState(false)
// Extract frames once when video URL is available
useEffect(() => {
if (!open || !videoUrl || !durationMs) return
framesCacheRef.current.forEach((f) => f.bitmap.close())
framesCacheRef.current = []
setFramesReady(false)
let cancelled = false
const video = document.createElement("video")
video.crossOrigin = "anonymous"
video.muted = true
video.preload = "auto"
video.src = videoUrl
const extract = async () => {
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve()
video.onerror = () => reject(new Error("video load error"))
if (video.readyState >= 1) resolve()
})
if (cancelled) return
const durationSec = durationMs / 1000
const frameCount = Math.min(
Math.ceil(durationSec / 2),
MAX_EXTRACTED_FRAMES,
)
const interval = durationSec / frameCount
const aspect = video.videoWidth / video.videoHeight || 16 / 9
const frameW = Math.round(FRAMES_HEIGHT * aspect)
const offCanvas = document.createElement("canvas")
offCanvas.width = frameW * 2
offCanvas.height = FRAMES_HEIGHT * 2
const offCtx = offCanvas.getContext("2d")!
const cache: { timeSec: number; bitmap: ImageBitmap }[] = []
for (let i = 0; i < frameCount; i++) {
if (cancelled) return
const timeSec = i * interval
video.currentTime = timeSec
await new Promise<void>((r) => {
video.onseeked = () => r()
})
if (cancelled) return
offCtx.drawImage(video, 0, 0, offCanvas.width, offCanvas.height)
const bitmap = await createImageBitmap(offCanvas)
cache.push({ timeSec, bitmap })
}
if (!cancelled) {
framesCacheRef.current = cache
setFramesReady(true)
}
}
extract().catch(() => {})
return () => {
cancelled = true
video.src = ""
video.load()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, videoUrl, durationMs])
const drawFrames = useCallback(() => {
const container = timelineRef.current
const canvas = framesCanvasRef.current
if (!container || !canvas || !framesReady) return
const cache = framesCacheRef.current
if (cache.length === 0) return
const sl = container.scrollLeft
const vw = container.clientWidth
if (!vw) return
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
const offset = Math.max(
0,
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
)
const dpr = window.devicePixelRatio || 1
canvas.width = canvasW * dpr
canvas.height = FRAMES_HEIGHT * dpr
canvas.style.width = `${canvasW}px`
canvas.style.height = `${FRAMES_HEIGHT}px`
canvas.style.transform = `translateX(${offset}px)`
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.fillStyle = "#111"
ctx.fillRect(0, 0, canvasW, FRAMES_HEIGHT)
for (let i = 0; i < cache.length; i++) {
const globalX = cache[i].timeSec * pixelsPerSecond
const nextGlobalX =
i < cache.length - 1
? cache[i + 1].timeSec * pixelsPerSecond
: totalWidth
// Skip frames entirely outside visible canvas
if (nextGlobalX < offset) continue
if (globalX > offset + canvasW) break
const x = globalX - offset
const tileW = nextGlobalX - globalX
ctx.drawImage(cache[i].bitmap, x, 0, tileW, FRAMES_HEIGHT)
}
}, [framesReady, pixelsPerSecond, totalWidth])
// --- Animation loop: playhead sync + canvas redraw on scroll ---
const [playheadMs, setPlayheadMs] = useState(0)
const animRef = useRef<number>(0)
const lastScrollRef = useRef(-1)
const lastViewportRef = useRef(-1)
useEffect(() => {
if (!open) return
const tick = () => {
// Sync playhead with video
if (playerRef.current) {
const timeMs = playerRef.current.currentTime * 1000
setPlayheadMs(timeMs)
const ws = wsRef.current
if (ws) {
try {
ws.setTime(playerRef.current.currentTime)
} catch {
// ignore
}
}
}
// Redraw canvases when scroll position or viewport size changes
const container = timelineRef.current
if (container) {
const sl = container.scrollLeft
const vw = container.clientWidth
if (sl !== lastScrollRef.current || vw !== lastViewportRef.current) {
lastScrollRef.current = sl
lastViewportRef.current = vw
drawRuler()
drawFrames()
}
}
animRef.current = requestAnimationFrame(tick)
}
animRef.current = requestAnimationFrame(tick)
return () => {
if (animRef.current) cancelAnimationFrame(animRef.current)
lastScrollRef.current = -1
lastViewportRef.current = -1
}
}, [open, drawRuler, drawFrames])
// --- Apply ---
const { mutate: applyMutate, isPending: isApplying } = useSubmitSilenceApply(
{
onSuccess: () => {
onOpenChange(false)
},
onError: (error) => {
console.error("Silence apply failed:", error)
},
},
)
const handleApply = () => {
if (!fileKey || cutRegions.length === 0) return
const fileName = fileKey.split("/").pop() ?? "video.mp4"
const outputName = `Без тишины ${fileName}`
// Body shape matches SilenceApplyRequest — types available after gen:api-types
;(applyMutate as (args: { body: Record<string, unknown> }) => void)({
body: {
file_key: fileKey,
out_folder: "",
project_id: projectId,
output_name: outputName,
cuts: cutRegions.map((r) => ({
start_ms: Math.round(r.startMs),
end_ms: Math.round(r.endMs),
})),
},
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удаление тишины"
description="Просмотрите и отредактируйте участки для удаления"
>
<div className={styles.root} data-testid="SilenceResultModal">
{/* Video player */}
<div className={styles.playerWrapper}>
{videoUrl && (
<MediaPlayer
ref={playerRef}
src={videoUrl}
crossOrigin=""
playsInline
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
)}
</div>
{/* Timeline section */}
<div className={styles.timelineSection}>
<div className={styles.zoomControls}>
<button
className={styles.zoomButton}
onClick={() =>
setPixelsPerSecond((p) => Math.max(MIN_PPS, p - PPS_STEP))
}
>
-
</button>
<span>Масштаб</span>
<button
className={styles.zoomButton}
onClick={() =>
setPixelsPerSecond((p) => Math.min(MAX_PPS, p + PPS_STEP))
}
>
+
</button>
</div>
<div
ref={timelineRef}
className={styles.timelineContainer}
onClick={handleTimelineClick}
onContextMenu={(e) => handleContextMenu(e, null)}
>
<div
className={styles.timelineInner}
style={{ width: `${totalWidth}px` }}
>
{/* Ruler */}
<div className={styles.rulerRow}>
<canvas ref={rulerRef} className={styles.rulerCanvas} />
</div>
{/* Video frames */}
<div className={styles.framesRow}>
<canvas
ref={framesCanvasRef}
className={styles.framesCanvas}
/>
</div>
{/* Waveform */}
<div className={styles.waveformRow}>
<div ref={waveformRef} style={{ height: WAVEFORM_HEIGHT }} />
</div>
{/* Cut regions */}
<div className={styles.cutRegionsRow}>
{cutRegions.map((region, index) => {
const left = msToPixels(region.startMs)
const width = msToPixels(region.endMs - region.startMs)
return (
<div
key={region.id}
className={styles.cutRegion}
style={{ left: `${left}px`, width: `${width}px` }}
onPointerDown={(e) => {
if (e.button === 0) {
handleRegionDragStart(e, index)
}
}}
onContextMenu={(e) =>
handleContextMenu(e, region.id)
}
>
<div
className={styles.handleLeft}
onPointerDown={(e) =>
handleResizePointerDown(e, index, "left")
}
/>
<div
className={styles.handleRight}
onPointerDown={(e) =>
handleResizePointerDown(e, index, "right")
}
/>
</div>
)
})}
{/* Playhead */}
<div
className={styles.playhead}
style={{ left: `${msToPixels(playheadMs)}px` }}
/>
</div>
</div>
</div>
</div>
{/* Info bar */}
<div className={styles.infoBar}>
<span>Фрагментов: {cutRegions.length}</span>
<span className={styles.infoTotal}>
Будет удалено: {formatDuration(totalRemovedMs)}
</span>
</div>
{/* Context menu */}
{contextMenu && (
<div
className={styles.contextMenu}
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{contextMenu.regionId && (
<button
className={cs(
styles.contextMenuItem,
styles.contextMenuDanger,
)}
onClick={() => {
removeRegion(contextMenu.regionId!)
setContextMenu(null)
}}
>
<Trash2 size={14} />
<span>Удалить</span>
</button>
)}
<button
className={styles.contextMenuItem}
onClick={() => {
addRegion(contextMenu.timeMs)
setContextMenu(null)
}}
>
<Plus size={14} />
<span>Добавить новый</span>
</button>
</div>
)}
{/* Actions */}
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isApplying}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="button"
variant="primary"
disabled={isApplying || cutRegions.length === 0}
onClick={handleApply}
>
Применить
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1 @@
export { SilenceResultModal } from "./SilenceResultModal"
@@ -0,0 +1,25 @@
import api from "@shared/api"
interface IUseSubmitSilenceApplyParams {
onSuccess?: (data: unknown) => void
onError?: (error: unknown) => void
}
export const useSubmitSilenceApply = ({
onSuccess,
onError,
}: IUseSubmitSilenceApplyParams = {}) => {
// NOTE: Endpoint types will be available after running `bun run gen:api-types`
return api.useMutation(
"post",
"/api/tasks/silence-apply/" as "/api/tasks/silence-remove/",
{
onSuccess: (data) => {
onSuccess?.(data)
},
onError: (error) => {
onError?.(error)
},
},
)
}
@@ -0,0 +1,5 @@
export interface ISilenceSettingsModalProps {
projectId: string
open: boolean
onOpenChange?: (open: boolean) => void
}
@@ -0,0 +1,49 @@
.root {
min-width: 520px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.selectField {
display: grid;
gap: 6px;
}
.selectLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
}
.rangeField {
display: grid;
gap: 6px;
}
.rangeLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
display: flex;
justify-content: space-between;
align-items: center;
}
.rangeValue {
@include typography.font-body-14(400);
color: variables.$text-secondary;
font-variant-numeric: tabular-nums;
}
.rangeInput {
width: 100%;
accent-color: variables.$color-primary;
}
@@ -0,0 +1,184 @@
"use client"
import type { ISilenceSettingsModalProps } from "./SilenceSettingsModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import api from "@shared/api"
import { Button, Form, Modal, Select, SelectItem } from "@shared/ui"
import { useSubmitSilenceDetect } from "./useSubmitSilenceDetect"
import styles from "./SilenceSettingsModal.module.scss"
interface ISilenceSettingsFormData {
file_key: string
min_silence_duration_ms: number
silence_threshold_db: number
padding_ms: number
}
const DEFAULT_MIN_SILENCE_MS = 200
const DEFAULT_THRESHOLD_DB = 16
const DEFAULT_PADDING_MS = 100
export const SilenceSettingsModal: FunctionComponent<
ISilenceSettingsModalProps
> = ({ projectId, open, onOpenChange }): JSX.Element => {
const { control, handleSubmit, reset, watch, setValue } =
useForm<ISilenceSettingsFormData>({
defaultValues: {
file_key: "",
min_silence_duration_ms: DEFAULT_MIN_SILENCE_MS,
silence_threshold_db: DEFAULT_THRESHOLD_DB,
padding_ms: DEFAULT_PADDING_MS,
},
})
const minSilence = watch("min_silence_duration_ms")
const threshold = watch("silence_threshold_db")
const padding = watch("padding_ms")
const { data: files } = api.useQuery("get", "/api/files/files/", {
queryKey: ["files", projectId],
})
const projectFiles = (files ?? []).filter(
(f) => f.project_id === projectId && !f.is_deleted,
)
const { mutate, isPending } = useSubmitSilenceDetect({
onSuccess: () => {
onOpenChange?.(false)
},
onError: (error) => {
console.error("Silence detect submit failed:", error)
},
})
useEffect(() => {
if (!open) reset()
}, [open, reset])
const onSubmit = (data: ISilenceSettingsFormData): void => {
// Body shape matches SilenceDetectRequest — types available after gen:api-types
;(mutate as (args: { body: Record<string, unknown> }) => void)({
body: {
file_key: data.file_key,
project_id: projectId,
min_silence_duration_ms: data.min_silence_duration_ms,
silence_threshold_db: data.silence_threshold_db,
padding_ms: data.padding_ms,
},
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удалить тишину"
description="Выберите файл и настройте параметры обнаружения тишины"
>
<div className={styles.root} data-testid="SilenceSettingsModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Файл</div>
<Controller
name="file_key"
control={control}
rules={{ required: "Выберите файл" }}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите файл"
>
{projectFiles.map((f) => (
<SelectItem key={f.id} value={f.path}>
{f.original_filename}
</SelectItem>
))}
</Select>
)}
/>
</div>
<div className={styles.rangeField}>
<div className={styles.rangeLabel}>
<span>Мин. длительность тишины</span>
<span className={styles.rangeValue}>{minSilence} мс</span>
</div>
<input
type="range"
className={styles.rangeInput}
min={100}
max={2000}
step={50}
value={minSilence}
onChange={(e) =>
setValue(
"min_silence_duration_ms",
Number(e.target.value),
)
}
/>
</div>
<div className={styles.rangeField}>
<div className={styles.rangeLabel}>
<span>Порог тишины</span>
<span className={styles.rangeValue}>{threshold} дБ</span>
</div>
<input
type="range"
className={styles.rangeInput}
min={6}
max={40}
step={2}
value={threshold}
onChange={(e) =>
setValue("silence_threshold_db", Number(e.target.value))
}
/>
</div>
<div className={styles.rangeField}>
<div className={styles.rangeLabel}>
<span>Отступ</span>
<span className={styles.rangeValue}>{padding} мс</span>
</div>
<input
type="range"
className={styles.rangeInput}
min={0}
max={500}
step={25}
value={padding}
onChange={(e) =>
setValue("padding_ms", Number(e.target.value))
}
/>
</div>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Запустить
</Button>
</div>
</Form>
</div>
</Modal>
)
}
@@ -0,0 +1 @@
export { SilenceSettingsModal } from "./SilenceSettingsModal"
@@ -0,0 +1,25 @@
import api from "@shared/api"
interface IUseSubmitSilenceDetectParams {
onSuccess?: (data: unknown) => void
onError?: (error: unknown) => void
}
export const useSubmitSilenceDetect = ({
onSuccess,
onError,
}: IUseSubmitSilenceDetectParams = {}) => {
// NOTE: Endpoint types will be available after running `bun run gen:api-types`
return api.useMutation(
"post",
"/api/tasks/silence-detect/" as "/api/tasks/silence-remove/",
{
onSuccess: (data) => {
onSuccess?.(data)
},
onError: (error) => {
onError?.(error)
},
},
)
}
+3
View File
@@ -0,0 +1,3 @@
export interface ISilenceTrackProps {
className?: string
}
@@ -0,0 +1,2 @@
.root {
}
@@ -0,0 +1,14 @@
import type { ISilenceTrackProps } from "./SilenceTrack.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import styles from "./SilenceTrack.module.scss"
export const SilenceTrack: FunctionComponent<ISilenceTrackProps> = (): JSX.Element => {
return (
<div className={styles.root} data-testid="SilenceTrack">
SilenceTrack
</div>
)
}
@@ -0,0 +1 @@
export * from "./SilenceTrack"
+10
View File
@@ -0,0 +1,10 @@
export interface ISubtitlesTrackProps {
artifactId: string
pixelsPerSecond: number
height: number
duration: number
scrollLeft?: number
viewportWidth?: number
videoUrl?: string
onSegmentClick?: (segmentIndex: number, artifactId: string) => void
}
@@ -0,0 +1,90 @@
.wrapper {
position: relative;
height: 100%;
}
.segment {
position: absolute;
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);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
overflow: hidden;
transition: background 0.1s;
&:hover {
background: rgba(139, 92, 246, 0.45);
}
}
.tooltip {
position: fixed;
z-index: 9999;
max-width: 320px;
padding: 6px 10px;
border-radius: variables.$radius-sm;
background: variables.$bg-surface;
color: variables.$text-primary;
font-size: 12px;
line-height: 1.4;
white-space: normal;
word-break: break-word;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.active {
background: rgba(139, 92, 246, 0.6);
&:hover {
background: rgba(139, 92, 246, 0.65);
}
}
.resizing {
background: rgba(139, 92, 246, 0.5);
z-index: 2;
}
.segmentText {
padding: 0 4px;
font-size: 11px;
line-height: 1.2;
color: variables.$text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
flex: 1;
min-width: 0;
}
.handleLeft,
.handleRight {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 3;
&:hover {
background: rgba(139, 92, 246, 0.5);
}
}
.handleLeft {
left: 0;
border-radius: variables.$radius-sm 0 0 variables.$radius-sm;
}
.handleRight {
right: 0;
border-radius: 0 variables.$radius-sm variables.$radius-sm 0;
}
@@ -0,0 +1,443 @@
"use client"
import type { ISubtitlesTrackProps } from "./SubtitlesTrack.d"
import type { JSX } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useMediaState } from "@vidstack/react"
import cs from "classnames"
import {
FunctionComponent,
memo,
useCallback,
useMemo,
useRef,
useState,
} from "react"
import { createPortal } from "react-dom"
import api from "@shared/api"
import { fetchClient } from "@shared/api"
import { useSegmentResize } from "@shared/hooks/useSegmentResize"
import { SegmentEditModal } from "@features/project/SegmentEditModal"
import styles from "./SubtitlesTrack.module.scss"
import {
type WordData,
estimateWordTimings,
} from "@shared/lib/transcriptionDocument"
interface Segment {
start: number
end: number
text: string
words?: WordData[]
}
const MIN_SEGMENT_DURATION = 0.1
const VIRTUAL_OVERSCAN_PX = 600
/* ------------------------------------------------------------------ */
/* Individual segment — memoized for performance */
/* ------------------------------------------------------------------ */
const SegmentBlock = memo(
({
segment,
index,
pixelsPerSecond,
isActive,
isResizing,
onClick,
onHandlePointerDown,
onMouseEnter,
onMouseLeave,
}: {
segment: Segment
index: number
pixelsPerSecond: number
isActive: boolean
isResizing: boolean
onClick: (index: number) => void
onHandlePointerDown: (
e: React.PointerEvent,
index: number,
edge: "left" | "right",
) => void
onMouseEnter: (e: React.MouseEvent, text: string) => void
onMouseLeave: () => void
}) => {
const left = segment.start * pixelsPerSecond
const width = Math.max(1, (segment.end - segment.start) * pixelsPerSecond)
return (
<div
className={cs(styles.segment, {
[styles.active]: isActive,
[styles.resizing]: isResizing,
})}
style={{ left, width }}
onClick={(e) => {
e.stopPropagation()
onClick(index)
}}
onMouseEnter={(e) => onMouseEnter(e, segment.text)}
onMouseLeave={onMouseLeave}
>
<div
className={styles.handleLeft}
onPointerDown={(e) => onHandlePointerDown(e, index, "left")}
/>
{width > 20 && (
<span className={styles.segmentText}>{segment.text}</span>
)}
<div
className={styles.handleRight}
onPointerDown={(e) => onHandlePointerDown(e, index, "right")}
/>
</div>
)
},
)
SegmentBlock.displayName = "SegmentBlock"
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
export const SubtitlesTrack: FunctionComponent<
ISubtitlesTrackProps
> = ({
artifactId,
pixelsPerSecond,
height,
duration,
scrollLeft = 0,
viewportWidth = 2000,
videoUrl,
}): JSX.Element => {
const currentTime = useMediaState("currentTime")
const queryClient = useQueryClient()
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingIndex, setEditingIndex] = useState(-1)
const { data: transcription } = api.useQuery(
"get",
"/api/transcribe/transcriptions/by-artifact/{artifact_id}/",
{ params: { path: { artifact_id: artifactId } } },
{ enabled: !!artifactId },
)
/* Parse segments from transcription document */
const [localSegments, setLocalSegments] = useState<Segment[] | null>(null)
const apiSegments: Segment[] = useMemo(() => {
if (!transcription?.document) return []
const doc = transcription.document as {
segments?: Array<{
time?: { start?: number; end?: number }
text?: string
lines?: Array<{
words?: Array<{
text?: string
time?: { start?: number; end?: number }
}>
}>
}>
}
return (
doc.segments?.map((seg) => {
const words: WordData[] = []
if (seg.lines) {
for (const line of seg.lines) {
if (line.words) {
for (const w of line.words) {
if (w.text) {
words.push({
text: w.text,
start: w.time?.start ?? 0,
end: w.time?.end ?? 0,
})
}
}
}
}
}
const segStart = seg.time?.start ?? 0
const segEnd = seg.time?.end ?? 0
const segText = seg.text ?? ""
return {
start: segStart,
end: segEnd,
text: segText,
words:
words.length > 0
? words
: estimateWordTimings(segText, segStart, segEnd),
}
}) ?? []
)
}, [transcription])
const segments = localSegments ?? apiSegments
/* Reset local overrides when API data changes */
const prevApiRef = useRef(apiSegments)
if (prevApiRef.current !== apiSegments) {
prevApiRef.current = apiSegments
if (localSegments !== null) setLocalSegments(null)
}
/* ---- Save helpers ---- */
const buildDocument = useCallback((segs: Segment[]) => {
return {
segments: segs.map((seg) => {
const wordNodes = seg.words
? seg.words.map((w) => ({
text: w.text,
semantic_tags: [],
structure_tags: [],
time: { start: w.start, end: w.end },
}))
: []
return {
text: seg.text,
semantic_tags: [],
structure_tags: [],
time: { start: seg.start, end: seg.end },
lines: [
{
text: seg.text,
semantic_tags: [],
structure_tags: [],
time: { start: seg.start, end: seg.end },
words: wordNodes,
},
],
}
}),
}
}, [])
const invalidateTranscription = useCallback(() => {
queryClient.invalidateQueries({
queryKey: api.queryOptions(
"get",
"/api/transcribe/transcriptions/by-artifact/{artifact_id}/",
{ params: { path: { artifact_id: artifactId } } },
).queryKey,
})
}, [queryClient, artifactId])
const patchTranscription = useCallback(
async (segs: Segment[]) => {
if (!transcription?.id) return
try {
await fetchClient.PATCH(
"/api/transcribe/transcriptions/{transcription_id}/",
{
params: {
path: { transcription_id: transcription.id },
},
body: { document: buildDocument(segs) },
},
)
invalidateTranscription()
} catch {
// Revert to API data on failure
setLocalSegments(null)
}
},
[transcription, buildDocument, invalidateTranscription],
)
/* ---- Resize logic ---- */
const onResize = useCallback(
(index: number, edge: "left" | "right", deltaSec: number) => {
setLocalSegments((prev) => {
const segs = [...(prev ?? apiSegments)]
const seg = { ...segs[index] }
const prevSeg = index > 0 ? segs[index - 1] : null
const nextSeg = index < segs.length - 1 ? segs[index + 1] : null
if (edge === "left") {
let newStart = seg.start + deltaSec
const minStart = prevSeg ? prevSeg.end : 0
newStart = Math.max(newStart, minStart)
newStart = Math.min(newStart, seg.end - MIN_SEGMENT_DURATION)
seg.start = Math.round(newStart * 1000) / 1000
} else {
let newEnd = seg.end + deltaSec
const maxEnd = nextSeg ? nextSeg.start : duration
newEnd = Math.min(newEnd, maxEnd)
newEnd = Math.max(newEnd, seg.start + MIN_SEGMENT_DURATION)
seg.end = Math.round(newEnd * 1000) / 1000
}
segs[index] = seg
return segs
})
},
[apiSegments, duration],
)
const localSegmentsRef = useRef(localSegments)
localSegmentsRef.current = localSegments
const onResizeEnd = useCallback(
() => {
const segs = localSegmentsRef.current ?? apiSegments
patchTranscription(segs)
},
[apiSegments, patchTranscription],
)
const { handlePointerDown, resizingIndex, justResizedRef } =
useSegmentResize({
pixelsPerSecond,
onResize,
onResizeEnd,
})
/* ---- Tooltip ---- */
const [tooltip, setTooltip] = useState<{
text: string
x: number
y: number
} | null>(null)
const handleSegmentMouseEnter = useCallback(
(e: React.MouseEvent, text: string) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setTooltip({ text, x: rect.left, y: rect.top - 4 })
},
[],
)
const handleSegmentMouseLeave = useCallback(() => {
setTooltip(null)
}, [])
/* ---- Click to open modal ---- */
const handleSegmentClick = useCallback(
(index: number) => {
if (justResizedRef.current) return
setEditingIndex(index)
setEditModalOpen(true)
},
[justResizedRef],
)
const handleModalSave = useCallback(
async (newText: string) => {
const segs = [...(localSegments ?? apiSegments)]
if (editingIndex < 0 || editingIndex >= segs.length) return
segs[editingIndex] = { ...segs[editingIndex], text: newText }
setLocalSegments(segs)
await patchTranscription(segs)
},
[localSegments, apiSegments, editingIndex, patchTranscription],
)
const handleModalSplit = useCallback(
async (
newSegments: Array<{
start: number
end: number
text: string
words?: WordData[]
}>,
) => {
const segs = [...(localSegments ?? apiSegments)]
if (editingIndex < 0 || editingIndex >= segs.length) return
segs.splice(editingIndex, 1, ...newSegments)
setLocalSegments(segs)
await patchTranscription(segs)
},
[localSegments, apiSegments, editingIndex, patchTranscription],
)
/* ---- Virtual windowing ---- */
const totalWidth = Math.max(duration * pixelsPerSecond, 200)
const overscan = Math.max(VIRTUAL_OVERSCAN_PX, viewportWidth * 0.5)
const visibleStartTime = Math.max(0, (scrollLeft - overscan) / pixelsPerSecond)
const visibleEndTime = (scrollLeft + viewportWidth + overscan) / pixelsPerSecond
const visibleSegments = useMemo(() => {
const result: Array<{ segment: Segment; index: number }> = []
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
if (seg.end >= visibleStartTime && seg.start <= visibleEndTime) {
result.push({ segment: seg, index: i })
}
}
return result
}, [segments, visibleStartTime, visibleEndTime])
const activeIndex = useMemo(
() => segments.findIndex((s) => currentTime >= s.start && currentTime <= s.end),
[segments, currentTime],
)
const editingSegment = editingIndex >= 0 && editingIndex < segments.length
? segments[editingIndex]
: { start: 0, end: 0, text: "" }
return (
<>
<div
className={styles.wrapper}
style={{ width: totalWidth, height }}
data-testid="SubtitlesTrack"
>
{visibleSegments.map(({ segment, index }) => (
<SegmentBlock
key={index}
segment={segment}
index={index}
pixelsPerSecond={pixelsPerSecond}
isActive={index === activeIndex}
isResizing={index === resizingIndex}
onClick={handleSegmentClick}
onHandlePointerDown={handlePointerDown}
onMouseEnter={handleSegmentMouseEnter}
onMouseLeave={handleSegmentMouseLeave}
/>
))}
</div>
<SegmentEditModal
open={editModalOpen}
onOpenChange={setEditModalOpen}
videoUrl={videoUrl}
segment={editingSegment}
onSave={handleModalSave}
onSplit={handleModalSplit}
/>
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{
left: tooltip.x,
top: tooltip.y,
transform: "translateY(-100%)",
}}
>
{tooltip.text}
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1 @@
export * from "./SubtitlesTrack"
@@ -0,0 +1,3 @@
export interface ITranscriptionEditorProps {
artifactId: string
}
@@ -0,0 +1,207 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.loader {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty {
padding: 24px;
text-align: center;
color: variables.$text-tertiary;
font-size: 14px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid variables.$border-default;
flex-shrink: 0;
}
.title {
font-size: 14px;
font-weight: 600;
color: variables.$text-primary;
margin: 0;
}
.saveButton {
display: inline-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;
&:hover {
opacity: 0.9;
}
&.disabled {
background: variables.$border-default;
color: variables.$text-tertiary;
cursor: default;
pointer-events: none;
}
}
.segmentsList {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.segment {
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
padding: 10px 12px;
background: variables.$bg-surface;
transition: border-color 0.3s, box-shadow 0.3s;
&.highlight {
border-color: variables.$color-primary;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
}
}
.segmentTimes {
display: flex;
align-items: flex-end;
gap: 10px;
margin-bottom: 8px;
}
.timeLabel {
display: flex;
flex-direction: column;
gap: 2px;
}
.timeLabelText {
font-size: 11px;
color: variables.$text-tertiary;
font-weight: 500;
}
.timeInput {
width: 100px;
padding: 4px 8px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
font-size: 13px;
font-family: monospace;
color: variables.$text-primary;
background: variables.$bg-default;
&:focus {
outline: none;
border-color: variables.$color-primary;
}
}
.splitButton {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 4px;
border: none;
background: none;
color: variables.$text-tertiary;
cursor: pointer;
border-radius: variables.$radius-sm;
&:hover:not(:disabled) {
color: variables.$color-primary;
background: variables.$bg-hover;
}
&:disabled {
opacity: 0.35;
cursor: default;
}
}
.removeButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
color: variables.$text-tertiary;
cursor: pointer;
border-radius: variables.$radius-sm;
&:hover {
color: variables.$color-danger;
background: variables.$bg-hover;
}
}
.textArea {
width: 100%;
padding: 8px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
font-size: 13px;
line-height: 1.5;
color: variables.$text-primary;
background: variables.$bg-default;
resize: vertical;
font-family: inherit;
&:focus {
outline: none;
border-color: variables.$color-primary;
}
}
.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;
color: variables.$text-secondary;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
&:hover {
background: variables.$bg-hover;
border-color: variables.$color-primary;
color: variables.$color-primary;
}
}
@@ -0,0 +1,263 @@
"use client"
import type { ITranscriptionEditorProps } from "./TranscriptionEditor.d"
import type { JSX } from "react"
import { useQueryClient } from "@tanstack/react-query"
import cs from "classnames"
import { LoaderCircle, Plus, Save, Scissors, Trash2 } from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react"
import api from "@shared/api"
import { fetchClient } from "@shared/api"
import { useWorkspaceFiles } from "@shared/context/WorkspaceContext"
import {
type EditorSegment,
documentToSegments,
segmentsToDocument,
} from "@shared/lib/transcriptionDocument"
import { SegmentSplitter } from "@features/project/SegmentSplitter"
import styles from "./TranscriptionEditor.module.scss"
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export const TranscriptionEditor: FunctionComponent<
ITranscriptionEditorProps
> = ({ artifactId }): JSX.Element => {
const queryClient = useQueryClient()
const { selectedFile, setSelectedFile } = useWorkspaceFiles()
const segmentsListRef = useRef<HTMLDivElement>(null)
const { data: transcription, isLoading } = api.useQuery(
"get",
"/api/transcribe/transcriptions/by-artifact/{artifact_id}/",
{ params: { path: { artifact_id: artifactId } } },
)
const [segments, setSegments] = useState<EditorSegment[]>([])
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [splittingIdx, setSplittingIdx] = useState<number | null>(null)
useEffect(() => {
if (transcription?.document) {
setSegments(documentToSegments(transcription.document))
setDirty(false)
}
}, [transcription])
// Scroll to segment when navigated from SubtitlesTrack
useEffect(() => {
if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return
if (segments.length === 0) return
const targetIdx = selectedFile.scrollToSegmentIndex
const container = segmentsListRef.current
if (!container) return
const segmentEl = container.querySelector<HTMLElement>(
`[data-segment-index="${targetIdx}"]`,
)
if (!segmentEl) return
// Brief delay to let the DOM settle
requestAnimationFrame(() => {
segmentEl.scrollIntoView({ behavior: "smooth", block: "center" })
segmentEl.classList.add(styles.highlight)
setTimeout(() => segmentEl.classList.remove(styles.highlight), 1500)
})
// Clear scrollToSegmentIndex so it doesn't re-trigger
setSelectedFile({
id: selectedFile.id,
path: selectedFile.path,
source: selectedFile.source,
artifactType: selectedFile.artifactType,
})
}, [selectedFile?.scrollToSegmentIndex, segments.length])
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 }
if (field === "text") updated.words = undefined
return updated
}),
)
setDirty(true)
},
[],
)
const handleSplit = useCallback(
(idx: number, newSegments: EditorSegment[]) => {
setSegments((prev) => [
...prev.slice(0, idx),
...newSegments,
...prev.slice(idx + 1),
])
setSplittingIdx(null)
setDirty(true)
},
[],
)
const addSegment = useCallback(() => {
const lastEnd =
segments.length > 0 ? segments[segments.length - 1].endTime : "00:00.000"
setSegments((prev) => [
...prev,
{ startTime: lastEnd, endTime: lastEnd, text: "" },
])
setDirty(true)
}, [segments])
const removeSegment = useCallback((idx: number) => {
setSegments((prev) => prev.filter((_, i) => i !== idx))
setDirty(true)
}, [])
const handleSave = useCallback(async () => {
if (!transcription?.id) return
setSaving(true)
try {
await fetchClient.PATCH(
"/api/transcribe/transcriptions/{transcription_id}/",
{
params: { path: { transcription_id: transcription.id } },
body: { document: segmentsToDocument(segments) },
},
)
setDirty(false)
queryClient.invalidateQueries({
queryKey: api.queryOptions(
"get",
"/api/transcribe/transcriptions/by-artifact/{artifact_id}/",
{ params: { path: { artifact_id: artifactId } } },
).queryKey,
})
} finally {
setSaving(false)
}
}, [transcription, segments, artifactId, queryClient])
/* Loading */
if (isLoading) {
return (
<div className={styles.root} data-testid="TranscriptionEditor">
<div className={styles.loader}>
<LoaderCircle size={24} className={styles.spinner} />
</div>
</div>
)
}
/* No transcription found */
if (!transcription) {
return (
<div className={styles.root} data-testid="TranscriptionEditor">
<p className={styles.empty}>Транскрипция не найдена</p>
</div>
)
}
return (
<div className={styles.root} data-testid="TranscriptionEditor">
{/* Header */}
<div className={styles.header}>
<h3 className={styles.title}>Редактор транскрипции</h3>
<button
className={cs(styles.saveButton, { [styles.disabled]: !dirty })}
onClick={handleSave}
disabled={!dirty || saving}
>
{saving ? (
<LoaderCircle size={16} className={styles.spinner} />
) : (
<Save size={16} />
)}
<span>Сохранить</span>
</button>
</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}>
<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>
<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>
{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>
)
}
@@ -0,0 +1 @@
export * from "./TranscriptionEditor"
@@ -0,0 +1,5 @@
export interface ITranscriptionModalProps {
projectId: string
open: boolean
onOpenChange?: (open: boolean) => void
}
@@ -0,0 +1,25 @@
.root {
min-width: 520px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.selectField {
display: grid;
gap: 6px;
}
.selectLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
}
@@ -0,0 +1,203 @@
"use client"
import type { ITranscriptionModalProps } from "./TranscriptionModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import api from "@shared/api"
import { Button, Form, Modal, Select, SelectItem } from "@shared/ui"
import { useSubmitTranscription } from "./useSubmitTranscription"
import styles from "./TranscriptionModal.module.scss"
interface ITranscriptionFormData {
file_key: string
engine: "whisper" | "google"
language: string
model: string
}
const ENGINE_OPTIONS = [
{ value: "whisper", label: "Whisper (локальный)" },
{ value: "google", label: "Google Speech" },
]
const LANGUAGE_OPTIONS = [
{ value: "auto", label: "Авто" },
{ value: "ru", label: "Русский" },
{ value: "en", label: "English" },
]
const MODEL_OPTIONS = [
{ value: "base", label: "Base" },
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
]
export const TranscriptionModal: FunctionComponent<
ITranscriptionModalProps
> = ({ projectId, open, onOpenChange }): JSX.Element => {
const { control, handleSubmit, reset, watch } =
useForm<ITranscriptionFormData>({
defaultValues: {
file_key: "",
engine: "whisper",
language: "auto",
model: "base",
},
})
const engine = watch("engine")
const { data: files } = api.useQuery("get", "/api/files/files/", {
queryKey: ["files", projectId],
})
const projectFiles = (files ?? []).filter(
(f) => f.project_id === projectId && !f.is_deleted,
)
const { mutate, isPending } = useSubmitTranscription({
onSuccess: () => {
onOpenChange?.(false)
},
onError: (error) => {
console.error("Transcription submit failed:", error)
},
})
useEffect(() => {
if (!open) reset()
}, [open, reset])
const onSubmit = (data: ITranscriptionFormData): void => {
mutate({
body: {
file_key: data.file_key,
project_id: projectId,
engine: data.engine,
language: data.language === "auto" ? undefined : data.language,
model: data.model,
},
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Создать транскрипцию"
description="Выберите файл и параметры транскрипции"
>
<div className={styles.root} data-testid="TranscriptionModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Файл</div>
<Controller
name="file_key"
control={control}
rules={{ required: "Выберите файл" }}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите файл"
>
{projectFiles.map((f) => (
<SelectItem key={f.id} value={f.path}>
{f.original_filename}
</SelectItem>
))}
</Select>
)}
/>
</div>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Движок</div>
<Controller
name="engine"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите движок"
>
{ENGINE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Язык</div>
<Controller
name="language"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите язык"
>
{LANGUAGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
{engine === "whisper" && (
<div className={styles.selectField}>
<div className={styles.selectLabel}>Модель</div>
<Controller
name="model"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите модель"
>
{MODEL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
)}
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Запустить транскрипцию
</Button>
</div>
</Form>
</div>
</Modal>
)
}
@@ -0,0 +1 @@
export { TranscriptionModal } from "./TranscriptionModal"
@@ -0,0 +1,24 @@
import api from "@shared/api"
interface IUseSubmitTranscriptionParams {
onSuccess?: (data: { job_id: string }) => void
onError?: (error: unknown) => void
}
export const useSubmitTranscription = ({
onSuccess,
onError,
}: IUseSubmitTranscriptionParams = {}) => {
return api.useMutation(
"post",
"/api/tasks/transcription-generate/",
{
onSuccess: (data) => {
onSuccess?.(data)
},
onError: (error) => {
onError?.(error)
},
},
)
}
@@ -0,0 +1,9 @@
export interface IVideoFramesTrackProps {
videoUrl: string
fileKey: string
pixelsPerSecond: number
height: number
duration: number
scrollLeft?: number
viewportWidth?: number
}
@@ -0,0 +1,14 @@
.root {
position: relative;
height: 100%;
background: #1a1a2e;
}
.canvas {
position: absolute;
top: 0;
left: 0;
display: block;
cursor: pointer;
background: #1a1a2e;
}

Some files were not shown because too many files have changed in this diff Show More