new features
This commit is contained in:
+1033
-48
File diff suppressed because it is too large
Load Diff
@@ -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 (0–100)
|
||||
* @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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export interface AppState {}
|
||||
export type ThemePreference = "light" | "dark" | "system"
|
||||
|
||||
export interface AppState {
|
||||
themePreference: ThemePreference
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,14 +114,3 @@
|
||||
animation: spin 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user