new features

This commit is contained in:
Daniil
2026-02-27 23:34:17 +03:00
parent 42ce5fa0fe
commit 71b974903a
191 changed files with 11300 additions and 373 deletions
@@ -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>
)
}