new features
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NotificationBell } from "./NotificationBell"
|
||||
export { NotificationPopup } from "./NotificationPopup"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
+2
-2
@@ -20,6 +20,6 @@
|
||||
}
|
||||
|
||||
.selectLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
+4
-49
@@ -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)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user