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
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import type { components } from "./__generated__/openapi.types"
import { fetchClient } from "."
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
type FileInfoResponse = components["schemas"]["FileInfoResponse"]
type ProgressCallback = (percent: number) => void
/**
* Upload a file to the storage API.
* Handles FormData construction and Content-Type header override
* required for multipart uploads via openapi-fetch.
*
* @param file - File object to upload
* @param folder - Target folder in storage (default: "")
* @returns FileInfoResponse with file_path and file_url
*/
export async function uploadFile(
file: File,
folder = "",
): Promise<FileInfoResponse> {
const formData = new FormData()
formData.append("file", file)
formData.append("folder", folder)
const { data, error } = await fetchClient.POST("/api/files/upload/", {
body: formData as unknown as { file: string; folder: string },
bodySerializer: () => formData as unknown as string,
headers: { "Content-Type": null },
})
if (error || !data) {
throw new Error("File upload failed")
}
return data
}
/**
* Upload a file with real-time progress tracking.
* Uses XMLHttpRequest for access to upload progress events.
*
* @param file - File object to upload
* @param folder - Target folder in storage (default: "")
* @param onProgress - Callback receiving upload percentage (0100)
* @returns FileInfoResponse with file_path and file_url
*/
export function uploadFileWithProgress(
file: File,
folder = "",
onProgress?: ProgressCallback,
): Promise<FileInfoResponse> {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append("file", file)
formData.append("folder", folder)
const xhr = new XMLHttpRequest()
xhr.open("POST", `${API_URL}/api/files/upload/`)
const token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
if (token.length) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`)
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as FileInfoResponse)
} catch {
reject(new Error("Не удалось разобрать ответ сервера"))
}
} else {
reject(new Error("Ошибка загрузки файла"))
}
}
xhr.onerror = () => reject(new Error("Ошибка сети при загрузке"))
xhr.send(formData)
})
}
+16 -5
View File
@@ -9,6 +9,8 @@ import { store } from "@shared/store"
import { BreadcrumbsProvider } from "./BreadcrumbsContext"
import { QueryClientProvider } from "./QueryClientProvider"
import { SocketProvider } from "./SocketProvider"
import { ThemeSync } from "./ThemeSync"
import { UserSync } from "./UserSync"
export const AppProviders = ({
@@ -20,11 +22,20 @@ export const AppProviders = ({
<ReduxProvider store={store}>
<QueryClientProvider>
<UserSync />
<BreadcrumbsProvider>
<Theme accentColor="iris" grayColor="slate" radius="medium" scaling="100%">
{children}
</Theme>
</BreadcrumbsProvider>
<SocketProvider>
<ThemeSync />
<BreadcrumbsProvider>
<Theme
accentColor="iris"
grayColor="slate"
radius="medium"
scaling="100%"
appearance="inherit"
>
{children}
</Theme>
</BreadcrumbsProvider>
</SocketProvider>
</QueryClientProvider>
</ReduxProvider>
)
+289
View File
@@ -0,0 +1,289 @@
"use client"
import type { JSX, ReactNode } from "react"
import { useQueryClient } from "@tanstack/react-query"
import Cookies from "js-cookie"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import { useDispatch } from "react-redux"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { API_URL } from "@shared/lib/constants"
import {
addNotification,
NotificationItem,
setNotifications,
} from "@shared/store/notifications"
interface SocketContextValue {
isConnected: boolean
}
const SocketContext = createContext<SocketContextValue>({ isConnected: false })
export const useSocket = () => useContext(SocketContext)
const MOCK_NOTIFICATIONS: NotificationItem[] = [
{
event: "task_update",
notification_id: null,
job_id: "mock-1",
project_id: null,
job_type: "TRANSCRIPTION_GENERATE",
status: "RUNNING",
progress_pct: 30,
message: "Транскрибирование (whisper)",
title: "Транскрипция",
created_at: new Date().toISOString(),
},
{
event: "task_update",
notification_id: null,
job_id: "mock-1",
project_id: null,
job_type: "TRANSCRIPTION_GENERATE",
status: "RUNNING",
progress_pct: 60,
message: "Транскрибирование (whisper)",
title: "Транскрипция",
created_at: new Date().toISOString(),
},
{
event: "task_update",
notification_id: "mock-notif-1",
job_id: "mock-1",
project_id: null,
job_type: "TRANSCRIPTION_GENERATE",
status: "DONE",
progress_pct: 100,
message: "Завершено",
title: "Транскрипция",
created_at: new Date().toISOString(),
},
{
event: "task_update",
notification_id: null,
job_id: "mock-2",
project_id: null,
job_type: "MEDIA_CONVERT",
status: "RUNNING",
progress_pct: 50,
message: "Конвертация",
title: "Конвертация",
created_at: new Date().toISOString(),
},
{
event: "task_update",
notification_id: "mock-notif-2",
job_id: "mock-2",
project_id: null,
job_type: "MEDIA_CONVERT",
status: "FAILED",
progress_pct: null,
message: "Ошибка конвертации",
title: "Конвертация",
created_at: new Date().toISOString(),
},
{
event: "task_update",
notification_id: "mock-notif-3",
job_id: "mock-3",
project_id: null,
job_type: "SILENCE_REMOVE",
status: "DONE",
progress_pct: 100,
message: "Завершено",
title: "Удаление тишины",
created_at: new Date().toISOString(),
},
]
const MAX_RECONNECT_ATTEMPTS = 10
const BASE_DELAY_MS = 1000
/** Shape returned by GET /api/notifications/ */
interface NotificationReadDTO {
id: string
job_id: string | null
project_id: string | null
notification_type: string
title: string
message: string | null
payload: { job_type?: string; status?: string; progress_pct?: number } | null
is_read: boolean
created_at: string
}
function mapDTOtoItem(dto: NotificationReadDTO): NotificationItem {
return {
event: "task_update",
notification_id: dto.id,
job_id: dto.job_id,
project_id: dto.project_id,
job_type: dto.payload?.job_type ?? null,
status: dto.payload?.status ?? null,
progress_pct: dto.payload?.progress_pct ?? null,
message: dto.message,
title: dto.title,
created_at: dto.created_at,
is_read: dto.is_read,
}
}
export const SocketProvider = ({
children,
}: {
children: ReactNode
}): JSX.Element => {
const [isConnected, setIsConnected] = useState(false)
const dispatch = useDispatch()
const queryClient = useQueryClient()
const userData = useAppSelector((state) => state.user.user)
const wsRef = useRef<WebSocket | null>(null)
const reconnectAttempts = useRef(0)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const hydratedRef = useRef(false)
// Hydrate persisted notifications from the backend on mount
useEffect(() => {
if (!userData || hydratedRef.current) return
hydratedRef.current = true
const token = Cookies.get("access_token")
if (!token) return
const baseUrl = API_URL || "http://localhost:8000"
fetch(`${baseUrl}/api/notifications/`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => (res.ok ? res.json() : []))
.then((data: NotificationReadDTO[]) => {
if (!data.length) return
// Backend returns newest-first. Deduplicate by job_id,
// keeping only the latest (first encountered) notification per job.
const seen = new Set<string>()
const unique: NotificationItem[] = []
for (const dto of data) {
if (dto.job_id) {
if (seen.has(dto.job_id)) continue
seen.add(dto.job_id)
}
unique.push(mapDTOtoItem(dto))
}
dispatch(setNotifications(unique))
})
.catch(() => {
// Silently ignore hydration errors — WS will still deliver live updates
})
}, [userData, dispatch])
useEffect(() => {
if (!userData) return
const isMock = process.env.NEXT_PUBLIC_MOCK_WS === "true"
if (isMock) {
let idx = 0
const interval = setInterval(() => {
if (idx < MOCK_NOTIFICATIONS.length) {
dispatch(addNotification(MOCK_NOTIFICATIONS[idx]))
idx++
} else {
clearInterval(interval)
}
}, 2000)
setIsConnected(true)
return () => {
clearInterval(interval)
setIsConnected(false)
}
}
const connect = () => {
const token = Cookies.get("access_token")
if (!token) return
const wsUrl =
process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8000"
const ws = new WebSocket(
`${wsUrl}/api/notifications/ws/?token=${token}`,
)
wsRef.current = ws
ws.onopen = () => {
setIsConnected(true)
reconnectAttempts.current = 0
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as NotificationItem
dispatch(addNotification(data))
if (
data.status === "DONE" &&
data.job_type === "TRANSCRIPTION_GENERATE"
) {
queryClient.invalidateQueries({
queryKey: ["get", "/api/transcribe/transcriptions/"],
})
queryClient.invalidateQueries({
queryKey: ["get", "/api/media/artifacts/"],
})
queryClient.invalidateQueries({
queryKey: ["get", "/api/files/files/"],
})
}
if (
data.status === "DONE" &&
data.job_type === "MEDIA_CONVERT"
) {
queryClient.invalidateQueries({
queryKey: ["get", "/api/media/artifacts/"],
})
queryClient.invalidateQueries({
queryKey: ["get", "/api/files/files/"],
})
}
} catch {
// Ignore malformed messages
}
}
ws.onclose = () => {
setIsConnected(false)
wsRef.current = null
if (reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
const delay =
BASE_DELAY_MS *
Math.pow(2, reconnectAttempts.current)
reconnectAttempts.current++
reconnectTimer.current = setTimeout(connect, delay)
}
}
ws.onerror = () => {
ws.close()
}
}
connect()
return () => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current)
}
if (wsRef.current) {
wsRef.current.close()
}
}
}, [userData, dispatch])
return (
<SocketContext.Provider value={{ isConnected }}>
{children}
</SocketContext.Provider>
)
}
+71
View File
@@ -0,0 +1,71 @@
"use client"
import type { JSX } from "react"
import { useEffect } from "react"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { setThemePreference } from "@shared/store/appState"
import type { ThemePreference } from "@shared/store/appState/types"
const STORAGE_KEY = "theme"
const resolveTheme = (preference: ThemePreference): "light" | "dark" => {
if (preference === "system") {
if (typeof window === "undefined") return "light"
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
return preference
}
const applyTheme = (preference: ThemePreference): void => {
const resolved = resolveTheme(preference)
document.documentElement.setAttribute("data-theme", resolved)
document.documentElement.classList.remove("light", "dark")
document.documentElement.classList.add(resolved)
}
export const useResolvedTheme = (): "light" | "dark" => {
const themePreference = useAppSelector(
(state) => state.appState.themePreference,
)
return resolveTheme(themePreference)
}
export const ThemeSync = (): JSX.Element | null => {
const dispatch = useAppDispatch()
const themePreference = useAppSelector(
(state) => state.appState.themePreference,
)
// On mount: read localStorage and sync to Redux
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as ThemePreference | null
if (stored && (stored === "light" || stored === "dark" || stored === "system")) {
dispatch(setThemePreference(stored))
}
}, [dispatch])
// On preference change: persist + apply
useEffect(() => {
localStorage.setItem(STORAGE_KEY, themePreference)
applyTheme(themePreference)
}, [themePreference])
// When "system": listen to OS theme changes
useEffect(() => {
if (themePreference !== "system") return
const mq = window.matchMedia("(prefers-color-scheme: dark)")
const handler = (): void => applyTheme("system")
mq.addEventListener("change", handler)
return () => mq.removeEventListener("change", handler)
}, [themePreference])
return null
}
+174
View File
@@ -0,0 +1,174 @@
"use client"
import {
createContext,
FunctionComponent,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import api from "@shared/api"
import { useDebounce } from "@shared/hooks/useDebounce"
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type SelectedFile = {
id: string
path: string
source: "file" | "artifact" | "media"
artifactType?: string
mimeType?: string
scrollToSegmentIndex?: number
}
export type UsedFile = SelectedFile & {
displayName: string
iconType: "video" | "audio" | "text" | "other"
}
interface WorkspaceFileContextValue {
selectedFile: SelectedFile | null
setSelectedFile: (file: SelectedFile | null) => void
usedFiles: UsedFile[]
addUsedFile: (file: UsedFile) => void
removeUsedFile: (id: string) => void
isFileUsed: (id: string) => boolean
isLoaded: boolean
}
/* ------------------------------------------------------------------ */
/* Context */
/* ------------------------------------------------------------------ */
const FileContext = createContext<WorkspaceFileContextValue | null>(null)
/* ------------------------------------------------------------------ */
/* Provider */
/* ------------------------------------------------------------------ */
const DEBOUNCE_MS = 1000
export const WorkspaceProvider: FunctionComponent<{
projectId: string
children: ReactNode
}> = ({ projectId, children }) => {
const [selectedFile, setSelectedFileState] = useState<SelectedFile | null>(
null,
)
const [usedFiles, setUsedFiles] = useState<UsedFile[]>([])
const isInitializedRef = useRef(false)
const initialValueRef = useRef<string | null>(null)
/* ---- Load from server ---- */
const { data: project, isSuccess } = api.useQuery(
"get",
"/api/projects/{project_id}/",
{ params: { path: { project_id: projectId } } },
{ enabled: !!projectId },
)
useEffect(() => {
if (!isSuccess || isInitializedRef.current) return
const saved = project?.workspace_state as
| { used_files?: UsedFile[] }
| null
| undefined
const loaded = saved?.used_files ?? []
setUsedFiles(loaded)
initialValueRef.current = JSON.stringify(loaded)
isInitializedRef.current = true
}, [isSuccess, project])
/* ---- Save to server (debounced) ---- */
const debouncedUsedFiles = useDebounce(usedFiles, DEBOUNCE_MS)
const saveMutation = api.useMutation(
"patch",
"/api/projects/{project_id}/",
)
useEffect(() => {
if (!isInitializedRef.current) return
const serialized = JSON.stringify(debouncedUsedFiles)
if (serialized === initialValueRef.current) return
initialValueRef.current = serialized
saveMutation.mutate({
params: { path: { project_id: projectId } },
body: {
workspace_state: { used_files: debouncedUsedFiles },
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedUsedFiles, projectId])
/* ---- Actions ---- */
const setSelectedFile = useCallback(
(file: SelectedFile | null) => setSelectedFileState(file),
[],
)
const addUsedFile = useCallback((file: UsedFile) => {
setUsedFiles((prev) => {
if (prev.some((f) => f.id === file.id)) return prev
return [...prev, file]
})
}, [])
const removeUsedFile = useCallback((id: string) => {
setUsedFiles((prev) => prev.filter((f) => f.id !== id))
}, [])
const isFileUsed = useCallback(
(id: string) => usedFiles.some((f) => f.id === id),
[usedFiles],
)
const value = useMemo<WorkspaceFileContextValue>(
() => ({
selectedFile,
setSelectedFile,
usedFiles,
addUsedFile,
removeUsedFile,
isFileUsed,
isLoaded: isInitializedRef.current,
}),
[
selectedFile,
setSelectedFile,
usedFiles,
addUsedFile,
removeUsedFile,
isFileUsed,
],
)
return <FileContext.Provider value={value}>{children}</FileContext.Provider>
}
/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */
/** File selection & used-files list — stable during playback */
export function useWorkspaceFiles(): WorkspaceFileContextValue {
const ctx = useContext(FileContext)
if (!ctx) {
throw new Error("useWorkspaceFiles must be used within WorkspaceProvider")
}
return ctx
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useState } from "react"
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
+90
View File
@@ -0,0 +1,90 @@
import { useCallback, useRef, useState } from "react"
export type ResizeEdge = "left" | "right"
interface UseSegmentResizeOptions {
pixelsPerSecond: number
onResize: (index: number, edge: ResizeEdge, deltaSec: number) => void
onResizeEnd: (index: number, edge: ResizeEdge) => void
}
export function useSegmentResize({
pixelsPerSecond,
onResize,
onResizeEnd,
}: UseSegmentResizeOptions) {
const [resizingIndex, setResizingIndex] = useState(-1)
const [resizingEdge, setResizingEdge] = useState<ResizeEdge | null>(null)
const startXRef = useRef(0)
const accDeltaRef = useRef(0)
const indexRef = useRef(-1)
const edgeRef = useRef<ResizeEdge>("left")
const ppsRef = useRef(pixelsPerSecond)
ppsRef.current = pixelsPerSecond
// Use refs for callbacks to avoid stale closures in event listeners
const onResizeRef = useRef(onResize)
onResizeRef.current = onResize
const onResizeEndRef = useRef(onResizeEnd)
onResizeEndRef.current = onResizeEnd
const justResizedRef = useRef(false)
// Stable references that never change — safe for addEventListener/removeEventListener
const moveHandlerRef = useRef<((e: PointerEvent) => void) | null>(null)
const upHandlerRef = useRef<((e: PointerEvent) => void) | null>(null)
if (!moveHandlerRef.current) {
moveHandlerRef.current = (e: PointerEvent) => {
const dx = e.clientX - startXRef.current
const deltaSec = dx / ppsRef.current
const prevDelta = accDeltaRef.current
const incrementalDelta = deltaSec - prevDelta
accDeltaRef.current = deltaSec
onResizeRef.current(indexRef.current, edgeRef.current, incrementalDelta)
}
}
if (!upHandlerRef.current) {
upHandlerRef.current = (e: PointerEvent) => {
document.removeEventListener("pointermove", moveHandlerRef.current!)
document.removeEventListener("pointerup", upHandlerRef.current!)
onResizeEndRef.current(indexRef.current, edgeRef.current)
setResizingIndex(-1)
setResizingEdge(null)
justResizedRef.current = true
setTimeout(() => {
justResizedRef.current = false
}, 200)
}
}
const handlePointerDown = useCallback(
(e: React.PointerEvent, index: number, edge: ResizeEdge) => {
e.stopPropagation()
e.preventDefault()
startXRef.current = e.clientX
accDeltaRef.current = 0
indexRef.current = index
edgeRef.current = edge
setResizingIndex(index)
setResizingEdge(edge)
document.addEventListener("pointermove", moveHandlerRef.current!)
document.addEventListener("pointerup", upHandlerRef.current!)
},
[],
)
return {
handlePointerDown,
resizingIndex,
resizingEdge,
justResizedRef,
}
}
+47
View File
@@ -0,0 +1,47 @@
import { useMemo } from "react"
import type { SelectedFile, UsedFile } from "@shared/context/WorkspaceContext"
export interface TimelineTracks {
showWaveform: boolean
showVideoFrames: boolean
showSubtitles: boolean
showSilence: boolean
showBRoll: boolean
}
const DEFAULT_TRACKS: TimelineTracks = {
showWaveform: false,
showVideoFrames: false,
showSubtitles: false,
showSilence: false,
showBRoll: false,
}
export function useTimelineTracks(
usedFiles: UsedFile[],
selectedFile: SelectedFile | null,
): TimelineTracks {
return useMemo(() => {
if (!selectedFile) return DEFAULT_TRACKS
const hasVideo = usedFiles.some((f) => f.iconType === "video")
const hasAudio = usedFiles.some(
(f) => f.iconType === "audio" || f.iconType === "video",
)
const hasTranscription = usedFiles.some(
(f) => f.artifactType === "TRANSCRIPTION_JSON",
)
const hasSilence = usedFiles.some(
(f) => f.artifactType === "SILENCE_REMOVED_VIDEO",
)
return {
showVideoFrames: hasVideo,
showWaveform: hasAudio,
showSubtitles: hasTranscription,
showSilence: hasSilence,
showBRoll: false,
}
}, [usedFiles, selectedFile])
}
+11
View File
@@ -0,0 +1,11 @@
import { format, formatDistanceToNow } from "date-fns"
import { ru } from "date-fns/locale"
export function formatDate(date: string | Date, pattern = "dd.MM.yyyy"): string {
return format(new Date(date), pattern, { locale: ru })
}
export function formatRelativeTime(date: string | Date | null): string {
if (!date) return ""
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: ru })
}
+155
View File
@@ -0,0 +1,155 @@
export interface WordData {
text: string
start: number
end: number
}
export interface EditorSegment {
startTime: string
endTime: string
text: string
words?: WordData[]
}
export function secondsToTimecode(sec: number): string {
const mins = Math.floor(sec / 60)
const secs = sec % 60
const whole = Math.floor(secs)
const ms = Math.round((secs - whole) * 1000)
return `${String(mins).padStart(2, "0")}:${String(whole).padStart(2, "0")}.${String(ms).padStart(3, "0")}`
}
export function timecodeToSeconds(tc: string): number {
const [minPart, secPart] = tc.split(":")
if (!minPart || !secPart) return 0
const mins = parseInt(minPart, 10) || 0
const secs = parseFloat(secPart) || 0
return mins * 60 + secs
}
export function documentToSegments(doc: unknown): EditorSegment[] {
const d = doc as {
segments?: Array<{
time?: { start?: number; end?: number }
text?: string
lines?: Array<{
words?: Array<{ text?: string; time?: { start?: number; end?: number } }>
}>
}>
}
if (!d?.segments) return []
return d.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 {
startTime: secondsToTimecode(segStart),
endTime: secondsToTimecode(segEnd),
text: segText,
words:
words.length > 0
? words
: estimateWordTimings(segText, segStart, segEnd),
}
})
}
export function segmentsToDocument(
segments: EditorSegment[],
): Record<string, unknown> {
return {
segments: segments.map((seg) => {
const start = timecodeToSeconds(seg.startTime)
const end = timecodeToSeconds(seg.endTime)
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, end },
lines: [
{
text: seg.text,
semantic_tags: [],
structure_tags: [],
time: { start, end },
words: wordNodes,
},
],
}
}),
}
}
export function estimateWordTimings(
text: string,
start: number,
end: number,
): WordData[] {
const tokens = text.split(/\s+/).filter(Boolean)
if (tokens.length === 0) return []
const totalChars = tokens.reduce((sum, t) => sum + t.length, 0)
const duration = end - start
let cursor = start
return tokens.map((token) => {
const ratio = token.length / totalChars
const wordDuration = duration * ratio
const wordStart = Math.round(cursor * 1000) / 1000
cursor += wordDuration
const wordEnd = Math.round(cursor * 1000) / 1000
return { text: token, start: wordStart, end: wordEnd }
})
}
export function splitSegmentAtMarkers(
segment: EditorSegment,
markerIndices: number[],
): EditorSegment[] {
if (!segment.words || segment.words.length < 2) return [segment]
if (markerIndices.length === 0) return [segment]
const sorted = [...markerIndices].sort((a, b) => a - b)
const groups: WordData[][] = []
let start = 0
for (const markerIdx of sorted) {
if (markerIdx >= start && markerIdx < segment.words.length) {
groups.push(segment.words.slice(start, markerIdx))
start = markerIdx
}
}
groups.push(segment.words.slice(start))
return groups
.filter((g) => g.length > 0)
.map((words) => ({
startTime: secondsToTimecode(words[0].start),
endTime: secondsToTimecode(words[words.length - 1].end),
text: words.map((w) => w.text).join(" "),
words,
}))
}
+10 -3
View File
@@ -1,8 +1,12 @@
import type { PayloadAction } from "@reduxjs/toolkit"
import { createSlice } from "@reduxjs/toolkit"
import { AppState } from "./types"
import type { AppState, ThemePreference } from "./types"
const initialState: AppState = {}
const initialState: AppState = {
themePreference: "system",
}
const appStateSlice = createSlice({
name: "appState",
@@ -11,8 +15,11 @@ const appStateSlice = createSlice({
resetAppState() {
return initialState
},
setThemePreference(state, action: PayloadAction<ThemePreference>) {
state.themePreference = action.payload
},
},
})
export const { resetAppState } = appStateSlice.actions
export const { resetAppState, setThemePreference } = appStateSlice.actions
export const appStateReducer = appStateSlice.reducer
+5 -1
View File
@@ -1 +1,5 @@
export interface AppState {}
export type ThemePreference = "light" | "dark" | "system"
export interface AppState {
themePreference: ThemePreference
}
+2
View File
@@ -1,11 +1,13 @@
import { configureStore } from "@reduxjs/toolkit"
import { appStateReducer } from "./appState"
import { notificationsReducer } from "./notifications"
import { userReducer } from "./user"
export const store = configureStore({
reducer: {
appState: appStateReducer,
notifications: notificationsReducer,
user: userReducer,
},
})
+85
View File
@@ -0,0 +1,85 @@
import type { PayloadAction } from "@reduxjs/toolkit"
import { createSlice } from "@reduxjs/toolkit"
const MAX_ITEMS = 50
export interface NotificationItem {
event: string
notification_id: string | null
job_id: string | null
project_id: string | null
job_type: string | null
status: string | null
progress_pct: number | null
message: string | null
title: string | null
created_at: string | null
is_read?: boolean
}
interface NotificationsState {
items: NotificationItem[]
unreadCount: number
}
const initialState: NotificationsState = {
items: [],
unreadCount: 0,
}
const notificationsSlice = createSlice({
name: "notifications",
initialState,
reducers: {
addNotification(state, action: PayloadAction<NotificationItem>) {
const incoming = action.payload
// Update existing item for same job_id if it's a progress update
const existingIdx = state.items.findIndex(
(n) => n.job_id && n.job_id === incoming.job_id,
)
if (existingIdx !== -1) {
state.items[existingIdx] = { ...state.items[existingIdx], ...incoming }
} else {
state.items.unshift(incoming)
}
// Cap at MAX_ITEMS
if (state.items.length > MAX_ITEMS) {
state.items = state.items.slice(0, MAX_ITEMS)
}
// Recalculate unread
state.unreadCount = state.items.filter((n) => !n.is_read).length
},
markRead(state, action: PayloadAction<string>) {
const item = state.items.find(
(n) => n.notification_id === action.payload,
)
if (item) {
item.is_read = true
}
state.unreadCount = state.items.filter((n) => !n.is_read).length
},
markAllRead(state) {
state.items.forEach((n) => {
n.is_read = true
})
state.unreadCount = 0
},
setNotifications(state, action: PayloadAction<NotificationItem[]>) {
state.items = action.payload.slice(0, MAX_ITEMS)
state.unreadCount = state.items.filter((n) => !n.is_read).length
},
resetNotifications() {
return initialState
},
},
})
export const {
addNotification,
markRead,
markAllRead,
setNotifications,
resetNotifications,
} = notificationsSlice.actions
export const notificationsReducer = notificationsSlice.reducer
+14 -1
View File
@@ -32,7 +32,20 @@ $color-black: var(--color-black);
$header-height: var(--header-height);
$text-primary: var(--text-primary);
$text-secondary: var(--text-secondary);
$text-tertiary: var(--text-tertiary);
$bg-default: var(--bg-default);
$bg-surface: var(--bg-surface);
$bg-canvas: var(--bg-canvas);
$bg-canvas: var(--bg-canvas);
$bg-hover: var(--bg-hover);
$border-default: var(--border-default);
$border-subtle: var(--border-subtle);
$shadow-sm: var(--shadow-sm);
$shadow-md: var(--shadow-md);
$shadow-lg: var(--shadow-lg);
$radius-sm: var(--radius-sm);
$radius-md: var(--radius-md);
$radius-lg: var(--radius-lg);
+46 -9
View File
@@ -14,11 +14,8 @@
}
body {
background-color: #f8f8f8;
background-color: var(--bg-canvas);
color: var(--text-primary);
// @media (prefers-color-scheme: dark) {
// background-color: #121212;
// }
}
:root {
@@ -54,18 +51,58 @@ body {
--color-secondary: var(--purple-400);
--color-white: #ffffff;
--color-black: #000000;
--text-primary: #0c1226;
--text-secondary: #5a5f73;
--text-primary: #0f1729;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
--bg-canvas: rgb(246, 245, 250);
--bg-default: rgb(255, 255, 255);
--bg-surface: rgba(245, 245, 245, 1);
--bg-canvas: #f8fafc;
--bg-default: #ffffff;
--bg-surface: #f1f5f9;
--bg-hover: #e8edf3;
--bg-default-invert: rgba(34, 35, 37, 1);
--border-default: #e2e8f0;
--border-subtle: #e8edf3;
--waveform-wave: var(--purple-400);
--waveform-progress: var(--purple-600);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.08);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--header-height: 56px;
}
[data-theme="dark"] {
--color-primary: var(--green-400);
--color-secondary: var(--purple-300);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--bg-canvas: #0f1219;
--bg-default: #1a1f2e;
--bg-surface: #242938;
--bg-hover: #1e2330;
--bg-default-invert: rgba(241, 245, 249, 1);
--border-default: #2e3447;
--border-subtle: #2a3040;
--waveform-wave: #e2e8f0;
--waveform-progress: #94a3b8;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.radix-themes {
--default-font-family: var(--font-open-sans);
}
+3 -3
View File
@@ -8,7 +8,7 @@
height: 128px;
.icon {
bottom: 13x;
bottom: 13px;
right: 13px;
}
}
@@ -18,7 +18,7 @@
height: 96px;
.icon {
bottom: 8x;
bottom: 8px;
right: 8px;
}
}
@@ -148,7 +148,7 @@
&.active {
display: inline;
background-color: #00ff00;
background-color: variables.$color-success;
}
}
+23 -3
View File
@@ -1,6 +1,6 @@
import type { JSX } from "react"
import { FunctionComponent, memo, useState } from "react"
import { FunctionComponent, memo, useEffect, useState } from "react"
import cs from "classnames"
import Image from "next/image"
@@ -9,6 +9,18 @@ import avatarPlaceholder from "@shared/assets/placeholder.png"
import { IAvatarProps } from "./Avatar.d"
import styles from "./Avatar.module.scss"
const isValidImageSrc = (src: string): boolean => {
if (src.startsWith("/") || src.startsWith("http://") || src.startsWith("https://")) {
return true
}
try {
new URL(src)
return true
} catch {
return false
}
}
const avatarProperties = {
xxxlarge: {
width: 1024,
@@ -44,10 +56,18 @@ export const Avatar: FunctionComponent<IAvatarProps> = memo(
active = false,
}): JSX.Element => {
const [loaded, setLoaded] = useState(false)
const [imgURL, setImgURL] = useState(url || avatarPlaceholder.src)
const validUrl = url && isValidImageSrc(url) ? url : undefined
const [imgURL, setImgURL] = useState(validUrl || avatarPlaceholder.src)
useEffect(() => {
if (validUrl) {
setImgURL(validUrl)
setLoaded(false)
}
}, [validUrl])
return (
<div className={cs(styles.root, styles[size])}>
{url ? (
{validUrl ? (
<>
<Image
priority
+3 -3
View File
@@ -17,9 +17,9 @@ const sizeMap = {
} as const
const variantMap = {
primary: { variant: "solid", color: "indigo" },
secondary: { variant: "soft", color: "grass" },
outline: { variant: "outline", color: "indigo" },
primary: { variant: "solid", color: "iris" },
secondary: { variant: "soft", color: "iris" },
outline: { variant: "outline", color: "gray" },
ghost: { variant: "ghost", color: "gray" },
danger: { variant: "solid", color: "ruby" },
icon: { variant: "ghost", color: "gray" },
+2 -6
View File
@@ -1,10 +1,6 @@
.card {
background-color: white;
background-color: var(--bg-default);
border-radius: 0.75rem;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
box-shadow: var(--shadow-sm);
padding: 1.5rem;
@media (prefers-color-scheme: dark) {
background-color: #1f1f1f;
}
}
+27 -25
View File
@@ -1,12 +1,18 @@
@use "@shared/styles/variables" as *;
.trigger {
all: unset;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
&:focus-visible {
outline: 2px solid $color-primary;
outline: 2px solid variables.$color-secondary;
outline-offset: 2px;
border-radius: variables.$radius-sm;
}
}
@@ -14,13 +20,11 @@
.subContent {
z-index: 100;
min-width: 180px;
padding: 8px;
background-color: $color-white;
border: 1px solid $color-primary;
border-radius: 8px;
box-shadow:
0 10px 38px -10px rgb(22 23 24 / 35%),
0 10px 20px -15px rgb(22 23 24 / 20%);
padding: 6px;
background-color: variables.$bg-default;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: var(--shadow-lg);
animation: fadeIn 0.15s ease-out;
}
@@ -31,22 +35,20 @@
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
padding: 8px 10px;
font-size: 14px;
color: $text-primary;
color: variables.$text-primary;
cursor: pointer;
border-radius: 4px;
border-radius: variables.$radius-sm;
outline: none;
transition: background-color 0.15s ease;
color: variables.$text-primary;
transition: background-color 0.12s ease;
&[data-highlighted] {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
background-color: variables.$bg-surface;
}
&[data-disabled] {
color: $text-secondary;
color: variables.$text-tertiary;
pointer-events: none;
opacity: 0.5;
}
@@ -56,7 +58,7 @@
justify-content: space-between;
&[data-state="open"] {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
background-color: variables.$bg-surface;
}
}
@@ -65,30 +67,30 @@
align-items: center;
justify-content: center;
width: 16px;
color: $color-secondary;
color: variables.$color-secondary;
}
.label {
padding: 8px 12px 4px;
padding: 8px 10px 4px;
font-size: 12px;
font-weight: 500;
color: $text-secondary;
color: variables.$text-secondary;
}
.separator {
height: 1px;
margin: 8px 0;
background-color: color-mix(in srgb, variables.$color-primary 20%, transparent);
margin: 4px 0;
background-color: variables.$border-default;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.96);
transform: scale(0.96) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1);
transform: scale(1) translateY(0);
}
}
-11
View File
@@ -114,14 +114,3 @@
animation: spin 0.5s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
+6 -9
View File
@@ -15,31 +15,28 @@
width: calc(100% - 2rem);
max-height: 85vh;
padding: 1.5rem;
background-color: white;
background-color: var(--bg-default);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
box-shadow: var(--shadow-lg);
z-index: 101;
animation: contentShow 0.15s ease;
&:focus {
outline: none;
}
@media (prefers-color-scheme: dark) {
background-color: #1f1f1f;
}
}
.title {
margin-bottom: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.description {
margin-bottom: 1rem;
font-size: 0.875rem;
color: #6b7280;
color: var(--text-secondary);
}
.close {
@@ -52,11 +49,11 @@
padding: 0.25rem;
background: transparent;
border-radius: 0.25rem;
color: #6b7280;
color: var(--text-secondary);
cursor: pointer;
&:hover {
background-color: #f3f4f6;
background-color: var(--bg-surface);
}
&:focus-visible {
+5 -5
View File
@@ -45,7 +45,7 @@ export const Pagination = forwardRef<HTMLDivElement, IPaginationProps>(
role="navigation"
align="center"
gap="1"
aria-label="Pagination"
aria-label="Пагинация"
{...props}
>
{showFirstLast && (
@@ -54,7 +54,7 @@ export const Pagination = forwardRef<HTMLDivElement, IPaginationProps>(
size="1"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
aria-label="First page"
aria-label="Первая страница"
>
<ChevronsLeft size={16} />
</IconButton>
@@ -64,7 +64,7 @@ export const Pagination = forwardRef<HTMLDivElement, IPaginationProps>(
size="1"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
aria-label="Предыдущая страница"
>
<ChevronLeft size={16} />
</IconButton>
@@ -92,7 +92,7 @@ export const Pagination = forwardRef<HTMLDivElement, IPaginationProps>(
size="1"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
aria-label="Следующая страница"
>
<ChevronRight size={16} />
</IconButton>
@@ -102,7 +102,7 @@ export const Pagination = forwardRef<HTMLDivElement, IPaginationProps>(
size="1"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
aria-label="Last page"
aria-label="Последняя страница"
>
<ChevronsRight size={16} />
</IconButton>
+7 -20
View File
@@ -7,28 +7,25 @@
.label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
@media (prefers-color-scheme: dark) {
color: #e5e7eb;
}
color: var(--text-primary);
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
background-color: white;
border: 1px solid #d1d5db;
background-color: var(--bg-default);
border: 1px solid var(--border-default);
border-radius: 0.5rem;
font-size: 0.875rem;
color: var(--text-primary);
transition: all 0.15s ease;
&::placeholder {
color: #9ca3af;
color: var(--text-tertiary);
}
&:hover:not(:disabled) {
border-color: #9ca3af;
border-color: var(--text-tertiary);
}
&:focus {
@@ -40,17 +37,7 @@
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #f3f4f6;
}
@media (prefers-color-scheme: dark) {
background-color: #1f1f1f;
border-color: #4b5563;
color: #f3f4f6;
&:hover:not(:disabled) {
border-color: #6b7280;
}
background-color: var(--bg-surface);
}
}